Skip to content

Commit c4d17f3

Browse files
committed
Manage ggsql kernel spec in Positron extension
1 parent fe4eef4 commit c4d17f3

2 files changed

Lines changed: 212 additions & 51 deletions

File tree

ggsql-jupyter/src/main.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ fn install_kernel(user: bool, sys_prefix: bool) -> Result<()> {
122122
"display_name": "ggsql",
123123
"language": "ggsql",
124124
"interrupt_mode": "signal",
125-
"env": {},
125+
"env": {
126+
"RUST_LOG": "error"
127+
},
126128
"metadata": {
127129
"debugger": false
128130
}

ggsql-vscode/src/manager.ts

Lines changed: 209 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,58 +14,109 @@ import type { JupyterKernelSpec, JupyterSession, JupyterKernel, PositronSupervis
1414
import { log } from './extension';
1515

1616
/**
17-
* Get the path to the ggsql-jupyter kernel executable
17+
* A discovered ggsql-jupyter kernel candidate
18+
*/
19+
interface KernelCandidate {
20+
/** Absolute path to the ggsql-jupyter binary (or bare name for PATH fallback) */
21+
kernelPath: string;
22+
/** Human-readable label for where this was found */
23+
source: string;
24+
}
25+
26+
/**
27+
* Discover all available ggsql-jupyter kernel binaries
1828
*
19-
* Checks in order:
29+
* Checks in priority order:
2030
* 1. Configured path in settings
21-
* 2. Jupyter kernelspec location (user)
22-
* 3. Jupyter kernelspec location (system)
31+
* 2. Jupyter kernelspec locations (user and system)
32+
* 3. Cargo-packager install locations
2333
* 4. Fall back to PATH
34+
*
35+
* Returns deduplicated candidates, keeping the highest-priority occurrence.
2436
*/
25-
function getKernelPath(): string {
37+
function discoverKernelPaths(): KernelCandidate[] {
38+
const candidates: KernelCandidate[] = [];
39+
const binaryName = process.platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter';
40+
41+
// 1. User-configured setting (highest priority)
2642
const config = vscode.workspace.getConfiguration('ggsql');
2743
const configuredPath = config.get<string>('kernelPath', '');
28-
2944
if (configuredPath && configuredPath.trim() !== '') {
30-
return configuredPath;
45+
candidates.push({ kernelPath: configuredPath, source: 'setting' });
3146
}
3247

33-
// Check Jupyter kernelspec locations
48+
// 2. Jupyter kernelspec locations
3449
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
35-
const kernelName = 'ggsql';
36-
const binaryName = process.platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter';
37-
38-
// Common Jupyter kernel locations
39-
const possiblePaths = [
40-
// User kernelspec (macOS/Linux)
41-
path.join(homeDir, 'Library', 'Jupyter', 'kernels', kernelName, binaryName),
50+
const kernelspecPaths = [
51+
// User kernelspec (macOS)
52+
path.join(homeDir, 'Library', 'Jupyter', 'kernels', 'ggsql', binaryName),
4253
// User kernelspec (Linux)
43-
path.join(homeDir, '.local', 'share', 'jupyter', 'kernels', kernelName, binaryName),
54+
path.join(homeDir, '.local', 'share', 'jupyter', 'kernels', 'ggsql', binaryName),
4455
// System kernelspec (macOS)
45-
path.join('/usr', 'local', 'share', 'jupyter', 'kernels', kernelName, binaryName),
56+
path.join('/usr', 'local', 'share', 'jupyter', 'kernels', 'ggsql', binaryName),
4657
// System kernelspec (Linux)
47-
path.join('/usr', 'share', 'jupyter', 'kernels', kernelName, binaryName),
58+
path.join('/usr', 'share', 'jupyter', 'kernels', 'ggsql', binaryName),
4859
];
60+
for (const p of kernelspecPaths) {
61+
if (fs.existsSync(p)) {
62+
candidates.push({ kernelPath: p, source: 'Jupyter' });
63+
}
64+
}
65+
66+
// 3. Cargo-packager install locations
67+
const packagerPaths: string[] = [];
68+
if (process.platform === 'darwin') {
69+
packagerPaths.push('/Applications/ggsql.app/Contents/MacOS/ggsql-jupyter');
70+
} else if (process.platform === 'win32') {
71+
const programFiles = process.env.PROGRAMFILES || 'C:\\Program Files';
72+
packagerPaths.push(path.join(programFiles, 'ggsql', 'ggsql-jupyter.exe'));
73+
const localAppData = process.env.LOCALAPPDATA;
74+
if (localAppData) {
75+
packagerPaths.push(path.join(localAppData, 'ggsql', 'ggsql-jupyter.exe'));
76+
}
77+
} else {
78+
// Linux deb package
79+
packagerPaths.push('/usr/bin/ggsql-jupyter');
80+
}
81+
for (const p of packagerPaths) {
82+
if (fs.existsSync(p)) {
83+
candidates.push({ kernelPath: p, source: 'System' });
84+
}
85+
}
4986

50-
for (const kernelPath of possiblePaths) {
51-
if (fs.existsSync(kernelPath)) {
52-
log(`Found kernel at: ${kernelPath}`);
53-
return kernelPath;
87+
// 4. PATH fallback (last resort)
88+
candidates.push({ kernelPath: binaryName, source: 'Path' });
89+
90+
// Deduplicate by resolved absolute path
91+
const seen = new Set<string>();
92+
const deduped: KernelCandidate[] = [];
93+
for (const candidate of candidates) {
94+
if (!path.isAbsolute(candidate.kernelPath)) {
95+
// Non-absolute paths (PATH fallback) can't be deduplicated
96+
deduped.push(candidate);
97+
continue;
98+
}
99+
let resolved: string;
100+
try {
101+
resolved = fs.realpathSync(candidate.kernelPath);
102+
} catch {
103+
resolved = candidate.kernelPath;
104+
}
105+
if (!seen.has(resolved)) {
106+
seen.add(resolved);
107+
deduped.push(candidate);
108+
} else {
109+
log(`Skipping duplicate kernel path: ${candidate.kernelPath} (resolves to ${resolved})`);
54110
}
55111
}
56112

57-
// Fall back to PATH
58-
log('Kernel not found in standard locations, falling back to PATH');
59-
return 'ggsql-jupyter';
113+
return deduped;
60114
}
61115

62116
/**
63-
* Check if the kernel executable exists and is accessible
117+
* Check if a kernel executable exists and is accessible
64118
*/
65-
async function isKernelAvailable(): Promise<boolean> {
66-
const kernelPath = getKernelPath();
67-
68-
// If it's an absolute path, check if the file exists
119+
async function isKernelAccessible(kernelPath: string): Promise<boolean> {
69120
if (path.isAbsolute(kernelPath)) {
70121
try {
71122
await fs.promises.access(kernelPath, fs.constants.X_OK);
@@ -81,21 +132,22 @@ async function isKernelAvailable(): Promise<boolean> {
81132
}
82133

83134
/**
84-
* Generate runtime metadata for ggsql
135+
* Generate runtime metadata for a ggsql kernel candidate
85136
*/
86137
function generateMetadata(
87-
context: vscode.ExtensionContext
138+
context: vscode.ExtensionContext,
139+
candidate: KernelCandidate,
140+
index: number
88141
): positron.LanguageRuntimeMetadata {
89-
const kernelPath = getKernelPath();
90142
const version = context.extension.packageJSON.version as string;
91143

92144
const iconPath = path.join(context.extensionPath, 'resources', 'ggsql-icon.svg');
93145
const base64Icon = fs.readFileSync(iconPath).toString('base64');
94146

95147
return {
96-
runtimeId: 'ggsql-jupyter',
97-
runtimePath: kernelPath,
98-
runtimeName: `ggsql ${version}`,
148+
runtimeId: index === 0 ? 'ggsql-jupyter' : `ggsql-jupyter-${index}`,
149+
runtimePath: candidate.kernelPath,
150+
runtimeName: `ggsql (${candidate.source})`,
99151
runtimeShortName: 'ggsql',
100152
runtimeVersion: version,
101153
runtimeSource: 'ggsql',
@@ -115,11 +167,10 @@ function generateMetadata(
115167
* Uses the startKernel callback to manually start the kernel process,
116168
* giving us more control over the launch process.
117169
*
170+
* @param kernelPath - Path to the ggsql-jupyter executable
118171
* @param workspacePath - Optional workspace path to use as the kernel's working directory
119172
*/
120-
function createKernelSpec(workspacePath?: string): JupyterKernelSpec {
121-
const kernelPath = getKernelPath();
122-
173+
function createKernelSpec(kernelPath: string, workspacePath?: string): JupyterKernelSpec {
123174
return {
124175
// argv is empty when using startKernel callback
125176
argv: [],
@@ -180,6 +231,95 @@ function createKernelSpec(workspacePath?: string): JupyterKernelSpec {
180231
};
181232
}
182233

234+
/**
235+
* Get the user-level Jupyter kernelspec directory for ggsql.
236+
*/
237+
function getUserJupyterKernelDir(): string {
238+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
239+
switch (process.platform) {
240+
case 'darwin':
241+
return path.join(homeDir, 'Library', 'Jupyter', 'kernels', 'ggsql');
242+
case 'win32':
243+
return path.join(
244+
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
245+
'jupyter', 'kernels', 'ggsql'
246+
);
247+
default:
248+
return path.join(homeDir, '.local', 'share', 'jupyter', 'kernels', 'ggsql');
249+
}
250+
}
251+
252+
/**
253+
* Get the Jupyter kernelspec directory for ggsql.
254+
*
255+
* If a Python virtual environment or non-base conda environment is active
256+
* (detected via process.env), uses the environment-level path so that
257+
* Jupyter's `prefer_environment_over_user()` precedence applies naturally.
258+
* Otherwise falls back to the user-level kernelspec directory.
259+
*/
260+
function getJupyterKernelDir(): string {
261+
// Prefer virtual environment path when active. Jupyter gives these
262+
// precedence over user-level paths when running inside the same env.
263+
const virtualEnv = process.env.VIRTUAL_ENV;
264+
if (virtualEnv) {
265+
return path.join(virtualEnv, 'share', 'jupyter', 'kernels', 'ggsql');
266+
}
267+
268+
const condaPrefix = process.env.CONDA_PREFIX;
269+
const condaEnv = process.env.CONDA_DEFAULT_ENV;
270+
if (condaPrefix && condaEnv && condaEnv !== 'base') {
271+
return path.join(condaPrefix, 'share', 'jupyter', 'kernels', 'ggsql');
272+
}
273+
274+
return getUserJupyterKernelDir();
275+
}
276+
277+
/**
278+
* Write a ggsql kernel.json to the given directory.
279+
*
280+
* Only writes if the content has changed to avoid unnecessary disk writes.
281+
*/
282+
function writeKernelJson(kernelDir: string, kernelPath: string): void {
283+
const kernelSpec = {
284+
argv: [kernelPath, '-f', '{connection_file}'],
285+
display_name: 'ggsql',
286+
language: 'ggsql',
287+
interrupt_mode: 'message',
288+
env: { RUST_LOG: 'error' },
289+
metadata: { debugger: false }
290+
};
291+
292+
const kernelJsonPath = path.join(kernelDir, 'kernel.json');
293+
const kernelSpecJson = JSON.stringify(kernelSpec, null, 2);
294+
295+
try {
296+
const existing = fs.existsSync(kernelJsonPath)
297+
? fs.readFileSync(kernelJsonPath, 'utf8')
298+
: null;
299+
300+
if (existing !== kernelSpecJson) {
301+
fs.mkdirSync(kernelDir, { recursive: true });
302+
fs.writeFileSync(kernelJsonPath, kernelSpecJson);
303+
log(`Wrote ggsql kernel spec to ${kernelJsonPath}`);
304+
}
305+
} catch (err) {
306+
log(`Failed to write ggsql kernel spec: ${err}`);
307+
}
308+
}
309+
310+
/**
311+
* Install a Jupyter kernel spec for ggsql so that external tools like Quarto
312+
* can discover it via `jupyter kernelspec list`.
313+
*
314+
* Writes kernel.json to the appropriate Jupyter kernelspec directory: the
315+
* active virtualenv/conda env if detected, otherwise the user-level dir.
316+
*
317+
* @param kernelPath Absolute path to the ggsql-jupyter binary
318+
*/
319+
function installJupyterKernelSpec(kernelPath: string): void {
320+
writeKernelJson(getJupyterKernelDir(), kernelPath);
321+
}
322+
183323
/**
184324
* ggsql Language Runtime Manager
185325
*
@@ -196,22 +336,35 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
196336
/**
197337
* Discover available ggsql runtimes.
198338
*
199-
* Returns a single ggsql runtime if the kernel is available.
339+
* Returns all accessible ggsql kernel binaries found on the system.
200340
*/
201341
discoverAllRuntimes(): AsyncGenerator<positron.LanguageRuntimeMetadata> {
202342
const context = this._context;
203343

204344
const generator = async function* discoverGgsqlRuntimes() {
205345
log('Discovering ggsql runtimes...');
206346

207-
// Check if the kernel is available
208-
const available = await isKernelAvailable();
209-
log(`Kernel available: ${available}`);
210-
211-
if (available) {
212-
const metadata = generateMetadata(context);
213-
log(`Yielding runtime: ${metadata.runtimeName} (${metadata.runtimeId})`);
214-
yield metadata;
347+
const candidates = discoverKernelPaths();
348+
log(`Found ${candidates.length} kernel candidate(s)`);
349+
350+
let index = 0;
351+
for (const candidate of candidates) {
352+
const accessible = await isKernelAccessible(candidate.kernelPath);
353+
if (accessible) {
354+
// When a system install is found, write the kernel spec to
355+
// the user kernelspec dir immediately so that Quarto/Jupyter
356+
// can discover ggsql even if no session is ever started.
357+
if (candidate.source === 'System') {
358+
writeKernelJson(getUserJupyterKernelDir(), candidate.kernelPath);
359+
}
360+
361+
const metadata = generateMetadata(context, candidate, index);
362+
log(`Yielding runtime: ${metadata.runtimeName} (${metadata.runtimeId}) at ${candidate.kernelPath}`);
363+
yield metadata;
364+
index++;
365+
} else {
366+
log(`Skipping inaccessible kernel: ${candidate.kernelPath}`);
367+
}
215368
}
216369

217370
log('Runtime discovery complete');
@@ -252,8 +405,8 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
252405
const workspaceFolders = vscode.workspace.workspaceFolders;
253406
const workspacePath = workspaceFolders?.[0]?.uri.fsPath;
254407

255-
// Create the kernel spec
256-
const kernelSpec = createKernelSpec(workspacePath);
408+
// Create the kernel spec using the runtime's kernel path
409+
const kernelSpec = createKernelSpec(runtimeMetadata.runtimePath, workspacePath);
257410

258411
// Create the dynamic state
259412
const dynState: positron.LanguageRuntimeDynState = {
@@ -262,6 +415,9 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
262415
sessionName: 'ggsql'
263416
};
264417

418+
// Advertise this kernel to external tools (Quarto, Jupyter)
419+
installJupyterKernelSpec(runtimeMetadata.runtimePath);
420+
265421
// Create the session using the supervisor
266422
const session = await supervisorApi.createSession(
267423
runtimeMetadata,
@@ -305,6 +461,9 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
305461
sessionName: 'ggsql'
306462
};
307463

464+
// Re-advertise this kernel on restore
465+
installJupyterKernelSpec(runtimeMetadata.runtimePath);
466+
308467
const session = await supervisorApi.restoreSession(
309468
runtimeMetadata,
310469
sessionMetadata,

0 commit comments

Comments
 (0)