diff --git a/package.json b/package.json index 2272e41..739ddc5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sciml-toolkit", "displayName": "SciML Toolkit", "description": "Julia SciML toolkit: inline execution, variable inspector, plot viewer, benchmarks, equation preview, and snippets", - "version": "0.2.0", + "version": "0.3.0", "publisher": "harsh", "license": "MIT", "repository": { @@ -41,6 +41,11 @@ "command": "sciml.showVariables", "title": "SciML: Show Variable Inspector", "icon": "$(table)" + }, + { + "command": "sciml.restartRepl", + "title": "SciML: Restart Julia Session", + "icon": "$(refresh)" } ], "menus": { diff --git a/src/extension.ts b/src/extension.ts index be87abd..b812b9d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,16 +4,51 @@ import { BenchmarkPanel } from './benchmarkPanel'; import { EquationPreview } from './equationPreview'; import { registerSnippets } from './snippets'; import { VariableInspector } from './variableInspector'; +import { PackageManager } from './packageManager'; +import { PlotViewer, tempPlotPath } from './plotViewer'; let runner: JuliaRunner | undefined; +let pkgManager: PackageManager | undefined; export function activate(context: vscode.ExtensionContext) { runner = new JuliaRunner(); + pkgManager = new PackageManager(runner.session); // Register SciML snippet completions registerSnippets(context); + // Start REPL session eagerly when a Julia file is open + const startSessionIfNeeded = () => { + if (!runner!.session.isReady()) { + void runner!.session.start().then(() => { + // Check packages once session is ready + const editor = vscode.window.activeTextEditor; + if (editor?.document.languageId === 'julia') { + void pkgManager!.checkAndOffer(editor.document); + } + }).catch(() => { /* Julia not found — commands will fall back to subprocess */ }); + } + }; + + // Start when any Julia file is opened or becomes active + if (vscode.window.activeTextEditor?.document.languageId === 'julia') { + startSessionIfNeeded(); + } + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor?.document.languageId === 'julia') { + startSessionIfNeeded(); + void pkgManager!.checkAndOffer(editor.document); + } + }), + + vscode.workspace.onDidSaveTextDocument((doc) => { + if (doc.languageId === 'julia') { + void pkgManager!.checkAndOffer(doc); + } + }), + vscode.commands.registerCommand('sciml.runBlock', () => { const editor = vscode.window.activeTextEditor; if (editor) runner?.runBlock(editor, context); @@ -35,6 +70,45 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('sciml.showVariables', () => { VariableInspector.show(context, []); + }), + + vscode.commands.registerCommand('sciml.plotVariable', async (varName: string) => { + if (!runner!.session.isReady()) { + void vscode.window.showWarningMessage('SciML: no active Julia session — run a block first.'); + return; + } + const plotPath = tempPlotPath(); + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + status.text = `$(sync~spin) SciML: plotting ${varName}…`; + status.show(); + try { + const code = ` +import Plots +Plots.plot(${varName}) +Plots.savefig(raw"${plotPath}") +println("__sciml_plot__:${plotPath}") +`; + await runner!.session.exec(code, 30_000); + PlotViewer.show(context, plotPath); + } catch (err) { + void vscode.window.showErrorMessage(`SciML: plot failed — ${err}`); + } finally { + status.dispose(); + } + }), + + vscode.commands.registerCommand('sciml.restartRepl', async () => { + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + status.text = '$(sync~spin) SciML: Restarting Julia session…'; + status.show(); + try { + await runner!.session.restart(); + void vscode.window.showInformationMessage('SciML: Julia session restarted.'); + } catch (err) { + void vscode.window.showErrorMessage(`SciML: restart failed — ${err}`); + } finally { + status.dispose(); + } }) ); } diff --git a/src/juliaRunner.ts b/src/juliaRunner.ts index b25cd9b..babced6 100644 --- a/src/juliaRunner.ts +++ b/src/juliaRunner.ts @@ -3,6 +3,7 @@ import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; import { BenchmarkPanel, buildBenchmarkScript, parseBenchmarkOutput } from './benchmarkPanel'; import { VariableInspector, VAR_INSPECT_SUFFIX, parseVariables } from './variableInspector'; import { PlotViewer, buildPlotSuffix, tempPlotPath, parsePlotPath } from './plotViewer'; +import { ReplSession } from './replSession'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -26,6 +27,7 @@ export class JuliaRunner { private outputDec: vscode.TextEditorDecorationType; private errorDec: vscode.TextEditorDecorationType; private runningDec: vscode.TextEditorDecorationType; + readonly session: ReplSession = new ReplSession(); constructor() { this.outputDec = vscode.window.createTextEditorDecorationType({ @@ -74,29 +76,26 @@ export class JuliaRunner { this.setRunning(editor, endLine); try { const plotPath = tempPlotPath(); - // Append variable inspector + plot capture suffixes const fullCode = code + '\n' + VAR_INSPECT_SUFFIX + '\n' + buildPlotSuffix(plotPath); - - const config = vscode.workspace.getConfiguration('sciml'); - const useRepl: boolean = config.get('useExistingRepl') ?? true; let output: string; - if (useRepl && (await this.juliaVscodeReplAvailable())) { - output = await this.runViaRepl(fullCode); + // Prefer the persistent REPL session (instant — no startup cost). + // Fall back to one-shot subprocess if session isn't ready yet. + if (this.session.isReady()) { + output = await this.session.exec(fullCode); } else { + // Try to start session in background for next run + void this.session.start().catch(() => { /* will retry next run */ }); output = await this.execJulia(fullCode); } - // Show first meaningful output line inline const displayLine = output.split('\n') .find((l) => l.trim() && !l.startsWith('__sciml_')); this.showResult(editor, endLine, displayLine ?? '(no output)', false); - // Update variable inspector panel const vars = parseVariables(output); VariableInspector.show(context, vars); - // Show plot if one was saved const pPath = parsePlotPath(output); if (pPath) PlotViewer.show(context, pPath); @@ -147,6 +146,7 @@ export class JuliaRunner { this.outputDec.dispose(); this.errorDec.dispose(); this.runningDec.dispose(); + this.session.dispose(); } // ─── Block detection ──────────────────────────────────────────────────────── diff --git a/src/packageManager.ts b/src/packageManager.ts new file mode 100644 index 0000000..6259d4d --- /dev/null +++ b/src/packageManager.ts @@ -0,0 +1,102 @@ +import * as vscode from 'vscode'; +import { ReplSession } from './replSession'; + +// ─── Package auto-installer ─────────────────────────────────────────────────── +// Scans the active document for `using X` / `import X` statements, +// checks which packages are missing from the Julia environment, +// and shows a notification offering to install them in one click. + +const USING_RE = /^\s*(?:using|import)\s+([\w.]+(?:\s*,\s*[\w.]+)*)/gm; + +// Packages that ship with Julia base — no install needed +const STDLIB = new Set([ + 'Base', 'Core', 'Main', 'Dates', 'LinearAlgebra', 'Random', 'Statistics', + 'Printf', 'Test', 'REPL', 'Logging', 'Pkg', 'InteractiveUtils', 'Markdown', + 'Serialization', 'Sockets', 'Profile', 'FileWatching', 'SHA', 'Unicode', + 'DelimitedFiles', 'SparseArrays', 'SharedArrays', 'Mmap', 'LibGit2', +]); + +export function extractUsedPackages(text: string): string[] { + const pkgs = new Set(); + for (const m of text.matchAll(USING_RE)) { + for (const raw of m[1].split(',')) { + const pkg = raw.trim().split('.')[0]; // `Plots.jl` → `Plots` + if (pkg && !STDLIB.has(pkg)) pkgs.add(pkg); + } + } + return [...pkgs]; +} + +export class PackageManager { + private session: ReplSession; + private lastChecked = new Map(); // pkg → timestamp + private RECHECK_MS = 5 * 60 * 1000; // re-check at most once per 5 min + + constructor(session: ReplSession) { + this.session = session; + } + + // Call after opening / saving a Julia file + async checkAndOffer(document: vscode.TextDocument): Promise { + if (!this.session.isReady()) return; + + const pkgs = extractUsedPackages(document.getText()); + if (pkgs.length === 0) return; + + // Filter out recently-checked packages + const now = Date.now(); + const toCheck = pkgs.filter( + (p) => !this.lastChecked.has(p) || now - this.lastChecked.get(p)! > this.RECHECK_MS + ); + if (toCheck.length === 0) return; + + // Ask Julia which ones are actually missing + const checkCode = `__sciml_pkg_status__(${JSON.stringify(toCheck)})`; + let output: string; + try { + output = await this.session.exec(checkCode, 10_000); + } catch { + return; + } + + const missing = output + .split('\n') + .filter((l) => l.startsWith('__sciml_missing__:')) + .map((l) => l.replace('__sciml_missing__:', '').trim()); + + // Mark all checked + for (const p of toCheck) this.lastChecked.set(p, now); + + if (missing.length === 0) return; + + const label = missing.length === 1 + ? `Install ${missing[0]}` + : `Install ${missing.length} packages (${missing.join(', ')})`; + + const choice = await vscode.window.showInformationMessage( + `SciML: missing Julia packages: ${missing.join(', ')}`, + label, + 'Ignore' + ); + + if (choice !== label) return; + + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + status.text = `$(sync~spin) Installing ${missing.join(', ')}…`; + status.show(); + + try { + const installCode = `import Pkg; Pkg.add(${JSON.stringify(missing)})`; + await this.session.exec(installCode, 300_000); + void vscode.window.showInformationMessage( + `SciML: installed ${missing.join(', ')} successfully.` + ); + } catch (err) { + void vscode.window.showErrorMessage( + `SciML: package install failed — ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + status.dispose(); + } + } +} diff --git a/src/replSession.ts b/src/replSession.ts new file mode 100644 index 0000000..b18eec2 --- /dev/null +++ b/src/replSession.ts @@ -0,0 +1,206 @@ +import * as vscode from 'vscode'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as crypto from 'crypto'; + +// ─── ReplSession ────────────────────────────────────────────────────────────── +// Keeps a single Julia process alive for the VS Code session. +// Code is sent via stdin; output is read until a unique end-sentinel appears. +// This eliminates the ~2-3s Julia startup cost on every Run Block. + +const STARTUP_CODE = ` +# Disable output truncation so previews are complete +Base.display_size() = (24, 200) + +# Silence Pkg precompile noise during sessions +ENV["JULIA_PKG_PRECOMPILE_AUTO"] = "0" + +# Helper the extension uses to detect missing packages +function __sciml_pkg_status__(pkgs) + import Pkg + installed = keys(Pkg.project().dependencies) + for pkg in pkgs + if !(pkg in installed) + println("__sciml_missing__:", pkg) + end + end +end +println("__sciml_ready__") +`; + +export class ReplSession { + private proc: ChildProcessWithoutNullStreams | undefined; + private ready = false; + private queue: Array<{ code: string; resolve: (v: string) => void; reject: (e: Error) => void }> = []; + private running = false; + private outputBuffer = ''; + private stderrBuffer = ''; + private currentSentinel = ''; + private currentResolve: ((v: string) => void) | undefined; + private currentReject: ((e: Error) => void) | undefined; + private startPromise: Promise | undefined; + private onLineCallback: ((line: string) => void) | undefined; + + private get juliaPath(): string { + return vscode.workspace.getConfiguration('sciml').get('juliaPath') ?? 'julia'; + } + + // ─── Lifecycle ────────────────────────────────────────────────────────────── + + start(): Promise { + if (this.startPromise) return this.startPromise; + this.startPromise = new Promise((resolve, reject) => { + const proc = spawn(this.juliaPath, ['--startup-file=no', '--color=no', '-i'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + proc.on('error', (err) => { + this.ready = false; + reject(err); + }); + + let bootBuffer = ''; + const onBoot = (chunk: Buffer) => { + bootBuffer += chunk.toString(); + if (bootBuffer.includes('__sciml_ready__')) { + proc.stdout.off('data', onBoot); + proc.stdout.on('data', (d: Buffer) => this.handleOutput(d)); + this.proc = proc; + this.ready = true; + resolve(); + } + }; + + proc.stdout.on('data', onBoot); + proc.stderr.on('data', (d: Buffer) => { + this.stderrBuffer += d.toString(); + // Forward progress sentinels to the current onLine callback + if (this.onLineCallback) { + for (const line of d.toString().split('\n')) { + if (line.trim()) this.onLineCallback(line); + } + } + }); + + proc.on('close', () => { + this.ready = false; + this.proc = undefined; + this.startPromise = undefined; + if (this.currentReject) { + this.currentReject(new Error('Julia REPL session closed unexpectedly')); + this.currentReject = undefined; + } + }); + + // Send startup code + proc.stdin.write(STARTUP_CODE + '\n'); + + // Timeout if Julia never becomes ready + const timeout = setTimeout(() => reject(new Error('Julia REPL failed to start')), 30_000); + this.startPromise?.then(() => clearTimeout(timeout)).catch(() => clearTimeout(timeout)); + }); + return this.startPromise; + } + + isReady(): boolean { + return this.ready && this.proc !== undefined; + } + + dispose() { + this.proc?.stdin.end(); + this.proc?.kill(); + this.proc = undefined; + this.ready = false; + this.startPromise = undefined; + } + + restart(): Promise { + this.dispose(); + return this.start(); + } + + // ─── Code execution ───────────────────────────────────────────────────────── + + exec(code: string, timeoutMs = 60_000, onLine?: (line: string) => void): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ code, resolve, reject }); + this.onLineCallback = onLine; + this.drainQueue(timeoutMs); + }); + } + + private drainQueue(timeoutMs: number) { + if (this.running || this.queue.length === 0 || !this.ready) return; + const { code, resolve, reject } = this.queue.shift()!; + this.running = true; + this.outputBuffer = ''; + this.stderrBuffer = ''; + + // Unique sentinel marks end of this execution + this.currentSentinel = `__sciml_end_${crypto.randomBytes(8).toString('hex')}__`; + this.currentResolve = (out) => { + clearTimeout(timer); + this.running = false; + resolve(out); + this.drainQueue(timeoutMs); + }; + this.currentReject = (err) => { + clearTimeout(timer); + this.running = false; + reject(err); + this.drainQueue(timeoutMs); + }; + + const timer = setTimeout(() => { + this.currentReject?.(new Error(`Julia timed out after ${timeoutMs / 1000}s`)); + this.currentReject = undefined; + this.currentResolve = undefined; + }, timeoutMs); + + // Wrap in try/catch so errors surface via the sentinel mechanism + const wrapped = ` +try +${code} +catch __sciml_err__ + println(stderr, "ERROR: ", sprint(showerror, __sciml_err__)) +end +println("${this.currentSentinel}") +flush(stdout) +`; + this.proc!.stdin.write(wrapped + '\n'); + } + + private handleOutput(chunk: Buffer) { + const text = chunk.toString(); + this.outputBuffer += text; + + // Emit lines to progress callback + if (this.onLineCallback) { + for (const line of text.split('\n')) { + if (line.trim()) this.onLineCallback(line); + } + } + + if (this.currentSentinel && this.outputBuffer.includes(this.currentSentinel)) { + // Extract output before the sentinel + const out = this.outputBuffer + .split(this.currentSentinel)[0] + .replace(/^julia>\s*/gm, '') // strip REPL prompts + .trim(); + + const hadError = this.stderrBuffer.includes('ERROR:'); + if (hadError) { + const errLine = this.stderrBuffer + .split('\n') + .find((l) => l.startsWith('ERROR:')) ?? this.stderrBuffer.trim(); + this.currentReject?.(new Error(errLine)); + } else { + this.currentResolve?.(out || '(no output)'); + } + + this.currentResolve = undefined; + this.currentReject = undefined; + this.outputBuffer = ''; + this.stderrBuffer = ''; + } + } +} diff --git a/src/variableInspector.ts b/src/variableInspector.ts index 269dfea..2346e8a 100644 --- a/src/variableInspector.ts +++ b/src/variableInspector.ts @@ -15,6 +15,13 @@ export class VariableInspector { private constructor(panel: vscode.WebviewPanel) { this.panel = panel; panel.onDidDispose(() => { VariableInspector.current = undefined; }); + + // Handle "Plot solution" button clicks from the WebView + panel.webview.onDidReceiveMessage((msg: { command: string; varName?: string }) => { + if (msg.command === 'plotSolution' && msg.varName) { + void vscode.commands.executeCommand('sciml.plotVariable', msg.varName); + } + }); } static show(context: vscode.ExtensionContext, vars: Variable[]) { @@ -82,6 +89,16 @@ export function parseVariables(raw: string): Variable[] { // ─── WebView ────────────────────────────────────────────────────────────────── +// Types that warrant a "Plot" action button +const PLOTTABLE_TYPES = [ + 'ODESolution', 'SDESolution', 'RODESolution', 'DAESolution', + 'BVPSolution', 'OptimizationSolution', 'EnsembleSolution', +]; + +function isPlottable(type: string): boolean { + return PLOTTABLE_TYPES.some((t) => type.includes(t)); +} + function buildHtml(vars: Variable[], nonce: string, cspSource: string): string { const rows = vars.length ? vars.map((v) => /* html */` @@ -90,8 +107,13 @@ function buildHtml(vars: Variable[], nonce: string, cspSource: string): string { ${escHtml(v.type)} ${escHtml(v.size)} ${escHtml(v.preview)} + + ${isPlottable(v.type) + ? `` + : ''} + `).join('') - : `No variables — run a block first.`; + : `No variables — run a block first.`; return /* html */` @@ -99,7 +121,9 @@ function buildHtml(vars: Variable[], nonce: string, cspSource: string): string { + content="default-src 'none'; + style-src ${cspSource} 'unsafe-inline'; + script-src 'nonce-${nonce}';"> SciML Variables