Skip to content

Commit 18fe747

Browse files
fix: support new Zig-based binary jac install (replaces pip install) (#95)
* fix: support binary jac install model (Zig-based, no pip venv) - envVersion: fast version detection via ~/.cache/jac/rt dist-info scan - envDetection: detect ~/.local/bin/jac and /usr/local/bin/jac directly - envDetection: check .jac/venv (plugin venv) before old-style .venv - manager: getPythonPath returns bare python for binary install paths - manager: hide meaningless env name (.local) for global binary installs * fix: refine binary install detection logic in environment manager and update default binary locator * fix: simplify workspace venv detection by removing old binary model fallback * fix: improve comments for clarity in environment detection and version retrieval * Broaden single-binary python resolution and discovery across platforms getPythonPath: check the sibling python exists on disk instead of matching the .local/bin substring, so brew (/opt/homebrew/bin), /usr/local/bin, and custom single-binary installs also resolve correctly (not just ~/.local/bin). findDefaultBinary: also locate the binary at /opt/homebrew/bin, /usr/local/bin, and the Windows %LOCALAPPDATA%\Programs\jac and %LOCALAPPDATA%\jac\bin locations, independent of PATH. Update getPythonPath tests to cover both the sibling-present (venv/conda) and sibling-absent (single binary) branches. --------- Co-authored-by: kugesan1105 <kugesan.sivasothynathan@jaseci.org>
1 parent 6801f35 commit 18fe747

4 files changed

Lines changed: 95 additions & 33 deletions

File tree

src/__tests__/env_test.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { EnvManager } from '../environment/manager';
2+
import * as fs from 'fs';
23
import * as vscode from 'vscode';
34
import * as envDetection from '../utils/envDetection';
45
import * as envVersion from '../utils/envVersion';
56
import { getLspManager, createAndStartLsp } from '../extension';
67

78
// ── Module Mocks ──────────────────────────────────────────────────────────────
89

10+
// Keep real fs behavior, but make existsSync overridable for getPythonPath tests.
11+
jest.mock('fs', () => ({
12+
...jest.requireActual('fs'),
13+
existsSync: jest.fn(jest.requireActual('fs').existsSync),
14+
}));
15+
916
jest.mock('vscode-languageclient/node', () => ({
1017
LanguageClient: class {
1118
start = jest.fn();
@@ -415,12 +422,21 @@ describe('EnvManager', () => {
415422
});
416423

417424
describe('getPythonPath()', () => {
418-
test('returns python in the same directory as the configured jac executable', () => {
425+
test('returns the sibling python when it exists (venv/conda install)', () => {
426+
(fs.existsSync as jest.Mock).mockReturnValue(true);
419427
(envManager as any).jacPath = '/home/user/.venv/bin/jac';
420428
const expected = process.platform === 'win32'
421429
? '/home/user/.venv/bin/python.exe'
422430
: '/home/user/.venv/bin/python';
423431
expect(envManager.getPythonPath()).toBe(expected);
432+
(fs.existsSync as jest.Mock).mockReset();
433+
});
434+
435+
test('returns bare python when no sibling exists (single binary install)', () => {
436+
(fs.existsSync as jest.Mock).mockReturnValue(false);
437+
(envManager as any).jacPath = '/home/user/.local/bin/jac';
438+
expect(envManager.getPythonPath()).toBe(process.platform === 'win32' ? 'python.exe' : 'python');
439+
(fs.existsSync as jest.Mock).mockReset();
424440
});
425441

426442
test('falls back to the platform default when no path is configured', () => {

src/environment/manager.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as vscode from 'vscode';
2+
import * as fs from 'fs';
23
import * as path from 'path';
34
import { discoverJacEnvironments, validateJacExecutable, JacEnvironment } from '../utils/envDetection';
45
import { getJacVersion, compareVersions } from '../utils/envVersion';
@@ -90,12 +91,17 @@ export class EnvManager {
9091
}
9192

9293
getPythonPath(): string {
94+
const pythonExe = process.platform === 'win32' ? 'python.exe' : 'python';
9395
if (this.jacPath) {
94-
const jacDir = path.dirname(this.jacPath);
95-
const pythonExe = process.platform === 'win32' ? 'python.exe' : 'python';
96-
return path.join(jacDir, pythonExe);
96+
// Legacy pip venv ships a sibling python in bin/; use it when present.
97+
const sibling = path.join(path.dirname(this.jacPath), pythonExe);
98+
if (fs.existsSync(sibling)) { return sibling; }
99+
100+
// Single binary (any install location - .local, brew, /usr/local) has
101+
// Python embedded, so there is no sibling. Fall back to bare python.
102+
return pythonExe;
97103
}
98-
return process.platform === 'win32' ? 'python.exe' : 'python';
104+
return pythonExe;
99105
}
100106

101107
getStatusBar(): vscode.StatusBarItem {
@@ -253,7 +259,7 @@ export class EnvManager {
253259
const typeLabel = envType.charAt(0).toUpperCase() + envType.slice(1);
254260

255261
const versionStr = version ? `Jac ${version}` : 'Jac';
256-
const namePart = envName ? ` (${envName})` : '';
262+
const namePart = envType === 'global' ? '' : envName ? ` (${envName})` : '';
257263
const label = `${isActive ? '$(check) ' : ''}${versionStr}${namePart}`;
258264
const description = `${this.formatPath(envPath)} · ${typeLabel}`;
259265

src/utils/envDetection.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from 'fs/promises';
22
import type { Dirent } from 'fs';
33
import * as path from 'path';
4+
import * as os from 'os';
45

56
// ── Constants ─────────────────────────────────────────────────────────────────
67

@@ -63,6 +64,26 @@ async function walkForVenvs(baseDir: string, depth: number): Promise<string[]> {
6364

6465
// ── Environment Locators ─────────────────────────────────────────────────────
6566

67+
// Locator 0: Checks well-known single-binary install locations directly - catches
68+
// fresh installs before the install dir is on PATH. Cross-platform.
69+
async function findDefaultBinary(): Promise<string[]> {
70+
const home = os.homedir();
71+
const candidates = process.platform === 'win32'
72+
? [
73+
path.join(process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'), 'Programs', 'jac', 'jac.exe'),
74+
path.join(process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'), 'jac', 'bin', 'jac.exe'),
75+
]
76+
: [
77+
path.join(home, '.local', 'bin', 'jac'),
78+
'/opt/homebrew/bin/jac', // macOS arm brew
79+
'/usr/local/bin/jac',
80+
];
81+
const results = await Promise.all(
82+
candidates.map(async c => (await fileExists(c)) ? c : null)
83+
);
84+
return results.filter((c): c is string => c !== null);
85+
}
86+
6687
// Locator 1: Scans $PATH for jac
6788
async function findInPath(): Promise<string[]> {
6889
const isWin = process.platform === 'win32';
@@ -149,7 +170,8 @@ export interface JacEnvironment {
149170

150171
// Discovers all Jac environments on-demand (~10-15ms)
151172
export async function discoverJacEnvironments(workspaceRoots: string[]): Promise<JacEnvironment[]> {
152-
const [pathEnvs, condaEnvs, homeEnvs, ...workspaceResults] = await Promise.all([
173+
const [defaultBinaries, pathEnvs, condaEnvs, homeEnvs, ...workspaceResults] = await Promise.all([
174+
findDefaultBinary(),
153175
findInPath(),
154176
findInCondaEnvs(),
155177
findInHome(),
@@ -167,8 +189,9 @@ export async function discoverJacEnvironments(workspaceRoots: string[]): Promise
167189
}
168190
};
169191

170-
// Priority: workspace > global > conda > home venvs
192+
// Priority: workspace > default binary (~/.local/bin) > PATH > conda > home venvs
171193
workspaceEnvs.forEach(envPath => add(envPath, 'workspace'));
194+
defaultBinaries.forEach(envPath => add(envPath, 'global'));
172195
pathEnvs.forEach(envPath => add(envPath, 'global'));
173196
condaEnvs.forEach(envPath => add(envPath, 'conda'));
174197
homeEnvs.forEach(envPath => add(envPath, 'venv'));

src/utils/envVersion.ts

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,52 @@
11
import * as fs from 'fs/promises';
22
import * as path from 'path';
3+
import * as os from 'os';
4+
import { execFile } from 'child_process';
5+
import { promisify } from 'util';
36

4-
// Finds jaclang version by scanning site-packages for a jaclang-*.dist-info folder (~1ms, no subprocess).
5-
// Returns undefined if version cannot be determined.
6-
export async function getJacVersion(jacPath: string): Promise<string | undefined> {
7-
try {
8-
const envRoot = path.dirname(path.dirname(jacPath));
9-
const libDir = path.join(envRoot, 'lib');
10-
11-
let libEntries: string[];
12-
try { libEntries = await fs.readdir(libDir); }
13-
catch { return undefined; }
14-
15-
for (const libEntry of libEntries.filter(entry => entry.startsWith('python'))) {
16-
for (const pkgDir of ['site-packages', 'dist-packages']) {
17-
try {
18-
const sitePackages = path.join(libDir, libEntry, pkgDir);
19-
const siteEntries = await fs.readdir(sitePackages);
20-
const distInfoDir = siteEntries.find(
21-
entry => entry.startsWith('jaclang-') && entry.endsWith('.dist-info')
22-
);
23-
if (distInfoDir) {
24-
return distInfoDir.slice('jaclang-'.length, -'.dist-info'.length);
25-
}
26-
} catch { continue; }
27-
}
7+
const execFileAsync = promisify(execFile);
8+
9+
// Fast path: read version from ~/.cache/jac/rt/*/site/jaclang-*.dist-info (~0.002s).
10+
async function getJacVersionFromCache(): Promise<string | undefined> {
11+
const cacheBase = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
12+
const rtDir = path.join(cacheBase, 'jac', 'rt');
13+
14+
let hashDirs: string[];
15+
try { hashDirs = await fs.readdir(rtDir); }
16+
catch { return undefined; }
17+
18+
const versions: string[] = [];
19+
20+
for (const hashDir of hashDirs) {
21+
const siteDir = path.join(rtDir, hashDir, 'site');
22+
let entries: string[];
23+
try { entries = await fs.readdir(siteDir); }
24+
catch { continue; }
25+
26+
const distInfo = entries.find(e => e.startsWith('jaclang-') && e.endsWith('.dist-info'));
27+
if (distInfo) {
28+
versions.push(distInfo.slice('jaclang-'.length, -'.dist-info'.length));
2829
}
29-
return undefined;
30+
}
31+
32+
// Ambiguous if multiple distinct versions (e.g. dev + release) — fall back to subprocess.
33+
const unique = [...new Set(versions)];
34+
return unique.length === 1 ? unique[0] : undefined;
35+
}
36+
37+
// Slow fallback (~1.8s): only runs if cache is missing (first-ever launch).
38+
async function getJacVersionFromBinary(jacPath: string): Promise<string | undefined> {
39+
try {
40+
const { stdout } = await execFileAsync(jacPath, ['--version'], { timeout: 5000 });
41+
return stdout.match(/Version:\s+([\d.]+)/)?.[1];
3042
} catch { return undefined; }
3143
}
3244

45+
// Cache first, subprocess fallback if cache is missing.
46+
export async function getJacVersion(jacPath: string): Promise<string | undefined> {
47+
return (await getJacVersionFromCache()) ?? (await getJacVersionFromBinary(jacPath));
48+
}
49+
3350
// Compares two semver strings. Returns positive if a > b, negative if a < b, 0 if equal.
3451
export function compareVersions(a: string, b: string): number {
3552
const aParts = a.split('.').map(Number);

0 commit comments

Comments
 (0)