Skip to content

Commit 671e255

Browse files
committed
Add custom ruff/ty binary path support
Closes #408 Users in offline environments cannot use the extension's managed ruff/ty language servers because `uv pip install` tries to download packages from the internet. This adds a 3-tier binary resolution strategy so these users can point to locally-installed binaries instead. Binary resolution now follows this priority for both ruff and ty: 1. User-configured path via `marimo.ruff.path` / `marimo.ty.path` 2. Companion extension discovery — checks the `charliermarsh.ruff` or `astral-sh.ty` extension's configured path setting, then its bundled binary at `<extensionPath>/bundled/libs/bin/<binary>` 3. Existing `uv pip install` fallback (unchanged behavior) Each candidate binary is validated by running `<binary> --version` and checking the result meets the minimum version requirement (ruff >= 0.15.0, ty >= 0.0.15). If validation fails at any tier, resolution falls through to the next.
1 parent eb788be commit 671e255

File tree

9 files changed

+381
-28
lines changed

9 files changed

+381
-28
lines changed

extension/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ icon in the editor title bar to open it as a notebook (see image above).
6464
| --------------------------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6565
| `marimo.lsp.path` | `array` | `[]` | Path to a custom `marimo-lsp` executable, e.g., `["/path/to/marimo-lsp"]`. Leave empty to use the bundled version via `uvx`. |
6666
| `marimo.uv.path` | `string` | | Path to the `uv` binary, e.g., `/Users/me/.local/bin/uv`. Leave empty to use `uv` from the system PATH. |
67+
| `marimo.ruff.path` | `string` | | Path to a custom `ruff` binary, e.g., `/usr/local/bin/ruff`. Useful for offline environments. Leave empty to auto-discover or install via uv. |
68+
| `marimo.ty.path` | `string` | | Path to a custom `ty` binary, e.g., `/usr/local/bin/ty`. Useful for offline environments. Leave empty to auto-discover or install via uv. |
6769
| `marimo.disableUvIntegration` | `boolean` | `false` | Disable uv integration features such as automatic package installation prompts. |
6870
| `marimo.disableManagedLanguageFeatures` | `boolean` | `false` | Disable marimo's managed Python language features (completions, diagnostics, formatting). When enabled, notebook cells use the standard `python` language ID and rely on external language servers. |
6971
| `marimo.telemetry` | `boolean` | `true` | Anonymous usage data. This helps us prioritize features for the marimo VSCode extension. |

