From 80fa0470c076858cc8f7ceb1da6e8c57ac539497 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Fri, 24 Apr 2026 17:10:04 +0200 Subject: [PATCH 1/9] feat: source catalog from cave, rewrite hovers, fix grammar, add formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Catalog path now resolved from the cave-selected code_aster version: extract `code_aster/Cata/` from the matching Docker image to a per-version cache and inject via `VS_CODE_ASTER_CATA_PATH` so the LSP serves completion / hover / signatures for the version the user actually runs. Falls back to the bundled vendored catalog when Docker/cave are unavailable. - New right-aligned status bar item (`CaveStatusBar`) mirrors VS Code's Python interpreter picker: click to switch, install (with auto-confirmed `y` prompt and progress notification), or remove versions. Reconciles orphan caches on startup and when images are removed. - `HoverManager` rewritten as TypeScript-style Markdown: signature rendered as a python code fence (theme-highlighted), description italic paragraph below, rules list, `Search documentation for …` link. Handles command, keyword, factor marker, allowed-value literal, variable reference, and plain literal assignment hovers. Localized EN/FR via LANG env. - `.comm` grammar rewritten around a `function-call` begin/end block with a nested `parens` sub-block so `_F(...)`, tuples, and multi-line kwargs all color correctly. Lowercase `e` in scientific notation, Python constants (`None/True/False`), and lowercase kwarg names are now supported. - New `CommFormatter` shells out to `python -m ruff format --quote-style=preserve` for `.comm` files. First-open flow probes for ruff and offers a one-click `pip install ruff` with progress; retries with `--user` under PEP 668. - Language aliases: `code_aster (comm)` and `code_aster (export)`. - `examples/lsp_showcase.comm`: demo file exercising every LSP surface. --- examples/lsp_showcase.comm | 125 +++++ language-configuration.json | 6 +- package.json | 24 +- python/asterstudy/datamodel/catalogs.py | 34 +- python/lsp/managers/hover_manager.py | 707 +++++++++++++++++++++++- src/CatalogResolver.ts | 327 +++++++++++ src/CaveStatusBar.ts | 519 +++++++++++++++++ src/CommFormatter.ts | 304 ++++++++++ src/LspServer.ts | 148 ++++- src/extension.ts | 49 ++ syntaxes/comm.tmLanguage.json | 73 ++- 11 files changed, 2239 insertions(+), 77 deletions(-) create mode 100644 examples/lsp_showcase.comm create mode 100644 src/CatalogResolver.ts create mode 100644 src/CaveStatusBar.ts create mode 100644 src/CommFormatter.ts diff --git a/examples/lsp_showcase.comm b/examples/lsp_showcase.comm new file mode 100644 index 0000000..8015ed1 --- /dev/null +++ b/examples/lsp_showcase.comm @@ -0,0 +1,125 @@ +# --------------------------------------------------------------------------- +# LSP showcase — small linear-elastic cantilever in a single file. +# +# What to try in this file (each section is annotated): +# * Hover a COMMAND name → full Python-fence signature + docstring + rules +# + documentation link. +# * Hover a KEYWORD inside a call → keyword-scoped card with type, default, +# allowed values, range, required/optional. +# * Hover a keyword inside a conditional BLOC (e.g. NOM_MED under FORMAT='MED') +# → only the active branch is offered. +# * Trigger completion at an empty line → list of all commands. +# * Trigger completion inside a call (after "(" or ",") → keyword list, +# filtered by active BLOCs given what you've +# already typed in the call. +# * Trigger signature help with "(" or "," → argument hints. +# * Save the file (or open alongside a .mess) → diagnostics in Problems panel. +# * Bottom-left status bar shows which family steps (Mesh / Material / +# Loads / Analysis / Output) are filled in this file. +# * Bottom-right status bar shows the active code_aster version (cave or +# "(bundled)"); click it to switch, install, or remove versions. +# --------------------------------------------------------------------------- + +DEBUT() + +# 1. Mesh -------------------------------------------------------------------- +# Try: hover LIRE_MAILLAGE → command signature +# hover FORMAT → keyword card (default="MED", into=(ASTER, ...)) +# hover NOM_MED → only visible because FORMAT='MED' is active +MESH = LIRE_MAILLAGE( + FORMAT='MED', + UNITE=20, + NOM_MED='Cantilever', + INFO_MED=0, +) + +# 2. Model ------------------------------------------------------------------- +# AFFE_MODELE has a `regles` rule: at least one of MAILLAGE / MODELE_IN ... +# Hover AFFE_MODELE → see the rendered "Rules" list in the hover. +MODEL = AFFE_MODELE( + MAILLAGE=MESH, + AFFE=_F( + TOUT='OUI', + PHENOMENE='MECANIQUE', + MODELISATION='3D', + ), +) + +# 3. Material ---------------------------------------------------------------- +# Hover DEFI_MATERIAU → factor keywords ELAS, ECRO_LINE ... +# Hover ELAS → required sub-keywords E, NU get "# required". +STEEL = DEFI_MATERIAU( + ELAS=_F( + E=210000.0, # Young's modulus [MPa] + NU=0.3, # Poisson's ratio + RHO=7.85e-9, # density [t/mm^3] — optional, shown with default + ), +) + +CHMAT = AFFE_MATERIAU( + MAILLAGE=MESH, + AFFE=_F( + TOUT='OUI', + MATER=STEEL, + ), +) + +# 4. Boundary conditions & loads -------------------------------------------- +# AFFE_CHAR_MECA is the richest call for hover features: +# - required MODELE +# - many optional factor keywords: DDL_IMPO, FORCE_NODALE, PRES_REP, ... +# - rules like AT_LEAST_ONE on several sub-factors +CLIM = AFFE_CHAR_MECA( + MODELE=MODEL, + DDL_IMPO=_F( + GROUP_MA='FIX', + DX=0.0, + DY=0.0, + DZ=0.0, + ), + FORCE_NODALE=_F( + GROUP_NO='TIP', + FZ=-1000.0, + ), +) + +# 5. Analysis ---------------------------------------------------------------- +# MECA_STATIQUE: EXCIT is a repeatable FACT (max="**") — hover EXCIT to see +# the factor block's required sub-keywords. +RESU = MECA_STATIQUE( + MODELE=MODEL, + CHAM_MATER=CHMAT, + EXCIT=_F(CHARGE=CLIM), +) + +# Post-processing: request stresses at nodes & Von Mises-equivalent. +RESU = CALC_CHAMP( + reuse=RESU, + RESULTAT=RESU, + CONTRAINTE=('SIGM_NOEU',), + CRITERES=('SIEQ_NOEU',), +) + +# 6. Output ------------------------------------------------------------------ +# IMPR_RESU shows BLOC filtering by FORMAT: +# FORMAT='MED' → UNITE, NOM_MED, ... are active +# FORMAT='RESULTAT' → different sub-keywords +# Change FORMAT below and re-hover the keywords to see the BLOC swap. +IMPR_RESU( + FORMAT='MED', + UNITE=80, + RESU=_F( + RESULTAT=RESU, + NOM_CHAM=('DEPL', 'SIGM_NOEU', 'SIEQ_NOEU'), + ), +) + +# Intentional typo / invalid argument demonstrations ------------------------ +# Uncomment any line below to see LSP surface a warning: +# +# BAD1 = LIRE_MAILLAGE(FORMAT='ASCII') # 'ASCII' not in allowed values +# BAD2 = DEFI_MATERIAU(ELAS=_F(NU=0.3)) # missing required E +# BAD3 = MECA_STATIQUE() # missing required MODELE / CHAM_MATER +# FOO # hover a random word — no tooltip + +FIN() diff --git a/language-configuration.json b/language-configuration.json index c5cda9f..0b2ffdc 100644 --- a/language-configuration.json +++ b/language-configuration.json @@ -11,6 +11,8 @@ { "open": "(", "close": ")" }, { "open": "[", "close": "]" }, { "open": "{", "close": "}" }, - { "open": "\"", "close": "\"" } - ] + { "open": "\"", "close": "\"" }, + { "open": "'", "close": "'" } + ], + "wordPattern": "(-?\\d*\\.\\d\\w*)|([A-Za-z_][A-Za-z0-9_]*)" } diff --git a/package.json b/package.json index 8a429cd..9d04c43 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,14 @@ "title": "Restart the LSP server for code_aster", "icon": "$(sync~spin)" }, + { + "command": "vs-code-aster.showCatalogInfo", + "title": "Show code_aster catalog info" + }, + { + "command": "vs-code-aster.selectCaveVersion", + "title": "Select code_aster version (cave)" + }, { "command": "vs-code-aster.addToMedExtensions", "title": "Open as MED mesh", @@ -80,7 +88,7 @@ { "id": "comm", "aliases": [ - "Aster Commands" + "code_aster (comm)" ], "extensions": [ ".comm", @@ -105,7 +113,7 @@ { "id": "export", "aliases": [ - "Aster Export" + "code_aster (export)" ], "extensions": [ ".export" @@ -250,6 +258,18 @@ "default": "python3", "markdownDescription": "Specifies the python executable used to run the language server." }, + "vs-code-aster.formatter": { + "order": 2.6, + "type": "string", + "default": "ruff", + "markdownDescription": "Formatter for `.comm` files. Use `ruff` (default) to format via `python -m ruff format` using the interpreter at `#vs-code-aster.pythonExecutablePath#`, `off` to disable formatting, or any custom shell command (e.g. `black - -q`) that reads source on stdin and writes the formatted result to stdout." + }, + "vs-code-aster.asterCatalogPath": { + "order": 2.5, + "type": "string", + "default": "", + "markdownDescription": "Optional path to a local `code_aster` directory (must contain a `Cata/` subdirectory) whose catalog should be used by the language server. When empty, the extension auto-detects the cave-selected version and extracts the catalog from the corresponding Docker image; if that fails, it falls back to the bundled catalog." + }, "vs-code-aster.maxRunLogs": { "order": 3, "type": "integer", diff --git a/python/asterstudy/datamodel/catalogs.py b/python/asterstudy/datamodel/catalogs.py index e53a878..c13c8b6 100644 --- a/python/asterstudy/datamodel/catalogs.py +++ b/python/asterstudy/datamodel/catalogs.py @@ -122,10 +122,36 @@ def read_catalogs(self, version=None): if not version: version = 'stable' debug_message("Loading catalog for {0!r}".format(version)) - # A FAIRE MODIFIER, : changer le chemin - import pathlib as pl - version_path = pl.Path(__file__).parent.parent / "code_aster_version" / "code_aster" - debug_message("from path {0!r}".format(version_path)) + import pathlib as pl + vendored_path = pl.Path(__file__).parent.parent / "code_aster_version" / "code_aster" + import sys as _sys + def _clog(msg): + # Log to stderr only — stdout is the LSP JSON-RPC transport + # and any pollution breaks the protocol. + _sys.stderr.write("[catalog] " + msg + "\n") + _sys.stderr.flush() + + env_path = os.environ.get("VS_CODE_ASTER_CATA_PATH") + if env_path: + candidate = pl.Path(env_path) + if candidate.is_dir() and (candidate / "Cata").is_dir(): + version_path = candidate + source = "env" + # Make the extracted `code_aster` package importable by + # putting its PARENT on sys.path. Without this, the loader + # would silently fall back to the vendored catalog already + # on sys.path (added by python/lsp/__init__.py). + parent = str(candidate.parent) + if parent not in _sys.path: + _sys.path.insert(0, parent) + else: + _clog("VS_CODE_ASTER_CATA_PATH={0!r} is not a valid code_aster directory, falling back to vendored".format(env_path)) + version_path = vendored_path + source = "vendored (env invalid)" + else: + version_path = vendored_path + source = "vendored" + _clog("Loading catalog from {0} (source: {1})".format(version_path, source)) # Enable marker AsterStudySession.set_cata() diff --git a/python/lsp/managers/hover_manager.py b/python/lsp/managers/hover_manager.py index 02668bd..1af4501 100644 --- a/python/lsp/managers/hover_manager.py +++ b/python/lsp/managers/hover_manager.py @@ -1,43 +1,700 @@ """ -HoverManager: provides hover information for code_aster commands -Relies on CommandCore to get the document registry and CATA metadata +HoverManager — TypeScript-style hovers for code_aster commands. + +Everything interesting lives in a fenced ```python block so VS Code applies +the active theme's syntax highlighting (keywords, strings, numbers, +identifiers all get their theme colors). Supplementary text is kept as +short Markdown below the fence. + +Three cases, in order of preference: + 1. cursor on a command name → full signature of that command + 2. cursor inside a command, on one of its keywords → single-keyword detail + 3. cursor on any known command name elsewhere → full signature, no context """ +import os import re from command_core import CommandCore -from lsprotocol.types import ( - Hover, - MarkupContent, - MarkupKind, -) +from lsprotocol.types import Hover, MarkupContent, MarkupKind +try: + from asterstudy.datamodel.dict_categories import DEPRECATED as _DEPRECATED_LIST +except Exception: + _DEPRECATED_LIST = [] +_DEPRECATED = set(_DEPRECATED_LIST) -class HoverManager: - """ - Manager for providing hover info in a code_aster document. + +# Currently the Simvia doc site only hosts `v17`. Once more versions are +# published, switch back to deriving the major from `CATA.version_number`. +DOC_BASE_URL = "https://demo-docaster.simvia-app.fr/versions/v17/search.html?q={name}" + + +def _lang() -> str: + """Very light locale detection: returns 'fr' if the environment suggests + French, 'en' otherwise. VS Code forwards LANG/LC_ALL to the LSP process + (see `vs-code-aster.pythonExecutablePath` spawn env), so this is enough + to pick a UI-language without wiring LSP `initialize.locale` through + pygls.""" + for var in ("VSCODE_NLS_CONFIG", "LC_ALL", "LC_MESSAGES", "LANG"): + v = os.environ.get(var, "") + if not v: + continue + if v.lower().startswith("fr") or '"locale":"fr' in v.lower(): + return "fr" + if v.lower().startswith("en") or '"locale":"en' in v.lower(): + return "en" + return "en" + + +_I18N = { + "search_doc": { + "en": "Search documentation for `{name}`", + "fr": "Rechercher la documentation de `{name}`", + }, + "rules": {"en": "Rules", "fr": "Règles"}, + "status": {"en": "Status", "fr": "Statut"}, + "required": {"en": "required", "fr": "obligatoire"}, + "optional": {"en": "optional", "fr": "facultatif"}, + "optional_defaulted": {"en": "optional (defaulted)", "fr": "facultatif (par défaut)"}, + "cached": {"en": "cached", "fr": "en cache"}, + "allowed_values": {"en": "Allowed values", "fr": "Valeurs permises"}, + "range": {"en": "Range", "fr": "Plage"}, + "assigned_by": { + "en": "Assigned by `{cmd}` at line {line}", + "fr": "Assigné par `{cmd}` à la ligne {line}", + }, + "assigned_at_line": { + "en": "Assigned at line {line}", + "fr": "Assigné à la ligne {line}", + }, + "allowed_value": { + "en": "Allowed value for `{parent}.{kw}`", + "fr": "Valeur permise pour `{parent}.{kw}`", + }, + "factor_marker_desc": { + "en": "Factor keyword block — groups related sub-keywords as a single record.", + "fr": "Mot-clé facteur — regroupe des sous-mots-clés liés en un seul bloc.", + }, + "legacy": { + "en": "Legacy command — still supported.", + "fr": "Commande héritée — toujours supportée.", + }, +} + + +def _t(key: str, **kwargs) -> str: + entry = _I18N.get(key, {}) + return entry.get(_lang(), entry.get("en", key)).format(**kwargs) + + +def _udocstring(obj, key: str | None = None, translation: dict | None = None) -> str: + """Pick the best-available docstring, in order: + 1. the command-scoped `translation={...}` dict, if a matching entry exists + (this is how code_aster catalogs expose English short labels for + command names AND their keywords); + 2. the DSL's `ang=` argument, if declared; + 3. the default `fr=` docstring (exposed via `udocstring`). """ + if translation and key and key in translation: + v = translation[key] + if v: + return str(v).strip() + try: + ang = obj.definition.get("ang") if hasattr(obj, "definition") else None + except Exception: + ang = None + if ang: + return str(ang).strip() + return (getattr(obj, "udocstring", "") or "").strip() + + +def _translation_of(cmd_obj) -> dict: + try: + return cmd_obj.definition.get("translation") or {} + except Exception: + return {} + +_RULE_LABELS = { + "AtLeastOne": "At least one of: {args}", + "ExactlyOne": "Exactly one of: {args}", + "AtMostOne": "At most one of: {args}", + "IfFirstAllPresent": "If `{first}` is set, all of these must also be set: {rest}", + "OnlyFirstPresent": "If `{first}` is set, none of these may be set: {rest}", + "AllTogether": "Either all or none of: {args}", + "NotEmpty": "At least one keyword must be provided", + "AtMostOneStartsWith": "At most one keyword starting with: {args}", +} + + +def _rules_to_bullets(rules) -> list[str]: + bullets: list[str] = [] + for rule in rules or []: + cls = type(rule).__name__ + args = getattr(rule, "ruleArgs", ()) or () + joined = ", ".join(f"`{a}`" for a in args) if args else "" + first = f"`{args[0]}`" if args else "" + rest = ", ".join(f"`{a}`" for a in args[1:]) if len(args) > 1 else "" + template = _RULE_LABELS.get(cls, f"{cls}({{args}})") + try: + bullets.append(template.format(args=joined, first=first, rest=rest)) + except Exception: + bullets.append(f"{cls}: {joined}") + return bullets + + +def _doc_url(name: str) -> str | None: + return DOC_BASE_URL.format(name=name) + + +class HoverManager: def __init__(self): self.core = CommandCore() - def display(self, doc_uri, position) -> Hover | None: - """ - Return a Hover object for the given document URI and position. - """ + def display(self, doc_uri, position): doc = self.core.get_doc_from_uri(doc_uri) + if doc is None or position.line >= len(doc.lines): + return None line_text = doc.lines[position.line] - word = self.extract_word_at_position(line_text, position.character) + word = _word_at(line_text, position.character) + if not word: + return None + + registry = self.core.get_registry(doc_uri) + cmd_info = registry.get_command_at_line(position.line + 1) if registry else None + context = cmd_info.parsed_params if cmd_info else None + cata = self.core.get_CATA() + + # (3) `_F` factor marker — standalone card; cheap check first. + if word == "_F": + return _hover(_render_factor_marker()) + + if cmd_info and word == cmd_info.name: + cmd_obj = cata.get_command_obj(word) + return _hover(_render_command(cmd_obj, context)) if cmd_obj else None + + if cmd_info: + cmd_obj = cata.get_command_obj(cmd_info.name) + if cmd_obj is not None: + # (2) Allowed-value literal: cursor is on a word inside a + # `KEY=` assignment within this command call. Only fire + # when the word is actually listed in the keyword's `into`. + kw_name = _enclosing_keyword_name(line_text, position.character) + if kw_name: + target = _find_keyword(cmd_obj.definition, kw_name, context) + if target is not None: + into = target.definition.get("into") or () + if _matches_into_value(word, into): + return _hover(_render_allowed_value(word, kw_name, cmd_obj)) + + kwd = _find_keyword(cmd_obj.definition, word, context) + if kwd is not None: + return _hover(_render_keyword(word, kwd, cmd_obj)) + + cmd_obj = cata.get_command_obj(word) + if cmd_obj is not None: + return _hover(_render_command(cmd_obj, context=None)) + + # (1) Variable reference: match against the nearest preceding + # assignment whose `var_name` equals `word`. This fires last so a + # command name hover (e.g. `LIRE_MAILLAGE`) still wins if somehow + # reused as a variable. + if registry is not None: + assignment = _nearest_assignment(registry, word, position.line + 1) + if assignment is not None: + return _hover(_render_variable_reference(word, assignment, cata)) + + # (1b) Plain Python assignments (e.g. `TempRef = 20.0`) aren't + # tracked by CommandRegistry. Scan the doc for the nearest preceding + # line-level `WORD = ` and infer a simple Python type. + literal = _nearest_literal_assignment(doc.lines, word, position.line) + if literal is not None: + line_no, rhs = literal + return _hover(_render_literal_assignment(word, rhs, line_no)) + return None + + +def _hover(markdown: str): + if not markdown: + return None + return Hover(contents=MarkupContent(kind=MarkupKind.Markdown, value=markdown)) + + +def _escape_italic(text: str) -> str: + """Stop `*` inside the docstring from closing our italic wrap.""" + return text.replace("*", "\\*") + + +# ---------- variable-reference helper ------------------------------------- + + +def _nearest_assignment(registry, var_name: str, cursor_line: int): + """Walk all tracked commands; return the CommandInfo whose `var_name` + matches and whose assignment line is on or before the cursor. Prefers + the most recent prior assignment.""" + best = None + for info in registry.commands.values(): + if getattr(info, "var_name", None) != var_name: + continue + if info.start_line > cursor_line: + continue + if best is None or info.start_line > best.start_line: + best = info + return best + + +def _render_variable_reference(name: str, info, cata) -> str: + cmd_obj = cata.get_command_obj(info.name) if info.name else None + type_str = _return_type_hint(cmd_obj) if cmd_obj else None + header = f"{name}: {type_str}" if type_str else name + + out: list[str] = [] + out.append("```python") + out.append(header) + out.append("```") + out.append("") + out.append("*" + _escape_italic(_t("assigned_by", cmd=info.name, line=info.start_line)) + "*") + # Footer still points at the command that produced it. + if info.name: + _append_doc_link(out, info.name) + return "\n".join(out) + "\n" + + +# ---------- plain-literal assignment helpers ------------------------------ + +_LITERAL_ASSIGN_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$") + + +def _nearest_literal_assignment(lines, word: str, cursor_line_0: int): + """Walk the doc backwards from the cursor; return `(line_1based, rhs)` + of the nearest assignment `word = rhs` whose RHS is NOT a call of the + form `COMMAND(...)` (those are handled by CommandRegistry).""" + end = min(cursor_line_0, len(lines) - 1) + for i in range(end, -1, -1): + line = lines[i] + m = _LITERAL_ASSIGN_RE.match(line) + if not m: + continue + if m.group(1) != word: + continue + rhs = m.group(2) + # Skip assignments that are LIRE_MAILLAGE(…)-style — handled above. + if re.match(r"^[A-Z_][A-Z0-9_]*\s*\(", rhs): + continue + # Skip the line where the cursor is, since that's a definition not + # a reference — unless the hover is on the LHS we want to describe + # the value anyway; return it. + return i + 1, rhs + return None + + +def _infer_literal_type(rhs: str) -> str | None: + rhs = rhs.strip().rstrip(",").strip() + if not rhs: + return None + if rhs in ("None",): + return "None" + if rhs in ("True", "False"): + return "bool" + if rhs[0] in ('"', "'"): + return "str" + if rhs.startswith("("): + return "tuple" + if rhs.startswith("["): + return "list" + if rhs.startswith("{"): + return "dict" + if re.match(r"^-?\d+$", rhs): + return "int" + if ( + re.match(r"^-?\d+\.\d*([eE][+-]?\d+)?$", rhs) + or re.match(r"^-?\d*\.\d+([eE][+-]?\d+)?$", rhs) + or re.match(r"^-?\d+[eE][+-]?\d+$", rhs) + ): + return "float" + return None + + +def _render_literal_assignment(name: str, rhs: str, line_no: int) -> str: + type_str = _infer_literal_type(rhs) + # Show the actual RHS as the "value" so the hover is instantly useful + # even when type inference falls short. + rhs_display = rhs if len(rhs) <= 80 else rhs[:77] + "..." + out: list[str] = [] + out.append("```python") + if type_str: + out.append(f"{name}: {type_str} = {rhs_display}") + else: + out.append(f"{name} = {rhs_display}") + out.append("```") + out.append("") + out.append("*" + _escape_italic(_t("assigned_at_line", line=line_no)) + "*") + return "\n".join(out) + "\n" + + +# ---------- allowed-value helper ------------------------------------------ + +# Finds the last `KEY=` on the line before the cursor, tolerating quoted or +# unquoted values. The keyword name is the last CAPS-ish identifier that was +# an LHS of `=`. +_KW_ASSIGN_RE = re.compile(r"\b([A-Z_][A-Z0-9_]*)\s*=\s*['\"]?") - if word: - docstring = self.core.get_docstring(word) - if docstring: - return Hover(contents=MarkupContent(kind=MarkupKind.PlainText, value=docstring)) + +def _enclosing_keyword_name(line: str, char_pos: int) -> str | None: + m = None + for candidate in _KW_ASSIGN_RE.finditer(line): + if candidate.end() > char_pos: + break + m = candidate + return m.group(1) if m else None + + +def _matches_into_value(word: str, into) -> bool: + try: + for v in into: + if isinstance(v, str) and v == word: + return True + if isinstance(v, (int, float)) and str(v) == word: + return True + except Exception: + pass + return False + + +def _render_allowed_value(value: str, kw_name: str, cmd_obj) -> str: + translation = _translation_of(cmd_obj) + parent_name = cmd_obj.name + + out: list[str] = [] + out.append("```python") + # Show as a value assignment so the theme's string/number color applies. + # Strings are quoted; integers/floats render bare. + out.append(f"{kw_name} = {_format_value(value)}") + out.append("```") + out.append("") + out.append("*" + _escape_italic(_t("allowed_value", parent=parent_name, kw=kw_name)) + "*") + + # If the keyword has a docstring / label, include it for extra context. + kwd = _find_keyword(cmd_obj.definition, kw_name, context=None) + if kwd is not None: + doc = _udocstring(kwd, key=kw_name, translation=translation) + if doc: + out.append("") + out.append(f"*{_escape_italic(doc)}*") + + _append_doc_link(out, parent_name) + return "\n".join(out) + "\n" + + +# ---------- _F marker helper ---------------------------------------------- + + +def _render_factor_marker() -> str: + out: list[str] = [] + out.append("```python") + out.append("_F(...)") + out.append("```") + out.append("") + out.append(f"*{_escape_italic(_t('factor_marker_desc'))}*") + return "\n".join(out) + "\n" + + +def _word_at(line: str, char_pos: int) -> str: + for m in re.finditer(r"\b\w+\b", line): + if m.start() <= char_pos <= m.end(): + return m.group() + return "" + + +def _is_factor(kwd) -> bool: + return "FactorKeyword" in type(kwd).__name__ + + +def _is_bloc(kwd) -> bool: + return "Bloc" in type(kwd).__name__ + + +def _format_type(typ) -> str: + if isinstance(typ, str): + return {"R": "float", "I": "int", "TXM": "str", "C": "complex"}.get(typ, typ) + if isinstance(typ, type): + return typ.__name__ + if isinstance(typ, (tuple, list)) and typ and all(isinstance(t, type) for t in typ): + return " | ".join(t.__name__ for t in typ) + cls = typ.__class__.__name__ + if cls == "UnitType": + return "UnitLogique" + return str(typ) + + +def _format_value(v) -> str: + # Python-literal rendering so the theme's string / number / bool / None + # colors apply inside a `python` fence. + if v is None: + return "None" + if isinstance(v, bool): + return "True" if v else "False" + if isinstance(v, str): + return '"' + v.replace('"', '\\"') + '"' + if isinstance(v, (int, float)): + return repr(v) + if isinstance(v, (list, tuple)): + return "(" + ", ".join(_format_value(x) for x in v) + ("," if len(v) == 1 else "") + ")" + return repr(v) + + +def _find_keyword(definition, name: str, context): + """Depth-first search through FACTs and BLOCs. BLOCs disabled by context + are skipped so context-inactive keywords are not matched.""" + for key, kwd in definition.items(): + if not hasattr(kwd, "definition"): + continue + if _is_bloc(kwd): + if context is not None: + try: + if not kwd.isEnabled(context): + continue + except Exception: + pass + found = _find_keyword(kwd.definition, name, context) + if found is not None: + return found + continue + if key == name: + return kwd + if _is_factor(kwd): + found = _find_keyword(kwd.definition, name, context) + if found is not None: + return found + return None + + +def _render_command(cmd_obj, context) -> str: + name = cmd_obj.name + return_type = _return_type_hint(cmd_obj) + translation = _translation_of(cmd_obj) + + doc = _udocstring(cmd_obj, key=name, translation=translation) + + signature_lines: list[str] = [f"{name}("] + _append_signature( + cmd_obj.definition, + context, + signature_lines, + indent=1, + factors=[], + translation=translation, + ) + signature_lines.append(f") → {return_type}" if return_type else ")") + signature = "\n".join(signature_lines) + + out: list[str] = [] + # 1. Signature — the highlighted artifact the user came for. + out.append("```python") + out.append(signature) + out.append("```") + + # 2. Description — italic paragraph below the signature, like TS / Rust + # hover layouts. + if doc: + out.append("") + out.append(f"*{_escape_italic(doc)}*") + + # 2b. Legacy note — some commands are marked as such in the upstream + # catalog (DEBUT/FIN/INCLUDE/POURSUITE/DEFI_FICHIER are still required + # but categorised as legacy/boilerplate). Small italic line, no alarm. + if name in _DEPRECATED: + out.append("") + out.append(f"*{_escape_italic(_t('legacy'))}*") + + # 3. Details — rules (if any). + rules = _rules_to_bullets(getattr(cmd_obj, "_rules", None)) + if rules: + out.append("") + out.append(f"**{_t('rules')}**") + for r in rules: + out.append(f"- {r}") + + # 4. Footer — doc link with a separator. + _append_doc_link(out, name) + return "\n".join(out) + "\n" + + +def _append_doc_link(out: list[str], command_name: str) -> None: + """Always the last block. Preceded by a horizontal rule so it reads as a + footer separate from the content above it.""" + url = _doc_url(command_name) + if not url: + return + out.append("") + out.append("---") + out.append(f"[📘 {_t('search_doc', name=command_name)}]({url})") + + +def _return_type_hint(cmd_obj) -> str | None: + sd_prod = cmd_obj.definition.get("sd_prod") if hasattr(cmd_obj, "definition") else None + if sd_prod is None: return None + if isinstance(sd_prod, type): + return sd_prod.__name__ + # sd_prod may be a callable returning the type dynamically; we can't + # safely evaluate it here, so report its name if introspectable. + return getattr(sd_prod, "__name__", None) + + +def _append_signature( + definition, + context, + out: list[str], + indent: int, + factors: list | None = None, + translation: dict | None = None, +) -> None: + """Render a command's keyword tree as Python-like lines inside a fence. + + - SIMP keywords become `NAME: type = default,` or `NAME: type,` if no default. + - FACTs: + * at the top level (factors list supplied) → rendered as a one-line + `NAME=_F(...),` placeholder and the real body is appended to + `factors` for later
rendering. + * nested (factors=None) → expanded inline with sub-keywords indented. + - BLOCs: + * with a known context → flatten active branches at the same indent; + * without a context → prefix children with `# when ` so + the fence still highlights the keyword names underneath. + """ + pad = " " * indent + for key, kwd in definition.items(): + if not hasattr(kwd, "definition"): + continue + + if _is_bloc(kwd): + is_enabled = None + if context is not None: + try: + is_enabled = bool(kwd.isEnabled(context)) + except Exception: + is_enabled = None + if is_enabled is False: + continue + if is_enabled is None and context is None: + cond = _clean_condition(kwd.getCondition()) + out.append(f"{pad}# when {cond}") + _append_signature( + kwd.definition, + None if is_enabled is None else context, + out, + indent, + factors, + translation, + ) + continue + + if _is_factor(kwd): + if factors is not None: + out.append(f"{pad}{key}=_F(...),") + factors.append((key, kwd)) + else: + out.append(f"{pad}{key}=_F(") + _append_signature(kwd.definition, context, out, indent + 1, None, translation) + out.append(f"{pad}),") + continue + + type_str = _format_type(kwd.definition.get("typ", "?")) + statut = kwd.definition.get("statut", "?") + required = statut == "o" + has_default = "defaut" in kwd.definition and kwd.definition["defaut"] is not None + + # Python convention: "required" = no default, "optional" = has default. + # For code_aster keywords that are optional without a declared default, + # we use Python's ellipsis literal `...` as a conventional "unspecified" + # sentinel, which keeps the fence valid Python. + if required: + line = f"{pad}{key}: {type_str}," + elif has_default: + default = _format_value(kwd.definition["defaut"]) + line = f"{pad}{key}: {type_str} = {default}," + else: + line = f"{pad}{key}: {type_str} = ...," + + out.append(line) + + +def _render_keyword(name: str, kwd, parent_cmd) -> str: + parent_name = parent_cmd.name if hasattr(parent_cmd, "name") else str(parent_cmd) + translation = _translation_of(parent_cmd) if hasattr(parent_cmd, "definition") else {} + + doc = _udocstring(kwd, key=name, translation=translation) + + out: list[str] = [] + # 1. Signature — the main readout. + out.append("```python") + if _is_factor(kwd): + out.append(f"{name}=_F(") + _append_signature(kwd.definition, context=None, out=out, indent=1, translation=translation) + out.append(")") + else: + type_str = _format_type(kwd.definition.get("typ", "?")) + statut = kwd.definition.get("statut", "?") + required = statut == "o" + has_default = "defaut" in kwd.definition and kwd.definition["defaut"] is not None + if required: + out.append(f"{name}: {type_str}") + elif has_default: + out.append(f"{name}: {type_str} = {_format_value(kwd.definition['defaut'])}") + else: + out.append(f"{name}: {type_str} = ...") + out.append("```") + + # 2. Description. + if doc: + out.append("") + out.append(f"*{_escape_italic(doc)}*") + + # 3. Details — status / allowed / range / rules collapsed into one block. + details: list[str] = [] + status = kwd.definition.get("statut", "?") + details.append(f"- {_t('status')}: **{_status_label(status)}**") + into = kwd.definition.get("into") + if into: + literals = " | ".join(_format_value(v) for v in into) + details.append(f"- {_t('allowed_values')}:") + details.append("") + details.append(" ```python") + details.append(f" {literals}") + details.append(" ```") + val_min = kwd.definition.get("val_min") + val_max = kwd.definition.get("val_max") + if val_min is not None or val_max is not None: + lo = val_min if val_min is not None else "−∞" + hi = val_max if val_max is not None else "+∞" + details.append(f"- {_t('range')}: `[{lo}, {hi}]`") + if details: + out.append("") + out.extend(details) + + rules = _rules_to_bullets(getattr(kwd, "_rules", None)) + if rules: + out.append("") + out.append(f"**{_t('rules')}**") + for r in rules: + out.append(f"- {r}") + + # 4. Footer — doc link with a separator. + _append_doc_link(out, parent_name) + return "\n".join(out) + "\n" + + +def _status_label(statut: str) -> str: + mapping = { + "o": _t("required"), + "f": _t("optional"), + "d": _t("optional_defaulted"), + "c": _t("cached"), + } + return mapping.get(statut, statut) + - def extract_word_at_position(self, line: str, char_pos: int) -> str: - matches = list(re.finditer(r"\b\w+\b", line)) - for match in matches: - if match.start() <= char_pos <= match.end(): - return match.group() - return "" +def _clean_condition(cond: str) -> str: + return re.sub(r"\s+", " ", (cond or "").strip()) diff --git a/src/CatalogResolver.ts b/src/CatalogResolver.ts new file mode 100644 index 0000000..773da32 --- /dev/null +++ b/src/CatalogResolver.ts @@ -0,0 +1,327 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; + +const LOG_PREFIX = '[catalog]'; +const IMAGE_PREFIX = 'simvia/code_aster'; + +export type CatalogSource = 'user-setting' | 'cave' | 'vendored'; + +export interface ResolvedCatalog { + path: string | null; + source: CatalogSource; + version: string | null; + reason?: string; +} + +let channel: vscode.OutputChannel | undefined; + +function log(line: string) { + if (!channel) { + channel = vscode.window.createOutputChannel('VS Code Aster — Catalog'); + } + const stamp = new Date().toISOString().slice(11, 23); + channel.appendLine(`${stamp} ${LOG_PREFIX} ${line}`); +} + +export function getCatalogChannel(): vscode.OutputChannel { + if (!channel) { + channel = vscode.window.createOutputChannel('VS Code Aster — Catalog'); + } + return channel; +} + +export function cacheRoot(): string { + return path.join(os.homedir(), '.cache', 'vs-code-aster', 'catalogs'); +} + +export function caveFilePath(): string { + return path.join(os.homedir(), '.cave'); +} + +export function getSelectedCaveVersion(): string | null { + try { + const raw = fs.readFileSync(caveFilePath(), 'utf8').trim(); + return raw || null; + } catch { + return null; + } +} + +let bundledVersionCache: string | null | undefined; + +/** + * Read the vendored catalog's version string (e.g. "16.7") from + * `python/asterstudy/code_aster_version/code_aster/Cata/aster_version.py`. + * Extension context is needed because the path is relative to the installed + * extension directory. Result is cached — the file is shipped with the + * extension and doesn't change at runtime. + */ +export function getBundledVersion(context: vscode.ExtensionContext): string | null { + if (bundledVersionCache !== undefined) { + return bundledVersionCache; + } + try { + const file = context.asAbsolutePath( + path.join( + 'python', + 'asterstudy', + 'code_aster_version', + 'code_aster', + 'Cata', + 'aster_version.py' + ) + ); + const txt = fs.readFileSync(file, 'utf8'); + const m = txt.match(/\*\[\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/); + if (!m) { + bundledVersionCache = null; + } else { + const [maj, min, patch] = [m[1], m[2], m[3]]; + bundledVersionCache = patch === '0' ? `${maj}.${min}` : `${maj}.${min}.${patch}`; + } + } catch { + bundledVersionCache = null; + } + return bundledVersionCache; +} + +function run( + cmd: string, + args: string[], + timeoutMs = 60_000 +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => { + clearTimeout(timer); + resolve({ code: -1, stdout, stderr: stderr + String(err) }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +async function dockerAvailable(): Promise { + const r = await run('docker', ['version', '--format', '{{.Server.Version}}'], 5_000); + return r.code === 0; +} + +async function imagePresent(image: string): Promise { + const r = await run('docker', ['image', 'inspect', image], 10_000); + return r.code === 0; +} + +async function findCataParentInImage(image: string): Promise { + log(`searching Cata directory inside ${image}…`); + const r = await run( + 'docker', + [ + 'run', + '--rm', + '--entrypoint', + 'sh', + image, + '-c', + 'find / -name Cata -type d 2>/dev/null; true', + ], + 30_000 + ); + // `find` often exits 1 on permission-denied entries even with stderr + // redirected, so rely on stdout rather than the exit code. + const lines = r.stdout + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length === 0) { + log(`find produced no output (exit ${r.code}): ${r.stderr.trim().slice(0, 200)}`); + return null; + } + const preferred = lines.find((l) => l.includes('.spack-env/._view')) ?? lines[0]; + if (!preferred) { + log('no Cata directory found in image'); + return null; + } + const parent = path.dirname(preferred); + log(`found Cata parent = ${parent}`); + return parent; +} + +export async function ensureCatalogExtracted(version: string): Promise { + const versionCache = path.join(cacheRoot(), version); + const target = path.join(versionCache, 'code_aster'); + if (fs.existsSync(path.join(target, 'Cata'))) { + log(`cache hit for ${version} → ${target}`); + return target; + } + + log(`cache miss for ${version}`); + if (!(await dockerAvailable())) { + log('docker is not available'); + return null; + } + const image = `${IMAGE_PREFIX}:${version}`; + if (!(await imagePresent(image))) { + log(`image ${image} not present locally (skipping auto-pull)`); + return null; + } + + const parent = await findCataParentInImage(image); + if (!parent) { + return null; + } + + fs.mkdirSync(versionCache, { recursive: true }); + + const statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + statusItem.text = `$(sync~spin) Extracting code_aster catalog (${version})…`; + statusItem.show(); + + const t0 = Date.now(); + log(`extracting ${parent} from ${image} → ${versionCache}`); + const r = await run( + 'docker', + [ + 'run', + '--rm', + '--entrypoint', + 'sh', + '-v', + `${versionCache}:/out`, + image, + '-c', + `cp -rL '${parent}' /out/code_aster && chown -R $(id -u):$(id -g) /out/code_aster 2>/dev/null || true`, + ], + 120_000 + ); + statusItem.dispose(); + const dt = ((Date.now() - t0) / 1000).toFixed(1); + log(`docker run exit ${r.code} in ${dt}s`); + if (r.code !== 0) { + log(`extraction stderr: ${r.stderr.trim().slice(0, 400)}`); + return null; + } + if (!fs.existsSync(path.join(target, 'Cata'))) { + log(`extraction produced no Cata dir at ${target}`); + return null; + } + log(`extracted to ${target}`); + return target; +} + +export async function resolveCatalogPath(): Promise { + log('resolveCatalogPath(): start'); + + const userSetting = vscode.workspace + .getConfiguration('vs-code-aster') + .get('asterCatalogPath', '') + .trim(); + if (userSetting) { + log(`user setting = ${userSetting}`); + if (fs.existsSync(path.join(userSetting, 'Cata'))) { + log(`resolved via user-setting → ${userSetting}`); + return { path: userSetting, source: 'user-setting', version: null }; + } + log(`user setting path invalid (no Cata/ subdir), ignoring`); + } else { + log('user setting not set'); + } + + const version = getSelectedCaveVersion(); + if (!version) { + log('~/.cave missing or empty'); + notifyFallback('no cave version selected'); + return { path: null, source: 'vendored', version: null, reason: 'no cave version' }; + } + log(`~/.cave → ${version}`); + + const extracted = await ensureCatalogExtracted(version); + if (extracted) { + log(`resolved via cave → ${extracted}`); + return { path: extracted, source: 'cave', version }; + } + + notifyFallback(`extraction failed for ${version}`); + return { path: null, source: 'vendored', version, reason: 'extraction failed' }; +} + +function notifyFallback(reason: string) { + // Status-bar item already signals the fallback state (warning icon + + // background + explanatory tooltip). Just log; no toast. + log(`falling back to vendored catalog (${reason})`); +} + +export async function getCatalogInfo(): Promise { + const resolved = await resolveCatalogPath(); + const lines = [ + `Source: ${resolved.source}`, + `Version: ${resolved.version ?? '(unknown)'}`, + `Path: ${resolved.path ?? '(vendored fallback)'}`, + ]; + if (resolved.reason) { + lines.push(`Reason: ${resolved.reason}`); + } + try { + const root = cacheRoot(); + if (fs.existsSync(root)) { + const versions = fs.readdirSync(root); + lines.push( + `Cache: ${root} (${versions.length} version(s): ${versions.join(', ') || 'none'})` + ); + } else { + lines.push(`Cache: ${root} (empty)`); + } + } catch { + /* ignore */ + } + return lines.join('\n'); +} + +export function clearCatalogCache(): void { + const root = cacheRoot(); + if (fs.existsSync(root)) { + fs.rmSync(root, { recursive: true, force: true }); + log(`cache cleared: ${root}`); + } +} + +export function clearCatalogCacheFor(version: string): void { + const dir = path.join(cacheRoot(), version); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + log(`cache cleared for ${version}: ${dir}`); + } +} + +/** + * Remove any catalog cache directory whose version does not appear in the + * supplied list of currently-installed versions. Called at activation to + * recover from images removed externally (e.g. `docker rmi` from a terminal). + */ +export function reconcileCatalogCache(installedVersions: string[]): string[] { + const root = cacheRoot(); + if (!fs.existsSync(root)) { + return []; + } + const installed = new Set(installedVersions); + const removed: string[] = []; + for (const entry of fs.readdirSync(root)) { + if (!installed.has(entry)) { + fs.rmSync(path.join(root, entry), { recursive: true, force: true }); + removed.push(entry); + } + } + if (removed.length) { + log(`reconcile removed orphan caches: ${removed.join(', ')}`); + } + return removed; +} diff --git a/src/CaveStatusBar.ts b/src/CaveStatusBar.ts new file mode 100644 index 0000000..3416dd3 --- /dev/null +++ b/src/CaveStatusBar.ts @@ -0,0 +1,519 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { spawn } from 'child_process'; +import { + caveFilePath, + clearCatalogCacheFor, + getBundledVersion, + getSelectedCaveVersion, + reconcileCatalogCache, +} from './CatalogResolver'; +import { LspServer } from './LspServer'; + +const COMMAND_ID = 'vs-code-aster.selectCaveVersion'; + +function exec( + cmd: string, + args: string[], + timeoutMs = 5_000 +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => { + clearTimeout(timer); + resolve({ code: -1, stdout, stderr: stderr + String(err) }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +// List installed versions by querying docker directly (~20ms) instead of +// `cave list` (~100ms + update-check latency on cold runs). `cave` itself +// is just a wrapper over `docker images` for listing. +// Compare version tags like "17.4.0" / "16.9.0" — split on non-digits and +// compare segment-wise numerically. Returns >0 if b > a (so Array.sort puts +// the newest first). +function compareVersionsDesc(a: string, b: string): number { + const pa = a + .split(/[^0-9]+/) + .filter(Boolean) + .map(Number); + const pb = b + .split(/[^0-9]+/) + .filter(Boolean) + .map(Number); + const n = Math.max(pa.length, pb.length); + for (let i = 0; i < n; i++) { + const ai = pa[i] ?? 0; + const bi = pb[i] ?? 0; + if (ai !== bi) { + return bi - ai; + } + } + return b.localeCompare(a); +} + +async function listInstalledVersions(): Promise { + const r = await exec('docker', ['images', '--format', '{{.Tag}}', 'simvia/code_aster']); + if (r.code !== 0) { + return []; + } + return Array.from( + new Set( + r.stdout + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s && s !== '') + ) + ).sort(compareVersionsDesc); +} + +interface AvailableVersion { + tag: string; + date: string; +} + +async function listAvailableVersions(): Promise { + const r = await exec('cave', ['available'], 15_000); + if (r.code !== 0) { + return []; + } + const out: AvailableVersion[] = []; + for (const line of r.stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || /^Tag\s+Date/i.test(trimmed)) { + continue; + } + const m = trimmed.match(/^(\S+)\s+(.*)$/); + if (m) { + out.push({ tag: m[1], date: m[2].trim() }); + } + } + return out.sort((a, b) => compareVersionsDesc(a.tag, b.tag)); +} + +export class CaveStatusBar { + private static _instance: CaveStatusBar | null = null; + private item: vscode.StatusBarItem; + private disposables: vscode.Disposable[] = []; + private caveWatcher?: fs.FSWatcher; + private caveDebounce?: NodeJS.Timeout; + private cachedVersions: string[] = []; + private versionsRefreshing: Promise | null = null; + private context?: vscode.ExtensionContext; + + private constructor() { + this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50); + this.item.command = COMMAND_ID; + } + + public static get instance(): CaveStatusBar { + if (!CaveStatusBar._instance) { + CaveStatusBar._instance = new CaveStatusBar(); + } + return CaveStatusBar._instance; + } + + public activate(context: vscode.ExtensionContext) { + this.context = context; + context.subscriptions.push( + vscode.commands.registerCommand(COMMAND_ID, () => this.pickVersion()) + ); + + this.disposables.push( + vscode.window.onDidChangeActiveTextEditor((editor) => this.refresh(editor)) + ); + this.watchCave(); + + this.refresh(vscode.window.activeTextEditor); + // Pre-warm the cache so the first click is instant; also re-refresh the + // status bar label once we know the true set of installed versions, so + // a stale ~/.cave doesn't linger as the displayed version. + void this.refreshVersions().then(() => this.refresh(vscode.window.activeTextEditor)); + + context.subscriptions.push(this.item, ...this.disposables); + } + + private refreshVersions(): Promise { + if (this.versionsRefreshing) { + return this.versionsRefreshing; + } + this.versionsRefreshing = listInstalledVersions() + .then((v) => { + this.cachedVersions = v; + }) + .finally(() => { + this.versionsRefreshing = null; + }); + return this.versionsRefreshing; + } + + private static readonly ASTER_LANGS = new Set(['comm', 'export']); + + private refresh(editor: vscode.TextEditor | undefined) { + if (!editor || !CaveStatusBar.ASTER_LANGS.has(editor.document.languageId)) { + this.item.hide(); + return; + } + // Re-query docker in the background so externally-removed images (e.g. + // `docker rmi simvia/code_aster:17.4.0` run from a terminal) are + // reflected in the label. `refreshVersions` is dedup'd internally, so + // rapid editor changes won't cause a storm. + void this.refreshVersions().then(() => this.renderLabel()); + this.renderLabel(); + } + + private renderLabel() { + const selected = getSelectedCaveVersion(); + // `~/.cave` can point at a version whose image has since been removed + // (either by us via the trash button, or manually via `docker rmi`). + // Only use it when the image actually exists on this host. + const isInstalled = selected !== null && this.cachedVersions.includes(selected); + if (selected && isInstalled) { + this.item.text = selected; + this.item.tooltip = new vscode.MarkdownString( + `Using **code_aster ${selected}** via cave.\n\n` + + `Click to switch version, install a new one, or remove this one.` + ); + this.item.backgroundColor = undefined; + } else { + const bundled = this.context ? getBundledVersion(this.context) : null; + const label = bundled ? `${bundled} (bundled)` : 'bundled'; + this.item.text = `$(warning) ${label}`; + this.item.tooltip = new vscode.MarkdownString( + `**No cave-installed code_aster version selected.**\n\n` + + `Language features (completion, hover, signatures) are served from ` + + `the **bundled ${bundled ?? '?'} catalog**, which is enough for ` + + `editing but **\`cave run\` will not work** until you install and ` + + `select a version.\n\n` + + `Click to install or select a version.` + ); + this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + } + this.item.show(); + } + + private watchCave() { + try { + this.caveWatcher = fs.watch(caveFilePath(), () => { + if (this.caveDebounce) { + clearTimeout(this.caveDebounce); + } + this.caveDebounce = setTimeout(() => this.refresh(vscode.window.activeTextEditor), 300); + }); + } catch { + /* ~/.cave not present yet */ + } + } + + private static readonly REMOVE_BUTTON: vscode.QuickInputButton = { + iconPath: new vscode.ThemeIcon('trash'), + tooltip: 'Remove this version (docker rmi)', + }; + + // Identity-based sentinel so we can tell the "install" row apart from real + // version rows without relying on label string comparison (codicon prefixes + // can trip that up). + private installItem: (vscode.QuickPickItem & { isInstall: true }) | null = null; + + private buildItems(versions: string[], current: string | null): vscode.QuickPickItem[] { + this.installItem = { + label: '$(cloud-download) Install a new version…', + detail: versions.length === 0 ? 'No versions installed yet.' : undefined, + alwaysShow: true, + isInstall: true, + }; + if (versions.length === 0) { + return [this.installItem]; + } + const separator: vscode.QuickPickItem = { + label: 'Installed', + kind: vscode.QuickPickItemKind.Separator, + }; + const items: vscode.QuickPickItem[] = versions.map((v) => ({ + label: v, + description: v === current ? '(current)' : undefined, + picked: v === current, + buttons: [CaveStatusBar.REMOVE_BUTTON], + })); + return [this.installItem, separator, ...items]; + } + + private async pickVersion() { + const current = getSelectedCaveVersion(); + const qp = vscode.window.createQuickPick(); + qp.placeholder = 'Select code_aster version'; + qp.matchOnDescription = true; + qp.items = this.buildItems(this.cachedVersions, current); + qp.busy = this.cachedVersions.length === 0; + qp.show(); + + void this.refreshVersions().then(() => { + qp.items = this.buildItems(this.cachedVersions, current); + qp.busy = false; + }); + + const action = await new Promise< + { kind: 'pick'; item: vscode.QuickPickItem } | { kind: 'remove'; tag: string } | undefined + >((resolve) => { + qp.onDidAccept(() => + resolve({ kind: 'pick', item: qp.selectedItems[0] ?? qp.activeItems[0] }) + ); + qp.onDidTriggerItemButton((e) => { + if (e.button === CaveStatusBar.REMOVE_BUTTON) { + resolve({ kind: 'remove', tag: e.item.label }); + } + }); + qp.onDidHide(() => resolve(undefined)); + }); + qp.dispose(); + + if (!action) { + return; + } + if (action.kind === 'remove') { + await this.removeVersion(action.tag, current); + return; + } + const picked = action.item; + if (!picked) { + return; + } + if ((picked as { isInstall?: boolean }).isInstall) { + await this.installNewVersion(current); + return; + } + if (picked.label === current) { + return; + } + + const r = await exec('cave', ['use', picked.label], 10_000); + if (r.code !== 0) { + vscode.window.showErrorMessage( + `cave use ${picked.label} failed: ${r.stderr.trim() || r.stdout.trim()}` + ); + return; + } + this.refresh(vscode.window.activeTextEditor); + void LspServer.instance.restart(); + vscode.window.showInformationMessage(`code_aster version set to ${picked.label}.`); + } + + private async removeVersion(tag: string, current: string | null) { + const warnCurrent = tag === current ? '\n\nThis is the currently selected version.' : ''; + const savedTooltip = this.item.tooltip; + // Mouse is still over the status-bar item after the click — clear the + // tooltip so it doesn't cover the confirmation modal / info toast. + this.item.tooltip = undefined; + const ok = await vscode.window.showWarningMessage( + `Remove code_aster ${tag}? This runs \`docker rmi simvia/code_aster:${tag}\` and cannot be undone.${warnCurrent}`, + { modal: true }, + 'Remove' + ); + if (ok !== 'Remove') { + this.item.tooltip = savedTooltip; + return; + } + const r = await exec('docker', ['rmi', `simvia/code_aster:${tag}`], 30_000); + if (r.code !== 0) { + vscode.window.showErrorMessage( + `docker rmi simvia/code_aster:${tag} failed: ${r.stderr.trim() || r.stdout.trim()}` + ); + this.item.tooltip = savedTooltip; + return; + } + clearCatalogCacheFor(tag); + vscode.window.showInformationMessage(`code_aster ${tag} removed.`); + this.cachedVersions = this.cachedVersions.filter((v) => v !== tag); + this.refresh(vscode.window.activeTextEditor); + void this.refreshVersions().then(() => { + this.refresh(vscode.window.activeTextEditor); + // If we just removed the version the LSP is currently backed by, the + // running server is still serving a catalog from a now-deleted cache + // dir. Restart so it re-resolves (will fall back to bundled or to + // whatever ~/.cave now points at). + if (tag === current) { + void LspServer.instance.restart(); + } + }); + } + + private async installNewVersion(rawCurrent: string | null) { + // Treat a selection pointing at a missing image as "nothing selected" so + // the user can re-install that exact version without hitting the + // already-current short-circuit below. + const current = rawCurrent && this.cachedVersions.includes(rawCurrent) ? rawCurrent : null; + const qp = vscode.window.createQuickPick(); + qp.placeholder = 'Install a code_aster version (fetching from DockerHub…)'; + qp.matchOnDescription = true; + qp.busy = true; + qp.show(); + + const [available] = await Promise.all([listAvailableVersions()]); + const installed = new Set(this.cachedVersions); + // Split into two groups: already-installed (shown first, as a no-op + // in practice — picking just switches to that version) and available + // for download. Grouping with separators avoids per-item markers that + // only show on hover. + const installedItems: vscode.QuickPickItem[] = []; + const availableItems: vscode.QuickPickItem[] = []; + for (const v of available) { + const item: vscode.QuickPickItem = { label: v.tag, description: v.date }; + if (installed.has(v.tag)) { + installedItems.push(item); + } else { + availableItems.push(item); + } + } + const grouped: vscode.QuickPickItem[] = []; + if (installedItems.length) { + grouped.push({ label: 'Installed', kind: vscode.QuickPickItemKind.Separator }); + grouped.push(...installedItems); + } + if (availableItems.length) { + grouped.push({ label: 'Available', kind: vscode.QuickPickItemKind.Separator }); + grouped.push(...availableItems); + } + qp.items = grouped; + qp.busy = false; + + if (grouped.length === 0) { + qp.hide(); + qp.dispose(); + vscode.window.showWarningMessage( + '`cave available` returned no versions. Check your network or that cave is on PATH.' + ); + return; + } + + const picked = await new Promise((resolve) => { + qp.onDidAccept(() => resolve(qp.selectedItems[0])); + qp.onDidHide(() => resolve(undefined)); + }); + qp.dispose(); + if (!picked || picked.label === current) { + return; + } + if (installed.has(picked.label)) { + // Already installed — no need to re-pull, just switch. + const r = await exec('cave', ['use', picked.label], 10_000); + if (r.code !== 0) { + vscode.window.showErrorMessage( + `cave use ${picked.label} failed: ${r.stderr.trim() || r.stdout.trim()}` + ); + return; + } + void LspServer.instance.restart(); + return; + } + + await this.installVersionWithProgress(picked.label); + } + + private installVersionWithProgress(version: string): Thenable { + // VS Code shows the status-bar tooltip whenever the mouse is over the + // item — which is exactly where the user's cursor is right after they + // clicked it. That tooltip would then cover the install progress + // notification. Clear it for the duration of the install; the refresh() + // call at the end restores it from the real state. + const savedTooltip = this.item.tooltip; + this.item.tooltip = undefined; + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Installing code_aster ${version}`, + cancellable: true, + }, + (progress, token) => + new Promise((resolve) => { + const child = spawn('cave', ['use', version], { stdio: ['pipe', 'pipe', 'pipe'] }); + // Auto-confirm the "Download it? (y/n)" prompt. + child.stdin.write('y\n'); + child.stdin.end(); + + let currentPhase = 'Starting…'; + progress.report({ message: currentPhase }); + + const classifyPhase = (line: string): string | null => { + // Map raw docker / cave output to a short, stable phase label so + // the notification doesn't flicker through every progress line. + if (/Downloading/i.test(line)) { + return 'Downloading image…'; + } + if (/Extracting|Verifying Checksum/i.test(line)) { + return 'Extracting…'; + } + if (/Pull complete|Digest:|Status: (Downloaded|Image is up to date)/i.test(line)) { + return 'Finalizing…'; + } + if (/Pulling (from|fs layer)/i.test(line)) { + return 'Pulling image…'; + } + return null; + }; + + const handleStream = (chunk: Buffer) => { + const text = chunk.toString(); + const fragments = text + .split(/[\r\n]+/) + .map((s) => s.trim()) + .filter(Boolean); + for (const line of fragments) { + const phase = classifyPhase(line); + if (phase && phase !== currentPhase) { + currentPhase = phase; + progress.report({ message: currentPhase }); + } + } + }; + child.stdout.on('data', handleStream); + child.stderr.on('data', handleStream); + + token.onCancellationRequested(() => { + child.kill('SIGTERM'); + }); + + child.on('error', (err) => { + vscode.window.showErrorMessage(`cave install failed: ${err.message}`); + resolve(); + }); + child.on('close', (code) => { + if (token.isCancellationRequested) { + vscode.window.showWarningMessage( + `Installation of code_aster ${version} was cancelled.` + ); + } else if (code !== 0) { + vscode.window.showErrorMessage( + `cave use ${version} exited with code ${code} during "${currentPhase}".` + ); + } else { + vscode.window.showInformationMessage(`code_aster ${version} installed.`); + // Invalidate cached versions so the next picker shows it as installed. + void this.refreshVersions(); + // Our ~/.cave watcher already triggers an LSP restart. + } + resolve(); + }); + }).finally(() => { + // Restore tooltip. A full refresh() rebuilds it from current state + // (may have changed if the selected version is now installed). + this.item.tooltip = savedTooltip; + this.refresh(vscode.window.activeTextEditor); + }) + ); + } + + public dispose() { + this.item.dispose(); + this.disposables.forEach((d) => d.dispose()); + this.caveWatcher?.close(); + } +} diff --git a/src/CommFormatter.ts b/src/CommFormatter.ts new file mode 100644 index 0000000..129d429 --- /dev/null +++ b/src/CommFormatter.ts @@ -0,0 +1,304 @@ +import * as vscode from 'vscode'; +import { spawn } from 'child_process'; + +const CHANNEL_NAME = 'VS Code Aster — Formatter'; +let channel: vscode.OutputChannel | undefined; +let missingToolWarned = false; +let ruffPromptShown = false; +let ruffProbeResult: boolean | null = null; + +function log(line: string) { + if (!channel) { + channel = vscode.window.createOutputChannel(CHANNEL_NAME); + } + const stamp = new Date().toISOString().slice(11, 23); + channel.appendLine(`${stamp} ${line}`); +} + +function pythonExecutable(): string { + return ( + vscode.workspace.getConfiguration('vs-code-aster').get('pythonExecutablePath') || + 'python3' + ); +} + +function runOnce( + cmd: string, + args: string[], + timeoutMs = 10_000 +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => { + clearTimeout(timer); + resolve({ code: -1, stdout, stderr: stderr + String(err) }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +async function ruffIsInstalled(): Promise { + if (ruffProbeResult !== null) { + return ruffProbeResult; + } + const r = await runOnce(pythonExecutable(), ['-m', 'ruff', '--version'], 5_000); + ruffProbeResult = r.code === 0; + log(`ruff probe: ${ruffProbeResult ? 'present' : `missing (${r.stderr.trim().slice(0, 120)})`}`); + return ruffProbeResult; +} + +/** + * If the formatter is set to `ruff` and ruff isn't installed in the + * configured Python, offer to install it. Called when the first `.comm` + * file is opened. Dedup'd to run at most once per session. + */ +export async function offerInstallRuff(context: vscode.ExtensionContext) { + if (ruffPromptShown) { + return; + } + const config = vscode.workspace.getConfiguration('vs-code-aster'); + const formatter = (config.get('formatter') || 'ruff').trim(); + if (formatter !== 'ruff') { + return; + } + // Respect "don't ask again" stored decision. + if (context.globalState.get('formatter.ruffDeclined')) { + return; + } + if (await ruffIsInstalled()) { + return; + } + ruffPromptShown = true; + + const choice = await vscode.window.showInformationMessage( + 'Install `ruff` to enable code_aster file formatting? It will be installed into the Python used by the extension (' + + pythonExecutable() + + ').', + 'Install', + 'Not now', + "Don't ask again" + ); + + if (choice === "Don't ask again") { + await context.globalState.update('formatter.ruffDeclined', true); + return; + } + if (choice !== 'Install') { + return; + } + await installRuff(); +} + +async function installRuff(): Promise { + const python = pythonExecutable(); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Installing ruff…', + cancellable: false, + }, + async (progress) => { + // Let pip decide where to install: inside a venv it goes into the venv; + // a system Python will fall back to site-packages (and on PEP 668 + // systems we catch the failure below). Passing `--user` unconditionally + // breaks venvs ("User site-packages are not visible in this virtualenv"). + progress.report({ message: `${python} -m pip install ruff` }); + log(`$ ${python} -m pip install ruff`); + let r = await runOnce(python, ['-m', 'pip', 'install', 'ruff'], 120_000); + // PEP 668 / "externally-managed-environment" → retry with --user which + // is the documented escape hatch for that case. + if (r.code !== 0 && /externally[- ]managed/i.test(r.stderr)) { + log('retrying with --user (externally-managed environment)'); + r = await runOnce(python, ['-m', 'pip', 'install', '--user', 'ruff'], 120_000); + } + if (r.code !== 0) { + log(`pip install failed (${r.code}): ${r.stderr.trim().slice(0, 500)}`); + const choice = await vscode.window.showErrorMessage( + `Could not install ruff: ${r.stderr.trim().split('\n').slice(-1)[0] || 'exit ' + r.code}`, + 'Show log' + ); + if (choice === 'Show log' && channel) { + channel.show(); + } + return; + } + log('ruff installed successfully'); + ruffProbeResult = true; // optimistic; next format will validate + vscode.window.showInformationMessage('ruff installed. Formatting is ready.'); + } + ); +} + +/** + * Resolve the command and args used to format a `.comm` file. Default is + * the configured Python interpreter running `ruff format`. Users can override + * `vs-code-aster.formatter` with a full shell command if they prefer a + * different tool (black, a wrapper script, …). + */ +function resolveFormatterCommand(documentPath: string): { cmd: string; args: string[] } | null { + const config = vscode.workspace.getConfiguration('vs-code-aster'); + const formatter = (config.get('formatter') || 'ruff').trim(); + if (!formatter || formatter === 'off') { + return null; + } + + // A user-supplied shell command: split on whitespace (naïve but matches + // the setting contract — one string, no fancy quoting expected). + if (formatter !== 'ruff') { + const parts = formatter.split(/\s+/).filter(Boolean); + if (parts.length === 0) { + return null; + } + return { cmd: parts[0], args: parts.slice(1) }; + } + + const python = config.get('pythonExecutablePath') || 'python3'; + return { + cmd: python, + args: [ + '-m', + 'ruff', + 'format', + // Preserve user-chosen quote style; without this ruff flips 'MED' to "MED". + '--config', + 'format.quote-style="preserve"', + '--line-length=100', + // Force .comm to be treated as Python source. + '--extension', + 'comm:python', + '--stdin-filename', + documentPath, + '-', + ], + }; +} + +function formatStdin( + cmd: string, + args: string[], + stdin: string, + timeoutMs = 15_000 +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => { + clearTimeout(timer); + resolve({ code: -1, stdout, stderr: stderr + String(err) }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? -1, stdout, stderr }); + }); + child.stdin.end(stdin); + }); +} + +export class CommFormatter + implements vscode.DocumentFormattingEditProvider, vscode.DocumentRangeFormattingEditProvider +{ + async provideDocumentFormattingEdits( + document: vscode.TextDocument + ): Promise { + return this.format(document); + } + + async provideDocumentRangeFormattingEdits( + document: vscode.TextDocument, + range: vscode.Range + ): Promise { + // ruff format doesn't have a clean range mode; format the selection in + // isolation. This is best-effort: a selection that spans partial + // expressions will fail to parse and we'll surface the error. + const text = document.getText(range); + const edits = await this.formatText(document, text); + if (!edits) { + return undefined; + } + // edits is synthesized against a [0,0 .. end] range — rebase onto `range`. + const formatted = edits[0].newText; + return [vscode.TextEdit.replace(range, formatted)]; + } + + private async format(document: vscode.TextDocument): Promise { + const text = document.getText(); + const edits = await this.formatText(document, text); + if (!edits) { + return undefined; + } + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ); + return [vscode.TextEdit.replace(fullRange, edits[0].newText)]; + } + + private async formatText( + document: vscode.TextDocument, + text: string + ): Promise { + const resolved = resolveFormatterCommand(document.uri.fsPath); + if (!resolved) { + return undefined; + } + + log(`$ ${resolved.cmd} ${resolved.args.join(' ')}`); + const r = await formatStdin(resolved.cmd, resolved.args, text); + if (r.code === 0) { + return [vscode.TextEdit.replace(new vscode.Range(0, 0, 0, 0), r.stdout)]; + } + + const stderr = r.stderr.trim(); + log(`formatter exited ${r.code}: ${stderr.slice(0, 1000)}`); + + // Detect "module not found" / "command not found" and surface a single + // actionable toast instead of a cryptic spawn error. + const isMissing = + r.code === -1 || + /No module named ruff/i.test(stderr) || + /command not found/i.test(stderr) || + /ENOENT/i.test(stderr); + if (isMissing) { + if (!missingToolWarned) { + missingToolWarned = true; + void vscode.window + .showWarningMessage( + 'ruff is not available for formatting. Install it in the Python interpreter used by the extension (e.g. `pip install ruff`), or set `vs-code-aster.formatter` to your preferred tool.', + 'Show log' + ) + .then((choice) => { + if (choice === 'Show log' && channel) { + channel.show(); + } + }); + } + return undefined; + } + + // Real formatter error (syntax error in the doc, ruff crash, …) — + // surface it once per invocation so the user can fix. + void vscode.window + .showErrorMessage( + `Formatter failed: ${stderr.split('\n')[0] || `exit ${r.code}`}`, + 'Show log' + ) + .then((choice) => { + if (choice === 'Show log' && channel) { + channel.show(); + } + }); + return undefined; + } +} diff --git a/src/LspServer.ts b/src/LspServer.ts index 0a47a43..6637510 100644 --- a/src/LspServer.ts +++ b/src/LspServer.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import * as path from 'path'; +import * as fs from 'fs'; import { LanguageClient, LanguageClientOptions, @@ -8,6 +9,13 @@ import { } from 'vscode-languageclient/node'; import { StatusBar } from './StatusBar'; import { SUPPORTED_COMM_EXTENSIONS } from './VisuManager'; +import { spawn } from 'child_process'; +import { + caveFilePath, + resolveCatalogPath, + getCatalogChannel, + reconcileCatalogCache, +} from './CatalogResolver'; /** * Singleton class to manage the Python LSP client for Code-Aster. * Handles client creation, start, restart, notifications, and editor listeners. @@ -15,6 +23,9 @@ import { SUPPORTED_COMM_EXTENSIONS } from './VisuManager'; export class LspServer { private static _instance: LspServer; private _client?: LanguageClient; + private _context?: vscode.ExtensionContext; + private _caveWatcher?: fs.FSWatcher; + private _caveDebounce?: NodeJS.Timeout; private constructor() {} @@ -36,19 +47,32 @@ export class LspServer { * Starts the LSP client */ public async start(context: vscode.ExtensionContext) { + this._context = context; + // Reconcile on-disk caches against what docker currently has. If an + // image was removed externally (docker rmi, cave internal cleanup), the + // matching extracted catalog lingers and our resolver would still serve + // it. Clear orphans before resolveCatalogPath runs. + await reconcileOnStartup(); if (!this._client) { this._client = await this.createClient(context); } - this._client - .start() - .then(() => { - vscode.window.showInformationMessage('LSP Python Code-Aster ready!'); - this.attachEditorListeners(); - }) - .catch((err: any) => { - vscode.window.showErrorMessage('Error starting LSP Python: ' + err.message); - }); + this.watchCaveFile(); + + void vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: 'Starting code_aster language server…', + }, + () => + this._client!.start() + .then(() => { + this.attachEditorListeners(); + }) + .catch((err: any) => { + vscode.window.showErrorMessage('Error starting LSP Python: ' + err.message); + }) + ); this._client.onDidChangeState((e) => console.log('LSP client state changed:', e)); @@ -78,16 +102,23 @@ export class LspServer { // Build file system watcher patterns const watchPatterns = commFileExtensions.map((ext) => `**/*${ext}`); + const resolved = await resolveCatalogPath(); + const env: NodeJS.ProcessEnv = { + ...process.env, + PYTHONPATH: context.asAbsolutePath('python'), + }; + if (resolved.path) { + env.VS_CODE_ASTER_CATA_PATH = resolved.path; + } + getCatalogChannel().appendLine( + `[catalog] LSP will start with source=${resolved.source}, path=${resolved.path ?? '(vendored)'}` + ); + const serverOptions: ServerOptions = { command: pythonExecutablePath, args: [serverModule], transport: TransportKind.stdio, - options: { - env: { - ...process.env, - PYTHONPATH: context.asAbsolutePath('python'), - }, - }, + options: { env }, }; const clientOptions: LanguageClientOptions = { @@ -135,13 +166,50 @@ export class LspServer { if (this._client && this._client.isRunning()) { await this._client.stop(); } + // Rebuild the client so a fresh catalog path is resolved and injected + // into the server process env (handles `cave use` changes). + if (this._context) { + this._client = await this.createClient(this._context); + } - this._client - ?.start() - .then(() => vscode.window.showInformationMessage('LSP Python Code-Aster restarted!')) - .catch((err: any) => - vscode.window.showErrorMessage('Error restarting LSP Python: ' + err.message) - ); + const client = this._client; + if (!client) { + return; + } + void vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: 'Restarting code_aster language server…', + }, + () => + client + .start() + .catch((err: any) => + vscode.window.showErrorMessage('Error restarting LSP Python: ' + err.message) + ) + ); + } + + private watchCaveFile() { + if (this._caveWatcher) { + return; + } + const cavePath = caveFilePath(); + const channel = getCatalogChannel(); + try { + this._caveWatcher = fs.watch(cavePath, () => { + if (this._caveDebounce) { + clearTimeout(this._caveDebounce); + } + this._caveDebounce = setTimeout(() => { + channel.appendLine(`[catalog] ~/.cave changed, restarting LSP`); + void this.restart(); + }, 500); + }); + channel.appendLine(`[catalog] watching ${cavePath}`); + } catch (err: any) { + channel.appendLine(`[catalog] cannot watch ${cavePath}: ${err?.message ?? err}`); + } } /** @@ -154,3 +222,41 @@ export class LspServer { return this._client.stop(); } } + +async function reconcileOnStartup(): Promise { + const installed = await new Promise((resolve) => { + const child = spawn('docker', ['images', '--format', '{{.Tag}}', 'simvia/code_aster'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + const timer = setTimeout(() => child.kill('SIGKILL'), 5_000); + child.stdout.on('data', (d) => (out += d.toString())); + child.on('error', () => { + clearTimeout(timer); + resolve([]); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + return resolve([]); + } + resolve( + Array.from( + new Set( + out + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s && s !== '') + ) + ) + ); + }); + }); + if (installed.length === 0) { + // Don't reconcile against an empty list — if docker is simply unavailable + // we'd nuke all caches and force re-extraction next time docker comes + // back. Treat "no info" as "keep what we have". + return; + } + reconcileCatalogCache(installed); +} diff --git a/src/extension.ts b/src/extension.ts index 7b1e810..13b2c27 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,13 +8,16 @@ import * as path from 'path'; import { VisuManager } from './VisuManager'; import { ExportEditor } from './ExportEditor'; import { ExportFormatter } from './ExportFormatter'; +import { CommFormatter, offerInstallRuff } from './CommFormatter'; import { RunAster } from './RunAster'; import { LspServer } from './LspServer'; import { StatusBar } from './StatusBar'; +import { CaveStatusBar } from './CaveStatusBar'; import { activateMedLanguageSync } from './MedLanguageSync'; import { MedEditorProvider, STATIC_MED_EXTS } from './MedEditorProvider'; import { activateMedAutoDetect, isExtensionConfigured, openAsMedMesh } from './MedAutoDetect'; import { setTelemetryContext } from './telemetry'; +import { clearCatalogCache, getCatalogChannel, getCatalogInfo } from './CatalogResolver'; /** * Main activation function for the extension. Registers all commands. @@ -45,6 +48,31 @@ export async function activate(context: vscode.ExtensionContext) { ) ); + const commFormatter = new CommFormatter(); + context.subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider({ language: 'comm' }, commFormatter), + vscode.languages.registerDocumentRangeFormattingEditProvider( + { language: 'comm' }, + commFormatter + ) + ); + + // Offer to install ruff the first time a .comm file is opened, so the user + // doesn't discover the dependency only when trying to format. + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((doc) => { + if (doc.languageId === 'comm') { + void offerInstallRuff(context); + } + }) + ); + for (const doc of vscode.workspace.textDocuments) { + if (doc.languageId === 'comm') { + void offerInstallRuff(context); + break; + } + } + const createMesh = vscode.commands.registerCommand('vs-code-aster.meshViewer', () => { VisuManager.instance.createOrShowMeshViewer(); }); @@ -54,7 +82,28 @@ export async function activate(context: vscode.ExtensionContext) { LspServer.instance.restart(); }); + const showCatalogInfo = vscode.commands.registerCommand( + 'vs-code-aster.showCatalogInfo', + async () => { + const info = await getCatalogInfo(); + const choice = await vscode.window.showInformationMessage( + info, + { modal: true }, + 'Show log', + 'Clear cache & restart LSP' + ); + if (choice === 'Show log') { + getCatalogChannel().show(); + } else if (choice === 'Clear cache & restart LSP') { + clearCatalogCache(); + await LspServer.instance.restart(); + } + } + ); + context.subscriptions.push(showCatalogInfo); + StatusBar.instance.activate(context); + CaveStatusBar.instance.activate(context); activateMedLanguageSync(context); diff --git a/syntaxes/comm.tmLanguage.json b/syntaxes/comm.tmLanguage.json index 93df603..b50415e 100644 --- a/syntaxes/comm.tmLanguage.json +++ b/syntaxes/comm.tmLanguage.json @@ -5,9 +5,9 @@ { "include": "#comments" }, { "include": "#strings" }, { "include": "#numbers" }, - { "include": "#functions" }, + { "include": "#constants" }, { "include": "#assignments" }, - { "include": "#parameters" } + { "include": "#function-call" } ], "repository": { "comments": { @@ -25,10 +25,7 @@ "begin": "\"", "end": "\"", "patterns": [ - { - "name": "constant.character.escape", - "match": "\\\\." - } + { "name": "constant.character.escape", "match": "\\\\." } ] }, { @@ -36,10 +33,7 @@ "begin": "'", "end": "'", "patterns": [ - { - "name": "constant.character.escape", - "match": "\\\\." - } + { "name": "constant.character.escape", "match": "\\\\." } ] } ] @@ -48,35 +42,68 @@ "patterns": [ { "name": "constant.numeric", - "match": "\\b\\d+(\\.\\d+)?(E[+-]?\\d+)?\\b" + "match": "\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?\\b" } ] }, - "functions": { + "constants": { "patterns": [ { - "name": "entity.name.function", - "match": "\\b([A-Z_]{3,})\\b(?=\\s*\\()" + "name": "constant.language", + "match": "\\b(None|True|False)\\b" } ] }, "assignments": { "patterns": [ { - "name": "variable.other.readwrite", - "match": "^\\s*([A-Z_][A-Z0-9_]*)\\s*=" + "match": "^\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*=(?!=)", + "captures": { + "1": { "name": "variable.other.readwrite" } + } } ] }, - "parameters": { + "function-call": { + "begin": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(\\()", + "beginCaptures": { + "1": { "name": "entity.name.function" }, + "2": { "name": "punctuation.section.arguments.begin" } + }, + "end": "\\)", + "endCaptures": { + "0": { "name": "punctuation.section.arguments.end" } + }, + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#constants" }, + { "include": "#keyword-arg" }, + { "include": "#function-call" }, + { "include": "#parens" } + ] + }, + "parens": { + "begin": "\\(", + "end": "\\)", + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#constants" }, + { "include": "#keyword-arg" }, + { "include": "#function-call" }, + { "include": "#parens" } + ] + }, + "keyword-arg": { "patterns": [ { - "name": "variable.parameter", - "match": "(?<=\\()\\s*\\b[A-Z_][A-Z0-9_]*\\b(?=\\s*=)" - }, - { - "name": "variable.parameter", - "match": ",\\s*\\b[A-Z_][A-Z0-9_]*\\b(?=\\s*=)" + "match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\b(?=\\s*=(?!=))", + "captures": { + "1": { "name": "variable.parameter" } + } } ] } From 0f4f7b96fe118234f6f1cfeb4cc97d1ed31e1862 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 13:47:51 +0200 Subject: [PATCH 2/9] feat: context-aware comm autocompletion with value, variable, and nested-factor suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the completion path around a single forward scan that walks from the enclosing command's start to the cursor, tracking paren depth, string state, factor frames, and per-frame written kwargs. Replaces the previous backward scan, which broke on mid-edit unmatched quotes. Highlights: - Trigger chars expanded to (, ',', =, space; client-side hide-then-trigger re-pops the suggest widget on each so it never sticks in "No suggestions". - Snippet inserts: command → NAME($0) and re-triggers; kwarg → NAME=$0; factor kwarg → NAME=_F($0); value/variable → trailing ", " + retrigger unless it's the last param. - Value position dispatch: shows allowed `into` literals, then variables whose source command's sd_prod is type-compatible (issubclass) with the expected SIMP class — including callable sd_prod resolved by inspecting its signature and passing __all__=True. - Nested _F(...) keyword completion via factor path; written-kwarg filter also works inside factor scopes. - Empty results in value position return is_incomplete=True so VS Code re-queries on the next keystroke. - Registry: on_document_change falls back to a full reparse when the change line isn't in any tracked command, so newly-typed commands register without waiting for a multi-line edit. - Disable editor.wordBasedSuggestions for [comm] so empty LSP results don't get padded with file-word noise. - Showcase example removed; it served its purpose during development. --- examples/lsp_showcase.comm | 125 ----- package.json | 5 + python/asterstudy/datamodel/catalogs.py | 4 + python/lsp/command_registry.py | 38 +- python/lsp/handlers.py | 5 +- python/lsp/managers/completion_manager.py | 619 +++++++++++++++++++--- python/lsp/managers/update_manager.py | 21 +- src/LspServer.ts | 46 +- 8 files changed, 655 insertions(+), 208 deletions(-) delete mode 100644 examples/lsp_showcase.comm diff --git a/examples/lsp_showcase.comm b/examples/lsp_showcase.comm deleted file mode 100644 index 8015ed1..0000000 --- a/examples/lsp_showcase.comm +++ /dev/null @@ -1,125 +0,0 @@ -# --------------------------------------------------------------------------- -# LSP showcase — small linear-elastic cantilever in a single file. -# -# What to try in this file (each section is annotated): -# * Hover a COMMAND name → full Python-fence signature + docstring + rules -# + documentation link. -# * Hover a KEYWORD inside a call → keyword-scoped card with type, default, -# allowed values, range, required/optional. -# * Hover a keyword inside a conditional BLOC (e.g. NOM_MED under FORMAT='MED') -# → only the active branch is offered. -# * Trigger completion at an empty line → list of all commands. -# * Trigger completion inside a call (after "(" or ",") → keyword list, -# filtered by active BLOCs given what you've -# already typed in the call. -# * Trigger signature help with "(" or "," → argument hints. -# * Save the file (or open alongside a .mess) → diagnostics in Problems panel. -# * Bottom-left status bar shows which family steps (Mesh / Material / -# Loads / Analysis / Output) are filled in this file. -# * Bottom-right status bar shows the active code_aster version (cave or -# "(bundled)"); click it to switch, install, or remove versions. -# --------------------------------------------------------------------------- - -DEBUT() - -# 1. Mesh -------------------------------------------------------------------- -# Try: hover LIRE_MAILLAGE → command signature -# hover FORMAT → keyword card (default="MED", into=(ASTER, ...)) -# hover NOM_MED → only visible because FORMAT='MED' is active -MESH = LIRE_MAILLAGE( - FORMAT='MED', - UNITE=20, - NOM_MED='Cantilever', - INFO_MED=0, -) - -# 2. Model ------------------------------------------------------------------- -# AFFE_MODELE has a `regles` rule: at least one of MAILLAGE / MODELE_IN ... -# Hover AFFE_MODELE → see the rendered "Rules" list in the hover. -MODEL = AFFE_MODELE( - MAILLAGE=MESH, - AFFE=_F( - TOUT='OUI', - PHENOMENE='MECANIQUE', - MODELISATION='3D', - ), -) - -# 3. Material ---------------------------------------------------------------- -# Hover DEFI_MATERIAU → factor keywords ELAS, ECRO_LINE ... -# Hover ELAS → required sub-keywords E, NU get "# required". -STEEL = DEFI_MATERIAU( - ELAS=_F( - E=210000.0, # Young's modulus [MPa] - NU=0.3, # Poisson's ratio - RHO=7.85e-9, # density [t/mm^3] — optional, shown with default - ), -) - -CHMAT = AFFE_MATERIAU( - MAILLAGE=MESH, - AFFE=_F( - TOUT='OUI', - MATER=STEEL, - ), -) - -# 4. Boundary conditions & loads -------------------------------------------- -# AFFE_CHAR_MECA is the richest call for hover features: -# - required MODELE -# - many optional factor keywords: DDL_IMPO, FORCE_NODALE, PRES_REP, ... -# - rules like AT_LEAST_ONE on several sub-factors -CLIM = AFFE_CHAR_MECA( - MODELE=MODEL, - DDL_IMPO=_F( - GROUP_MA='FIX', - DX=0.0, - DY=0.0, - DZ=0.0, - ), - FORCE_NODALE=_F( - GROUP_NO='TIP', - FZ=-1000.0, - ), -) - -# 5. Analysis ---------------------------------------------------------------- -# MECA_STATIQUE: EXCIT is a repeatable FACT (max="**") — hover EXCIT to see -# the factor block's required sub-keywords. -RESU = MECA_STATIQUE( - MODELE=MODEL, - CHAM_MATER=CHMAT, - EXCIT=_F(CHARGE=CLIM), -) - -# Post-processing: request stresses at nodes & Von Mises-equivalent. -RESU = CALC_CHAMP( - reuse=RESU, - RESULTAT=RESU, - CONTRAINTE=('SIGM_NOEU',), - CRITERES=('SIEQ_NOEU',), -) - -# 6. Output ------------------------------------------------------------------ -# IMPR_RESU shows BLOC filtering by FORMAT: -# FORMAT='MED' → UNITE, NOM_MED, ... are active -# FORMAT='RESULTAT' → different sub-keywords -# Change FORMAT below and re-hover the keywords to see the BLOC swap. -IMPR_RESU( - FORMAT='MED', - UNITE=80, - RESU=_F( - RESULTAT=RESU, - NOM_CHAM=('DEPL', 'SIGM_NOEU', 'SIEQ_NOEU'), - ), -) - -# Intentional typo / invalid argument demonstrations ------------------------ -# Uncomment any line below to see LSP surface a warning: -# -# BAD1 = LIRE_MAILLAGE(FORMAT='ASCII') # 'ASCII' not in allowed values -# BAD2 = DEFI_MATERIAU(ELAS=_F(NU=0.3)) # missing required E -# BAD3 = MECA_STATIQUE() # missing required MODELE / CHAM_MATER -# FOO # hover a random word — no tooltip - -FIN() diff --git a/package.json b/package.json index 9d04c43..bc0ac91 100644 --- a/package.json +++ b/package.json @@ -207,6 +207,11 @@ } ] }, + "configurationDefaults": { + "[comm]": { + "editor.wordBasedSuggestions": "off" + } + }, "configuration": { "title": "VS Code Aster", "type": "object", diff --git a/python/asterstudy/datamodel/catalogs.py b/python/asterstudy/datamodel/catalogs.py index c13c8b6..c40ea48 100644 --- a/python/asterstudy/datamodel/catalogs.py +++ b/python/asterstudy/datamodel/catalogs.py @@ -602,8 +602,12 @@ def _format_type(typ): "name": key, "required": (statut == "o"), "type": typ_str, + "type_obj": typ, # raw class / tuple / validator for cross-type matching "default": defaut, "allowed": into, + "val_min": kwd.definition.get("val_min"), + "val_max": kwd.definition.get("val_max"), + "doc": (getattr(kwd, "udocstring", "") or "").strip(), "children": [], "bloc": None } diff --git a/python/lsp/command_registry.py b/python/lsp/command_registry.py index 17611f4..fb3879f 100644 --- a/python/lsp/command_registry.py +++ b/python/lsp/command_registry.py @@ -90,15 +90,41 @@ def on_document_change( affected_cmd_key = self._find_command_at_line(change_start_line) if affected_cmd_key is None: - self.ls.send_notification("logParser", {"text": "Change outside a command"}) + # The change is on a line that wasn't part of any tracked + # command — most commonly the user is just starting to type a + # NEW command on an empty line. Re-parse the whole document so + # the new call gets tracked. + self._full_reparse(lines) return - # Check if we are inside the command (needs updating end_char) - self.ls.send_notification( - "logParser", {"text": f"Command: {affected_cmd_key}, change: {text_change}"} - ) - self._reparse_command(lines, affected_cmd_key) + # Whether or not we just touched a tracked command, the user may + # also have invalidated its end paren on this keystroke (e.g. typed + # past it, or deleted into a sibling). Cheap safety net: rebuild + # the global ranges so a new top-level command typed *after* an + # existing one becomes findable. + self._full_reparse(lines) + + def _full_reparse(self, lines: list[str]) -> None: + """Re-run _parse_all_commands and rebuild the range index.""" + raw_commands = self._parse_all_commands(lines) + new_commands: dict[str, CommandInfo] = {} + for cmd_data in raw_commands: + cmd_info = CommandInfo( + name=cmd_data["name"], + var_name=cmd_data["var_name"], + start_line=cmd_data["start_line"], + end_line=cmd_data["end_line"], + zone_end=cmd_data["zone_end"], + end_char=cmd_data["end_char"], + is_complete=cmd_data["is_complete"], + ) + cmd_info.parsed_params = self._parse_params_level1( + lines, cmd_info.start_line, cmd_info.zone_end + ) + new_commands[cmd_info.get_key()] = cmd_info + self.commands = new_commands + self._rebuild_ranges() def get_command_at_line(self, line: int) -> CommandInfo | None: """ diff --git a/python/lsp/handlers.py b/python/lsp/handlers.py index 4da461c..8af3fec 100644 --- a/python/lsp/handlers.py +++ b/python/lsp/handlers.py @@ -26,7 +26,10 @@ def on_initialize(ls: LanguageServer, params: InitializeParams): return { "capabilities": { "textDocumentSync": 1, - "completionProvider": {"resolveProvider": False, "triggerCharacters": [" ", "."]}, + "completionProvider": { + "resolveProvider": False, + "triggerCharacters": ["(", ",", "=", " "], + }, "hoverProvider": True, "definitionProvider": True, } diff --git a/python/lsp/managers/completion_manager.py b/python/lsp/managers/completion_manager.py index 053d691..ed103a5 100644 --- a/python/lsp/managers/completion_manager.py +++ b/python/lsp/managers/completion_manager.py @@ -1,109 +1,590 @@ +import sys +import traceback + from command_core import CommandCore from lsprotocol.types import ( + Command, CompletionItem, CompletionItemKind, CompletionList, + InsertTextFormat, + MarkupContent, + MarkupKind, ) +def _retrigger_command() -> Command: + """A `Command` that asks VS Code to re-open the suggestion popup right + after the snippet finishes inserting. Used so accepting `LIRE_MAILLAGE` + immediately offers its keyword args, and accepting `FORMAT=` immediately + offers its allowed values.""" + return Command(title="Trigger suggest", command="editor.action.triggerSuggest") + + +def _log(msg: str) -> None: + """Write to stderr so the line surfaces in the LSP's Output channel + (Python Language Server), same place the [catalog] lines land.""" + sys.stderr.write(msg + "\n") + sys.stderr.flush() + + class CompletionManager: - """ - Manager responsible for providing auto-completion in code_aster .comm files. - Fetches registries and command definitions through CommandCore. + """Auto-completion for code_aster `.comm` files. + + Dispatches one of four behaviors based on the cursor context: + * outside any command call → list of all catalog commands + * inside `KEY=` (value position) → allowed-value literals (`into`) + * inside a `_F(...)` factor frame → that factor's sub-keywords + * otherwise inside a call → remaining top-level keywords + + All dispatch decisions come from a single forward scan over the document + bytes between the enclosing command's opening `(` and the cursor. Forward + scanning (rather than backwards) makes mid-edit unmatched quotes / parens + a non-issue: strings open and close left-to-right. """ def __init__(self): self.core = CommandCore() + # ---------------------------------------------------------------- entry + def completion(self, doc_uri: str, position) -> CompletionList: - """ - Entry point called by the handler. - Returns a CompletionList based on the command context. - """ - registry = self.core.get_registry(doc_uri) - if registry is None: + try: + return self._completion(doc_uri, position) + except Exception as exc: + _log("[completion] ERROR: " + repr(exc) + "\n" + traceback.format_exc()) return CompletionList(is_incomplete=False, items=[]) - self.core.log("completion appelée") + + def _completion(self, doc_uri: str, position) -> CompletionList: + registry = self.core.get_registry(doc_uri) + doc = self.core.get_doc_from_uri(doc_uri) + if registry is None or doc is None: + _log( + f"[completion] line={position.line} col={position.character} " + f"registry={registry is not None} doc={doc is not None} → empty" + ) + return CompletionList(is_incomplete=True, items=[]) + cmd_info = registry.get_command_at_line(position.line + 1) if not cmd_info: - return self._suggest_commands() + result = self._suggest_commands() + _log( + f"[completion] line={position.line} col={position.character} " + f"cmd=None registry_size={len(registry.commands)} " + f"ranges={registry.ranges} items={len(result.items)} kind=Function" + ) + return result + + cmd_def = self.core.get_command_def(cmd_info.name) + if not cmd_def or "params" not in cmd_def: + _log(f"[completion] cmd={cmd_info.name} but parse_command returned no params → empty") + return CompletionList(is_incomplete=True, items=[]) + + scan = _scan_forward(doc.lines, cmd_info, position) + + # Descend into the factor path to scope the visible parameters. + params_list = cmd_def["params"] + for factor_name in scan.factor_path: + entry = _find_param(params_list, factor_name) + if not entry or not entry["children"]: + break + params_list = entry["children"] + + # Value position takes precedence over keyword listing. + if scan.value_keyword is not None: + target = _find_param(params_list, scan.value_keyword) + items: list[CompletionItem] = [] + if target is not None: + remaining = _remaining_keyword_count( + params_list, scan.written_keys | {scan.value_keyword} + ) + more = remaining > 0 + if target.get("allowed"): + items.extend(_value_items(target, scan.inside_quotes, append_comma=more)) + items.extend( + _variable_items(registry, self.core, target, position, append_comma=more) + ) + _log( + f"[completion] cmd={cmd_info.name} factor_path={scan.factor_path} " + f"value_keyword={scan.value_keyword} inside_quotes={scan.inside_quotes} " + f"items={len(items)} kind=Value" + ) + return CompletionList(is_incomplete=True, items=items) + + # Keyword-arg list at the current scope. The forward scan tracks + # `written_keys` per scope, so the factor branch is now able to + # filter already-typed sub-keywords too. + written = set(scan.written_keys) + if not scan.factor_path: + # At the top level, also include the registry's parsed_params + # (which were tracked at didOpen / didChange time and may + # cover params written in earlier sessions of this call). + written |= set(cmd_info.parsed_params.keys()) + context = cmd_info.parsed_params.copy() + else: + context = None - return self._suggest_parameters(cmd_info) + result = self._suggest_parameters(params_list, written, context) + _log( + f"[completion] cmd={cmd_info.name} factor_path={scan.factor_path} " + f"value_keyword=None inside_quotes={scan.inside_quotes} " + f"written={sorted(written)} " + f"available={[p['name'] for p in params_list if not p.get('bloc')]} " + f"items={len(result.items)} kind=Property" + ) + return result + + # ----------------------------------------------- top-level command list def _suggest_commands(self) -> CompletionList: - """ - Suggest all available code_aster commands. - """ items = [] - commands = self.core.get_CATA_commands() - - for cmd in commands: + for cmd in self.core.get_CATA_commands(): items.append( CompletionItem( label=cmd["name"], kind=CompletionItemKind.Function, - documentation=cmd.get("doc", ""), + insert_text=cmd["name"] + "($0)", + insert_text_format=InsertTextFormat.Snippet, + command=_retrigger_command(), + documentation=_md(cmd.get("doc", "")), ) ) + return CompletionList(is_incomplete=False, items=items) + # -------------------------------------------------- keyword suggestions + + def _suggest_parameters(self, params_list, written, context) -> CompletionList: + visible = self._expand_condition_bloc(params_list, context) + items = [] + for param in visible: + name = param["name"] + if name in written: + continue + if param.get("bloc") is not None: + if context is None: + for child in param["children"]: + if child["name"] not in written: + items.append(_keyword_item(child)) + continue + items.append(_keyword_item(param)) return CompletionList(is_incomplete=False, items=items) - def _suggest_parameters(self, cmd_info) -> CompletionList: - """ - Suggest parameters of a code_aster command depending on the context. - """ - cmd_def = self.core.get_command_def(cmd_info.name) + # _suggest_values is split into module-level helpers (_value_items, + # _variable_items) so the value-position branch can combine sources + # without re-instantiating CompletionList multiple times. - if not cmd_def or "params" not in cmd_def: - return CompletionList(is_incomplete=False, items=[]) + def _expand_condition_bloc(self, params, context): + if context is None: + return params + out = [] + for arg in params: + if arg.get("bloc"): + if arg["bloc"].isEnabled(context): + out.extend(arg["children"]) + else: + out.append(arg) + return out - context = cmd_info.parsed_params.copy() - visible_params = self._expand_condition_bloc(cmd_def["params"], context) +# ===================== helpers ============================================ + + +def _find_param(params, name): + for p in params: + if p.get("name") == name: + return p + if p.get("bloc"): + inner = _find_param(p.get("children", []), name) + if inner: + return inner + return None + + +def _md(text: str) -> MarkupContent | None: + text = (text or "").strip() + if not text: + return None + return MarkupContent(kind=MarkupKind.Markdown, value=text) + + +def _detail(param) -> str | None: + bits = [] + typ = param.get("type") + if typ: + bits.append(typ) + default = param.get("default") + if default is not None: + if isinstance(default, str): + bits.append(f'= "{default}"') + else: + bits.append(f"= {default}") + if param.get("required"): + bits.append("required") + return " · ".join(bits) if bits else None + + +def _doc_md(param) -> MarkupContent | None: + parts = [] + doc = (param.get("doc") or "").strip() + if doc: + parts.append(f"*{doc}*") + allowed = param.get("allowed") + if allowed: + rendered = " | ".join(f'"{v}"' if isinstance(v, str) else str(v) for v in allowed) + parts.append(f"**Allowed:** {rendered}") + if not parts: + return None + return MarkupContent(kind=MarkupKind.Markdown, value="\n\n".join(parts)) - items = [] - written = set(cmd_info.parsed_params.keys()) - for param in visible_params: - if param["name"] in written: - continue - # Conditional bloc : we display the children parameters if it's enabled - if param["bloc"]: - if param["bloc"].isEnabled(context): - for arg in param["children"]: - items.append( - CompletionItem( - label=arg["name"], - kind=CompletionItemKind.Property, - insert_text=arg["name"] + "=", - ) - ) - # Normal parameter (can be a single param or a dico (_F)) +def _remaining_keyword_count(params_list, used_keys) -> int: + """How many SIMP/FACT keywords would still be valid choices once the + `used_keys` set has been written. Counts BLOC children too — without a + context to filter them we just assume any could become available.""" + count = 0 + for p in params_list: + if p.get("bloc") is not None: + for child in p.get("children", []): + if child["name"] not in used_keys: + count += 1 + elif p["name"] not in used_keys: + count += 1 + return count + + +def _value_items(param, inside_quotes: bool, append_comma: bool = True) -> list[CompletionItem]: + suffix = ", " if (append_comma and not inside_quotes) else "" + out = [] + for v in param["allowed"]: + if isinstance(v, str): + if inside_quotes: + insert = v else: - insert_text = param["name"] + "=" - if param["children"]: - insert_text += "_F" - items.append( - CompletionItem( - label=param["name"], - kind=CompletionItemKind.Property, - insert_text=insert_text, - ) - ) + insert = f"'{v}'{suffix}" + label = f"'{v}'" + else: + insert = f"{v}{suffix}" + label = str(v) + out.append( + CompletionItem( + label=label, + kind=CompletionItemKind.Value, + insert_text=insert, + command=None if inside_quotes or not suffix else _retrigger_command(), + detail=param.get("type") or None, + ) + ) + return out - return CompletionList(is_incomplete=False, items=items) - def _expand_condition_bloc(self, params, context): - """ - Expand conditional blocks depending on the current context. - Returns a flat list of visible parameters. - """ - visible_params = [] - for arg in params: - if arg["bloc"]: - if arg["bloc"].isEnabled(context): - for param in arg["children"]: - visible_params.append(param) +def _command_return_types(cmd_obj) -> tuple: + """Resolve a command's `sd_prod` to a tuple of concrete output classes. + + `sd_prod` may be (a) a class, (b) a callable that returns a class or + tuple of classes when invoked with `__all__=True` (the convention + used in code_aster catalogs — see e.g. `lire_maillage_sdprod` which + returns `(maillage_sdaster, maillage_p)`), or (c) None. Callable form + typically requires positional args (like `FORMAT, PARTITIONNEUR`) so + we introspect the signature and pad the call with `None`s before + setting `__all__=True`. + """ + sd = cmd_obj.definition.get("sd_prod") if hasattr(cmd_obj, "definition") else None + if sd is None: + return () + if isinstance(sd, type): + return (sd,) + if not callable(sd): + return () + + import inspect + + try: + sig = inspect.signature(sd) + positional = [ + p + for p in sig.parameters.values() + if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + and p.default is inspect.Parameter.empty + ] + result = sd(*([None] * len(positional)), __all__=True) + except Exception: + return () + if isinstance(result, type): + return (result,) + if isinstance(result, (tuple, list)): + return tuple(t for t in result if isinstance(t, type)) + return () + + +def _expected_classes(param) -> tuple: + """Pull the raw type(s) the SIMP keyword expects.""" + typ = param.get("type_obj") + if typ is None: + return () + if isinstance(typ, type): + return (typ,) + if isinstance(typ, (tuple, list)): + return tuple(t for t in typ if isinstance(t, type)) + return () + + +def _types_compatible(var_types, expected) -> bool: + if not var_types or not expected: + return False + for vt in var_types: + for et in expected: + try: + if issubclass(vt, et): + return True + except TypeError: + continue + return False + + +def _variable_items( + registry, core, param, position, append_comma: bool = True +) -> list[CompletionItem]: + """Suggest already-declared variables whose type is compatible with + the SIMP keyword the cursor is filling in.""" + expected = _expected_classes(param) + if not expected: + return [] + cursor_line_1based = position.line + 1 + out: list[CompletionItem] = [] + seen: set[str] = set() + cata = core.get_CATA() + # Iterate in the registry's natural order so we can list earliest + # assignments first, but skip duplicates (var reassigned later). + for cmd_info in sorted(registry.commands.values(), key=lambda c: c.start_line): + var = getattr(cmd_info, "var_name", None) + if not var or var in seen: + continue + # Don't suggest a variable whose assignment is below the cursor. + if cmd_info.start_line >= cursor_line_1based: + continue + cmd_obj = cata.get_command_obj(cmd_info.name) + if cmd_obj is None: + continue + var_types = _command_return_types(cmd_obj) + if not _types_compatible(var_types, expected): + continue + seen.add(var) + type_name = ", ".join(t.__name__ for t in var_types) or "?" + suffix = ", " if append_comma else "" + out.append( + CompletionItem( + label=var, + kind=CompletionItemKind.Variable, + insert_text=f"{var}{suffix}", + insert_text_format=InsertTextFormat.PlainText, + command=_retrigger_command() if suffix else None, + detail=f"{type_name} (line {cmd_info.start_line})", + documentation=_md(f"Assigned by `{cmd_info.name}` at line {cmd_info.start_line}."), + ) + ) + return out + + +def _keyword_item(param) -> CompletionItem: + name = param["name"] + is_factor = bool(param["children"]) and not param.get("bloc") + if is_factor: + insert = f"{name}=_F($0)" + else: + # `$0` after `=` lets VS Code know to leave the cursor right after + # the `=`; combined with the trigger-suggest command below, the + # value popup opens automatically when the param has `into=(...)`. + insert = f"{name}=$0" + return CompletionItem( + label=name, + kind=CompletionItemKind.Property, + insert_text=insert, + insert_text_format=InsertTextFormat.Snippet, + command=_retrigger_command(), + detail=_detail(param), + documentation=_doc_md(param), + ) + + +# ----------------- forward cursor-context scan ---------------------------- + + +_IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") + + +class _Scan: + """Result of the forward scan.""" + + __slots__ = ("factor_path", "value_keyword", "inside_quotes", "written_keys") + + def __init__(self): + self.factor_path: list[str] = [] # outer → inner, excluding the command itself + self.value_keyword: str | None = None + self.inside_quotes: bool = False + # Names of kwargs already written at the cursor's current scope — + # used to filter the keyword popup so we don't suggest a kwarg the + # user has already filled in (works inside _F(...) too, where the + # registry's parsed_params doesn't reach). + self.written_keys: set[str] = set() + + +def _scan_forward(doc_lines, cmd_info, position) -> _Scan: + """Walk forward from `cmd_info.start_line` to the cursor, classifying + where we are. Strings open/close monotonically, paren depth ditto.""" + cursor_line = position.line # 0-based + cursor_col = position.character + start_idx = max(0, cmd_info.start_line - 1) + + # Stack frames are (name, depth_at_open). depth_at_open is the depth + # right BEFORE the open paren (so the matching close pops when depth + # falls back to it). + stack: list[tuple[str, int]] = [] + # `seen_stack[i]` holds the set of kwarg names already typed at the + # depth of `stack[i]`. The top of the stack is always the cursor's + # current scope; we additionally maintain `seen_top` for the cursor's + # outermost scope (top-level call) when no factor is open. + seen_stack: list[set[str]] = [set()] + depth = 0 + in_string: str | None = None + # `last_kw` is the last identifier seen at the current scope that was + # immediately followed by `=`. Reset on commas, closing parens, or any + # significant non-whitespace token after the `=` is consumed. + last_kw: str | None = None + # `value_pos` is True when we've seen `KEY=` and not yet seen any + # significant token (anything other than whitespace or an opening quote). + value_pos: bool = False + # Identifier accumulator for parsing names left-to-right. + pending_name: str | None = None + + for line_idx in range(start_idx, cursor_line + 1): + if line_idx >= len(doc_lines): + break + line = doc_lines[line_idx] + col_end = cursor_col if line_idx == cursor_line else len(line) + i = 0 + while i < col_end: + c = line[i] + + if in_string: + # Consume the string body. Closing quote terminates. + if c == in_string and (i == 0 or line[i - 1] != "\\"): + in_string = None + # Exiting a string is a "significant token" — clear + # value_pos so a later `KEY=` is required to reactivate. + value_pos = False + last_kw = None + i += 1 + continue + + if c in ("'", '"'): + # Opening a string. If we were in value_pos, we stay there + # so the cursor inside the string still resolves to + # value_keyword=last_kw. + in_string = c + i += 1 + continue + + # Outside a string. Identifier accumulation. + if c in _IDENT_CHARS: + if pending_name is None: + pending_name = c + else: + pending_name += c + i += 1 + continue else: - visible_params.append(arg) - return visible_params + # Non-identifier: see if pending_name + this char is `NAME=` + # (kwarg) or `NAME(` (call). If `=`, mark value_pos. If `(`, + # push a stack frame. Otherwise just discard pending_name. + # Skip whitespace inline first. + if c.isspace(): + if pending_name is not None: + # finalize: still pending; allow whitespace then `=`/`(` + pass + i += 1 + continue + + if c == "=": + # `KEY=` is a kwarg only when we're inside a call (depth>0). + # At depth 0 it's a Python statement assignment (`VAR = + # CMD(...)`) and the LHS should not be treated as a + # keyword name. + if ( + depth > 0 + and pending_name is not None + and (i + 1 >= len(line) or line[i + 1] != "=") + ): + last_kw = pending_name + value_pos = True + # Mark this key as already-written in the current + # scope (top of seen_stack, parallel to call stack). + seen_stack[-1].add(pending_name) + pending_name = None + else: + pending_name = None + value_pos = False + last_kw = None + i += 1 + continue + + if c == "(": + # `KEY=_F(...)` — the factor's identity in the catalog + # is KEY, not the literal `_F`. Push the kwarg name so + # the descent in `params_list` resolves correctly. + if value_pos and last_kw is not None: + stack.append((last_kw, depth)) + elif pending_name is not None: + stack.append((pending_name, depth)) + pending_name = None + depth += 1 + seen_stack.append(set()) + value_pos = False + last_kw = None + i += 1 + continue + + if c == ")": + depth -= 1 + while stack and stack[-1][1] >= depth: + stack.pop() + if len(seen_stack) > 1: + seen_stack.pop() + pending_name = None + value_pos = False + last_kw = None + i += 1 + continue + + if c == ",": + pending_name = None + value_pos = False + last_kw = None + i += 1 + continue + + if c == "#": + # Comment runs to end of line. + break + + # Any other char (digits, dots, operators, etc.) — clears + # the identifier accumulator but NOT the value-position + # state. The user typing `APLAT=0.5` is still in + # APLAT's value scope until a `,` or `)`. + pending_name = None + i += 1 + + # End of this line slice. If we end inside a string, that's part of + # the cursor's state we need to remember. + + out = _Scan() + out.inside_quotes = in_string is not None + if value_pos and last_kw is not None: + out.value_keyword = last_kw + # Drop the outer command frame from the stack to get the factor path. + factor_frames = stack[:] + if factor_frames and factor_frames[0][0] == cmd_info.name: + factor_frames = factor_frames[1:] + out.factor_path = [name for name, _d in factor_frames] + # The current scope's already-written kwargs (top of seen_stack). + out.written_keys = set(seen_stack[-1]) if seen_stack else set() + return out diff --git a/python/lsp/managers/update_manager.py b/python/lsp/managers/update_manager.py index 68ba199..07d963d 100644 --- a/python/lsp/managers/update_manager.py +++ b/python/lsp/managers/update_manager.py @@ -1,7 +1,14 @@ +import sys + from command_core import CommandCore from command_registry import CommandRegistry +def _log(msg: str) -> None: + sys.stderr.write(msg + "\n") + sys.stderr.flush() + + class UpdateManager: """ Manager for handling document open and change events for .comm files. @@ -20,10 +27,9 @@ def init_registry(self, doc, doc_uri): self.core.set_registry(doc_uri, registry) commands = registry.get_all_commands() - lines = [f"Document opened: {doc_uri}, {len(commands)} commands detected"] + _log(f"[registry] init {doc_uri}: {len(commands)} commands; ranges={registry.ranges}") for key, value in commands.items(): - lines.append(f" - {key} → {value}") - self.core.log("\n".join(lines)) + _log(f"[registry] - {key} → {value}") def update_registry(self, doc, doc_uri, changes): """ @@ -44,8 +50,11 @@ def update_registry(self, doc, doc_uri, changes): # Handle multi-line deletions or additions if end_line - start_line > 0 or text.count("\n") > 0 or text == "(": - self.core.log("on reload entierement") registry.initialize(ls, doc.lines) + _log( + f"[registry] full reparse on multi-line change: " + f"{len(registry.get_all_commands())} commands" + ) continue # Update the affected command (single line change) @@ -53,3 +62,7 @@ def update_registry(self, doc, doc_uri, changes): else: registry.initialize(ls, doc.lines) + _log( + f"[registry] full reparse (no range): " + f"{len(registry.get_all_commands())} commands" + ) diff --git a/src/LspServer.ts b/src/LspServer.ts index 6637510..c734d86 100644 --- a/src/LspServer.ts +++ b/src/LspServer.ts @@ -151,10 +151,50 @@ export class LspServer { return; } - //activate signature and completion triggers - const lastChange = changes[changes.length - 1]; - if (['(', ','].includes(lastChange.text) && editor.document.languageId === 'comm') { + if (editor.document.languageId !== 'comm') { + return; + } + + // Auto-closing pairs collapse a `(` keystroke into a single change + // whose text is `()`, so element-equality on the change list misses + // it. Use substring matching across all changes. + const typed = changes.map((c) => c.text).join(''); + + // Hide-then-trigger: VS Code's suggest widget can lock into a + // sticky "No suggestions" state when an LSP returns an empty list, + // and `triggerSuggest` alone won't re-open it. Explicitly hiding + // first guarantees a fresh query. + const popSuggest = () => { + vscode.commands.executeCommand('hideSuggestWidget'); + setTimeout(() => vscode.commands.executeCommand('editor.action.triggerSuggest'), 0); + }; + + if (typed.includes('(')) { vscode.commands.executeCommand('editor.action.triggerParameterHints'); + popSuggest(); + return; + } + if (typed.includes(',')) { + popSuggest(); + return; + } + if (typed.includes('=')) { + popSuggest(); + return; + } + if (typed.includes('\n')) { + popSuggest(); + return; + } + if (typed === ' ') { + const pos = editor.selection.active; + const before = editor.document + .lineAt(pos.line) + .text.slice(0, pos.character) + .replace(/\s+$/, ''); + if (before.endsWith(',')) { + popSuggest(); + } } }); } From 4bc036ccad050e6504ad77dc6d173707b840f9b2 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 13:56:03 +0200 Subject: [PATCH 3/9] chore: rename Output channels under a unified `code_aster:` prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LSP channel was previously named "Python Language Server" — misleading since it serves code_aster, not Python. Renamed to "code_aster: Language Server" and aligned the catalog/formatter channels to the same prefix so they group together in VS Code's Output dropdown. LSP error toasts updated accordingly. Internal LanguageClient id is unchanged to preserve any saved user trace settings. --- src/CatalogResolver.ts | 4 ++-- src/CommFormatter.ts | 2 +- src/LspServer.ts | 12 +++++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/CatalogResolver.ts b/src/CatalogResolver.ts index 773da32..0223cea 100644 --- a/src/CatalogResolver.ts +++ b/src/CatalogResolver.ts @@ -20,7 +20,7 @@ let channel: vscode.OutputChannel | undefined; function log(line: string) { if (!channel) { - channel = vscode.window.createOutputChannel('VS Code Aster — Catalog'); + channel = vscode.window.createOutputChannel('code_aster: Catalog'); } const stamp = new Date().toISOString().slice(11, 23); channel.appendLine(`${stamp} ${LOG_PREFIX} ${line}`); @@ -28,7 +28,7 @@ function log(line: string) { export function getCatalogChannel(): vscode.OutputChannel { if (!channel) { - channel = vscode.window.createOutputChannel('VS Code Aster — Catalog'); + channel = vscode.window.createOutputChannel('code_aster: Catalog'); } return channel; } diff --git a/src/CommFormatter.ts b/src/CommFormatter.ts index 129d429..ec318bb 100644 --- a/src/CommFormatter.ts +++ b/src/CommFormatter.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { spawn } from 'child_process'; -const CHANNEL_NAME = 'VS Code Aster — Formatter'; +const CHANNEL_NAME = 'code_aster: Formatter'; let channel: vscode.OutputChannel | undefined; let missingToolWarned = false; let ruffPromptShown = false; diff --git a/src/LspServer.ts b/src/LspServer.ts index c734d86..202c5e2 100644 --- a/src/LspServer.ts +++ b/src/LspServer.ts @@ -70,7 +70,9 @@ export class LspServer { this.attachEditorListeners(); }) .catch((err: any) => { - vscode.window.showErrorMessage('Error starting LSP Python: ' + err.message); + vscode.window.showErrorMessage( + 'Error starting code_aster language server: ' + err.message + ); }) ); @@ -129,8 +131,10 @@ export class LspServer { }; return new LanguageClient( + // Internal id stays for backward-compat with any user-saved trace + // settings; only the display name (next arg) is user-visible. 'pythonLanguageServer', - 'Python Language Server', + 'code_aster: Language Server', serverOptions, clientOptions ); @@ -225,7 +229,9 @@ export class LspServer { client .start() .catch((err: any) => - vscode.window.showErrorMessage('Error restarting LSP Python: ' + err.message) + vscode.window.showErrorMessage( + 'Error restarting code_aster language server: ' + err.message + ) ) ); } From 681fb034e20acdd3601642b6752d14bf5cb38af2 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 15:09:51 +0200 Subject: [PATCH 4/9] feat: guided setup flow + activity-bar sidebar panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses the install path from 8 manual README steps to "open a .comm or .export file and click yes on the toasts". Setup is opt-in at every step, with persisted "Don't ask again" choices in globalState. Pieces: - PythonEnv: shared probe/install helpers; auto-creates a managed venv in globalStorageUri when the user hasn't picked an interpreter, runs `pip install pygls numpy medcoupling` (PEP-668 fallback), and writes the venv path back to vs-code-aster.pythonExecutablePath. - SetupOnboarding: orchestrates Python deps → ruff → Docker → cave → code_aster image. Cave install runs in an integrated terminal (sudo visible), then polls `which cave` for up to 5 min and chains into the version-install picker on success. Native Windows skips the auto path and opens the cave install instructions instead. - SidebarView: new activity-bar view container with Setup / Quick actions / Versions / Settings groups. Setup auto-collapses when all five probes are green; per-setting icons; clicking a version row runs `cave use` directly, "Install another version…" goes straight to the install picker. Activity-bar icon is the simvia logo recolored to currentColor with a tightened viewBox to remove the original padding. - CommFormatter is constructed with the extension context now and uses the shared Python helpers; the embedded ruff prompt moves into the onboarding chain. - Two new commands: vs-code-aster.runSetup (force the chain) and vs-code-aster.switchCaveVersion / .installCaveVersion (sidebar-only direct entry points). --- media/images/activity-bar-icon.svg | 18 ++ package.json | 34 +++ src/CatalogResolver.ts | 2 +- src/CaveStatusBar.ts | 57 ++++- src/CommFormatter.ts | 153 +------------ src/PythonEnv.ts | 217 ++++++++++++++++++ src/SetupOnboarding.ts | 305 ++++++++++++++++++++++++ src/SidebarView.ts | 357 +++++++++++++++++++++++++++++ src/extension.ts | 27 ++- 9 files changed, 1015 insertions(+), 155 deletions(-) create mode 100644 media/images/activity-bar-icon.svg create mode 100644 src/PythonEnv.ts create mode 100644 src/SetupOnboarding.ts create mode 100644 src/SidebarView.ts diff --git a/media/images/activity-bar-icon.svg b/media/images/activity-bar-icon.svg new file mode 100644 index 0000000..ba82166 --- /dev/null +++ b/media/images/activity-bar-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/package.json b/package.json index bc0ac91..796cb94 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,15 @@ "command": "vs-code-aster.selectCaveVersion", "title": "Select code_aster version (cave)" }, + { + "command": "vs-code-aster.runSetup", + "title": "Run code_aster setup checks" + }, + { + "command": "vs-code-aster.sidebar.refresh", + "title": "Refresh code_aster panel", + "icon": "$(refresh)" + }, { "command": "vs-code-aster.addToMedExtensions", "title": "Open as MED mesh", @@ -151,6 +160,24 @@ "path": "./syntaxes/export.tmLanguage.json" } ], + "viewsContainers": { + "activitybar": [ + { + "id": "vs-code-aster", + "title": "code_aster", + "icon": "./media/images/activity-bar-icon.svg" + } + ] + }, + "views": { + "vs-code-aster": [ + { + "id": "vs-code-aster.sidebar", + "name": "code_aster", + "icon": "./media/images/activity-bar-icon.svg" + } + ] + }, "customEditors": [ { "viewType": "vs-code-aster.medViewer", @@ -177,6 +204,13 @@ } ], "menus": { + "view/title": [ + { + "command": "vs-code-aster.sidebar.refresh", + "when": "view == vs-code-aster.sidebar", + "group": "navigation" + } + ], "editor/title": [ { "command": "vs-code-aster.exportDoc", diff --git a/src/CatalogResolver.ts b/src/CatalogResolver.ts index 0223cea..6db3bd7 100644 --- a/src/CatalogResolver.ts +++ b/src/CatalogResolver.ts @@ -111,7 +111,7 @@ function run( }); } -async function dockerAvailable(): Promise { +export async function dockerAvailable(): Promise { const r = await run('docker', ['version', '--format', '{{.Server.Version}}'], 5_000); return r.code === 0; } diff --git a/src/CaveStatusBar.ts b/src/CaveStatusBar.ts index 3416dd3..a4a504c 100644 --- a/src/CaveStatusBar.ts +++ b/src/CaveStatusBar.ts @@ -61,7 +61,7 @@ function compareVersionsDesc(a: string, b: string): number { return b.localeCompare(a); } -async function listInstalledVersions(): Promise { +export async function listInstalledVersions(): Promise { const r = await exec('docker', ['images', '--format', '{{.Tag}}', 'simvia/code_aster']); if (r.code !== 0) { return []; @@ -125,7 +125,16 @@ export class CaveStatusBar { public activate(context: vscode.ExtensionContext) { this.context = context; context.subscriptions.push( - vscode.commands.registerCommand(COMMAND_ID, () => this.pickVersion()) + vscode.commands.registerCommand(COMMAND_ID, () => this.pickVersion()), + vscode.commands.registerCommand('vs-code-aster.installCaveVersion', () => + this.openInstallVersion() + ), + vscode.commands.registerCommand('vs-code-aster.switchCaveVersion', (tag?: string) => { + if (typeof tag === 'string' && tag) { + return this.switchTo(tag); + } + return this.pickVersion(); + }) ); this.disposables.push( @@ -188,14 +197,16 @@ export class CaveStatusBar { const bundled = this.context ? getBundledVersion(this.context) : null; const label = bundled ? `${bundled} (bundled)` : 'bundled'; this.item.text = `$(warning) ${label}`; - this.item.tooltip = new vscode.MarkdownString( + const md = new vscode.MarkdownString( `**No cave-installed code_aster version selected.**\n\n` + `Language features (completion, hover, signatures) are served from ` + `the **bundled ${bundled ?? '?'} catalog**, which is enough for ` + `editing but **\`cave run\` will not work** until you install and ` + `select a version.\n\n` + - `Click to install or select a version.` + `Click to install or select a version, or [run setup checks](command:vs-code-aster.runSetup).` ); + md.isTrusted = true; + this.item.tooltip = md; this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); } this.item.show(); @@ -346,6 +357,44 @@ export class CaveStatusBar { }); } + /** + * Public entry point for the install-version picker (the "available + * from DockerHub" list). Used by the SetupOnboarding flow and the + * sidebar view's "Install another version…" row. + */ + public async openInstallVersion() { + return this.installNewVersion(getSelectedCaveVersion()); + } + + /** + * Switch the active code_aster version to `tag` (running `cave use`) + * without going through the picker. No-op if `tag` is already current + * or not in the installed list. + */ + public async switchTo(tag: string): Promise { + if (!this.cachedVersions.includes(tag)) { + // Make sure we're up-to-date before bailing. + await this.refreshVersions(); + if (!this.cachedVersions.includes(tag)) { + vscode.window.showWarningMessage(`code_aster ${tag} is not installed.`); + return; + } + } + if (tag === getSelectedCaveVersion()) { + return; + } + const r = await exec('cave', ['use', tag], 10_000); + if (r.code !== 0) { + vscode.window.showErrorMessage( + `cave use ${tag} failed: ${r.stderr.trim() || r.stdout.trim()}` + ); + return; + } + this.refresh(vscode.window.activeTextEditor); + void LspServer.instance.restart(); + vscode.window.showInformationMessage(`code_aster version set to ${tag}.`); + } + private async installNewVersion(rawCurrent: string | null) { // Treat a selection pointing at a missing image as "nothing selected" so // the user can re-install that exact version without hitting the diff --git a/src/CommFormatter.ts b/src/CommFormatter.ts index ec318bb..61fb1f8 100644 --- a/src/CommFormatter.ts +++ b/src/CommFormatter.ts @@ -1,11 +1,10 @@ import * as vscode from 'vscode'; import { spawn } from 'child_process'; +import { resolvePythonExecutable } from './PythonEnv'; const CHANNEL_NAME = 'code_aster: Formatter'; let channel: vscode.OutputChannel | undefined; let missingToolWarned = false; -let ruffPromptShown = false; -let ruffProbeResult: boolean | null = null; function log(line: string) { if (!channel) { @@ -15,143 +14,22 @@ function log(line: string) { channel.appendLine(`${stamp} ${line}`); } -function pythonExecutable(): string { - return ( - vscode.workspace.getConfiguration('vs-code-aster').get('pythonExecutablePath') || - 'python3' - ); -} - -function runOnce( - cmd: string, - args: string[], - timeoutMs = 10_000 -): Promise<{ code: number; stdout: string; stderr: string }> { - return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); - child.stdout.on('data', (d) => (stdout += d.toString())); - child.stderr.on('data', (d) => (stderr += d.toString())); - child.on('error', (err) => { - clearTimeout(timer); - resolve({ code: -1, stdout, stderr: stderr + String(err) }); - }); - child.on('close', (code) => { - clearTimeout(timer); - resolve({ code: code ?? -1, stdout, stderr }); - }); - }); -} - -async function ruffIsInstalled(): Promise { - if (ruffProbeResult !== null) { - return ruffProbeResult; - } - const r = await runOnce(pythonExecutable(), ['-m', 'ruff', '--version'], 5_000); - ruffProbeResult = r.code === 0; - log(`ruff probe: ${ruffProbeResult ? 'present' : `missing (${r.stderr.trim().slice(0, 120)})`}`); - return ruffProbeResult; -} - -/** - * If the formatter is set to `ruff` and ruff isn't installed in the - * configured Python, offer to install it. Called when the first `.comm` - * file is opened. Dedup'd to run at most once per session. - */ -export async function offerInstallRuff(context: vscode.ExtensionContext) { - if (ruffPromptShown) { - return; - } - const config = vscode.workspace.getConfiguration('vs-code-aster'); - const formatter = (config.get('formatter') || 'ruff').trim(); - if (formatter !== 'ruff') { - return; - } - // Respect "don't ask again" stored decision. - if (context.globalState.get('formatter.ruffDeclined')) { - return; - } - if (await ruffIsInstalled()) { - return; - } - ruffPromptShown = true; - - const choice = await vscode.window.showInformationMessage( - 'Install `ruff` to enable code_aster file formatting? It will be installed into the Python used by the extension (' + - pythonExecutable() + - ').', - 'Install', - 'Not now', - "Don't ask again" - ); - - if (choice === "Don't ask again") { - await context.globalState.update('formatter.ruffDeclined', true); - return; - } - if (choice !== 'Install') { - return; - } - await installRuff(); -} - -async function installRuff(): Promise { - const python = pythonExecutable(); - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Installing ruff…', - cancellable: false, - }, - async (progress) => { - // Let pip decide where to install: inside a venv it goes into the venv; - // a system Python will fall back to site-packages (and on PEP 668 - // systems we catch the failure below). Passing `--user` unconditionally - // breaks venvs ("User site-packages are not visible in this virtualenv"). - progress.report({ message: `${python} -m pip install ruff` }); - log(`$ ${python} -m pip install ruff`); - let r = await runOnce(python, ['-m', 'pip', 'install', 'ruff'], 120_000); - // PEP 668 / "externally-managed-environment" → retry with --user which - // is the documented escape hatch for that case. - if (r.code !== 0 && /externally[- ]managed/i.test(r.stderr)) { - log('retrying with --user (externally-managed environment)'); - r = await runOnce(python, ['-m', 'pip', 'install', '--user', 'ruff'], 120_000); - } - if (r.code !== 0) { - log(`pip install failed (${r.code}): ${r.stderr.trim().slice(0, 500)}`); - const choice = await vscode.window.showErrorMessage( - `Could not install ruff: ${r.stderr.trim().split('\n').slice(-1)[0] || 'exit ' + r.code}`, - 'Show log' - ); - if (choice === 'Show log' && channel) { - channel.show(); - } - return; - } - log('ruff installed successfully'); - ruffProbeResult = true; // optimistic; next format will validate - vscode.window.showInformationMessage('ruff installed. Formatting is ready.'); - } - ); -} - /** * Resolve the command and args used to format a `.comm` file. Default is * the configured Python interpreter running `ruff format`. Users can override * `vs-code-aster.formatter` with a full shell command if they prefer a * different tool (black, a wrapper script, …). */ -function resolveFormatterCommand(documentPath: string): { cmd: string; args: string[] } | null { +function resolveFormatterCommand( + context: vscode.ExtensionContext, + documentPath: string +): { cmd: string; args: string[] } | null { const config = vscode.workspace.getConfiguration('vs-code-aster'); const formatter = (config.get('formatter') || 'ruff').trim(); if (!formatter || formatter === 'off') { return null; } - // A user-supplied shell command: split on whitespace (naïve but matches - // the setting contract — one string, no fancy quoting expected). if (formatter !== 'ruff') { const parts = formatter.split(/\s+/).filter(Boolean); if (parts.length === 0) { @@ -160,18 +38,16 @@ function resolveFormatterCommand(documentPath: string): { cmd: string; args: str return { cmd: parts[0], args: parts.slice(1) }; } - const python = config.get('pythonExecutablePath') || 'python3'; + const python = resolvePythonExecutable(context); return { cmd: python, args: [ '-m', 'ruff', 'format', - // Preserve user-chosen quote style; without this ruff flips 'MED' to "MED". '--config', 'format.quote-style="preserve"', '--line-length=100', - // Force .comm to be treated as Python source. '--extension', 'comm:python', '--stdin-filename', @@ -209,6 +85,8 @@ function formatStdin( export class CommFormatter implements vscode.DocumentFormattingEditProvider, vscode.DocumentRangeFormattingEditProvider { + constructor(private context: vscode.ExtensionContext) {} + async provideDocumentFormattingEdits( document: vscode.TextDocument ): Promise { @@ -219,17 +97,12 @@ export class CommFormatter document: vscode.TextDocument, range: vscode.Range ): Promise { - // ruff format doesn't have a clean range mode; format the selection in - // isolation. This is best-effort: a selection that spans partial - // expressions will fail to parse and we'll surface the error. const text = document.getText(range); const edits = await this.formatText(document, text); if (!edits) { return undefined; } - // edits is synthesized against a [0,0 .. end] range — rebase onto `range`. - const formatted = edits[0].newText; - return [vscode.TextEdit.replace(range, formatted)]; + return [vscode.TextEdit.replace(range, edits[0].newText)]; } private async format(document: vscode.TextDocument): Promise { @@ -249,7 +122,7 @@ export class CommFormatter document: vscode.TextDocument, text: string ): Promise { - const resolved = resolveFormatterCommand(document.uri.fsPath); + const resolved = resolveFormatterCommand(this.context, document.uri.fsPath); if (!resolved) { return undefined; } @@ -263,8 +136,6 @@ export class CommFormatter const stderr = r.stderr.trim(); log(`formatter exited ${r.code}: ${stderr.slice(0, 1000)}`); - // Detect "module not found" / "command not found" and surface a single - // actionable toast instead of a cryptic spawn error. const isMissing = r.code === -1 || /No module named ruff/i.test(stderr) || @@ -275,7 +146,7 @@ export class CommFormatter missingToolWarned = true; void vscode.window .showWarningMessage( - 'ruff is not available for formatting. Install it in the Python interpreter used by the extension (e.g. `pip install ruff`), or set `vs-code-aster.formatter` to your preferred tool.', + 'ruff is not available for formatting. Run "code_aster: Run setup checks" to install it.', 'Show log' ) .then((choice) => { @@ -287,8 +158,6 @@ export class CommFormatter return undefined; } - // Real formatter error (syntax error in the doc, ruff crash, …) — - // surface it once per invocation so the user can fix. void vscode.window .showErrorMessage( `Formatter failed: ${stderr.split('\n')[0] || `exit ${r.code}`}`, diff --git a/src/PythonEnv.ts b/src/PythonEnv.ts new file mode 100644 index 0000000..a04deba --- /dev/null +++ b/src/PythonEnv.ts @@ -0,0 +1,217 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; + +export const LSP_DEPS = ['pygls==1.3.1', 'numpy', 'medcoupling'] as const; + +export interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +export function runProc(cmd: string, args: string[], timeoutMs = 60_000): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => { + clearTimeout(timer); + resolve({ code: -1, stdout, stderr: stderr + String(err) }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +/** + * Resolve the Python interpreter the LSP should use. Order: + * 1. user-set `vs-code-aster.pythonExecutablePath` (anything other than + * the literal default `python3`), + * 2. our managed venv at `/.venv`, + * 3. fall back to `python3` (so the extension can at least boot). + */ +export function resolvePythonExecutable(context: vscode.ExtensionContext): string { + const config = vscode.workspace.getConfiguration('vs-code-aster'); + const userSetting = (config.get('pythonExecutablePath') || '').trim(); + if (userSetting && userSetting !== 'python3') { + return userSetting; + } + const managed = managedVenvPython(context); + if (managed && fs.existsSync(managed)) { + return managed; + } + return 'python3'; +} + +export function managedVenvDir(context: vscode.ExtensionContext): string { + return path.join(context.globalStorageUri.fsPath, '.venv'); +} + +export function managedVenvPython(context: vscode.ExtensionContext): string { + const dir = managedVenvDir(context); + return process.platform === 'win32' + ? path.join(dir, 'Scripts', 'python.exe') + : path.join(dir, 'bin', 'python'); +} + +/** + * "Has the user explicitly chosen an interpreter?" Used to decide + * whether auto-installing into a managed venv would override their + * choice. + */ +export function userHasCustomPython(): boolean { + const userSetting = ( + vscode.workspace.getConfiguration('vs-code-aster').get('pythonExecutablePath') || '' + ).trim(); + return userSetting !== '' && userSetting !== 'python3'; +} + +/** Probe the LSP's three Python deps via the configured interpreter. */ +export async function probeLspDeps( + context: vscode.ExtensionContext +): Promise<{ ok: boolean; missing: string[] }> { + const python = resolvePythonExecutable(context); + const r = await runProc(python, ['-c', 'import pygls, numpy, medcoupling'], 8_000); + if (r.code === 0) { + return { ok: true, missing: [] }; + } + // Parse the ImportError message to figure out which package is missing. + const m = r.stderr.match(/No module named ['"]([^'"]+)['"]/); + const missing = m ? [m[1]] : [...LSP_DEPS]; + return { ok: false, missing }; +} + +/** Probe ruff via the configured interpreter. Reused by CommFormatter. */ +export async function probeRuff(context: vscode.ExtensionContext): Promise { + const python = resolvePythonExecutable(context); + const r = await runProc(python, ['-m', 'ruff', '--version'], 5_000); + return r.code === 0; +} + +async function findBootstrapPython(): Promise { + const candidates = + process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python']; + for (const c of candidates) { + const r = await runProc(c, ['-c', 'import sys; print(sys.version_info[:2])'], 4_000); + if (r.code === 0) { + return c; + } + } + return null; +} + +/** + * Create the managed venv if missing. Returns the absolute path of the + * venv's Python interpreter, or null on failure. + */ +export async function ensureManagedVenv(context: vscode.ExtensionContext): Promise { + const venvPython = managedVenvPython(context); + if (fs.existsSync(venvPython)) { + return venvPython; + } + fs.mkdirSync(context.globalStorageUri.fsPath, { recursive: true }); + const bootstrap = await findBootstrapPython(); + if (!bootstrap) { + return null; + } + const r = await runProc(bootstrap, ['-m', 'venv', managedVenvDir(context)], 60_000); + if (r.code !== 0) { + return null; + } + return fs.existsSync(venvPython) ? venvPython : null; +} + +/** + * Install the LSP deps into the user's chosen interpreter (if they set + * one) or into the managed venv. Surfaces progress through the supplied + * progress reporter. + */ +export async function installLspDeps( + context: vscode.ExtensionContext, + progress: vscode.Progress<{ message?: string }> +): Promise<{ ok: boolean; pythonPath: string; error?: string }> { + let pythonPath: string; + if (userHasCustomPython()) { + pythonPath = resolvePythonExecutable(context); + } else { + progress.report({ message: 'Creating managed venv…' }); + const venvPython = await ensureManagedVenv(context); + if (!venvPython) { + return { + ok: false, + pythonPath: '', + error: + 'Could not create a managed Python venv (is `python -m venv` available on this system?).', + }; + } + pythonPath = venvPython; + } + + progress.report({ message: 'Upgrading pip…' }); + await runProc(pythonPath, ['-m', 'pip', 'install', '--upgrade', 'pip'], 60_000); + + progress.report({ message: `Installing ${LSP_DEPS.join(', ')}…` }); + let r = await runProc(pythonPath, ['-m', 'pip', 'install', ...LSP_DEPS], 240_000); + if (r.code !== 0 && /externally[- ]managed/i.test(r.stderr)) { + progress.report({ message: 'Retrying with --user (externally-managed env)…' }); + r = await runProc(pythonPath, ['-m', 'pip', 'install', '--user', ...LSP_DEPS], 240_000); + } + if (r.code !== 0) { + return { + ok: false, + pythonPath, + error: r.stderr.trim().split('\n').slice(-1)[0] || `pip exit ${r.code}`, + }; + } + + // If we used the managed venv, write its path back to the setting so + // subsequent sessions skip the bootstrap entirely. + if (!userHasCustomPython()) { + await vscode.workspace + .getConfiguration('vs-code-aster') + .update('pythonExecutablePath', pythonPath, vscode.ConfigurationTarget.Global); + } + + return { ok: true, pythonPath }; +} + +/** Install ruff into the same interpreter the LSP uses. */ +export async function installRuff( + context: vscode.ExtensionContext, + progress: vscode.Progress<{ message?: string }> +): Promise<{ ok: boolean; error?: string }> { + // Make sure we have a venv to install into; otherwise share the user's. + if (!userHasCustomPython()) { + const venv = await ensureManagedVenv(context); + if (!venv) { + return { ok: false, error: 'Managed venv unavailable.' }; + } + } + const python = resolvePythonExecutable(context); + progress.report({ message: `${python} -m pip install ruff` }); + let r = await runProc(python, ['-m', 'pip', 'install', 'ruff'], 120_000); + if (r.code !== 0 && /externally[- ]managed/i.test(r.stderr)) { + r = await runProc(python, ['-m', 'pip', 'install', '--user', 'ruff'], 120_000); + } + if (r.code !== 0) { + return { + ok: false, + error: r.stderr.trim().split('\n').slice(-1)[0] || `pip exit ${r.code}`, + }; + } + return { ok: true }; +} + +/** Used by readme-style messaging. Hides $HOME for prettier display. */ +export function prettyPath(p: string): string { + const home = os.homedir(); + return p.startsWith(home) ? '~' + p.slice(home.length) : p; +} diff --git a/src/SetupOnboarding.ts b/src/SetupOnboarding.ts new file mode 100644 index 0000000..21bd20e --- /dev/null +++ b/src/SetupOnboarding.ts @@ -0,0 +1,305 @@ +import * as vscode from 'vscode'; +import { + installLspDeps, + installRuff, + probeLspDeps, + probeRuff, + resolvePythonExecutable, + runProc, +} from './PythonEnv'; +import { dockerAvailable, getSelectedCaveVersion } from './CatalogResolver'; +import { CaveStatusBar, listInstalledVersions } from './CaveStatusBar'; +import { LspServer } from './LspServer'; + +const COMMAND_NAME = 'code_aster: Run setup checks'; + +const KEY_PYTHON_DEPS = 'setup.pythonDepsDeclined'; +const KEY_RUFF = 'formatter.ruffDeclined'; +const KEY_DOCKER = 'setup.dockerDeclined'; +const KEY_CAVE = 'setup.caveDeclined'; +const KEY_CAVE_VERSION = 'setup.caveVersionDeclined'; + +let runningInSession = false; +let firedThisSession = false; + +const CAVE_INSTALL_CMD = + 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/simvia-tech/cave/main/tools/install.sh)"'; + +/** + * Walks the user through the setup chain in order: + * 1. Python LSP deps (pygls, numpy, medcoupling) + * 2. ruff (formatter) + * 3. Docker daemon + * 4. cave on PATH + * 5. at least one code_aster image + * + * Each step is opt-in with three buttons (Install / Not now / Don't ask + * again). Persisted decisions live in `context.globalState`. + * + * Called automatically on first `.comm` / `.export` open per session; + * also exposed as `vs-code-aster.runSetup` for explicit invocation. + */ +export async function runSetupProbes( + context: vscode.ExtensionContext, + options: { force?: boolean } = {} +): Promise { + if (runningInSession) { + return; + } + if (firedThisSession && !options.force) { + return; + } + runningInSession = true; + firedThisSession = true; + try { + await stepPythonDeps(context, options.force); + await stepRuff(context, options.force); + const hasDocker = await stepDocker(context, options.force); + const hasCave = await stepCave(context, hasDocker, options.force); + if (hasCave) { + await stepCaveVersion(context, options.force); + } + } finally { + runningInSession = false; + } +} + +// ---------------- step 1: Python LSP deps ---------------- + +async function stepPythonDeps(context: vscode.ExtensionContext, force?: boolean) { + if (!force && context.globalState.get(KEY_PYTHON_DEPS)) { + return; + } + const probe = await probeLspDeps(context); + if (probe.ok) { + return; + } + const choice = await vscode.window.showInformationMessage( + `code_aster language server needs Python packages (${probe.missing.join(', ')}). ` + + 'Install them now? They will go into a managed virtual environment owned by the extension.', + 'Install', + 'Not now', + "Don't ask again" + ); + if (choice === "Don't ask again") { + await context.globalState.update(KEY_PYTHON_DEPS, true); + return; + } + if (choice !== 'Install') { + return; + } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Installing code_aster LSP dependencies…', + cancellable: false, + }, + async (progress) => { + const result = await installLspDeps(context, progress); + if (!result.ok) { + const action = await vscode.window.showErrorMessage( + `Could not install LSP deps: ${result.error ?? 'unknown error'}`, + 'Retry', + 'Open README' + ); + if (action === 'Retry') { + await stepPythonDeps(context, true); + } else if (action === 'Open README') { + void vscode.env.openExternal( + vscode.Uri.parse('https://github.com/simvia-tech/vs-code-aster#installation') + ); + } + return; + } + vscode.window.showInformationMessage( + 'code_aster LSP dependencies installed. Restarting language server…' + ); + void LspServer.instance.restart(); + } + ); +} + +// ---------------- step 2: ruff ---------------- + +async function stepRuff(context: vscode.ExtensionContext, force?: boolean) { + if (!force && context.globalState.get(KEY_RUFF)) { + return; + } + const config = vscode.workspace.getConfiguration('vs-code-aster'); + if ((config.get('formatter') || 'ruff').trim() !== 'ruff') { + return; // user picked a different formatter, nothing to install. + } + if (await probeRuff(context)) { + return; + } + const choice = await vscode.window.showInformationMessage( + 'Install `ruff` to enable code_aster file formatting?', + 'Install', + 'Not now', + "Don't ask again" + ); + if (choice === "Don't ask again") { + await context.globalState.update(KEY_RUFF, true); + return; + } + if (choice !== 'Install') { + return; + } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Installing ruff…', + cancellable: false, + }, + async (progress) => { + const result = await installRuff(context, progress); + if (!result.ok) { + vscode.window.showErrorMessage( + `Could not install ruff: ${result.error ?? 'unknown error'}` + ); + return; + } + vscode.window.showInformationMessage('ruff installed. Formatting is ready.'); + } + ); +} + +// ---------------- step 3: Docker ---------------- + +async function stepDocker(context: vscode.ExtensionContext, force?: boolean): Promise { + if (await dockerAvailable()) { + return true; + } + if (!force && context.globalState.get(KEY_DOCKER)) { + return false; + } + const choice = await vscode.window.showInformationMessage( + 'Docker is required to install cave and run code_aster simulations. ' + 'Install it now?', + 'Install', + 'Not now', + "Don't ask again" + ); + if (choice === "Don't ask again") { + await context.globalState.update(KEY_DOCKER, true); + return false; + } + if (choice === 'Install') { + void vscode.env.openExternal(vscode.Uri.parse('https://docs.docker.com/get-docker/')); + } + return false; +} + +// ---------------- step 4: cave ---------------- + +async function caveOnPath(): Promise { + const r = await runProc(process.platform === 'win32' ? 'where' : 'which', ['cave'], 3_000); + return r.code === 0; +} + +async function stepCave( + context: vscode.ExtensionContext, + hasDocker: boolean, + force?: boolean +): Promise { + if (await caveOnPath()) { + return true; + } + if (!hasDocker) { + return false; // Docker step already declined / unavailable; don't pile on. + } + if (!force && context.globalState.get(KEY_CAVE)) { + return false; + } + + // Native Windows: no install script. Open instructions instead. + if (process.platform === 'win32') { + const choice = await vscode.window.showInformationMessage( + 'cave (the code_aster version manager) is not installed.', + 'Open install instructions', + "Don't ask again" + ); + if (choice === "Don't ask again") { + await context.globalState.update(KEY_CAVE, true); + } else if (choice === 'Open install instructions') { + void vscode.env.openExternal( + vscode.Uri.parse('https://github.com/simvia-tech/cave#installation') + ); + } + return false; + } + + const choice = await vscode.window.showInformationMessage( + 'Docker is set up. Install cave to manage code_aster versions? ' + + 'This runs an install script with `sudo`.', + 'Install', + 'Show me the command', + 'Not now', + "Don't ask again" + ); + if (choice === "Don't ask again") { + await context.globalState.update(KEY_CAVE, true); + return false; + } + if (choice === 'Show me the command') { + await vscode.window.showInformationMessage( + `cave install command:\n\n${CAVE_INSTALL_CMD}`, + { modal: true }, + 'OK' + ); + return false; + } + if (choice !== 'Install') { + return false; + } + + const term = vscode.window.createTerminal({ name: 'cave install' }); + term.show(); + term.sendText(CAVE_INSTALL_CMD); + + // Poll for cave to appear on PATH (up to 5 minutes). + return await pollForCave(); +} + +async function pollForCave(): Promise { + const deadline = Date.now() + 5 * 60_000; + while (Date.now() < deadline) { + if (await caveOnPath()) { + return true; + } + await new Promise((r) => setTimeout(r, 2_000)); + } + return false; +} + +// ---------------- step 5: code_aster image ---------------- + +async function stepCaveVersion(context: vscode.ExtensionContext, force?: boolean): Promise { + if (!force && context.globalState.get(KEY_CAVE_VERSION)) { + return; + } + // Already have a selection AND the image is present? skip. + const selected = getSelectedCaveVersion(); + const installed = await listInstalledVersions(); + if (selected && installed.includes(selected)) { + return; + } + if (installed.length > 0 && !selected) { + // Image present but ~/.cave is empty — let the picker handle it, + // but don't be pushy. + } + + const choice = await vscode.window.showInformationMessage( + 'cave is set up. Install a code_aster version to enable simulations?', + 'Install version…', + 'Not now', + "Don't ask again" + ); + if (choice === "Don't ask again") { + await context.globalState.update(KEY_CAVE_VERSION, true); + return; + } + if (choice !== 'Install version…') { + return; + } + await CaveStatusBar.instance.openInstallVersion(); +} diff --git a/src/SidebarView.ts b/src/SidebarView.ts new file mode 100644 index 0000000..4956297 --- /dev/null +++ b/src/SidebarView.ts @@ -0,0 +1,357 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { + caveFilePath, + dockerAvailable, + getBundledVersion, + getSelectedCaveVersion, +} from './CatalogResolver'; +import { listInstalledVersions } from './CaveStatusBar'; +import { probeLspDeps, probeRuff, runProc } from './PythonEnv'; + +const VIEW_ID = 'vs-code-aster.sidebar'; + +type Status = 'ok' | 'warn' | 'error' | 'info'; + +interface Probe { + pythonOk: boolean; + pythonMissing: string[]; + ruffOk: boolean; + dockerOk: boolean; + caveOk: boolean; + installedVersions: string[]; + currentVersion: string | null; + bundledVersion: string | null; +} + +class Item extends vscode.TreeItem { + children?: Item[]; + constructor( + label: string | vscode.TreeItemLabel, + collapsible: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.None + ) { + super(label, collapsible); + } +} + +function statusIcon(s: Status): vscode.ThemeIcon { + switch (s) { + case 'ok': + return new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('testing.iconPassed')); + case 'warn': + return new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')); + case 'error': + return new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); + case 'info': + return new vscode.ThemeIcon('info'); + } +} + +async function caveOnPath(): Promise { + const r = await runProc(process.platform === 'win32' ? 'where' : 'which', ['cave'], 3_000); + return r.code === 0; +} + +export class SidebarProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private cached: Probe | null = null; + private probing: Promise | null = null; + private caveWatcher?: fs.FSWatcher; + private caveDebounce?: NodeJS.Timeout; + + constructor(private readonly context: vscode.ExtensionContext) { + // Re-render on relevant config changes. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('vs-code-aster')) { + this.refresh(); + } + }) + ); + // Re-render whenever ~/.cave changes (cave use, install, remove). + try { + this.caveWatcher = fs.watch(caveFilePath(), () => { + if (this.caveDebounce) { + clearTimeout(this.caveDebounce); + } + this.caveDebounce = setTimeout(() => this.refresh(), 300); + }); + context.subscriptions.push({ dispose: () => this.caveWatcher?.close() }); + } catch { + /* ~/.cave may not exist yet */ + } + } + + refresh(): void { + this.cached = null; + this._onDidChangeTreeData.fire(); + } + + getTreeItem(item: Item): vscode.TreeItem { + return item; + } + + async getChildren(parent?: Item): Promise { + if (!parent) { + return this.topLevel(); + } + return parent.children ?? []; + } + + // -------------------------------------------------------- top-level + + private async topLevel(): Promise { + const probe = await this.getProbe(); + return [ + this.setupGroup(probe), + this.actionsGroup(), + this.versionsGroup(probe), + this.settingsGroup(), + ]; + } + + // --------------------------------------------------------------- probes + + private async getProbe(): Promise { + if (this.cached) { + return this.cached; + } + if (!this.probing) { + this.probing = this.runProbes().finally(() => { + this.probing = null; + }); + } + await this.probing; + return ( + this.cached ?? { + pythonOk: false, + pythonMissing: [], + ruffOk: false, + dockerOk: false, + caveOk: false, + installedVersions: [], + currentVersion: null, + bundledVersion: null, + } + ); + } + + private async runProbes(): Promise { + const [pythonResult, ruffOk, dockerOk, caveOk, installed] = await Promise.all([ + probeLspDeps(this.context), + probeRuff(this.context), + dockerAvailable(), + caveOnPath(), + listInstalledVersions(), + ]); + this.cached = { + pythonOk: pythonResult.ok, + pythonMissing: pythonResult.missing, + ruffOk, + dockerOk, + caveOk, + installedVersions: installed, + currentVersion: getSelectedCaveVersion(), + bundledVersion: getBundledVersion(this.context), + }; + } + + // ------------------------------------------------------------ Setup + + private setupGroup(p: Probe): Item { + const versionOk = !!p.currentVersion && p.installedVersions.includes(p.currentVersion); + const allOk = p.pythonOk && p.ruffOk && p.dockerOk && p.caveOk && versionOk; + const item = new Item( + 'Setup', + allOk ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded + ); + item.iconPath = new vscode.ThemeIcon('checklist'); + const versionStatus: Status = versionOk ? 'ok' : 'warn'; + + const items: Item[] = [ + this.statusItem( + 'Python LSP dependencies', + p.pythonOk ? 'ok' : 'warn', + p.pythonOk + ? 'pygls, numpy, medcoupling installed' + : `missing: ${p.pythonMissing.join(', ') || '?'}`, + 'vs-code-aster.runSetup' + ), + this.statusItem( + 'ruff (formatter)', + p.ruffOk ? 'ok' : 'warn', + p.ruffOk ? 'available' : 'not installed', + 'vs-code-aster.runSetup' + ), + this.statusItem( + 'Docker', + p.dockerOk ? 'ok' : 'warn', + p.dockerOk ? 'running' : 'not available', + 'vs-code-aster.runSetup' + ), + this.statusItem( + 'cave', + p.caveOk ? 'ok' : 'warn', + p.caveOk ? 'on PATH' : 'not installed', + 'vs-code-aster.runSetup' + ), + this.statusItem( + 'code_aster version', + versionStatus, + p.installedVersions.length === 0 + ? 'no image installed' + : p.currentVersion && p.installedVersions.includes(p.currentVersion) + ? `using ${p.currentVersion}` + : `bundled ${p.bundledVersion ?? '?'} fallback`, + // No image installed → straight to the install picker; otherwise + // open the regular picker so the user can switch / install / remove. + p.installedVersions.length === 0 + ? 'vs-code-aster.installCaveVersion' + : 'vs-code-aster.selectCaveVersion' + ), + ]; + item.children = items; + return item; + } + + private statusItem(label: string, status: Status, description: string, command: string): Item { + const it = new Item(label); + it.iconPath = statusIcon(status); + it.description = description; + it.tooltip = `${label}: ${description}`; + it.command = { title: 'Run setup checks', command }; + return it; + } + + // ---------------------------------------------------------- Actions + + private actionsGroup(): Item { + const item = new Item('Quick actions', vscode.TreeItemCollapsibleState.Expanded); + item.iconPath = new vscode.ThemeIcon('rocket'); + item.children = [ + this.actionItem('New export file…', 'new-file', 'vs-code-aster.exportDoc'), + this.actionItem('Run with code_aster', 'play', 'vs-code-aster.run-aster'), + this.actionItem('Open mesh viewer', 'eye', 'vs-code-aster.meshViewer'), + this.actionItem('Restart language server', 'sync', 'vs-code-aster.restartLSPServer'), + this.actionItem('Run setup checks', 'checklist', 'vs-code-aster.runSetup'), + this.actionItem('Show catalog info', 'info', 'vs-code-aster.showCatalogInfo'), + ]; + return item; + } + + private actionItem(label: string, icon: string, command: string): Item { + const it = new Item(label); + it.iconPath = new vscode.ThemeIcon(icon); + it.command = { title: label, command }; + return it; + } + + // ---------------------------------------------------------- Versions + + private versionsGroup(p: Probe): Item { + const item = new Item('Versions', vscode.TreeItemCollapsibleState.Expanded); + item.iconPath = new vscode.ThemeIcon('versions'); + const children: Item[] = []; + if (!p.caveOk) { + const missing = new Item('cave is not installed'); + missing.iconPath = statusIcon('warn'); + missing.command = { title: 'Set up', command: 'vs-code-aster.runSetup' }; + missing.description = 'click to run setup'; + children.push(missing); + } else if (p.installedVersions.length === 0) { + const empty = new Item('No code_aster image installed'); + empty.iconPath = statusIcon('warn'); + empty.description = 'click to install'; + empty.command = { title: 'Install', command: 'vs-code-aster.installCaveVersion' }; + children.push(empty); + } else { + for (const v of p.installedVersions) { + const it = new Item(v); + const isCurrent = v === p.currentVersion; + it.description = isCurrent ? '(current)' : undefined; + it.iconPath = new vscode.ThemeIcon( + isCurrent ? 'check' : 'circle-outline', + isCurrent ? new vscode.ThemeColor('testing.iconPassed') : undefined + ); + // Click → cave use directly. The wrapper skips no-ops when + // is already current. + it.command = { + title: 'Switch', + command: 'vs-code-aster.switchCaveVersion', + arguments: [v], + }; + children.push(it); + } + } + const install = new Item('Install another version…'); + install.iconPath = new vscode.ThemeIcon('cloud-download'); + install.command = { title: 'Install', command: 'vs-code-aster.installCaveVersion' }; + children.push(install); + item.children = children; + return item; + } + + // ---------------------------------------------------------- Settings + + private settingsGroup(): Item { + const item = new Item('Settings', vscode.TreeItemCollapsibleState.Collapsed); + item.iconPath = new vscode.ThemeIcon('settings-gear'); + item.children = [ + this.settingItem('Python interpreter', 'vs-code-aster.pythonExecutablePath', 'terminal'), + this.settingItem('Formatter', 'vs-code-aster.formatter', 'wand'), + this.settingItem('Run alias', 'vs-code-aster.aliasForRun', 'play-circle'), + this.settingItem('Catalog path (override)', 'vs-code-aster.asterCatalogPath', 'library'), + this.settingItem( + 'Supported .comm extensions', + 'vs-code-aster.commFileExtensions', + 'file-code' + ), + this.settingItem( + 'Supported MED extensions', + 'vs-code-aster.medFileExtensions', + 'file-binary' + ), + this.settingItem('Max run logs', 'vs-code-aster.maxRunLogs', 'history'), + ]; + return item; + } + + private settingItem(label: string, key: string, icon: string): Item { + const it = new Item(label); + it.iconPath = new vscode.ThemeIcon(icon); + const config = vscode.workspace.getConfiguration(); + const value = config.get(key); + it.description = formatSettingValue(value); + it.tooltip = `${key} = ${JSON.stringify(value)}`; + it.command = { + title: 'Open setting', + command: 'workbench.action.openSettings', + arguments: [key], + }; + return it; + } +} + +function formatSettingValue(v: unknown): string { + if (v === undefined || v === null || v === '') { + return '(default)'; + } + if (Array.isArray(v)) { + return v.length > 4 ? `${v.slice(0, 4).join(', ')}, +${v.length - 4}` : v.join(', '); + } + if (typeof v === 'string') { + return v.length > 40 ? v.slice(0, 37) + '…' : v; + } + return String(v); +} + +export function registerSidebar(context: vscode.ExtensionContext): SidebarProvider { + const provider = new SidebarProvider(context); + context.subscriptions.push( + vscode.window.registerTreeDataProvider(VIEW_ID, provider), + vscode.commands.registerCommand('vs-code-aster.sidebar.refresh', () => provider.refresh()) + ); + return provider; +} diff --git a/src/extension.ts b/src/extension.ts index 13b2c27..df3c02b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,7 +8,9 @@ import * as path from 'path'; import { VisuManager } from './VisuManager'; import { ExportEditor } from './ExportEditor'; import { ExportFormatter } from './ExportFormatter'; -import { CommFormatter, offerInstallRuff } from './CommFormatter'; +import { CommFormatter } from './CommFormatter'; +import { runSetupProbes } from './SetupOnboarding'; +import { registerSidebar } from './SidebarView'; import { RunAster } from './RunAster'; import { LspServer } from './LspServer'; import { StatusBar } from './StatusBar'; @@ -48,7 +50,7 @@ export async function activate(context: vscode.ExtensionContext) { ) ); - const commFormatter = new CommFormatter(); + const commFormatter = new CommFormatter(context); context.subscriptions.push( vscode.languages.registerDocumentFormattingEditProvider({ language: 'comm' }, commFormatter), vscode.languages.registerDocumentRangeFormattingEditProvider( @@ -57,21 +59,30 @@ export async function activate(context: vscode.ExtensionContext) { ) ); - // Offer to install ruff the first time a .comm file is opened, so the user - // doesn't discover the dependency only when trying to format. + // Run the full setup chain (Python deps → ruff → Docker → cave → image) + // the first time a .comm or .export file is opened. Each step is opt-in + // via toast; "Don't ask again" answers persist in globalState. + const ASTER_LANGS = new Set(['comm', 'export']); context.subscriptions.push( vscode.workspace.onDidOpenTextDocument((doc) => { - if (doc.languageId === 'comm') { - void offerInstallRuff(context); + if (ASTER_LANGS.has(doc.languageId)) { + void runSetupProbes(context); } }) ); for (const doc of vscode.workspace.textDocuments) { - if (doc.languageId === 'comm') { - void offerInstallRuff(context); + if (ASTER_LANGS.has(doc.languageId)) { + void runSetupProbes(context); break; } } + const sidebar = registerSidebar(context); + context.subscriptions.push( + vscode.commands.registerCommand('vs-code-aster.runSetup', async () => { + await runSetupProbes(context, { force: true }); + sidebar.refresh(); + }) + ); const createMesh = vscode.commands.registerCommand('vs-code-aster.meshViewer', () => { VisuManager.instance.createOrShowMeshViewer(); From 2f9ac69954835235075434b125d20461dca07ca7 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 15:43:43 +0200 Subject: [PATCH 5/9] feat: edit-time diagnostics for .comm files with quick-fix code actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces eight categories of issue as VS Code Diagnostics, all sourced from the existing CommandRegistry + CATA introspection: unknown command, unknown keyword, value-not-in-into, missing required keyword, regles violation, undefined variable, variable type mismatch, and a soft information-level note for legacy/boilerplate commands. Quick fixes are offered for the high-confidence cases (fuzzy-match command or keyword name; pick from allowed values). Robustness — every CATA touch is wrapped so a single quirky catalog entry can't crash the LSP or shadow other features. validate() always returns a (possibly empty) list, never raises; the publish helper swallows exceptions and falls back to clearing diagnostics. The extra `---` HR before the doc link in hovers is removed: when VS Code stacks multiple hover contributions (our hover + a diagnostic), the rule was only as wide as our block and looked broken. New: validators.py (shared CATA helpers), diagnostics_manager.py, code_action_manager.py, position-aware parse_keyword_positions on CommandRegistry. Wired into handlers.py with a 200ms debounce on didChange. --- python/lsp/command_registry.py | 200 +++++++++ python/lsp/handlers.py | 64 +++ python/lsp/managers/__init__.py | 4 + python/lsp/managers/code_action_manager.py | 108 +++++ python/lsp/managers/diagnostics_manager.py | 464 +++++++++++++++++++++ python/lsp/managers/hover_manager.py | 8 +- python/lsp/managers_container.py | 4 + python/lsp/validators.py | 232 +++++++++++ 8 files changed, 1081 insertions(+), 3 deletions(-) create mode 100644 python/lsp/managers/code_action_manager.py create mode 100644 python/lsp/managers/diagnostics_manager.py create mode 100644 python/lsp/validators.py diff --git a/python/lsp/command_registry.py b/python/lsp/command_registry.py index fb3879f..3116880 100644 --- a/python/lsp/command_registry.py +++ b/python/lsp/command_registry.py @@ -29,6 +29,26 @@ def contains_line(self, line: int) -> bool: return self.start_line <= line <= self.zone_end +@dataclass +class KwargPosition: + """A top-level `KEY=VALUE` pair as it appears in the source. + + Coordinates are 0-based (LSP convention). All four positions are + inside the original document — `value` is the raw source slice + (whitespace stripped, trailing comma stripped) used by the + diagnostics layer to reason about the literal the user typed. + """ + + name: str + value: str + name_line: int + name_col_start: int + name_col_end: int + value_line: int + value_col_start: int + value_col_end: int + + class CommandRegistry: """ Command registry with incremental updates (Unique for each .comm file) @@ -375,6 +395,186 @@ def _parse_params_level1( return params + def parse_keyword_positions( + self, lines: list[str], cmd_info: CommandInfo + ) -> list[KwargPosition]: + """Walk the call's character stream tracking line/column to + produce per-kwarg ranges. Top-level only (skips inside `_F(...)` + and other nested calls). String- and comment-aware. + + Returns an empty list on any parse failure so diagnostics never + crash the LSP.""" + try: + return self._parse_keyword_positions(lines, cmd_info) + except Exception: + return [] + + def _parse_keyword_positions( + self, lines: list[str], cmd_info: CommandInfo + ) -> list[KwargPosition]: + out: list[KwargPosition] = [] + start_idx = max(0, cmd_info.start_line - 1) + end_idx = min(len(lines) - 1, cmd_info.zone_end - 1) + if start_idx > end_idx: + return out + + # Find the call's opening `(`. + line_idx = start_idx + col = 0 + found_open = False + while line_idx <= end_idx: + line = lines[line_idx] + paren_pos = line.find("(", col) + if paren_pos != -1: + col = paren_pos + 1 + found_open = True + break + line_idx += 1 + col = 0 + if not found_open: + return out + + depth = 0 # depth INSIDE the call (excluding the call's own `(`) + in_string: str | None = None + + # Scanner state for the current pending kwarg (we collect chars + # one at a time and snapshot ranges at delimiter boundaries). + pending_name = "" + pending_name_start = (-1, -1) # (line, col) + kwarg_name: str | None = None + kwarg_name_range: tuple[int, int, int] | None = None # (line, col_start, col_end) + value_start: tuple[int, int] | None = None # (line, col) + value_chars: list[str] = [] # accumulated source chars of the current value + + def flush_kwarg(end_line: int, end_col: int) -> None: + nonlocal kwarg_name, kwarg_name_range, value_start, value_chars + if kwarg_name and value_start and kwarg_name_range: + value = "".join(value_chars).strip().rstrip(",").strip() + vl, vc = value_start + # Trim trailing whitespace from the value's column range + # so squiggles don't include the comma the user just typed. + vc_end = end_col + out.append( + KwargPosition( + name=kwarg_name, + value=value, + name_line=kwarg_name_range[0], + name_col_start=kwarg_name_range[1], + name_col_end=kwarg_name_range[2], + value_line=vl, + value_col_start=vc, + value_col_end=vc_end, + ) + ) + kwarg_name = None + kwarg_name_range = None + value_start = None + value_chars = [] + + while line_idx <= end_idx: + line = lines[line_idx] + while col < len(line): + ch = line[col] + + if in_string: + if value_start is not None: + value_chars.append(ch) + if ch == in_string and (col == 0 or line[col - 1] != "\\"): + in_string = None + col += 1 + continue + + if ch in ("'", '"'): + if value_start is not None: + value_chars.append(ch) + in_string = ch + col += 1 + continue + + if ch == "#": + # Inline comment runs to end of line. + break # break inner while; outer advances line + + if ch == "(": + depth += 1 + if value_start is not None: + value_chars.append(ch) + col += 1 + continue + + if ch == ")": + if depth == 0: + # End of the call. + flush_kwarg(line_idx, col) + return out + depth -= 1 + if value_start is not None: + value_chars.append(ch) + col += 1 + continue + + if ch == "," and depth == 0: + flush_kwarg(line_idx, col) + pending_name = "" + pending_name_start = (-1, -1) + col += 1 + continue + + if depth > 0: + if value_start is not None: + value_chars.append(ch) + col += 1 + continue + + # Top-level scope: track identifiers and `=`. + if ch.isalnum() or ch == "_": + if pending_name == "": + pending_name_start = (line_idx, col) + pending_name += ch + if value_start is not None: + value_chars.append(ch) + col += 1 + continue + + if ch == "=" and pending_name and (col + 1 >= len(line) or line[col + 1] != "="): + # `KEY=` boundary — flush any prior kwarg, start a new one. + flush_kwarg(line_idx, col) + kwarg_name = pending_name + kwarg_name_range = ( + pending_name_start[0], + pending_name_start[1], + pending_name_start[1] + len(pending_name), + ) + pending_name = "" + pending_name_start = (-1, -1) + col += 1 + # skip whitespace before value + while col < len(line) and line[col].isspace(): + col += 1 + value_start = (line_idx, col) + value_chars = [] + continue + + # any other char (whitespace, operator…) — keep value running + if value_start is not None: + value_chars.append(ch) + if ch.isspace(): + pending_name = "" + pending_name_start = (-1, -1) + col += 1 + + # next line + line_idx += 1 + col = 0 + if value_start is not None: + value_chars.append("\n") + pending_name = "" + pending_name_start = (-1, -1) + + # Document ended before the call closed — flush whatever we have. + flush_kwarg(line_idx if line_idx <= end_idx else end_idx, 0) + return out + def _reparse_command(self, lines: list[str], cmd_key: str): """Re-parse a specific command after modification""" cmd_info = self.commands[cmd_key] diff --git a/python/lsp/handlers.py b/python/lsp/handlers.py index 8af3fec..83547cd 100644 --- a/python/lsp/handlers.py +++ b/python/lsp/handlers.py @@ -1,6 +1,11 @@ # python/lsp/full_parsing.py +import asyncio +import sys + from lsprotocol.types import ( + CodeActionKind, + CodeActionParams, CompletionList, CompletionParams, DidChangeTextDocumentParams, @@ -18,6 +23,47 @@ managers = ManagerContainer() +# Per-document debounce tasks for diagnostics so we don't re-validate on +# every keystroke. Keyed by document URI. +_diag_tasks: dict[str, asyncio.Task] = {} +_DEBOUNCE_S = 0.2 + + +def _publish_diagnostics(ls: LanguageServer, doc_uri: str) -> None: + """Run validation and ship diagnostics to the client. Wrapped so + that a crash in the diagnostics layer can't propagate.""" + try: + diags = managers.diagnostics.validate(doc_uri) + ls.publish_diagnostics(doc_uri, diags) + except Exception as exc: + sys.stderr.write(f"[diagnostics] publish failed: {exc!r}\n") + sys.stderr.flush() + try: + ls.publish_diagnostics(doc_uri, []) + except Exception: + pass + + +def _schedule_diagnostics(ls: LanguageServer, doc_uri: str) -> None: + """Debounce: cancel any in-flight task for this URI and queue a new + one to fire after `_DEBOUNCE_S` seconds.""" + prev = _diag_tasks.pop(doc_uri, None) + if prev is not None and not prev.done(): + prev.cancel() + + async def _delayed(): + try: + await asyncio.sleep(_DEBOUNCE_S) + _publish_diagnostics(ls, doc_uri) + except asyncio.CancelledError: + return + + try: + _diag_tasks[doc_uri] = asyncio.ensure_future(_delayed()) + except RuntimeError: + # No running loop (e.g. unit-test path) — fall back to synchronous. + _publish_diagnostics(ls, doc_uri) + def register_handlers(server: LanguageServer): @@ -32,6 +78,9 @@ def on_initialize(ls: LanguageServer, params: InitializeParams): }, "hoverProvider": True, "definitionProvider": True, + "codeActionProvider": { + "codeActionKinds": [CodeActionKind.QuickFix], + }, } } @@ -42,6 +91,7 @@ def on_document_open(ls: LanguageServer, params: DidOpenTextDocumentParams): doc = ls.workspace.get_document(doc_uri) managers.update.init_registry(doc, doc_uri) + _publish_diagnostics(ls, doc_uri) @server.feature("textDocument/didChange") def on_text_change(ls: LanguageServer, params: DidChangeTextDocumentParams): @@ -50,6 +100,7 @@ def on_text_change(ls: LanguageServer, params: DidChangeTextDocumentParams): doc = ls.workspace.get_document(doc_uri) managers.update.update_registry(doc, doc_uri, params.content_changes) + _schedule_diagnostics(ls, doc_uri) @server.feature("textDocument/completion") def completion(ls: LanguageServer, params: CompletionParams) -> CompletionList: @@ -73,6 +124,19 @@ def hover(ls: LanguageServer, params: HoverParams) -> Hover: return managers.hover.display(doc_uri, position) + @server.feature("textDocument/codeAction") + def code_action(ls: LanguageServer, params: CodeActionParams): + """Quick fixes for diagnostics. The diagnostics carry the + candidate replacements in their `data` field, so this handler + is just a dispatcher.""" + try: + diags = list(getattr(params.context, "diagnostics", []) or []) + return managers.code_action.actions(params.text_document.uri, diags) + except Exception as exc: + sys.stderr.write(f"[codeAction] handler crashed: {exc!r}\n") + sys.stderr.flush() + return [] + @server.feature("workspace/didChangeWatchedFiles") def ignore_watched_files(ls: LanguageServer, params: DidChangeWatchedFilesParams): """Handler vide pour ignorer les notifications de fichiers surveillés.""" diff --git a/python/lsp/managers/__init__.py b/python/lsp/managers/__init__.py index e63a97b..9c793b6 100644 --- a/python/lsp/managers/__init__.py +++ b/python/lsp/managers/__init__.py @@ -1,12 +1,16 @@ # managers/__init__.py +from .code_action_manager import CodeActionManager from .completion_manager import CompletionManager +from .diagnostics_manager import DiagnosticsManager from .hover_manager import HoverManager from .signature_manager import SignatureManager from .status_bar_manager import StatusBarManager from .update_manager import UpdateManager __all__ = [ + "CodeActionManager", "CompletionManager", + "DiagnosticsManager", "SignatureManager", "HoverManager", "UpdateManager", diff --git a/python/lsp/managers/code_action_manager.py b/python/lsp/managers/code_action_manager.py new file mode 100644 index 0000000..d013479 --- /dev/null +++ b/python/lsp/managers/code_action_manager.py @@ -0,0 +1,108 @@ +"""Quick-fix code actions for diagnostics emitted by DiagnosticsManager. + +The dispatch lives entirely off `Diagnostic.code` and the `data` payload +each diagnostic carries — no recomputation of fuzzy matches. +""" + +from __future__ import annotations + +import sys + +from lsprotocol.types import ( + CodeAction, + CodeActionKind, + Diagnostic, + TextEdit, + WorkspaceEdit, +) +from managers.diagnostics_manager import ( + CODE_UNKNOWN_COMMAND, + CODE_UNKNOWN_KWARG, + CODE_VALUE_NOT_IN_INTO, +) + + +def _log(msg: str) -> None: + sys.stderr.write(msg + "\n") + sys.stderr.flush() + + +class CodeActionManager: + def actions(self, doc_uri: str, diagnostics: list[Diagnostic]) -> list[CodeAction]: + out: list[CodeAction] = [] + for d in diagnostics or []: + try: + out.extend(self._actions_for(doc_uri, d)) + except Exception as exc: + _log(f"[codeAction] crashed for code={d.code}: {exc!r}") + return out + + def _actions_for(self, doc_uri: str, d: Diagnostic) -> list[CodeAction]: + code = getattr(d, "code", None) + data = _data_dict(d) + if code == CODE_UNKNOWN_COMMAND: + return self._replace_actions( + doc_uri, d, "Replace with `{cand}`", data.get("candidates") or [] + ) + if code == CODE_UNKNOWN_KWARG: + return self._replace_actions( + doc_uri, d, "Rename to `{cand}`", data.get("candidates") or [] + ) + if code == CODE_VALUE_NOT_IN_INTO: + allowed = data.get("allowed") or [] + # Emit a quick fix per allowed value, quoted as the user + # would have typed it (strings get single quotes). + literals = [_format_literal(v) for v in allowed] + return self._replace_actions(doc_uri, d, "Replace with `{cand}`", literals) + return [] + + def _replace_actions( + self, + doc_uri: str, + d: Diagnostic, + title_template: str, + candidates: list[str], + ) -> list[CodeAction]: + out: list[CodeAction] = [] + for cand in candidates[:6]: + try: + edit = WorkspaceEdit(changes={doc_uri: [TextEdit(range=d.range, new_text=cand)]}) + out.append( + CodeAction( + title=title_template.format(cand=cand), + kind=CodeActionKind.QuickFix, + diagnostics=[d], + edit=edit, + is_preferred=(cand == candidates[0]), + ) + ) + except Exception as exc: + _log(f"[codeAction] could not build edit for {cand!r}: {exc!r}") + return out + + +def _data_dict(d: Diagnostic) -> dict: + raw = getattr(d, "data", None) + if isinstance(raw, dict): + return raw + # pygls may surface .data as an attrs-style object; do best-effort. + out: dict = {} + for k in ("candidates", "allowed", "name", "keyword", "rule", "args"): + try: + v = getattr(raw, k) + if v is not None: + out[k] = v + except Exception: + continue + return out + + +def _format_literal(v) -> str: + if isinstance(v, str): + # Heuristic: numeric strings are tags like "0"/"1" that should stay bare. + try: + float(v) + return v + except (TypeError, ValueError): + return f"'{v}'" + return str(v) diff --git a/python/lsp/managers/diagnostics_manager.py b/python/lsp/managers/diagnostics_manager.py new file mode 100644 index 0000000..03af45b --- /dev/null +++ b/python/lsp/managers/diagnostics_manager.py @@ -0,0 +1,464 @@ +"""Edit-time diagnostics for code_aster `.comm` files. + +Walks each command in the registry, validates against CATA, and emits +LSP `Diagnostic` objects. Every per-command and per-keyword check is +wrapped to swallow exceptions so a CATA quirk can never block hover, +completion, or formatting. +""" + +from __future__ import annotations + +import re +import sys +import traceback + +from command_core import CommandCore +from lsprotocol.types import ( + Diagnostic, + DiagnosticSeverity, + Position, + Range, +) +from validators import ( + command_return_types, + expected_classes, + find_keyword, + find_param, + is_bare_identifier, + required_keywords, + types_compatible, + value_in_into, + visible_keywords, +) + + +def _log(msg: str) -> None: + sys.stderr.write(msg + "\n") + sys.stderr.flush() + + +# Diagnostic codes (kept stable so CodeActionManager can dispatch on them). +CODE_UNKNOWN_COMMAND = "unknown-command" +CODE_UNKNOWN_KWARG = "unknown-kwarg" +CODE_VALUE_NOT_IN_INTO = "value-not-in-into" +CODE_REQUIRED_MISSING = "required-missing" +CODE_RULE_VIOLATION = "rule-violation" +CODE_UNDEFINED_VARIABLE = "undefined-variable" +CODE_TYPE_MISMATCH = "type-mismatch" +CODE_DEPRECATED = "deprecated" + + +_RULE_TEMPLATES = { + "AtLeastOne": "At least one of {args} must be defined.", + "ExactlyOne": "Exactly one of {args} must be defined.", + "AtMostOne": "At most one of {args} may be defined.", + "IfFirstAllPresent": "If `{first}` is set, all of {rest} must also be set.", + "OnlyFirstPresent": "If `{first}` is set, none of {rest} may be set.", + "AllTogether": "Either all or none of {args} must be defined.", + "NotEmpty": "At least one keyword must be provided.", +} + + +class DiagnosticsManager: + def __init__(self): + self.core = CommandCore() + try: + from asterstudy.datamodel.dict_categories import DEPRECATED as _DEP + + self._deprecated = set(_DEP or []) + except Exception: + self._deprecated = set() + + # -------------------------------------------------------- entry + + def validate(self, doc_uri: str) -> list[Diagnostic]: + """Validate the whole document. Always returns a (possibly empty) + list — never raises.""" + try: + return self._validate(doc_uri) + except Exception as exc: + _log(f"[diagnostics] validate({doc_uri}) crashed: {exc!r}\n{traceback.format_exc()}") + return [] + + def _validate(self, doc_uri: str) -> list[Diagnostic]: + registry = self.core.get_registry(doc_uri) + doc = self.core.get_doc_from_uri(doc_uri) + if registry is None or doc is None: + return [] + cata = self.core.get_CATA() + diags: list[Diagnostic] = [] + + # Build a `var_name → (start_line_1based, command_name)` index for + # variable-reference / type-compat checks. The earliest assignment + # wins on duplicates (we'll re-evaluate later if needed). + var_index: dict[str, tuple[int, str]] = {} + for ci in registry.commands.values(): + try: + if ci.var_name and ci.var_name not in var_index: + var_index[ci.var_name] = (ci.start_line, ci.name) + except Exception: + continue + + for ci in registry.commands.values(): + try: + diags.extend(self._validate_command(doc.lines, registry, ci, cata, var_index)) + except Exception as exc: + _log(f"[diagnostics] cmd={ci.name} crashed: {exc!r}") + _log(f"[diagnostics] {doc_uri}: {len(diags)} issue(s)") + return diags + + # -------------------------------------------------------- per command + + def _validate_command( + self, + lines: list[str], + registry, + ci, + cata, + var_index: dict[str, tuple[int, str]], + ) -> list[Diagnostic]: + diags: list[Diagnostic] = [] + + # -- 1. unknown command ------------------------------------------ + cmd_obj = None + try: + cmd_obj = cata.get_command_obj(ci.name) + except Exception: + cmd_obj = None + if cmd_obj is None: + diags.append(self._diag_unknown_command(lines, ci, cata)) + return diags + + # -- 8. deprecated (information, doesn't gate other checks) ------ + if ci.name in self._deprecated: + diags.append(self._diag_deprecated(lines, ci)) + + # Position-aware kwarg parse. + try: + pairs = registry.parse_keyword_positions(lines, ci) + except Exception: + pairs = [] + + context = {} + try: + context = ci.parsed_params.copy() + except Exception: + context = {} + + try: + cmd_def_params = self.core.get_command_def(ci.name).get("params", []) + except Exception: + cmd_def_params = [] + + typed_names: set[str] = set() + for pair in pairs: + try: + typed_names.add(pair.name) + self._check_pair(pair, cmd_obj, cmd_def_params, context, var_index, ci, diags) + except Exception as exc: + _log(f"[diagnostics] pair {pair.name} in {ci.name} crashed: {exc!r}") + + # -- 4. required keywords missing in active scope ---------------- + try: + for required in required_keywords(cmd_obj.definition, context): + if required not in typed_names: + diags.append(self._diag_required_missing(lines, ci, required)) + except Exception: + pass + + # -- 5. regles violations --------------------------------------- + try: + rules = getattr(cmd_obj, "_rules", None) or [] + for rule in rules: + try: + diag = self._check_rule(lines, ci, rule, typed_names) + if diag: + diags.append(diag) + except Exception: + continue + except Exception: + pass + + return diags + + # -------------------------------------------------------- per pair + + def _check_pair(self, pair, cmd_obj, cmd_def_params, context, var_index, ci, diags) -> None: + # -- 2. unknown keyword ----------------------------------------- + kwd = find_keyword(cmd_obj.definition, pair.name, context) + if kwd is None: + visible_names = [n for n, _ in visible_keywords(cmd_obj.definition, context)] + diags.append(self._diag_unknown_kwarg(pair, visible_names)) + return # rest of the pair's checks don't apply + + # -- 3. value not in `into` (SIMP scalars only) ----------------- + param = find_param(cmd_def_params, pair.name) + if param is not None: + try: + into = param.get("allowed") + if into and not is_factor_value(pair.value): + if not value_in_into(pair.value, into): + diags.append(self._diag_value_not_in_into(pair, into)) + # don't double-flag with type mismatch + return + except Exception: + pass + + # -- 6/7. variable reference checks ----------------------------- + if is_bare_identifier(pair.value): + ref_name = pair.value.strip().rstrip(",").strip() + if ref_name not in var_index: + diags.append(self._diag_undefined_var(pair, ref_name)) + return + assigned_line, src_cmd = var_index[ref_name] + if assigned_line >= ci.start_line: + diags.append(self._diag_undefined_var(pair, ref_name, used_before_def=True)) + return + # type compatibility (only when we have classes on both sides) + try: + expected = expected_classes(param) if param else () + if expected: + src_obj = None + try: + src_obj = self.core.get_CATA().get_command_obj(src_cmd) + except Exception: + src_obj = None + if src_obj is not None: + var_types = command_return_types(src_obj) + if var_types and not types_compatible(var_types, expected): + diags.append( + self._diag_type_mismatch(pair, ref_name, var_types, expected) + ) + except Exception: + pass + + # -------------------------------------------------------- diagnostic builders + + @staticmethod + def _line_text(lines: list[str], idx: int) -> str: + if 0 <= idx < len(lines): + return lines[idx] + return "" + + def _diag_unknown_command(self, lines, ci, cata) -> Diagnostic: + # Locate the command name on its start line. + idx = max(0, ci.start_line - 1) + line = self._line_text(lines, idx) + m = re.search(rf"\b{re.escape(ci.name)}\b", line) + if m: + rng = Range(Position(idx, m.start()), Position(idx, m.end())) + else: + rng = Range(Position(idx, 0), Position(idx, max(0, len(line)))) + candidates = [] + try: + all_cmds = [c["name"] for c in cata.get_commands()] + candidates = nearest(ci.name, all_cmds, n=3) + except Exception: + candidates = [] + msg = f"Unknown code_aster command `{ci.name}`." + if candidates: + msg += " Did you mean " + ", ".join(f"`{c}`" for c in candidates) + "?" + return Diagnostic( + range=rng, + severity=DiagnosticSeverity.Error, + code=CODE_UNKNOWN_COMMAND, + source="code_aster", + message=msg, + data={"candidates": candidates, "name": ci.name}, + ) + + def _diag_unknown_kwarg(self, pair, visible_names: list[str]) -> Diagnostic: + candidates = nearest(pair.name, visible_names, n=3) + msg = f"Unknown keyword `{pair.name}`." + if candidates: + msg += " Did you mean " + ", ".join(f"`{c}`" for c in candidates) + "?" + return Diagnostic( + range=Range( + Position(pair.name_line, pair.name_col_start), + Position(pair.name_line, pair.name_col_end), + ), + severity=DiagnosticSeverity.Error, + code=CODE_UNKNOWN_KWARG, + source="code_aster", + message=msg, + data={"candidates": candidates, "name": pair.name}, + ) + + def _diag_value_not_in_into(self, pair, into) -> Diagnostic: + rendered = ", ".join(f'"{v}"' if isinstance(v, str) else str(v) for v in into) + return Diagnostic( + range=Range( + Position(pair.value_line, pair.value_col_start), + Position(pair.value_line, pair.value_col_end), + ), + severity=DiagnosticSeverity.Error, + code=CODE_VALUE_NOT_IN_INTO, + source="code_aster", + message=f"`{pair.value}` is not allowed for `{pair.name}`. Allowed: {rendered}.", + data={"keyword": pair.name, "allowed": [str(v) for v in into]}, + ) + + def _diag_required_missing(self, lines, ci, required: str) -> Diagnostic: + idx = max(0, ci.start_line - 1) + line = self._line_text(lines, idx) + m = re.search(rf"\b{re.escape(ci.name)}\b", line) + if m: + rng = Range(Position(idx, m.start()), Position(idx, m.end())) + else: + rng = Range(Position(idx, 0), Position(idx, max(0, len(line)))) + return Diagnostic( + range=rng, + severity=DiagnosticSeverity.Error, + code=CODE_REQUIRED_MISSING, + source="code_aster", + message=f"`{ci.name}` requires keyword `{required}` (not provided).", + data={"keyword": required}, + ) + + def _check_rule(self, lines, ci, rule, typed_names: set[str]) -> Diagnostic | None: + cls = type(rule).__name__ + args = tuple(getattr(rule, "ruleArgs", ()) or ()) + if not args and cls != "NotEmpty": + return None + present = [a for a in args if a in typed_names] + violated = False + if cls == "AtLeastOne" and len(present) < 1: + violated = True + elif cls == "ExactlyOne" and len(present) != 1: + violated = True + elif cls == "AtMostOne" and len(present) > 1: + violated = True + elif cls == "IfFirstAllPresent": + if args[0] in typed_names and any(a not in typed_names for a in args[1:]): + violated = True + elif cls == "OnlyFirstPresent": + if args[0] in typed_names and any(a in typed_names for a in args[1:]): + violated = True + elif cls == "AllTogether": + if 0 < len(present) < len(args): + violated = True + elif cls == "NotEmpty": + if not typed_names: + violated = True + if not violated: + return None + template = _RULE_TEMPLATES.get(cls, f"{cls} rule violated.") + first = f"`{args[0]}`" if args else "" + rest = ", ".join(f"`{a}`" for a in args[1:]) if len(args) > 1 else "" + joined = ", ".join(f"`{a}`" for a in args) if args else "" + msg = template.format(args=joined, first=first, rest=rest) + + idx = max(0, ci.start_line - 1) + line = self._line_text(lines, idx) + m = re.search(rf"\b{re.escape(ci.name)}\b", line) + rng = ( + Range(Position(idx, m.start()), Position(idx, m.end())) + if m + else Range(Position(idx, 0), Position(idx, max(0, len(line)))) + ) + return Diagnostic( + range=rng, + severity=DiagnosticSeverity.Error, + code=CODE_RULE_VIOLATION, + source="code_aster", + message=msg, + data={"rule": cls, "args": list(args)}, + ) + + def _diag_undefined_var(self, pair, name: str, used_before_def: bool = False) -> Diagnostic: + msg = ( + f"`{name}` is used before it is assigned." + if used_before_def + else f"`{name}` is not defined." + ) + return Diagnostic( + range=Range( + Position(pair.value_line, pair.value_col_start), + Position(pair.value_line, pair.value_col_end), + ), + severity=DiagnosticSeverity.Error, + code=CODE_UNDEFINED_VARIABLE, + source="code_aster", + message=msg, + data={"name": name}, + ) + + def _diag_type_mismatch(self, pair, name: str, var_types, expected) -> Diagnostic: + var_str = ", ".join(t.__name__ for t in var_types) or "?" + exp_str = ", ".join(t.__name__ for t in expected) or "?" + return Diagnostic( + range=Range( + Position(pair.value_line, pair.value_col_start), + Position(pair.value_line, pair.value_col_end), + ), + severity=DiagnosticSeverity.Warning, + code=CODE_TYPE_MISMATCH, + source="code_aster", + message=(f"`{name}` has type `{var_str}`, but `{pair.name}` expects `{exp_str}`."), + data={"name": name, "varTypes": [t.__name__ for t in var_types]}, + ) + + def _diag_deprecated(self, lines, ci) -> Diagnostic: + idx = max(0, ci.start_line - 1) + line = self._line_text(lines, idx) + m = re.search(rf"\b{re.escape(ci.name)}\b", line) + rng = ( + Range(Position(idx, m.start()), Position(idx, m.end())) + if m + else Range(Position(idx, 0), Position(idx, max(0, len(line)))) + ) + return Diagnostic( + range=rng, + severity=DiagnosticSeverity.Information, + code=CODE_DEPRECATED, + source="code_aster", + message=f"`{ci.name}` is categorised as legacy / boilerplate.", + data={"name": ci.name}, + ) + + +def is_factor_value(raw: str) -> bool: + """Heuristic: a value beginning with `_F(` is a factor block, not a + SIMP scalar — `into` validation doesn't apply to it.""" + s = (raw or "").strip() + return s.startswith("_F(") or s.startswith("(_F(") or s.startswith("(") and "_F(" in s + + +# ---- fuzzy match (simple Levenshtein, top-N) ------------------------------- + + +def nearest(target: str, candidates, n: int = 3) -> list[str]: + target = (target or "").upper() + scored = [] + for c in candidates: + if not isinstance(c, str): + continue + d = _levenshtein(target, c.upper()) + scored.append((d, c)) + scored.sort() + # Only suggest if at least somewhat close; cap edit distance to half-length. + cutoff = max(2, len(target) // 2) + out = [c for d, c in scored if d <= cutoff][:n] + return out + + +def _levenshtein(a: str, b: str) -> int: + if a == b: + return 0 + if not a: + return len(b) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + cur = [i] + for j, cb in enumerate(b, 1): + cur.append( + min( + cur[j - 1] + 1, + prev[j] + 1, + prev[j - 1] + (0 if ca == cb else 1), + ) + ) + prev = cur + return prev[-1] diff --git a/python/lsp/managers/hover_manager.py b/python/lsp/managers/hover_manager.py index 1af4501..78a03e2 100644 --- a/python/lsp/managers/hover_manager.py +++ b/python/lsp/managers/hover_manager.py @@ -522,13 +522,15 @@ def _render_command(cmd_obj, context) -> str: def _append_doc_link(out: list[str], command_name: str) -> None: - """Always the last block. Preceded by a horizontal rule so it reads as a - footer separate from the content above it.""" + """Always the last block. A blank line keeps the link visually + separated from the body above; we deliberately don't emit a `---` + HR — when VS Code stacks multiple hover contributions (e.g. our + hover + a diagnostic), our HR would be only as wide as our block, + not the popup, which looks broken.""" url = _doc_url(command_name) if not url: return out.append("") - out.append("---") out.append(f"[📘 {_t('search_doc', name=command_name)}]({url})") diff --git a/python/lsp/managers_container.py b/python/lsp/managers_container.py index 5584df7..e377333 100644 --- a/python/lsp/managers_container.py +++ b/python/lsp/managers_container.py @@ -1,5 +1,7 @@ from managers import ( + CodeActionManager, CompletionManager, + DiagnosticsManager, HoverManager, SignatureManager, StatusBarManager, @@ -19,3 +21,5 @@ def __init__(self): self.update = UpdateManager() self.signature = SignatureManager() self.completion = CompletionManager() + self.diagnostics = DiagnosticsManager() + self.code_action = CodeActionManager() diff --git a/python/lsp/validators.py b/python/lsp/validators.py new file mode 100644 index 0000000..635210d --- /dev/null +++ b/python/lsp/validators.py @@ -0,0 +1,232 @@ +"""Shared catalog-validation helpers used by completion, diagnostics, and +code actions. + +Every helper that touches a CATA object is defensive: the upstream +catalog has known quirks (callable `sd_prod` that throw on +introspection, type fields that aren't classes, BLOC.isEnabled that +raise on partial context). Each helper degrades to a benign default on +any exception. +""" + +from __future__ import annotations + +import inspect +import re +import sys + + +def _is_factor(kwd) -> bool: + return "FactorKeyword" in type(kwd).__name__ + + +def _is_bloc(kwd) -> bool: + return "Bloc" in type(kwd).__name__ + + +def find_keyword(definition, name: str, context): + """Walk the keyword tree (descending into BLOCs and FACTs) and return + the first keyword whose key matches `name`. BLOC branches that are + disabled by `context` are skipped so a keyword only reachable via an + inactive branch is not returned when a context is known.""" + try: + for key, kwd in definition.items(): + if not hasattr(kwd, "definition"): + continue + if _is_bloc(kwd): + if context is not None: + try: + if not kwd.isEnabled(context): + continue + except Exception: + pass + found = find_keyword(kwd.definition, name, context) + if found is not None: + return found + continue + if key == name: + return kwd + if _is_factor(kwd): + found = find_keyword(kwd.definition, name, context) + if found is not None: + return found + except Exception: + return None + return None + + +def visible_keywords(definition, context): + """Yield (name, kwd) pairs for keywords visible at this scope, with + BLOC filtering when `context` is known.""" + try: + for key, kwd in definition.items(): + if not hasattr(kwd, "definition"): + continue + if _is_bloc(kwd): + if context is not None: + try: + if not kwd.isEnabled(context): + continue + except Exception: + pass + yield from visible_keywords(kwd.definition, context) + continue + yield (key, kwd) + except Exception: + return + + +def required_keywords(definition, context): + """Yield names of required keywords visible at this scope.""" + for name, kwd in visible_keywords(definition, context): + try: + if kwd.definition.get("statut") == "o": + yield name + except Exception: + continue + + +def find_param(params: list[dict], name: str) -> dict | None: + """Find a parsed-param dict (the shape produced by + `Catalogs.parse_kwd`) by name, descending into BLOC children.""" + try: + for p in params: + if p.get("name") == name: + return p + if p.get("bloc"): + inner = find_param(p.get("children", []) or [], name) + if inner is not None: + return inner + except Exception: + return None + return None + + +# ------------------------------------------------------- type compatibility + + +def command_return_types(cmd_obj) -> tuple: + """Resolve `sd_prod` to a tuple of concrete output classes. + + `sd_prod` may be (a) a class, (b) a callable that returns a class + or tuple of classes when called with `__all__=True` (the asterstudy + convention), or (c) None. Returns () on any failure. + """ + try: + sd = cmd_obj.definition.get("sd_prod") if hasattr(cmd_obj, "definition") else None + except Exception: + return () + if sd is None: + return () + if isinstance(sd, type): + return (sd,) + if not callable(sd): + return () + try: + sig = inspect.signature(sd) + positional = [ + p + for p in sig.parameters.values() + if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + and p.default is inspect.Parameter.empty + ] + result = sd(*([None] * len(positional)), __all__=True) + except Exception: + return () + if isinstance(result, type): + return (result,) + if isinstance(result, (tuple, list)): + return tuple(t for t in result if isinstance(t, type)) + return () + + +def expected_classes(param: dict) -> tuple: + """Pull the raw class(es) the SIMP keyword expects (stored as + `type_obj` by `parse_kwd`). Returns () when the type is a string tag + (like "R"/"I"/"TXM"), a validator instance, or None.""" + try: + typ = param.get("type_obj") + except Exception: + return () + if typ is None: + return () + if isinstance(typ, type): + return (typ,) + if isinstance(typ, (tuple, list)): + return tuple(t for t in typ if isinstance(t, type)) + return () + + +def types_compatible(var_types, expected) -> bool: + if not var_types or not expected: + return False + for vt in var_types: + for et in expected: + try: + if issubclass(vt, et): + return True + except TypeError: + continue + return False + + +# ------------------------------------------------------- value matching + + +_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_INT_RE = re.compile(r"^-?\d+$") +_FLOAT_RE = re.compile(r"^-?(\d+\.\d*|\d*\.\d+)([eE][+-]?\d+)?$|^-?\d+[eE][+-]?\d+$") + + +def value_in_into(raw: str, into) -> bool: + """True if the source-side string `raw` matches an entry in `into`.""" + raw = (raw or "").strip() + if not raw or not into: + return False + try: + for v in into: + if isinstance(v, str): + if raw == v: + return True + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'): + if raw[1:-1] == v: + return True + else: + # numeric comparison + if raw == str(v): + return True + try: + if float(raw) == float(v): + return True + except (TypeError, ValueError): + continue + except Exception: + return False + return False + + +def is_bare_identifier(raw: str) -> bool: + """True when `raw` looks like a Python variable reference (not a + string, not a number, not a literal True/False/None).""" + raw = (raw or "").strip().rstrip(",").strip() + if not raw or raw in ("None", "True", "False"): + return False + if raw[0] in ('"', "'"): + return False + if _INT_RE.match(raw) or _FLOAT_RE.match(raw): + return False + return bool(_IDENT_RE.match(raw)) + + +# ------------------------------------------------------- safety wrapper + + +def safe(fn, default=None, log_label: str | None = None): + """Call `fn()` and swallow any exception. Returns `default` on + failure. Used at every CATA boundary in the diagnostics path.""" + try: + return fn() + except Exception as exc: + if log_label: + sys.stderr.write(f"[diagnostics] {log_label} swallowed {type(exc).__name__}: {exc}\n") + sys.stderr.flush() + return default From 1210719433251e76c7ca9e8da05d0a7edb165dce Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 15:44:01 +0200 Subject: [PATCH 6/9] fix: reuse LanguageClient on restart to avoid duplicate providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously each restart constructed a new LanguageClient, which re-registered hover/completion/code-action providers on top of the existing ones — hence duplicate tooltips after every cave use. The intermediate fix (dispose the old client) introduced a different bug: VS Code kept calling the disposed client's code-action provider until its provider list was purged, throwing "Client got disposed and can't be restarted". Solution: keep a single LanguageClient for the extension lifetime and just stop()/start() it on restart. Server-side env (catalog path) is refreshed by mutating the saved serverOptions.options.env in place; LanguageClient re-reads it on each spawn. --- src/LspServer.ts | 73 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/src/LspServer.ts b/src/LspServer.ts index 202c5e2..eb2243e 100644 --- a/src/LspServer.ts +++ b/src/LspServer.ts @@ -26,6 +26,9 @@ export class LspServer { private _context?: vscode.ExtensionContext; private _caveWatcher?: fs.FSWatcher; private _caveDebounce?: NodeJS.Timeout; + // Kept as a class field so `restart()` can mutate `options.env` before + // bouncing the server — `LanguageClient` re-reads it on the next spawn. + private _serverOptions?: { command: string; args: string[]; options: { env: NodeJS.ProcessEnv } }; private constructor() {} @@ -122,6 +125,12 @@ export class LspServer { transport: TransportKind.stdio, options: { env }, }; + // Save so restart() can refresh env without rebuilding the client. + this._serverOptions = { + command: pythonExecutablePath, + args: [serverModule], + options: { env }, + }; const clientOptions: LanguageClientOptions = { documentSelector: [{ scheme: 'file', language: 'comm' }], @@ -207,32 +216,62 @@ export class LspServer { * Restarts the LSP server */ public async restart() { - if (this._client && this._client.isRunning()) { - await this._client.stop(); + // Reuse the same LanguageClient instance — constructing a new one + // would (a) re-register hover/completion/code-action providers + // (duplicate tooltips), (b) leave the old client's registrations in + // VS Code's provider list (the "Client got disposed and can't be + // restarted" error). stop() + start() on the same client avoids + // both. We still need fresh env vars on `cave use`; LanguageClient + // re-reads `serverOptions.options.env` on each spawn, so mutating + // it in place is enough. + if (!this._client || !this._context) { + // First-time start path — defer to start(). + if (this._context) { + await this.start(this._context); + } + return; } - // Rebuild the client so a fresh catalog path is resolved and injected - // into the server process env (handles `cave use` changes). - if (this._context) { - this._client = await this.createClient(this._context); + + try { + const resolved = await resolveCatalogPath(); + if (this._serverOptions) { + const env = this._serverOptions.options.env; + delete env.VS_CODE_ASTER_CATA_PATH; + if (resolved.path) { + env.VS_CODE_ASTER_CATA_PATH = resolved.path; + } + } + getCatalogChannel().appendLine( + `[catalog] LSP will restart with source=${resolved.source}, path=${resolved.path ?? '(vendored)'}` + ); + } catch (err: any) { + getCatalogChannel().appendLine( + `[catalog] failed to resolve before restart: ${err?.message ?? err}` + ); } const client = this._client; - if (!client) { - return; - } void vscode.window.withProgress( { location: vscode.ProgressLocation.Window, title: 'Restarting code_aster language server…', }, - () => - client - .start() - .catch((err: any) => - vscode.window.showErrorMessage( - 'Error restarting code_aster language server: ' + err.message - ) - ) + async () => { + try { + if (client.isRunning()) { + await client.stop(); + } + } catch (err: any) { + getCatalogChannel().appendLine(`[lsp] stop() during restart: ${err?.message ?? err}`); + } + try { + await client.start(); + } catch (err: any) { + vscode.window.showErrorMessage( + 'Error restarting code_aster language server: ' + (err?.message ?? err) + ); + } + } ); } From cd9b074d25a32775ee3747f1427166aaf03c0f54 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 15:49:04 +0200 Subject: [PATCH 7/9] fix: disable dream background by default Cosmetic feature was opt-in by intent but shipped opt-out. Users who explicitly enabled it keep their preference; users on the original default switch to off on next reload. Updates package.json, the webview-side fallbacks, and the popup's reset target so all four defaults agree. --- package.json | 2 +- src/WebviewVisu.ts | 2 +- webviews/viewer/src/components/popups/SettingsPopup.svelte | 2 +- webviews/viewer/src/lib/settings/GlobalSettings.ts | 2 +- webviews/viewer/src/lib/state.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 796cb94..f10c264 100644 --- a/package.json +++ b/package.json @@ -421,7 +421,7 @@ "vs-code-aster.viewer.dreamBackground": { "order": 22, "type": "boolean", - "default": true, + "default": false, "markdownDescription": "Cosmetic animated EDF orange and blue light blobs slowly breathing behind the mesh. Purely decorative — does not affect mesh lighting." }, "vs-code-aster.viewer.autoRotate": { diff --git a/src/WebviewVisu.ts b/src/WebviewVisu.ts index 435ca00..2ea8717 100644 --- a/src/WebviewVisu.ts +++ b/src/WebviewVisu.ts @@ -222,7 +222,7 @@ export class WebviewVisu implements vscode.Disposable { showOrientationWidget: config.get('viewer.showOrientationWidget', true), showBoundingBox: config.get('viewer.showBoundingBox', false), showWireframe: config.get('viewer.showWireframe', false), - dreamBackground: config.get('viewer.dreamBackground', true), + dreamBackground: config.get('viewer.dreamBackground', false), autoRotate: config.get('viewer.autoRotate', false), autoRotateSpeed: config.get('viewer.autoRotateSpeed', 15), autoRotateReverse: config.get('viewer.autoRotateReverse', false), diff --git a/webviews/viewer/src/components/popups/SettingsPopup.svelte b/webviews/viewer/src/components/popups/SettingsPopup.svelte index 30ec562..249bd79 100644 --- a/webviews/viewer/src/components/popups/SettingsPopup.svelte +++ b/webviews/viewer/src/components/popups/SettingsPopup.svelte @@ -264,7 +264,7 @@ }; const DISPLAY_DEFAULTS = { showOrientationWidget: true, - dreamBackground: true, + dreamBackground: false, }; const TOOLBAR_DEFAULTS = { showBoundingBox: false, diff --git a/webviews/viewer/src/lib/settings/GlobalSettings.ts b/webviews/viewer/src/lib/settings/GlobalSettings.ts index f70e546..c99a885 100644 --- a/webviews/viewer/src/lib/settings/GlobalSettings.ts +++ b/webviews/viewer/src/lib/settings/GlobalSettings.ts @@ -48,7 +48,7 @@ export class GlobalSettings { showOrientationWidget = true; showBoundingBox = false; showWireframe = false; - dreamBackground = true; + dreamBackground = false; autoRotate = false; autoRotateSpeed = 15; autoRotateReverse = false; diff --git a/webviews/viewer/src/lib/state.ts b/webviews/viewer/src/lib/state.ts index 7e11f2d..ed671f2 100644 --- a/webviews/viewer/src/lib/state.ts +++ b/webviews/viewer/src/lib/state.ts @@ -58,7 +58,7 @@ export const settings = writable({ showOrientationWidget: true, showBoundingBox: false, showWireframe: false, - dreamBackground: true, + dreamBackground: false, autoRotate: false, autoRotateSpeed: 15, autoRotateReverse: false, From 306b4e481c23987cbfebd5607ec7d6607ba93a4b Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 16:55:26 +0200 Subject: [PATCH 8/9] feat: command-browser sidebar group, context-aware actions, external links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the sidebar into a single home for navigation, browsing, and discovery so the status bar can shed its drilldown. - Command browser: a new collapsible group lists the five canonical command families with the file's commands first (✓) and the rest of the catalog dim. Visible only when a .comm file is active. Search action in the view title bar runs a fuzzy QuickPick over the full catalog. - Quick actions reshape per active editor: New export file / Restart language server / Show catalog info are always there; Run with code_aster only on .export; Open mesh viewer only on .comm and configured MED extensions. Run setup checks moved out (still reachable via the Setup group rows or palette). - External group at the very end, always expanded: Star on GitHub / Rate on Marketplace / Browse code_aster website / Browse code_aster documentation / Visit simvia.tech. The website row uses light/dark variants of the brand mark so it matches the surrounding codicon foreground. - Setup floats: at the top when any probe fails (auto-expanded), at the bottom as `Setup (n/5)` when healthy (collapsed). Status bar now a single icon — neutral when 3+ command families are present in the file, warning-tinted otherwise — that opens and expands the Command browser group on click. The disk-read analyzer in status_bar_manager.py is replaced by a registry walk so unsaved edits are reflected immediately. LspServer fires an `onReady` event on start/restart; the sidebar and status bar listen so their first probe (which lands before the server is up) self-corrects without a file switch. --- media/images/code-aster-icon-dark.svg | 13 + media/images/code-aster-icon-light.svg | 13 + package.json | 20 +- python/lsp/managers/status_bar_manager.py | 139 +++----- src/LspServer.ts | 8 + src/SidebarView.ts | 402 +++++++++++++++++++++- src/StatusBar.ts | 150 ++------ 7 files changed, 523 insertions(+), 222 deletions(-) create mode 100644 media/images/code-aster-icon-dark.svg create mode 100644 media/images/code-aster-icon-light.svg diff --git a/media/images/code-aster-icon-dark.svg b/media/images/code-aster-icon-dark.svg new file mode 100644 index 0000000..7f6040b --- /dev/null +++ b/media/images/code-aster-icon-dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/media/images/code-aster-icon-light.svg b/media/images/code-aster-icon-light.svg new file mode 100644 index 0000000..86a6b32 --- /dev/null +++ b/media/images/code-aster-icon-light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/package.json b/package.json index f10c264..4d54145 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,19 @@ "title": "Refresh code_aster panel", "icon": "$(refresh)" }, + { + "command": "vs-code-aster.commandBrowser.search", + "title": "Search code_aster commands", + "icon": "$(search)" + }, + { + "command": "vs-code-aster.commandBrowser.openDoc", + "title": "Open code_aster command documentation" + }, + { + "command": "vs-code-aster.commandBrowser.focus", + "title": "Focus code_aster command browser" + }, { "command": "vs-code-aster.addToMedExtensions", "title": "Open as MED mesh", @@ -205,10 +218,15 @@ ], "menus": { "view/title": [ + { + "command": "vs-code-aster.commandBrowser.search", + "when": "view == vs-code-aster.sidebar", + "group": "navigation@1" + }, { "command": "vs-code-aster.sidebar.refresh", "when": "view == vs-code-aster.sidebar", - "group": "navigation" + "group": "navigation@2" } ], "editor/title": [ diff --git a/python/lsp/managers/status_bar_manager.py b/python/lsp/managers/status_bar_manager.py index 5f6c641..02aed67 100644 --- a/python/lsp/managers/status_bar_manager.py +++ b/python/lsp/managers/status_bar_manager.py @@ -1,17 +1,19 @@ -# status_bar.py -import re -from pathlib import Path +"""Command-family browser data source. + +Exposes two LSP custom requests consumed by the sidebar's "Command +browser" group and the (now icon-only) status-bar nudge: + * `codeaster/analyzeCommandFamilies` — what's in the current file, + grouped by family. Reads from `CommandRegistry` (live, no disk + I/O), so unsaved edits are reflected immediately. + * `codeaster/getCompleteFamilies` — the full catalog, grouped by + family. Used to populate the dim "browseable" entries in the + sidebar. +""" from command_core import CommandCore class StatusBarManager: - """ - Class that encapsulates the logic for: - - analyzing a .comm file and returning commands by family - - returning the complete list of known commands for each family - """ - FAMILY_MAP = { "Mesh": "mesh", "Material": "material", @@ -21,95 +23,50 @@ class StatusBarManager: } def __init__(self): - """ - Initialize the StatusBarManager with a CATA instance. - - Args: - cata: The CATA catalog object from asterstudy.datamodel.catalogs - """ self.cata = CommandCore().get_CATA() self.family_map = self.FAMILY_MAP - def analyze_command_families(self, uri: str) -> dict[str, list[str]]: - """ - Parse a .comm file and return the commands found in each family. - - Args: - uri (str): URI of the .comm file, - - Returns: - Dict[str, List[str]]: Commands grouped by family - """ - if uri.startswith("file://"): - path = Path(uri[7:]) - else: - path = Path(uri) - - if not path.exists(): - return {} + # ----------------------------------------------------------- per file - with open(path, encoding="utf-8") as f: - lines = f.readlines() - content = "\n".join(lines) - return self._parse_comm_file(content) - - def get_complete_families(self) -> dict[str, list[str]]: - """ - Return the complete list of known commands for each family. - - Returns: - Dict[str, List[str]]: Complete commands grouped by family - """ - families_result: dict[str, list[str]] = {v: [] for v in self.family_map.values()} - - for display_name, key in self.family_map.items(): + def analyze_command_families(self, uri: str) -> dict[str, list[str]]: + """Walk the registry's tracked commands for `uri` and group them + by family. Live — uses whatever the registry has, which the + update_manager keeps in sync with `didChange`.""" + try: + return self._analyze(uri) + except Exception: + return {v: [] for v in self.family_map.values()} + + def _analyze(self, uri: str) -> dict[str, list[str]]: + registry = CommandCore().get_registry(uri) + result: dict[str, list[str]] = {v: [] for v in self.family_map.values()} + if registry is None: + return result + seen: set[str] = set() + for cmd in registry.commands.values(): try: - category_commands = self.cata.get_category(display_name) - if category_commands is None: - category_commands = [] - families_result[key] = category_commands + name = cmd.name + if name in seen: + continue + seen.add(name) + family_display = self.cata.get_command_category(name) + family_key = self.family_map.get(family_display) + if family_key: + result[family_key].append(name) except Exception: - families_result[key] = [] - - return families_result - - def _parse_comm_file(self, content: str) -> dict[str, list[str]]: - """ - Parse the content of a .comm file and extract commands by family. - - Args: - content (str): Content of the .comm file - - Returns: - Dict[str, List[str]]: Commands grouped by family - """ - lines = content.split("\n") - code_without_comments = [ - re.sub(r"#.*$", "", line).strip() for line in lines if line.strip() - ] - full_text = " ".join(code_without_comments) - - command_pattern = r"(?:^|\s)(?:[\w]+\s*=\s*)?(?!_F\b)([A-Z][A-Z0-9_]*)\s*\(" - matches = re.finditer(command_pattern, full_text, re.VERBOSE) - found_commands = set() - for m in matches: - cmd_name = m.group(1) - if cmd_name not in ["DEBUT", "FIN", "POURSUITE"]: - found_commands.add(cmd_name) + continue + return result - if re.search(r"\bDEBUT\s*\(", full_text): - found_commands.add("DEBUT") - if re.search(r"\bFIN\s*\(", full_text): - found_commands.add("FIN") + # ----------------------------------------------------------- catalog - families_result: dict[str, list[str]] = {v: [] for v in self.family_map.values()} - for cmd_name in found_commands: + def get_complete_families(self) -> dict[str, list[str]]: + """Return the complete list of catalog commands grouped by family. + Used as the dictionary backing the sidebar's Command browser.""" + result: dict[str, list[str]] = {v: [] for v in self.family_map.values()} + for display_name, key in self.family_map.items(): try: - family_raw = self.cata.get_command_category(cmd_name) - family = self.family_map.get(family_raw) - if family: - families_result[family].append(cmd_name) + items = self.cata.get_category(display_name) + result[key] = list(items or []) except Exception: - continue - - return families_result + result[key] = [] + return result diff --git a/src/LspServer.ts b/src/LspServer.ts index eb2243e..cde9c20 100644 --- a/src/LspServer.ts +++ b/src/LspServer.ts @@ -29,6 +29,12 @@ export class LspServer { // Kept as a class field so `restart()` can mutate `options.env` before // bouncing the server — `LanguageClient` re-reads it on the next spawn. private _serverOptions?: { command: string; args: string[]; options: { env: NodeJS.ProcessEnv } }; + // Fires every time the language server transitions into a usable + // state (after start() resolves and after restart() finishes). The + // sidebar and status bar listen to this so they retry their LSP- + // backed probes that ran too early on activation. + private _readyEmitter = new vscode.EventEmitter(); + public readonly onReady = this._readyEmitter.event; private constructor() {} @@ -71,6 +77,7 @@ export class LspServer { this._client!.start() .then(() => { this.attachEditorListeners(); + this._readyEmitter.fire(); }) .catch((err: any) => { vscode.window.showErrorMessage( @@ -266,6 +273,7 @@ export class LspServer { } try { await client.start(); + this._readyEmitter.fire(); } catch (err: any) { vscode.window.showErrorMessage( 'Error restarting code_aster language server: ' + (err?.message ?? err) diff --git a/src/SidebarView.ts b/src/SidebarView.ts index 4956297..717af2b 100644 --- a/src/SidebarView.ts +++ b/src/SidebarView.ts @@ -7,12 +7,30 @@ import { getSelectedCaveVersion, } from './CatalogResolver'; import { listInstalledVersions } from './CaveStatusBar'; +import { LspServer } from './LspServer'; import { probeLspDeps, probeRuff, runProc } from './PythonEnv'; const VIEW_ID = 'vs-code-aster.sidebar'; type Status = 'ok' | 'warn' | 'error' | 'info'; +type FamilyKey = 'mesh' | 'material' | 'bcAndLoads' | 'analysis' | 'output'; +const FAMILIES: { key: FamilyKey; label: string }[] = [ + { key: 'mesh', label: 'Mesh' }, + { key: 'material', label: 'Material' }, + { key: 'bcAndLoads', label: 'Boundary Conditions & Loads' }, + { key: 'analysis', label: 'Analysis' }, + { key: 'output', label: 'Output' }, +]; +type CommandFamilies = Record; +const EMPTY_FAMILIES: CommandFamilies = { + mesh: [], + material: [], + bcAndLoads: [], + analysis: [], + output: [], +}; + interface Probe { pythonOk: boolean; pythonMissing: string[]; @@ -22,6 +40,10 @@ interface Probe { installedVersions: string[]; currentVersion: string | null; bundledVersion: string | null; + // Command browser data + inFile: CommandFamilies; + catalog: CommandFamilies; + catalogLoaded: boolean; } class Item extends vscode.TreeItem { @@ -61,6 +83,7 @@ export class SidebarProvider implements vscode.TreeDataProvider { private caveWatcher?: fs.FSWatcher; private caveDebounce?: NodeJS.Timeout; + private _editDebounce?: NodeJS.Timeout; constructor(private readonly context: vscode.ExtensionContext) { // Re-render on relevant config changes. context.subscriptions.push( @@ -82,6 +105,35 @@ export class SidebarProvider implements vscode.TreeDataProvider { } catch { /* ~/.cave may not exist yet */ } + // The first probe runs at extension activation time, before the LSP + // has finished starting — so the catalog comes back empty. Refresh + // when the LSP signals it's ready. + context.subscriptions.push( + LspServer.instance.onReady(() => { + this._catalogCache = null; // re-fetch catalog from the new server. + this.refresh(); + }) + ); + // Re-render on any active-editor change so the Command browser + // appears when switching TO a .comm file and disappears when + // switching AWAY (to .export, plain text, no editor at all, …). + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(() => { + this.refresh(); + }), + vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.languageId !== 'comm') { + return; + } + if (event.document !== vscode.window.activeTextEditor?.document) { + return; + } + if (this._editDebounce) { + clearTimeout(this._editDebounce); + } + this._editDebounce = setTimeout(() => this.refresh(), 300); + }) + ); } refresh(): void { @@ -89,6 +141,20 @@ export class SidebarProvider implements vscode.TreeDataProvider { this._onDidChangeTreeData.fire(); } + /** Used by the Command browser fuzzy-search QuickPick. Forces a probe + * if the catalog hasn't been loaded yet. */ + async flatCatalog(): Promise<{ name: string; familyKey: FamilyKey; familyLabel: string }[]> { + const probe = await this.getProbe(); + const out: { name: string; familyKey: FamilyKey; familyLabel: string }[] = []; + for (const f of FAMILIES) { + for (const name of probe.catalog[f.key] ?? []) { + out.push({ name, familyKey: f.key, familyLabel: f.label }); + } + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; + } + getTreeItem(item: Item): vscode.TreeItem { return item; } @@ -100,16 +166,121 @@ export class SidebarProvider implements vscode.TreeDataProvider { return parent.children ?? []; } + /** Required by `TreeView.reveal()`. We only ever reveal top-level + * group items (which have no parent), so always returning undefined + * is correct for our usage. */ + getParent(_item: Item): Item | undefined { + return undefined; + } + + /** Hold the TreeView so external commands can call reveal(). Set by + * `registerSidebar` after construction. */ + treeView?: vscode.TreeView; + + /** Open the panel and expand the Command browser group. Used by the + * status-bar nudge. */ + async revealCommandBrowser(): Promise { + // Make sure the activity-bar container is visible first. + try { + await vscode.commands.executeCommand('workbench.view.extension.vs-code-aster'); + } catch { + /* ignore */ + } + // Force a re-render so we have a fresh Item handle to reveal. + await this.getProbe(); + const item = this._commandBrowserItem; + if (item && this.treeView) { + try { + await this.treeView.reveal(item, { expand: true, focus: true, select: false }); + } catch { + /* swallow — best-effort */ + } + } + } + // -------------------------------------------------------- top-level private async topLevel(): Promise { const probe = await this.getProbe(); - return [ - this.setupGroup(probe), - this.actionsGroup(), - this.versionsGroup(probe), - this.settingsGroup(), + const versionOk = + !!probe.currentVersion && probe.installedVersions.includes(probe.currentVersion); + const setupOk = probe.pythonOk && probe.ruffOk && probe.dockerOk && probe.caveOk && versionOk; + const isCommActive = vscode.window.activeTextEditor?.document.languageId === 'comm'; + + const setup = this.setupGroup(probe); + const actions = this.actionsGroup(); + const versions = this.versionsGroup(probe); + const settings = this.settingsGroup(); + + const groups: Item[] = []; + // 0. Setup at top when something needs attention. + if (!setupOk) { + groups.push(setup); + } + // 1. Quick actions — always. + groups.push(actions); + // 2. Command browser — only when a .comm file is active. + if (isCommActive) { + groups.push(this.commandBrowserGroup(probe)); + } + // 3–4. Versions and settings (both collapsed). + groups.push(versions, settings); + // 5. Setup near the bottom when healthy. Out of the way but + // still glanceable via the n/5 counter in its title. + if (setupOk) { + groups.push(setup); + } + // 6. External links — out-of-band actions that don't need + // in-extension UI. Always last, always expanded. + groups.push(this.externalGroup()); + return groups; + } + + // ---------------------------------------------------------- External + + private externalGroup(): Item { + const item = new Item('External', vscode.TreeItemCollapsibleState.Expanded); + item.iconPath = new vscode.ThemeIcon('link'); + // VS Code doesn't apply theme foreground to custom-SVG iconPaths, so + // ship two near-foreground variants (light = #424242, dark = #cccccc) + // and let the renderer pick. + const codeAsterIcon = { + light: vscode.Uri.file(this.context.asAbsolutePath('media/images/code-aster-icon-light.svg')), + dark: vscode.Uri.file(this.context.asAbsolutePath('media/images/code-aster-icon-dark.svg')), + }; + + const link = ( + label: string, + icon: string | vscode.Uri | { light: vscode.Uri; dark: vscode.Uri }, + url: string + ): Item => { + const it = new Item(label); + it.iconPath = typeof icon === 'string' ? new vscode.ThemeIcon(icon) : icon; + it.tooltip = url; + it.command = { + title: label, + command: 'vscode.open', + arguments: [vscode.Uri.parse(url)], + }; + return it; + }; + + item.children = [ + link('Star on GitHub', 'star-empty', 'https://github.com/simvia-tech/vs-code-aster'), + link( + 'Rate on VS Code Marketplace', + 'feedback', + 'https://marketplace.visualstudio.com/items?itemName=simvia.vs-code-aster&ssr=false#review-details' + ), + link('Browse code_aster website', codeAsterIcon, 'https://www.code-aster.org/'), + link( + 'Browse code_aster documentation', + 'library', + 'https://demo-docaster.simvia-app.fr/versions/v17/' + ), + link('Visit simvia.tech', 'globe', 'https://simvia.tech/'), ]; + return item; } // --------------------------------------------------------------- probes @@ -134,17 +305,21 @@ export class SidebarProvider implements vscode.TreeDataProvider { installedVersions: [], currentVersion: null, bundledVersion: null, + inFile: { ...EMPTY_FAMILIES }, + catalog: { ...EMPTY_FAMILIES }, + catalogLoaded: false, } ); } private async runProbes(): Promise { - const [pythonResult, ruffOk, dockerOk, caveOk, installed] = await Promise.all([ + const [pythonResult, ruffOk, dockerOk, caveOk, installed, families] = await Promise.all([ probeLspDeps(this.context), probeRuff(this.context), dockerAvailable(), caveOnPath(), listInstalledVersions(), + this.fetchCommandFamilies(), ]); this.cached = { pythonOk: pythonResult.ok, @@ -155,6 +330,65 @@ export class SidebarProvider implements vscode.TreeDataProvider { installedVersions: installed, currentVersion: getSelectedCaveVersion(), bundledVersion: getBundledVersion(this.context), + inFile: families.inFile, + catalog: families.catalog, + catalogLoaded: families.catalogLoaded, + }; + } + + /** Talk to the LSP to fill the Command browser group's data. Catalog + * data is cached forever; in-file data is re-fetched per refresh. */ + private _catalogCache: CommandFamilies | null = null; + private async fetchCommandFamilies(): Promise<{ + inFile: CommandFamilies; + catalog: CommandFamilies; + catalogLoaded: boolean; + }> { + let client: any; + try { + client = LspServer.instance.client; + } catch { + return { + inFile: { ...EMPTY_FAMILIES }, + catalog: { ...EMPTY_FAMILIES }, + catalogLoaded: false, + }; + } + if (!client || !client.isRunning?.()) { + return { + inFile: { ...EMPTY_FAMILIES }, + catalog: { ...EMPTY_FAMILIES }, + catalogLoaded: false, + }; + } + // Catalog: fetch once, cache for the session. + if (!this._catalogCache) { + try { + this._catalogCache = + (await client.sendRequest('codeaster/getCompleteFamilies', {})) ?? null; + } catch { + this._catalogCache = null; + } + } + // In-file: scoped to the active editor. + let inFile: CommandFamilies = { ...EMPTY_FAMILIES }; + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'comm') { + try { + const result = await client.sendRequest('codeaster/analyzeCommandFamilies', { + uri: editor.document.uri.toString(), + }); + if (result && typeof result === 'object') { + inFile = { ...EMPTY_FAMILIES, ...(result as Partial) }; + } + } catch { + /* keep defaults */ + } + } + return { + inFile, + catalog: this._catalogCache ?? { ...EMPTY_FAMILIES }, + catalogLoaded: this._catalogCache !== null, }; } @@ -162,9 +396,11 @@ export class SidebarProvider implements vscode.TreeDataProvider { private setupGroup(p: Probe): Item { const versionOk = !!p.currentVersion && p.installedVersions.includes(p.currentVersion); - const allOk = p.pythonOk && p.ruffOk && p.dockerOk && p.caveOk && versionOk; + const checks = [p.pythonOk, p.ruffOk, p.dockerOk, p.caveOk, versionOk]; + const passed = checks.filter(Boolean).length; + const allOk = passed === checks.length; const item = new Item( - 'Setup', + `Setup (${passed}/${checks.length})`, allOk ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded ); item.iconPath = new vscode.ThemeIcon('checklist'); @@ -230,14 +466,38 @@ export class SidebarProvider implements vscode.TreeDataProvider { private actionsGroup(): Item { const item = new Item('Quick actions', vscode.TreeItemCollapsibleState.Expanded); item.iconPath = new vscode.ThemeIcon('rocket'); - item.children = [ + + // Filter actions to whatever makes sense for the active editor. + // Skip the file-specific ones rather than showing them grayed-out. + const activeLang = vscode.window.activeTextEditor?.document.languageId; + const activePath = vscode.window.activeTextEditor?.document.uri.fsPath ?? ''; + const isExport = activeLang === 'export'; + // Mesh viewer is the editor-title button on .comm files and .med + // siblings (.mmed/.rmed plus user-configured numeric variants in + // `vs-code-aster.medFileExtensions`). Mirror the same rule here. + const medExts = new Set( + vscode.workspace + .getConfiguration('vs-code-aster') + .get('medFileExtensions', ['.med', '.mmed', '.rmed']) + .map((e) => e.toLowerCase()) + ); + const ext = activePath.includes('.') ? activePath.slice(activePath.lastIndexOf('.')) : ''; + const isMeshViewable = activeLang === 'comm' || medExts.has(ext.toLowerCase()); + + const children: Item[] = [ this.actionItem('New export file…', 'new-file', 'vs-code-aster.exportDoc'), - this.actionItem('Run with code_aster', 'play', 'vs-code-aster.run-aster'), - this.actionItem('Open mesh viewer', 'eye', 'vs-code-aster.meshViewer'), - this.actionItem('Restart language server', 'sync', 'vs-code-aster.restartLSPServer'), - this.actionItem('Run setup checks', 'checklist', 'vs-code-aster.runSetup'), - this.actionItem('Show catalog info', 'info', 'vs-code-aster.showCatalogInfo'), ]; + if (isExport) { + children.push(this.actionItem('Run with code_aster', 'play', 'vs-code-aster.run-aster')); + } + if (isMeshViewable) { + children.push(this.actionItem('Open mesh viewer', 'eye', 'vs-code-aster.meshViewer')); + } + children.push( + this.actionItem('Restart language server', 'sync', 'vs-code-aster.restartLSPServer'), + this.actionItem('Show catalog info', 'info', 'vs-code-aster.showCatalogInfo') + ); + item.children = children; return item; } @@ -251,7 +511,7 @@ export class SidebarProvider implements vscode.TreeDataProvider { // ---------------------------------------------------------- Versions private versionsGroup(p: Probe): Item { - const item = new Item('Versions', vscode.TreeItemCollapsibleState.Expanded); + const item = new Item('Versions', vscode.TreeItemCollapsibleState.Collapsed); item.iconPath = new vscode.ThemeIcon('versions'); const children: Item[] = []; if (!p.caveOk) { @@ -295,13 +555,81 @@ export class SidebarProvider implements vscode.TreeDataProvider { // ---------------------------------------------------------- Settings + // ---------------------------------------------------- Command browser + + /** Identity-stable reference to the Command browser group, so the + * status-bar click can `reveal()` it. Replaced on every refresh. */ + private _commandBrowserItem: Item | null = null; + + private commandBrowserGroup(p: Probe): Item { + // Default to expanded whenever the user has a `.comm` file open — + // that's the context where the dictionary is useful. Otherwise + // collapsed so it doesn't crowd the panel. + const expanded = + vscode.window.activeTextEditor?.document.languageId === 'comm' + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + const item = new Item('Command browser', expanded); + item.iconPath = new vscode.ThemeIcon('book'); + item.tooltip = 'code_aster commands grouped by family'; + item.contextValue = 'commandBrowser'; + this._commandBrowserItem = item; + + if (!p.catalogLoaded) { + const placeholder = new Item('Loading catalog…'); + placeholder.iconPath = new vscode.ThemeIcon('sync~spin'); + item.children = [placeholder]; + return item; + } + + item.children = FAMILIES.map((f) => { + const inFile = new Set(p.inFile[f.key] ?? []); + const all = p.catalog[f.key] ?? []; + const family = new Item(f.label, vscode.TreeItemCollapsibleState.Collapsed); + family.iconPath = new vscode.ThemeIcon( + inFile.size > 0 ? 'pass-filled' : 'circle-outline', + inFile.size > 0 ? new vscode.ThemeColor('testing.iconPassed') : undefined + ); + family.description = + all.length === 0 + ? 'no commands' + : inFile.size === 0 + ? `${all.length} total` + : `${inFile.size} in file · ${all.length} total`; + const inFileSorted = [...inFile].sort(); + const restSorted = all.filter((n) => !inFile.has(n)).sort(); + family.children = [ + ...inFileSorted.map((n) => this._commandItem(n, true)), + ...restSorted.map((n) => this._commandItem(n, false)), + ]; + return family; + }); + return item; + } + + private _commandItem(name: string, inFile: boolean): Item { + const it = new Item(name); + it.iconPath = new vscode.ThemeIcon( + inFile ? 'check' : 'symbol-method', + inFile ? new vscode.ThemeColor('testing.iconPassed') : undefined + ); + it.description = inFile ? 'in file' : undefined; + it.tooltip = inFile ? `${name} — used in this file` : `${name} — open documentation`; + it.command = { + title: 'Open documentation', + command: 'vs-code-aster.commandBrowser.openDoc', + arguments: [name], + }; + return it; + } + private settingsGroup(): Item { const item = new Item('Settings', vscode.TreeItemCollapsibleState.Collapsed); item.iconPath = new vscode.ThemeIcon('settings-gear'); item.children = [ this.settingItem('Python interpreter', 'vs-code-aster.pythonExecutablePath', 'terminal'), this.settingItem('Formatter', 'vs-code-aster.formatter', 'wand'), - this.settingItem('Run alias', 'vs-code-aster.aliasForRun', 'play-circle'), + this.settingItem('Run alias', 'vs-code-aster.aliasForRun', 'play'), this.settingItem('Catalog path (override)', 'vs-code-aster.asterCatalogPath', 'library'), this.settingItem( 'Supported .comm extensions', @@ -349,9 +677,47 @@ function formatSettingValue(v: unknown): string { export function registerSidebar(context: vscode.ExtensionContext): SidebarProvider { const provider = new SidebarProvider(context); + // createTreeView (not registerTreeDataProvider) gives us a TreeView + // handle whose .reveal() can expand a specific item. + const treeView = vscode.window.createTreeView(VIEW_ID, { + treeDataProvider: provider, + showCollapseAll: true, + }); + provider.treeView = treeView; context.subscriptions.push( - vscode.window.registerTreeDataProvider(VIEW_ID, provider), - vscode.commands.registerCommand('vs-code-aster.sidebar.refresh', () => provider.refresh()) + treeView, + vscode.commands.registerCommand('vs-code-aster.sidebar.refresh', () => provider.refresh()), + vscode.commands.registerCommand('vs-code-aster.commandBrowser.openDoc', (name: string) => { + const url = `https://demo-docaster.simvia-app.fr/versions/v17/search.html?q=${encodeURIComponent(name)}`; + void vscode.env.openExternal(vscode.Uri.parse(url)); + }), + vscode.commands.registerCommand('vs-code-aster.commandBrowser.search', () => + runCommandSearch(provider) + ), + vscode.commands.registerCommand('vs-code-aster.commandBrowser.focus', () => + provider.revealCommandBrowser() + ) ); return provider; } + +async function runCommandSearch(provider: SidebarProvider): Promise { + // Reuse the provider's cached catalog to avoid re-querying the LSP. + const flat = await provider.flatCatalog(); + if (flat.length === 0) { + vscode.window.showWarningMessage('Catalog is not yet available.'); + return; + } + const items: vscode.QuickPickItem[] = flat.map((c) => ({ + label: c.name, + description: c.familyLabel, + })); + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Search code_aster commands by name…', + matchOnDescription: true, + }); + if (!picked) { + return; + } + void vscode.commands.executeCommand('vs-code-aster.commandBrowser.openDoc', picked.label); +} diff --git a/src/StatusBar.ts b/src/StatusBar.ts index d555efe..c5df7e1 100644 --- a/src/StatusBar.ts +++ b/src/StatusBar.ts @@ -9,33 +9,22 @@ interface CommandFamiliesAnalysis { output: string[]; } -type FamilyKey = keyof CommandFamiliesAnalysis; - -export const COMMAND_FAMILIES: { key: FamilyKey; label: string }[] = [ - { key: 'mesh', label: 'Mesh' }, - { key: 'material', label: 'Material' }, - { key: 'bcAndLoads', label: 'Boundary Conditions & Loads' }, - { key: 'analysis', label: 'Analysis' }, - { key: 'output', label: 'Output' }, -]; - +/** + * Tiny left-side status-bar nudge: a single icon, neutral when the + * active `.comm` file already covers most of the canonical command + * families, warning-tinted when it covers fewer than three. Click + * focuses the sidebar's Command browser group, which is where the + * real dictionary now lives. + */ export class StatusBar { private static _instance: StatusBar | null = null; private statusBarItem: vscode.StatusBarItem; private currentAnalysis: CommandFamiliesAnalysis | null = null; private disposables: vscode.Disposable[] = []; - private completeFamilies: CommandFamiliesAnalysis = { - mesh: [], - material: [], - bcAndLoads: [], - analysis: [], - output: [], - }; private constructor() { this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); - this.statusBarItem.command = 'codeaster.showCommandFamiliesDetails'; - this.statusBarItem.tooltip = 'Click to see details'; + this.statusBarItem.command = 'vs-code-aster.commandBrowser.focus'; } public static get instance(): StatusBar { @@ -45,63 +34,31 @@ export class StatusBar { return StatusBar._instance; } - /** Activate status bar and register listeners */ public async activate(context: vscode.ExtensionContext) { - await this.getCompleteFamilies(); - - const showDetailsCommand = vscode.commands.registerCommand( - 'codeaster.showCommandFamiliesDetails', - () => this.showDetails() - ); - context.subscriptions.push(showDetailsCommand); - - const editorChangeListener = vscode.window.onDidChangeActiveTextEditor((editor) => + const editorChange = vscode.window.onDidChangeActiveTextEditor((editor) => this.onEditorChange(editor) ); - this.disposables.push(editorChangeListener); - + this.disposables.push(editorChange); + // Retry once the LSP is ready (the first analyze on activation + // typically lands before the server is up and silently bails). + this.disposables.push( + LspServer.instance.onReady(() => this.onEditorChange(vscode.window.activeTextEditor)) + ); this.onEditorChange(vscode.window.activeTextEditor); - context.subscriptions.push(this.statusBarItem, ...this.disposables); } - /** Update status bar visibility and trigger analysis on editor change */ + /** Re-analyze + render. Called after `cave use` (LSP restart) and on + * editor-change. */ public async onEditorChange(editor: vscode.TextEditor | undefined) { - if (!editor) { + if (!editor || editor.document.languageId !== 'comm') { this.statusBarItem.hide(); return; } - - const document = editor.document; - if (document.languageId !== 'comm') { - this.statusBarItem.hide(); - return; - } - this.statusBarItem.show(); - await this.analyzeDocument(document); - } - - /** Fetch all command families from the LSP server */ - private async getCompleteFamilies() { - try { - const client = LspServer.instance.client; - if (!client) { - return; - } - - const completeFamilies = await client.sendRequest( - 'codeaster/getCompleteFamilies', - {} - ); - - this.completeFamilies = completeFamilies; - } catch (error) { - console.error('Error fetching command families:', error); - } + await this.analyzeDocument(editor.document); } - /** Analyze the current document and update status bar */ private async analyzeDocument(document: vscode.TextDocument) { try { const client = LspServer.instance.client; @@ -109,68 +66,37 @@ export class StatusBar { this.statusBarItem.hide(); return; } - const analysis = await client.sendRequest( 'codeaster/analyzeCommandFamilies', { uri: document.uri.toString() } ); - this.currentAnalysis = analysis; - this.updateStatusBarText(analysis); - } catch (error) { - console.error('Error analyzing command families:', error); + this.render(analysis); + } catch { + // LSP may not be ready yet (e.g. first activation). Hide silently; + // we'll re-render on the next editor / file change. this.statusBarItem.hide(); } } - /** Update the status bar text based on analysis result */ - private updateStatusBarText(analysis: CommandFamiliesAnalysis) { - const completed = Object.values(analysis).filter( - (commands) => Array.isArray(commands) && commands.length > 0 + private render(analysis: CommandFamiliesAnalysis) { + const filled = Object.values(analysis).filter( + (cmds) => Array.isArray(cmds) && cmds.length > 0 ).length; - - const icon = completed === 5 ? '$(check)' : '$(info)'; - this.statusBarItem.text = `${icon} code_aster: ${completed}/5 steps`; - } - - /** Show a QuickPick with detailed commands for each family */ - private async showDetails() { - if (!this.currentAnalysis) { - return; - } - - const analysis = this.currentAnalysis; - - const items = COMMAND_FAMILIES.map((family) => { - const commands = analysis[family.key] || []; - const icon = commands.length > 0 ? '$(check)' : '$(circle-slash)'; - const detail = - commands.length > 0 ? `Commands: ${commands.join(', ')}` : 'No commands defined yet'; - return { label: `${icon} ${family.label}`, detail, key: family.key }; - }); - - const selectedFamily = await vscode.window.showQuickPick(items, { - placeHolder: 'code_aster command families: select a family to view commands', - }); - - if (!selectedFamily) { - return; - } - - const selectedKey = selectedFamily.key; - const commands = this.completeFamilies[selectedKey] || []; - if (commands.length === 0) { - return; - } - - const commandItems = commands.map((cmd) => ({ label: `$(symbol-method) ${cmd}` })); - await vscode.window.showQuickPick(commandItems, { - placeHolder: `${selectedFamily.key} commands`, - canPickMany: false, - }); + // Warning tint only on near-empty files (0–2 families). Anything + // with at least three families set looks "in-progress" enough. + const isEmpty = filled < 3; + this.statusBarItem.text = isEmpty ? '$(circle-outline)' : '$(symbol-namespace)'; + this.statusBarItem.tooltip = new vscode.MarkdownString( + isEmpty + ? `**code_aster:** ${filled}/5 command families used.\n\nClick to browse commands.` + : `**code_aster:** ${filled}/5 command families used.\n\nClick to browse commands.` + ); + this.statusBarItem.backgroundColor = isEmpty + ? new vscode.ThemeColor('statusBarItem.warningBackground') + : undefined; } - /** Dispose status bar and listeners */ public dispose() { this.statusBarItem.dispose(); this.disposables.forEach((d) => d.dispose()); From 6526d0a72cbb5effcf6c4827bfc9aa4f46d822ba Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 27 Apr 2026 17:03:15 +0200 Subject: [PATCH 9/9] [1.10.0] Bump version to 1.10.0 --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ CITATION.cff | 2 +- README.md | 2 +- ROADMAP.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b0896..45aa238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to the **VS Code Aster** extension will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.10.0] - 2026-04-27 + +A broad LSP and IDE-experience pass: cave-driven catalog resolution, a TypeScript-style hover layer, context-aware autocompletion, edit-time diagnostics with quick fixes, a `.comm` formatter, a guided setup flow, and a new activity-bar panel that doubles as a command dictionary. The status bar slims to an icon and Output channels are grouped under a single `code_aster:` prefix. + +### Added + +- **Catalog from cave** — the language server now reads its catalog directly from the Docker image of the cave-selected code_aster version (extracted on demand to `~/.cache/vs-code-aster/catalogs//` and cached). A bundled 16.7 catalog ships as a zero-config fallback, so the editor still works when Docker / cave aren't installed. The selection follows `~/.cave` live and an in-memory reconcile clears orphan caches when an image is removed. +- **Cave version picker** — right-aligned status-bar item modeled on VS Code's Python interpreter chip. Shows the current version, click to switch / install / remove. Install runs `cave use ` with `y` piped in and reports progress through phases (Pulling / Downloading / Extracting / Finalizing). Cancellable. +- **Guided setup flow** — a one-shot toast chain on first `.comm` / `.export` open walks the user through Python LSP deps (auto-installed into a managed venv at `/.venv`), ruff, Docker, cave, and a code_aster image. Each step is opt-in with `Install` / `Not now` / `Don't ask again`, and the new `code_aster: Run setup checks` command re-fires the chain. +- **Activity-bar sidebar panel** with seven groups: Setup (`Setup (n/5)`, top when failing, bottom when healthy), Quick actions (filtered per active editor), Command browser (only when a `.comm` is active), Versions, Settings, External links, and the bottom Setup. The brand mark gets a tightened monochrome icon for the activity bar plus light/dark variants for in-tree use. +- **Command browser** — five canonical families (Mesh / Material / BC & Loads / Analysis / Output), each listing the file's commands first (✓) then the rest of the catalog dim. Title-bar action runs a fuzzy `Cmd+P`-style QuickPick over every catalog command. Reads from `CommandRegistry` so it updates live without saving. +- **Hover rewrite** — TypeScript-style cards inside a `python` code fence (signature → description → details → doc link). Required vs optional read as Python defaults, BLOC branches filter by the parameters already typed at the call site, `regles` rules render as a bullet list, and the doc link points at `demo-docaster.simvia-app.fr/versions/v17/`. Surfaces the `translation={...}` short labels when present and is locale-aware (FR / EN via `LANG`). Variants for command, keyword, allowed-value literal, factor marker, plain-Python variable assignment, and legacy-command notes. +- **Context-aware autocompletion** — forward scanner replaces the old backward one (no longer confused by mid-edit unmatched quotes). Detects nested `_F(...)` scopes, suggests allowed `into` values when the cursor is in a value position, suggests previously-defined variables compatible with the SIMP's expected class (resolves callable `sd_prod` via `__all__=True`), and filters already-typed kwargs at every depth (not just the outer call). Snippet inserts: `LIRE_MAILLAGE($0)`, `KEY=$0`, `KEY=_F($0)` — each retriggers the popup. Trigger characters expanded to `(`, `,`, `=`, space; client-side hide-then-trigger keeps the suggest widget from sticking on "No suggestions". +- **Edit-time diagnostics** for `.comm` files with quick-fix code actions: unknown command, unknown keyword, value not in `into`, missing required keyword, `regles` violations, undefined variable, type mismatch, and a soft information note for legacy commands. Quick fixes offer fuzzy-matched replacements (Levenshtein) and allowed-value swaps. Wrapped end-to-end so a CATA quirk can never block hover, completion, or formatting. +- **`.comm` formatter** via `python -m ruff format --quote-style=preserve --line-length=100`. First-open prompt offers to install ruff with one click into the managed venv; PEP-668 retry with `--user`. +- **`.comm` syntax grammar overhaul** — TextMate rules rewritten around a `function-call` begin/end block with a nested `parens` sub-block so `_F(...)`, tuples, and multi-line kwargs all color correctly. Lowercase scientific notation, Python constants (`None`/`True`/`False`), and lowercase kwarg names are now recognized. Single-quote auto-close added; `wordPattern` keeps `_` attached. +- **External links group** in the sidebar (Star on GitHub, Rate on Marketplace, Browse code_aster website / documentation, Visit simvia.tech). Always at the bottom, always expanded. + +### Changed + +- **Status bar** down to a single icon: `$(symbol-namespace)` neutral when 3+ command families are present in the file, `$(circle-outline)` warning-tinted otherwise. Click opens and expands the sidebar's Command browser group. +- **Output channels** unified under a `code_aster:` prefix — `code_aster: Language Server` (replaces the misleading `Python Language Server`), `code_aster: Catalog`, `code_aster: Formatter`. LSP error toasts updated accordingly. +- **Language aliases** are now `code_aster (comm)` and `code_aster (export)`. +- **Hover layout**: signature first, then italic description, then details and rules, then doc-link footer. Drops the per-keyword inline `# …` doc strings to keep the signature scannable. +- **Dream background** off by default for new users (existing explicit-on choices preserved). + +### Fixed + +- LSP `restart()` no longer recreates the `LanguageClient`, so hover / completion / code-action providers don't duplicate after `cave use`. Server env (catalog path) is refreshed by mutating `serverOptions.options.env` in place before bouncing. +- Sidebar and status bar wait for an `LspServer.onReady` event to re-probe, so the Command browser populates without a file switch even when the LSP starts after activation. +- Stale `~/.cave` selections (image removed via `docker rmi`) now correctly fall back to the bundled catalog. The trash button on the version picker also clears the matching extracted-catalog cache. +- `.comm` `CommandRegistry` falls back to a full reparse when a single-line edit is outside any tracked command, so newly-typed top-level commands register immediately. +- `pip install` for ruff / LSP deps no longer passes `--user` unconditionally (broke venv installs); retried with `--user` only on PEP-668 errors. + ## [1.9.2] - 2026-04-23 Better rendering for 1D meshes and a flatter, more readable face shading. diff --git a/CITATION.cff b/CITATION.cff index db09f96..94cbb12 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,4 +1,4 @@ -cff-version: 1.9.2 +cff-version: 1.10.0 title: VS Code Aster message: >- If you use this software, please cite it using the diff --git a/README.md b/README.md index 18ca399..aed1d12 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Simvia Logo

- Version + Version License CI Status GitHub issues diff --git a/ROADMAP.md b/ROADMAP.md index a66e2f0..7db88dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ The extension aims to reduce friction between modeling, validation, execution, and analysis by bringing **code_aster** native workflows into the editor. -## Current Capabilities (v1.9.2) +## Current Capabilities (v1.10.0) - `.export` file generator - 3D mesh viewer diff --git a/package-lock.json b/package-lock.json index 20634c0..dfe04fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vs-code-aster", - "version": "1.9.2", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vs-code-aster", - "version": "1.9.2", + "version": "1.10.0", "license": "GPL-3.0", "dependencies": { "@kitware/vtk.js": "^35.10.0", diff --git a/package.json b/package.json index 4d54145..aa58fcd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vs-code-aster", "displayName": "VS Code Aster", - "version": "1.9.2", + "version": "1.10.0", "description": "VS Code extension for code_aster", "publisher": "simvia", "license": "GPL-3.0",