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/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/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/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-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 8a429cd..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", @@ -67,6 +67,36 @@ "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.runSetup", + "title": "Run code_aster setup checks" + }, + { + "command": "vs-code-aster.sidebar.refresh", + "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", @@ -80,7 +110,7 @@ { "id": "comm", "aliases": [ - "Aster Commands" + "code_aster (comm)" ], "extensions": [ ".comm", @@ -105,7 +135,7 @@ { "id": "export", "aliases": [ - "Aster Export" + "code_aster (export)" ], "extensions": [ ".export" @@ -143,6 +173,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", @@ -169,6 +217,18 @@ } ], "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@2" + } + ], "editor/title": [ { "command": "vs-code-aster.exportDoc", @@ -199,6 +259,11 @@ } ] }, + "configurationDefaults": { + "[comm]": { + "editor.wordBasedSuggestions": "off" + } + }, "configuration": { "title": "VS Code Aster", "type": "object", @@ -250,6 +315,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", @@ -362,7 +439,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/python/asterstudy/datamodel/catalogs.py b/python/asterstudy/datamodel/catalogs.py index e53a878..c40ea48 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() @@ -576,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..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) @@ -90,15 +110,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: """ @@ -349,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 4da461c..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): @@ -26,9 +72,15 @@ def on_initialize(ls: LanguageServer, params: InitializeParams): return { "capabilities": { "textDocumentSync": 1, - "completionProvider": {"resolveProvider": False, "triggerCharacters": [" ", "."]}, + "completionProvider": { + "resolveProvider": False, + "triggerCharacters": ["(", ",", "=", " "], + }, "hoverProvider": True, "definitionProvider": True, + "codeActionProvider": { + "codeActionKinds": [CodeActionKind.QuickFix], + }, } } @@ -39,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): @@ -47,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: @@ -70,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/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/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 02668bd..78a03e2 100644 --- a/python/lsp/managers/hover_manager.py +++ b/python/lsp/managers/hover_manager.py @@ -1,43 +1,702 @@ """ -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. 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(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/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/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/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 diff --git a/src/CatalogResolver.ts b/src/CatalogResolver.ts new file mode 100644 index 0000000..6db3bd7 --- /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('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('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 }); + }); + }); +} + +export 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..a4a504c --- /dev/null +++ b/src/CaveStatusBar.ts @@ -0,0 +1,568 @@ +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); +} + +export 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()), + 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( + 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}`; + 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, 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(); + } + + 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(); + } + }); + } + + /** + * 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 + // 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..61fb1f8 --- /dev/null +++ b/src/CommFormatter.ts @@ -0,0 +1,173 @@ +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; + +function log(line: string) { + if (!channel) { + channel = vscode.window.createOutputChannel(CHANNEL_NAME); + } + const stamp = new Date().toISOString().slice(11, 23); + channel.appendLine(`${stamp} ${line}`); +} + +/** + * 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( + 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; + } + + 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 = resolvePythonExecutable(context); + return { + cmd: python, + args: [ + '-m', + 'ruff', + 'format', + '--config', + 'format.quote-style="preserve"', + '--line-length=100', + '--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 +{ + constructor(private context: vscode.ExtensionContext) {} + + async provideDocumentFormattingEdits( + document: vscode.TextDocument + ): Promise { + return this.format(document); + } + + async provideDocumentRangeFormattingEdits( + document: vscode.TextDocument, + range: vscode.Range + ): Promise { + const text = document.getText(range); + const edits = await this.formatText(document, text); + if (!edits) { + return undefined; + } + return [vscode.TextEdit.replace(range, edits[0].newText)]; + } + + 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(this.context, 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)}`); + + 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. Run "code_aster: Run setup checks" to install it.', + 'Show log' + ) + .then((choice) => { + if (choice === 'Show log' && channel) { + channel.show(); + } + }); + } + return undefined; + } + + 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..cde9c20 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,18 @@ 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; + // 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() {} @@ -36,19 +56,35 @@ 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(); + this._readyEmitter.fire(); + }) + .catch((err: any) => { + vscode.window.showErrorMessage( + 'Error starting code_aster language server: ' + err.message + ); + }) + ); this._client.onDidChangeState((e) => console.log('LSP client state changed:', e)); @@ -78,16 +114,29 @@ 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 }, + }; + // Save so restart() can refresh env without rebuilding the client. + this._serverOptions = { + command: pythonExecutablePath, + args: [serverModule], + options: { env }, }; const clientOptions: LanguageClientOptions = { @@ -98,8 +147,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 ); @@ -120,10 +171,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(); + } } }); } @@ -132,16 +223,86 @@ 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; } - this._client - ?.start() - .then(() => vscode.window.showInformationMessage('LSP Python Code-Aster restarted!')) - .catch((err: any) => - vscode.window.showErrorMessage('Error restarting LSP Python: ' + err.message) + 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; + void vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: 'Restarting code_aster language server…', + }, + async () => { + try { + if (client.isRunning()) { + await client.stop(); + } + } catch (err: any) { + getCatalogChannel().appendLine(`[lsp] stop() during restart: ${err?.message ?? err}`); + } + try { + await client.start(); + this._readyEmitter.fire(); + } catch (err: any) { + vscode.window.showErrorMessage( + 'Error restarting code_aster language server: ' + (err?.message ?? err) + ); + } + } + ); + } + + 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 +315,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/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..717af2b --- /dev/null +++ b/src/SidebarView.ts @@ -0,0 +1,723 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { + caveFilePath, + dockerAvailable, + getBundledVersion, + 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[]; + ruffOk: boolean; + dockerOk: boolean; + caveOk: boolean; + installedVersions: string[]; + currentVersion: string | null; + bundledVersion: string | null; + // Command browser data + inFile: CommandFamilies; + catalog: CommandFamilies; + catalogLoaded: boolean; +} + +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; + + private _editDebounce?: 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 */ + } + // 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 { + this.cached = null; + 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; + } + + async getChildren(parent?: Item): Promise { + if (!parent) { + return this.topLevel(); + } + 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(); + 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 + + 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, + inFile: { ...EMPTY_FAMILIES }, + catalog: { ...EMPTY_FAMILIES }, + catalogLoaded: false, + } + ); + } + + private async runProbes(): Promise { + 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, + pythonMissing: pythonResult.missing, + ruffOk, + dockerOk, + caveOk, + 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, + }; + } + + // ------------------------------------------------------------ Setup + + private setupGroup(p: Probe): Item { + const versionOk = !!p.currentVersion && p.installedVersions.includes(p.currentVersion); + 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 (${passed}/${checks.length})`, + 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'); + + // 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'), + ]; + 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; + } + + 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.Collapsed); + 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 + + // ---------------------------------------------------- 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'), + 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); + // 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( + 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()); 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/src/extension.ts b/src/extension.ts index 7b1e810..df3c02b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,13 +8,18 @@ import * as path from 'path'; import { VisuManager } from './VisuManager'; import { ExportEditor } from './ExportEditor'; import { ExportFormatter } from './ExportFormatter'; +import { CommFormatter } from './CommFormatter'; +import { runSetupProbes } from './SetupOnboarding'; +import { registerSidebar } from './SidebarView'; 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 +50,40 @@ export async function activate(context: vscode.ExtensionContext) { ) ); + const commFormatter = new CommFormatter(context); + context.subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider({ language: 'comm' }, commFormatter), + vscode.languages.registerDocumentRangeFormattingEditProvider( + { language: 'comm' }, + commFormatter + ) + ); + + // 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 (ASTER_LANGS.has(doc.languageId)) { + void runSetupProbes(context); + } + }) + ); + for (const doc of vscode.workspace.textDocuments) { + 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(); }); @@ -54,7 +93,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" } + } } ] } 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,