commit 46c5561ee53650b405fd09b2acf076bf377d9d9a Author: brice.boisson Date: Tue Oct 21 22:00:23 2025 +0200 First version diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac911c5 --- /dev/null +++ b/package.json @@ -0,0 +1,75 @@ +{ + "name": "modelsim-wave-ext", + "displayName": "ModelSim Wave Extension", + "description": "Send add wave commands to ModelSim from VS Code", + "version": "0.0.2", + "engines": { "vscode": "^1.70.0" }, + "activationEvents": [ + "onCommand:modelsim.addWaveUnderCursor", + "onStartupFinished", + "onView:myCustomPanel" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { "command": "modelsim.addWaveUnderCursor", "title": "Add Wave for Word Under Cursor" }, + { "command": "modelsim.toggleWaveMode", "title": "Toggle Wave Debug Mode" } + ], + "keybindings": [ + { "command": "modelsim.addWaveUnderCursor", "key": "ctrl+w" }, + { "command": "modelsim.toggleWaveMode", "key": "ctrl+alt+m" }, + { "command": "modelsim.zoomInWave", "key": "ctrl+alt+u" } + ], + "viewsContainers": { + "panel": [ + { + "id": "myPanelContainer", + "title": "My Tools", + "icon": "media/tools.svg" + } + ] + }, + "views": { + "myPanelContainer": [ + { + "type": "webview", + "id": "myCustomPanel", + "name": "My Panel" + } + ] + }, + "configuration": { + "title": "ModelSim Wave", + "properties": { + "modelsimWave.sharedDir": { + "type": "string", + "default": ".", + "description": "Folder shared between VS Code and ModelSim." + }, + "modelsimWave.topScope": { + "type": "string", + "default": "sim:/tb_top/", + "description": "Simulation top scope (e.g., sim:/top_tb)." + }, + "modelsimWave.timeoutMs": { + "type": "number", + "default": 5000, + "description": "Timeout waiting for ModelSim response (ms)." + }, + "modelsimWave.debugKeepResults": { + "type": "boolean", + "default": false, + "description": "Keep ModelSim result files for debugging." + } + } + } + }, + "scripts": { + "build": "tsc -p ." + }, + "devDependencies": { + "typescript": "^5.9.2", + "@types/node": "^20.0.0", + "@types/vscode": "^1.70.0" + } +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..84e48ff --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,482 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { promises as fsp } from 'fs'; + +// Tracks the most recent hover position per document, with a timestamp. +const lastHoverByDoc = new Map(); +const HOVER_FRESH_MS = 2000; + +// Hold MANY instance paths per module name, e.g. queue_slot -> ["/test/...[0]", "/test/...[1]", ...] +let moduleTree: Record = {}; + +let waveMode = false; +let statusItem: vscode.StatusBarItem; + +function toggleWaveMode() { + waveMode = !waveMode; + statusItem.text = waveMode ? '🌊 Wave Debug Mode' : '✏️ Edit Mode'; + statusItem.tooltip = waveMode + ? 'Wave Debug Mode β€” click to switch to Edit Mode' + : 'Edit Mode β€” click to switch to Wave Debug Mode'; +} + +// vscode.commands.registerCommand("myext.ctrlW", async () => { +// if (waveMode) { +// await sendToModelSim("add wave " + getSelectedSignal()); +// } else { +// await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); +// } +// }); + + +function getNonce() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < 16; i++) result += chars.charAt(Math.floor(Math.random() * chars.length)); + return result; +} + +class MyWebviewViewProvider implements vscode.WebviewViewProvider { + constructor(private readonly ctx: vscode.ExtensionContext) {} + + resolveWebviewView(webviewView: vscode.WebviewView): void | Thenable { + // Normal webview options go here (retainContextWhenHidden is NOT set here) + webviewView.webview.options = { + enableScripts: true, + // If you serve local files, set allowed roots: + // localResourceRoots: [vscode.Uri.joinPath(this.ctx.extensionUri, 'media')] + }; + + webviewView.webview.onDidReceiveMessage(async (msg) => { + if ((msg?.type ?? '').toLowerCase() !== 'wheel') return; + + // Example: latest-only (cancel previous) using an AbortController + try { + if (waveMode && !!msg.ctrl) { + const cfg = vscode.workspace.getConfiguration('modelsimWave'); + const sharedDir = cfg.get('sharedDir') || '.'; + const timeoutMs = cfg.get('timeoutMs') ?? 5000; + console.log(msg.xPercent100); + if (msg.direction === 'zoom-in') { + const cmd = (`zoom_in_at ` + msg.xPercent100).replace(/\/+/g, '/'); + const resp = await sendAndAwait(sharedDir, cmd, timeoutMs); + } else { + const cmd = (`zoom_out_at ` + msg.xPercent100).replace(/\/+/g, '/'); + const resp = await sendAndAwait(sharedDir, cmd, timeoutMs); + } + } + } catch (e) { + if ((e as any).name !== 'AbortError') console.error(e); + } + }); + + // Paint immediately with a small shell + webviewView.webview.html = this.getHtml(webviewView.webview); + + // (Optional) Load data after first paint and send it to the webview + // void this.init(w + // ebviewView.webview); + + + } + + private getHtml(webview: vscode.Webview): string { + // You can also reference local resources if needed: + // const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, 'media', 'main.js')); + // const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, 'media', 'main.css')); + + const nonce = getNonce(); + return /* html */ ` + + + + + +My Panel + + + +