extension/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@
9494
"default": true,
9595
"description": "Anonymous usage data. This helps us prioritize features for the marimo VSCode extension."
9696
},
97+
"marimo.ruff.path": {
98+
"type": "string",
99+
"default": "",
100+
"markdownDescription": "Path to a custom `ruff` binary, e.g., `/usr/local/bin/ruff`. Useful for offline environments. Leave empty to auto-discover or install via uv.",
101+
"scope": "resource"
102+
},
103+
"marimo.ty.path": {
104+
"type": "string",
105+
"default": "",
106+
"markdownDescription": "Path to a custom `ty` binary, e.g., `/usr/local/bin/ty`. Useful for offline environments. Leave empty to auto-discover or install via uv.",
107+
"scope": "resource"
108+
},
97109
"marimo.disableManagedLanguageFeatures": {
98110
"type": "boolean",
99111
"default": false,

extension/src/services/Config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export class Config extends Effect.Service<Config>()("Config", {
1818
path: Effect.succeed(Option.none<string>()),
1919
enabled: Effect.succeed(false),
2020
},
21+
ruff: {
22+
path: Effect.succeed(Option.none<string>()),
23+
},
24+
ty: {
25+
path: Effect.succeed(Option.none<string>()),
26+
},
2127
lsp: {
2228
executable: Effect.succeed(Option.none()),
2329
},
@@ -45,6 +51,28 @@ export class Config extends Effect.Service<Config>()("Config", {
4551
);
4652
},
4753
},
54+
ruff: {
55+
get path() {
56+
return Effect.map(
57+
code.value.workspace.getConfiguration("marimo.ruff"),
58+
(config) =>
59+
Option.fromNullable(config.get<string>("path")).pipe(
60+
Option.filter((p) => p.length > 0),
61+
),
62+
);
63+
},
64+
},
65+
ty: {
66+
get path() {
67+
return Effect.map(
68+
code.value.workspace.getConfiguration("marimo.ty"),
69+
(config) =>
70+
Option.fromNullable(config.get<string>("path")).pipe(
71+
Option.filter((p) => p.length > 0),
72+
),
73+
);
74+
},
75+
},
4876
lsp: {
4977
get executable() {
5078
return Effect.gen(function* () {

extension/src/services/Uv.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,6 @@ const handleUvNotInstalled = Effect.fn("handleUvNotInstalled")(function* (
687687
return yield* Effect.die(error);
688688
});
689689

690-
function resolvePlatformBinaryName(name: "uv" | "ruff" | "ty") {
690+
export function resolvePlatformBinaryName(name: "uv" | "ruff" | "ty") {
691691
return NodeProcess.platform === "win32" ? `${name}.exe` : name;
692692
}

extension/src/services/completions/RuffLanguageServer.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as NodePath from "node:path";
2+
13
import {
24
type Cause,
35
Data,
@@ -11,6 +13,7 @@ import {
1113
import type * as vscode from "vscode";
1214
import * as lsp from "vscode-languageclient/node";
1315

16+
import { validateBinary } from "../../utils/binaryResolution.ts";
1417
import {
1518
createManagedLanguageClient,
1619
type ManagedLanguageClient,
@@ -19,7 +22,8 @@ import { showErrorAndPromptLogs } from "../../utils/showErrorAndPromptLogs.ts";
1922
import { Config } from "../Config.ts";
2023
import { OutputChannel } from "../OutputChannel.ts";
2124
import { Sentry } from "../Sentry.ts";
22-
import { Uv } from "../Uv.ts";
25+
import { ExtensionContext } from "../Storage.ts";
26+
import { resolvePlatformBinaryName, Uv } from "../Uv.ts";
2327
import { VariablesService } from "../variables/VariablesService.ts";
2428
import { VsCode } from "../VsCode.ts";
2529
import {
@@ -91,14 +95,16 @@ export class RuffLanguageServer extends Effect.Service<RuffLanguageServer>()(
9195
return;
9296
}
9397

94-
// Phase 1: Install the language server
98+
// Phase 1: Resolve the ruff binary using 3-tier strategy
9599
yield* Effect.logInfo("Starting language server").pipe(
96100
Effect.annotateLogs({
97101
server: RUFF_SERVER.name,
98102
version: RUFF_SERVER.version,
99103
}),
100104
);
101105

106+
const binaryPath = yield* resolveRuffBinary();
107+
102108
// Create isolated sync instance with its own cell count tracking.
103109
const notebookSync = yield* sync.forClient();
104110

@@ -116,7 +122,7 @@ export class RuffLanguageServer extends Effect.Service<RuffLanguageServer>()(
116122
});
117123

118124
const installExit = yield* Effect.exit(
119-
createManagedLanguageClient(RUFF_SERVER, {
125+
createManagedLanguageClient(RUFF_SERVER, binaryPath, {
120126
notebookSync,
121127
clientOptions: {
122128
outputChannelName: "marimo (ruff)",
@@ -224,6 +230,85 @@ export class RuffLanguageServer extends Effect.Service<RuffLanguageServer>()(
224230
},
225231
) {}
226232

233+
/**
234+
* Resolves the ruff binary path using a 3-tier strategy:
235+
* 1. User-configured path (marimo.ruff.path)
236+
* 2. Companion extension discovery (charliermarsh.ruff bundled binary or ruff.path setting)
237+
* 3. Fallback to uv installation
238+
*/
239+
const resolveRuffBinary = Effect.fn(function* () {
240+
const code = yield* VsCode;
241+
const config = yield* Config;
242+
const uv = yield* Uv;
243+
const context = yield* ExtensionContext;
244+
245+
// Tier 1: User-configured path
246+
const userPath = yield* config.ruff.path;
247+
if (Option.isSome(userPath)) {
248+
const validated = yield* validateBinary(
249+
userPath.value,
250+
RUFF_SERVER.version,
251+
);
252+
if (Option.isSome(validated)) {
253+
yield* Effect.logInfo(
254+
`Using user-configured ruff binary: ${validated.value}`,
255+
);
256+
return validated.value;
257+
}
258+
yield* Effect.logWarning(
259+
`User-configured ruff path "${userPath.value}" is invalid, falling back to discovery`,
260+
);
261+
}
262+
263+
// Tier 2: Companion extension discovery
264+
const ruffExtension = code.extensions.getExtension(RUFF_EXTENSION_ID);
265+
if (Option.isSome(ruffExtension)) {
266+
// First check the companion extension's ruff.path setting
267+
const ruffExtConfig = yield* code.workspace.getConfiguration("ruff");
268+
const extConfiguredPath = Option.fromNullable(
269+
ruffExtConfig.get<string>("path"),
270+
).pipe(Option.filter((p) => p.length > 0));
271+
272+
if (Option.isSome(extConfiguredPath)) {
273+
const validated = yield* validateBinary(
274+
extConfiguredPath.value,
275+
RUFF_SERVER.version,
276+
);
277+
if (Option.isSome(validated)) {
278+
yield* Effect.logInfo(
279+
`Using ruff binary from ruff.path setting: ${validated.value}`,
280+
);
281+
return validated.value;
282+
}
283+
}
284+
285+
// Then check bundled binary in the extension's install directory
286+
const bundledPath = NodePath.join(
287+
ruffExtension.value.extensionPath,
288+
"bundled",
289+
"libs",
290+
"bin",
291+
resolvePlatformBinaryName("ruff"),
292+
);
293+
const validated = yield* validateBinary(bundledPath, RUFF_SERVER.version);
294+
if (Option.isSome(validated)) {
295+
yield* Effect.logInfo(
296+
`Using bundled ruff binary from ${RUFF_EXTENSION_ID}: ${validated.value}`,
297+
);
298+
return validated.value;
299+
}
300+
}
301+
302+
// Tier 3: Fallback to uv installation
303+
yield* Effect.logInfo(
304+
"No custom or companion ruff binary found, falling back to uv installation",
305+
);
306+
const targetPath = NodePath.resolve(context.globalStorageUri.fsPath, "libs");
307+
return yield* uv.ensureLanguageServerBinaryInstalled(RUFF_SERVER, {
308+
targetPath,
309+
});
310+
});
311+
227312
/**
228313
* Read ruff.* settings from a WorkspaceConfiguration and return them in the format
229314
* expected by the Ruff language server's initializationOptions.

extension/src/services/completions/TyLanguageServer.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as NodePath from "node:path";
2+
13
import { NodeContext } from "@effect/platform-node";
24
import {
35
type Cause,
@@ -13,6 +15,7 @@ import {
1315
import * as lsp from "vscode-languageclient/node";
1416
import { ResponseError } from "vscode-languageclient/node";
1517

18+
import { validateBinary } from "../../utils/binaryResolution.ts";
1619
import {
1720
createManagedLanguageClient,
1821
type ManagedLanguageClient,
@@ -24,7 +27,8 @@ import { Constants } from "../Constants.ts";
2427
import { OutputChannel } from "../OutputChannel.ts";
2528
import { PythonExtension } from "../PythonExtension.ts";
2629
import { Sentry } from "../Sentry.ts";
27-
import { Uv } from "../Uv.ts";
30+
import { ExtensionContext } from "../Storage.ts";
31+
import { resolvePlatformBinaryName, Uv } from "../Uv.ts";
2832
import { VariablesService } from "../variables/VariablesService.ts";
2933
import { VsCode } from "../VsCode.ts";
3034
import {
@@ -35,6 +39,7 @@ import {
3539
// TODO: make TY_VERSION configurable?
3640
// For now, since we are doing rolling releases, we can bump this as needed.
3741
const TY_SERVER = { name: "ty", version: "0.0.23" } as const;
42+
const TY_EXTENSION_ID = "astral-sh.ty";
3843

3944
export const TyLanguageServerStatus = Data.taggedEnum<TyLanguageServerStatus>();
4045

@@ -102,19 +107,21 @@ export class TyLanguageServer extends Effect.Service<TyLanguageServer>()(
102107
return;
103108
}
104109

105-
// Phase 1: Install the language server
110+
// Phase 1: Resolve the ty binary using 3-tier strategy
106111
yield* Effect.logInfo("Starting language server").pipe(
107112
Effect.annotateLogs({
108113
server: TY_SERVER.name,
109114
version: TY_SERVER.version,
110115
}),
111116
);
112117

118+
const binaryPath = yield* resolveTyBinary();
119+
113120
// Create isolated sync instance with its own cell count tracking
114121
const notebookSync = yield* sync.forClient();
115122

116123
const installExit = yield* Effect.exit(
117-
createManagedLanguageClient(TY_SERVER, {
124+
createManagedLanguageClient(TY_SERVER, binaryPath, {
118125
notebookSync,
119126
clientOptions: {
120127
outputChannelName: "marimo (ty)",
@@ -272,6 +279,85 @@ export class TyLanguageServer extends Effect.Service<TyLanguageServer>()(
272279
},
273280
) {}
274281

282+
/**
283+
* Resolves the ty binary path using a 3-tier strategy:
284+
* 1. User-configured path (marimo.ty.path)
285+
* 2. Companion extension discovery (astral-sh.ty bundled binary or ty.path setting)
286+
* 3. Fallback to uv installation
287+
*/
288+
const resolveTyBinary = Effect.fn(function* () {
289+
const code = yield* VsCode;
290+
const config = yield* Config;
291+
const uv = yield* Uv;
292+
const context = yield* ExtensionContext;
293+
294+
// Tier 1: User-configured path
295+
const userPath = yield* config.ty.path;
296+
if (Option.isSome(userPath)) {
297+
const validated = yield* validateBinary(userPath.value, TY_SERVER.version);
298+
if (Option.isSome(validated)) {
299+
yield* Effect.logInfo(
300+
`Using user-configured ty binary: ${validated.value}`,
301+
);
302+
return validated.value;
303+
}
304+
yield* Effect.logWarning(
305+
`User-configured ty path "${userPath.value}" is invalid, falling back to discovery`,
306+
);
307+
}
308+
309+
// Tier 2: Companion extension discovery
310+
const tyExtension = code.extensions.getExtension(TY_EXTENSION_ID);
311+
if (Option.isSome(tyExtension)) {
312+
// First check the companion extension's ty.path setting
313+
const tyExtConfig = yield* code.workspace.getConfiguration("ty");
314+
const extConfiguredPath = Option.fromNullable(
315+
tyExtConfig.get<string[]>("path"),
316+
).pipe(
317+
Option.filter((p) => p.length > 0),
318+
Option.map((p) => p[0]),
319+
);
320+
321+
if (Option.isSome(extConfiguredPath)) {
322+
const validated = yield* validateBinary(
323+
extConfiguredPath.value,
324+
TY_SERVER.version,
325+
);
326+
if (Option.isSome(validated)) {
327+
yield* Effect.logInfo(
328+
`Using ty binary from ty.path setting: ${validated.value}`,
329+
);
330+
return validated.value;
331+
}
332+
}
333+
334+
// Then check bundled binary in the extension's install directory
335+
const bundledPath = NodePath.join(
336+
tyExtension.value.extensionPath,
337+
"bundled",
338+
"libs",
339+
"bin",
340+
resolvePlatformBinaryName("ty"),
341+
);
342+
const validated = yield* validateBinary(bundledPath, TY_SERVER.version);
343+
if (Option.isSome(validated)) {
344+
yield* Effect.logInfo(
345+
`Using bundled ty binary from ${TY_EXTENSION_ID}: ${validated.value}`,
346+
);
347+
return validated.value;
348+
}
349+
}
350+
351+
// Tier 3: Fallback to uv installation
352+
yield* Effect.logInfo(
353+
"No custom or companion ty binary found, falling back to uv installation",
354+
);
355+
const targetPath = NodePath.resolve(context.globalStorageUri.fsPath, "libs");
356+
return yield* uv.ensureLanguageServerBinaryInstalled(TY_SERVER, {
357+
targetPath,
358+
});
359+
});
360+
275361
interface InitializationOptions {
276362
logLevel?: "error" | "warn" | "info" | "debug" | "trace";
277363
logFile?: string;

0 commit comments

Comments
 (0)