My Panel

+

This webview is retained when hidden. Re-open should be instant.

+ +

+
+s
+
+`;
+  }
+
+  // Example async init after first paint
+  // private async init(webview: vscode.Webview) {
+  //   const data = await Promise.resolve({ message: "hello" });
+  //   webview.postMessage({ type: 'init', data });
+  // }
+}
+
+export function activate(context: vscode.ExtensionContext) {
+  statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 1000);
+  statusItem.command = 'modelsim.toggleWaveMode'; // click to toggle
+
+  const cmd_2 = vscode.commands.registerCommand('modelsim.toggleWaveMode', async () => {
+    toggleWaveMode();
+  });
+  const cmd = vscode.commands.registerCommand('modelsim.addWaveUnderCursor', async () => {
+  if (waveMode) {
+
+      const editor = vscode.window.activeTextEditor;
+      if (!editor) return;
+
+      const cfg = vscode.workspace.getConfiguration('modelsimWave');
+      const sharedDir = cfg.get('sharedDir') || '.';
+      const topScope = cfg.get('topScope') || 'sim:/test/'; // keep in sync with package.json
+      const timeoutMs = cfg.get('timeoutMs') ?? 5000;
+
+      // Prefer last hover over caret
+      const docKey = editor.document.uri.toString();
+      const hover = lastHoverByDoc.get(docKey);
+      const now = Date.now();
+      const position =
+        hover && now - hover.at <= HOVER_FRESH_MS ? hover.pos : editor.selection.active;
+
+      const wordRange = editor.document.getWordRangeAtPosition(position);
+      if (!wordRange) {
+        vscode.window.showInformationMessage('Hover a signal, then press your shortcut.');
+        return;
+      }
+
+      const tokenText = editor.document.getText(wordRange);
+      const isVariable = await looksLikeVariable(editor, position);
+      if (!isVariable) {
+        const choice = await vscode.window.showWarningMessage(
+          `"${tokenText}" doesn’t look like a variable here. Add anyway?`,
+          'Add',
+          'Cancel'
+        );
+        if (choice !== 'Add') return;
+      }
+
+      // Load module tree once per session
+      if (Object.keys(moduleTree).length === 0) {
+        moduleTree = await requestModuleTree(sharedDir, topScope, timeoutMs);
+        console.log("Lenght: ", Object.keys(moduleTree).length);
+        if (Object.keys(moduleTree).length === 0) {
+          vscode.window.showErrorMessage('Failed to load module hierarchy from ModelSim.');
+          return;
+        }
+      }
+
+      // Get module name from file name (language-independent)
+      const modName = path.basename(editor.document.fileName, path.extname(editor.document.fileName));
+
+      let instancePaths =
+        moduleTree[modName] ?? moduleTree[modName.toLowerCase()];
+      if (!instancePaths || instancePaths.length === 0) {
+        console.log(
+          `No instance paths found for module "${modName}". ` +
+            `Ensure your poller uses "find instances" and the design is loaded.`
+        );
+        return;
+      }
+
+      // Add the token for every instance of this module (dedupe paths just in case)
+      instancePaths = Array.from(new Set(instancePaths)).map(p => p.replace(/\/+/g, '/'));
+
+      let ok = true;
+      for (const p of instancePaths) {
+        const cmd = `quietly add wave -noupdate ${p}/${tokenText}`.replace(/\/+/g, '/');
+        const res = await sendAndAwait(sharedDir, cmd, timeoutMs);
+        ok = ok && !!res;
+      }
+      ok = ok && !!(await sendAndAwait(sharedDir, 'update', timeoutMs));
+
+      if (ok) {
+        vscode.window.showInformationMessage(
+          `Added "${tokenText}" for ${instancePaths.length} instance(s) of ${modName}.`
+        );
+      } else {
+        vscode.window.showWarningMessage('No response from ModelSim (timeout or error).');
+      }
+    } else {
+      await vscode.commands.executeCommand("workbench.action.closeActiveEditor");
+    }
+  });
+
+  // Record last hover (no UI needed)
+  const svSelectors: vscode.DocumentSelector = [
+    { language: 'systemverilog', scheme: 'file' },
+    { language: 'verilog', scheme: 'file' }
+  ];
+  const hoverRecorder = vscode.languages.registerHoverProvider(svSelectors, {
+    provideHover(doc, position) {
+      lastHoverByDoc.set(doc.uri.toString(), { pos: position, at: Date.now() });
+      return undefined;
+    }
+  });
+
+  const zoom = vscode.commands.registerCommand('modelsim.zoomInWave', async () => {
+    if (waveMode) {
+      const cfg = vscode.workspace.getConfiguration('modelsimWave');
+      const sharedDir = cfg.get('sharedDir') || '.';
+      const timeoutMs = cfg.get('timeoutMs') ?? 5000;
+      const cmd = `zoom_in_at 10`.replace(/\/+/g, '/');
+      const res = await sendAndAwait(sharedDir, cmd, timeoutMs);
+      if (!res) {
+        vscode.window.showWarningMessage('No response from ModelSim (timeout or error).');
+      }
+
+    }
+  });
+
+  const provider = new MyWebviewViewProvider(context);
+
+  context.subscriptions.push(
+    vscode.window.registerWebviewViewProvider(
+      'myCustomPanel',
+      provider,
+      { webviewOptions: { retainContextWhenHidden: true } } // πŸ‘ˆ the important bit
+    )
+  );
+
+  context.subscriptions.push(cmd, hoverRecorder, statusItem, cmd_2, zoom);
+  statusItem.text = waveMode ? '🌊 Wave Debug Mode' : '✏️ Edit Mode';
+  statusItem.tooltip = waveMode
+    ? 'Wave Debug Mode β€” click to switch to Edit Mode'
+    : 'Edit Mode β€” click to switch to Wave Debug Mode';
+  statusItem.show();
+}
+
+export function deactivate() {
+  statusItem?.dispose();
+}
+
+// ───────────────────────────────────────── Symbol utilities
+
+async function looksLikeVariable(
+  editor: vscode.TextEditor,
+  position: vscode.Position
+): Promise {
+  try {
+    const symbols = await vscode.commands.executeCommand(
+      'vscode.executeDocumentSymbolProvider',
+      editor.document.uri
+    );
+    if (!symbols || symbols.length === 0) return true;
+
+    const flat = flattenSymbols(symbols);
+    const hit = flat
+      .filter(s => s.range.contains(position))
+      .sort((a, b) => a.range.end.compareTo(b.range.end))
+      .pop();
+    if (!hit) return true;
+
+    const variableKinds = new Set([
+      vscode.SymbolKind.Variable,
+      vscode.SymbolKind.Field,
+      vscode.SymbolKind.Constant,
+      vscode.SymbolKind.Property,
+      vscode.SymbolKind.EnumMember
+    ]);
+    return variableKinds.has(hit.kind);
+  } catch {
+    return true;
+  }
+}
+
+function flattenSymbols(items: vscode.DocumentSymbol[]): vscode.DocumentSymbol[] {
+  const out: vscode.DocumentSymbol[] = [];
+  const walk = (arr: vscode.DocumentSymbol[]) => {
+    for (const s of arr) {
+      out.push(s);
+      if (s.children?.length) walk(s.children);
+    }
+  };
+  walk(items);
+  return out;
+}
+
+function parseModuleNameFromDoc(doc: vscode.TextDocument): string | null {
+  const m = doc.getText().match(/\bmodule\s+([A-Za-z_][A-Za-z0-9_$]*)\b/);
+  return m ? m[1] : null;
+}
+
+// ───────────────────────────────────────── ModelSim command protocol
+
+async function requestModuleTree(
+  sharedDir: string,
+  topScope: string,
+  timeoutMs: number
+): Promise> {
+  const tree: Record = {};
+  const payload = `get_module_tree ${topScope}`;
+  console.log("Requesting ModuleTree");
+  vscode.window.showInformationMessage('Requesting ModuleTree');
+  const res = await sendAndAwait(sharedDir, payload, timeoutMs);
+
+  const text = (await res) ?? "";
+  console.log("Module Hierarchy: ", text)
+  const rx = /\{([^}]+?)\s+\(([^)]+)\)\}/g; // { ()}
+  const moduleTree: Record = {};
+
+  let m: RegExpExecArray | null;
+  while ((m = rx.exec(text))) {
+    const rawPath = m[1].trim();   // e.g. "/test/u_queue/u_queue_slot[0]"
+    const modType = m[2].trim();   // e.g. "queue_slot"
+
+    if (rawPath.startsWith("/std") || rawPath.startsWith("/std::")) continue;
+    console.log("RawPath: ", rawPath);
+    if (!(rawPath === `/${topScope}` || rawPath.startsWith(`/${topScope}/`))) continue;
+    console.log("RawPath: ", rawPath);
+
+    // normalize and keep indices
+    // const normPath =
+    //   "/" +
+    //   rawPath
+    //     .replace(/^\//, "")
+    //     .split("/")
+    //     .map(seg => (seg.startsWith("u_") ? seg : "u_" + seg))
+    //     .join("/");
+
+    (moduleTree[modType] ??= []).push(rawPath);
+  }
+
+  console.log(moduleTree);
+
+  return moduleTree;
+
+  // if (!res) {
+  //   vscode.window.showErrorMessage('No response from ModelSim (timeout).');
+  //   return tree;
+  // }
+
+  // const preview = res.slice(0, 400).replace(/\r?\n/g, '\\n');
+  // console.log(`[modelsim-wave] module-tree raw preview: ${preview}`);
+
+  // if (res.startsWith('ERROR:')) {
+  //   vscode.window.showErrorMessage(`ModelSim error: ${res}`);
+  //   return tree;
+  // }
+
+  // const lines = res.replace(/\r/g, '').split('\n').map(l => l.trim()).filter(Boolean);
+
+  // const push = (mod: string, p: string) => {
+  //   // normalize paths: strip sim:/, ensure single leading slash, collapse slashes
+  //   let path = p.replace(/^sim:\//i, '/').replace(/\/+/g, '/');
+  //   if (!path.startsWith('/')) path = `/${path}`;
+  //   (tree[mod] ??= []).push(path);
+  // };
+
+  // for (const line of lines) {
+  //   let m: RegExpExecArray | null;
+
+  //   // 1) " /path"   or   " sim:/path"
+  //   m = /^([A-Za-z_][A-Za-z0-9_$\.]*)\s+(?:sim:\/)?(\/.*)$/.exec(line);
+  //   if (m) { push(m[1], m[2]); continue; }
+
+  //   // 2) "{/path (module)}"  β†’ raw `find instances` formatted line
+  //   m = /^\{(?:sim:\/)?(\/[^ ]*) \(([^)]+)\)\}$/.exec(line);
+  //   if (m) { push(m[2], m[1]); continue; }
+
+  //   // 3) "lib.module /path"
+  //   m = /^(\S+)\.([A-Za-z_][A-Za-z0-9_$\.]*)\s+(?:sim:\/)?(\/.*)$/.exec(line);
+  //   if (m) { push(m[2], m[3]); continue; }
+
+  //   // 4) Fallback: " "
+  //   m = /^(\S+)\s+(?:sim:\/)?(\/.*)$/.exec(line);
+  //   if (m) { push(m[1], m[2]); continue; }
+
+  //   // If nothing matched, keep going; some lines may be comments.
+  // }
+
+  // if (Object.keys(tree).length === 0) {
+  //   vscode.window.showWarningMessage(
+  //     'ModelSim replied, but no modules were parsed. Check β€œLog (Extension Host)” for the raw preview and verify Top Scope.'
+  //   );
+  // }
+
+  // return tree;
+}
+
+async function sendAndAwait(
+  sharedDir: string,
+  command: string,
+  timeoutMs: number
+): Promise {
+  const cfg = vscode.workspace.getConfiguration('modelsimWave');
+  const keep = cfg.get('debugKeepResults') === true;
+
+  const id = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
+  const resultsPath = path.join(sharedDir, `modelsim_results_${id}.txt`);
+  const commandsPath = path.join(sharedDir, 'modelsim_commands.txt');
+
+  const line = `${id}|${resultsPath}|${command}\n`;
+  await fsp.appendFile(commandsPath, line, 'utf8');
+
+  const start = Date.now();
+  while (Date.now() - start < timeoutMs) {
+    try {
+      const buf = await fsp.readFile(resultsPath);
+      const txt = buf.toString('utf8');
+      if (!keep) {
+        try { await fsp.unlink(resultsPath); } catch {}
+      }
+      return txt;
+    } catch {
+      await sleep(200);
+    }
+  }
+  return null;
+}
+
+function sleep(ms: number) {
+  return new Promise(r => setTimeout(r, ms));
+}
diff --git a/test/back.do b/test/back.do
new file mode 100644
index 0000000..c700b53
--- /dev/null
+++ b/test/back.do
@@ -0,0 +1,104 @@
+# modelsim_poll.tcl β€” robust, logged, per-request protocol
+
+# === CONFIG: set to your absolute shared folder ===
+set shared_dir "/home/brice/Code/modelsim_wave_ext/test/tmp"
+
+# === Derived paths ===
+set commands_file [file join $shared_dir "modelsim_commands.txt"]
+
+# Minimal logger
+proc elog {msg} {
+    puts "[clock format [clock seconds] -format {%H:%M:%S}] [info nameofexecutable]: $msg"
+}
+
+# Return lines: " /test/...", one per INSTANCE (not unique by module)
+proc extract_modules_from_find {} {
+    set result [find instances -r /*]
+
+    elog $result
+
+    return $result
+}
+
+proc getTimeAtPercent {percent} {
+    lassign [wave zoom range] start end
+    lassign $start sVal sUnit
+    lassign $end   eVal eUnit
+    # (Usually sUnit == eUnit; if not, convert eVal to sUnit as needed.)
+    set t [expr {$sVal + ($eVal - $sVal) * $percent / 100.0}]
+    return "$t $sUnit"
+}
+
+proc zoomAtPercent {percent {factor 1.1}} {
+    set time [getTimeAtPercent $percent]
+    lassign [wave zoom range] start end
+    lassign $start sVal sUnit
+    lassign $end   eVal eUnit
+    set new [expr {$eVal + 1}]
+    wave seetime "${new}${eUnit}" -at 0
+    wave seetime $time -at 50
+    wave zoom in $factor
+    lassign [wave zoom range] start end
+    lassign $start sVal sUnit
+    lassign $end   eVal eUnit
+    set new [expr {$eVal + 1}]
+    wave seetime "${new}${eUnit}" -at 0
+    wave seetime $time -at $percent
+}
+
+proc handle_command {payload} {
+    if {[regexp {^get_module_tree\s+(.+)$} $payload -> top]} {
+        return [extract_modules_from_find]
+    } elseif {[regexp {^zoom_in_at\s+(.+)$} $payload -> percent]} {
+        return [zoomAtPercent $percent]
+    } elseif {[regexp {^zoom_out_at\s+(.+)$} $payload -> percent]} {
+        return [zoomAtPercent $percent 0.9]
+    } else {
+        if {[catch {eval $payload} out]} { return "ERROR: $out" }
+        return $out
+    }
+}
+
+proc poll_commands {} {
+    global commands_file
+    if {[file exists $commands_file]} {
+        set fid [open $commands_file r]
+        # Handle both \n and \r\n
+        set content [string map {\r ""} [read $fid]]
+        close $fid
+        file delete -force $commands_file
+
+        foreach raw [split $content "\n"] {
+            set line [string trim $raw]
+            if {$line eq ""} { continue }
+
+            # Expect: ||
+            if {![regexp {^(\S+)\|(\S+)\|(.*)$} $line -> id result_path payload]} {
+                elog "WARN: bad command line: $line"
+                continue
+            }
+
+            elog "CMD id=$id -> $payload"
+            set out [handle_command $payload]
+
+            # Ensure directory exists
+            set dir [file dirname $result_path]
+            if {![file isdirectory $dir]} {
+                catch { file mkdir $dir }
+            }
+
+            if {[catch { set rf [open $result_path w] } err]} {
+                elog "ERROR: cannot open result file '$result_path': $err"
+                continue
+            }
+            puts $rf $out
+            close $rf
+            elog "WROTE result -> $result_path (len=[string length $out])"
+        }
+    }
+    after 200 poll_commands
+}
+
+# Start
+elog "Poller starting. shared_dir=$shared_dir  pwd=[pwd]"
+poll_commands
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e409242
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "module": "commonjs",
+    "outDir": "out",
+    "rootDir": "src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "types": ["node", "vscode"]
+  },
+  "include": ["src"]
+}