дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(cargo): dependency version inspections and updates (#684)
Cargo.toml had no way to tell that a dependency had a newer release.
Dependencies in [dependencies], [dev-dependencies], [build-dependencies], target tables and [workspace.dependencies] now get a weak warning when the newest stable release is outside the version requirement, with a quick fix that rewrites the requirement at its original precision ("0.7" → "0.10") in both string and inline-table forms. Versions complete inside the version string, newest first, without yanked releases. Path, git and workspace-inherited dependencies are skipped, prereleases are only suggested to prerelease requirements, and failures stay silent.
Versions come from the crates.io sparse index through the backend's HTTP client, with an in-memory and on-disk cache and coalesced requests, behind a dependency version source shared by file-type parsers so other manifests can be added later. semver is now used directly; it was already linked into the app.
Fixes #479
Цей коміт міститься в:
1 джерело
4034853f91
коміт
b5dbd3c020
26 files changed
+1257
-25
No files matched your search
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { DependencyVersions } from "@/bindings";
|
||||
|
||||
const dependencyVersions = vi.fn();
|
||||
const cancelRequest = vi.fn(async () => {});
|
||||
const lspCodeActions = vi.fn(async () => ({ status: "error", error: { kind: "unavailable", message: "No server" } }));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
commands: { ...actual.commands, dependencyVersions, cancelRequest, lspCodeActions },
|
||||
};
|
||||
});
|
||||
const { activate, versionCompletions } = await import("./index");
|
||||
const { editorContributions, contributedIntentions } = await import("@/editor/api");
|
||||
const { diagnosticsFor } = await import("@/editor/lsp/diagnostics");
|
||||
const { lspDoc } = await import("@/editor/lsp/lsp");
|
||||
const { quickFixesAt, applyCodeAction, showIntentions } = await import("@/editor/lsp/intentions");
|
||||
const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { DEPENDENCY_INSPECTION } = await import("@/platform/inspections");
|
||||
const text = '# 中文 😀\n[dependencies]\ntokio = { version = "0.2", features = ["full"] }\n';
|
||||
const from = text.indexOf('"0.2"') + 1;
|
||||
const item: DependencyVersions = {
|
||||
dependency: { package: "tokio", requirement: "0.2", from, to: from + 3 },
|
||||
versions: ["2.0.0-beta.1", "1.10.0", "1.9.0", "0.2.1"],
|
||||
update: { latest: "1.10.0", replacement: "1.10" },
|
||||
};
|
||||
let deactivate: () => void;
|
||||
const views: EditorView[] = [];
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
dependencyVersions.mockResolvedValue({ status: "ok", data: [item] });
|
||||
useProject.setState({ project: { root: "/p" } as never, settings: {} as never });
|
||||
deactivate = activate();
|
||||
});
|
||||
afterEach(() => {
|
||||
views.splice(0).forEach((view) => view.destroy());
|
||||
deactivate();
|
||||
closePopup();
|
||||
useProject.setState({ project: null, settings: null });
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
function editor(path = "/p/Cargo.toml", readOnly = false) {
|
||||
const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly };
|
||||
const contribution = editorContributions().find((c) => c.id === "dependency-versions")!;
|
||||
const view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
selection: { anchor: from },
|
||||
extensions: [lspDoc.of(ctx), contribution.extensions(ctx)],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
async function settle() {
|
||||
await vi.advanceTimersByTimeAsync(550);
|
||||
}
|
||||
|
||||
describe("Cargo dependency versions", () => {
|
||||
it("publishes a weak warning and fixes only the version through context actions", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
expect(dependencyVersions).toHaveBeenCalledWith(7, null, expect.any(Number));
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toMatchObject([
|
||||
{
|
||||
severity: "info",
|
||||
message: "A newer version is available: 1.10.0",
|
||||
range: { start: { line: 2, col: 21 }, end: { line: 2, col: 24 } },
|
||||
},
|
||||
]);
|
||||
const [fix] = contributedIntentions(view);
|
||||
expect(fix?.label).toBe("Update to 1.10");
|
||||
await fix!.run();
|
||||
expect(view.state.doc.toString()).toBe(text.replace('"0.2"', '"1.10"'));
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
});
|
||||
it("provides direct and hover quick fixes even without a language server", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
const [fix] = await quickFixesAt(view, from, from + 3);
|
||||
expect(fix?.title).toBe("Update to 1.10");
|
||||
await applyCodeAction(view, fix!);
|
||||
expect(view.state.doc.toString()).toContain('version = "1.10", features = ["full"]');
|
||||
});
|
||||
it.each(["0.10", "^0.10", "=0.10.3"])(
|
||||
"shows the exact replacement %s with weak-warning inspection controls",
|
||||
async (replacement) => {
|
||||
dependencyVersions.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [{ ...item, update: { latest: "0.10.3", replacement } }],
|
||||
});
|
||||
const view = editor();
|
||||
await settle();
|
||||
await showIntentions(view);
|
||||
const popup = useLspPopup.getState().popup;
|
||||
expect(popup?.kind).toBe("list");
|
||||
if (popup?.kind !== "list") throw new Error("Missing intentions");
|
||||
expect(popup.title).toBe("Intentions");
|
||||
expect(popup.items).toHaveLength(1);
|
||||
expect(popup.items[0]).toMatchObject({ label: `Update to ${replacement}`, icon: "bulb", quickFix: true });
|
||||
expect(popup.items[0]!.submenu).toHaveLength(2);
|
||||
await popup.items[0]!.run();
|
||||
expect(view.state.doc.toString()).toBe(text.replace('"0.2"', `"${replacement}"`));
|
||||
},
|
||||
);
|
||||
it("keeps diagnostics when one of two split editors closes", async () => {
|
||||
editor();
|
||||
const split = editor();
|
||||
await settle();
|
||||
split.destroy();
|
||||
views.pop();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toHaveLength(1);
|
||||
});
|
||||
it("invalidates fixes immediately after edits and rejects a stale fix callback", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
const [fix] = contributedIntentions(view);
|
||||
view.dispatch({ changes: { from: 0, insert: "# edit\n" } });
|
||||
expect(contributedIntentions(view)).toEqual([]);
|
||||
await fix!.run();
|
||||
expect(view.state.doc.toString()).toBe("# edit\n" + text);
|
||||
});
|
||||
it("discards delayed responses after an edit, project switch or editor close", async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
dependencyVersions.mockImplementation(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
const view = editor();
|
||||
await settle();
|
||||
view.dispatch({ changes: { from: 0, insert: "# edit\n" } });
|
||||
resolve({ status: "ok", data: [item] });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
await settle();
|
||||
useProject.setState({ project: { root: "/other" } as never });
|
||||
resolve({ status: "ok", data: [item] });
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
expect(cancelRequest).toHaveBeenCalled();
|
||||
const other = editor("/other/Cargo.toml");
|
||||
await settle();
|
||||
other.destroy();
|
||||
views.pop();
|
||||
resolve({ status: "ok", data: [item] });
|
||||
await settle();
|
||||
expect(diagnosticsFor("/other/Cargo.toml")).toEqual([]);
|
||||
});
|
||||
it("stays silent offline, with compatible versions and for non-Cargo or readonly files", async () => {
|
||||
dependencyVersions.mockRejectedValue(new Error("offline"));
|
||||
editor();
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
dependencyVersions.mockResolvedValue({ status: "ok", data: [{ ...item, update: null }] });
|
||||
editor("/p/sub/Cargo.toml");
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/sub/Cargo.toml")).toEqual([]);
|
||||
dependencyVersions.mockClear();
|
||||
editor("/p/other.toml");
|
||||
editor("/p/readonly/Cargo.toml", true);
|
||||
await settle();
|
||||
expect(dependencyVersions).not.toHaveBeenCalled();
|
||||
});
|
||||
it("honours the inspection profile and cleans up on close", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
useProject.setState({
|
||||
settings: { inspections: { default: { [DEPENDENCY_INSPECTION]: { enabled: false } } } } as never,
|
||||
});
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
expect(contributedIntentions(view)).toEqual([]);
|
||||
view.destroy();
|
||||
views.pop();
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
});
|
||||
it("completes only at a parsed dependency version, preserving descending source order", async () => {
|
||||
const view = editor();
|
||||
const source = view.state.languageDataAt<(ctx: CompletionContext) => Promise<unknown>>(
|
||||
"openideaCompletion",
|
||||
from,
|
||||
)[0]!;
|
||||
const result = await source(new CompletionContext(view.state, from + 3, true, view));
|
||||
expect(result).toMatchObject({
|
||||
from,
|
||||
to: from + 3,
|
||||
filter: false,
|
||||
options: item.versions.map((label) => ({ label })),
|
||||
});
|
||||
expect(dependencyVersions).toHaveBeenLastCalledWith(7, from + 3, expect.any(Number));
|
||||
dependencyVersions.mockResolvedValue({ status: "ok", data: [] });
|
||||
expect(await source(new CompletionContext(view.state, 0, true, view))).toBeNull();
|
||||
});
|
||||
it("replaces the whole version and retains a simple requirement operator", () => {
|
||||
const state = EditorState.create({ doc: "^1.2" });
|
||||
const context = new CompletionContext(state, 4, true);
|
||||
const result = versionCompletions({ ...item, dependency: { ...item.dependency, from: 0, to: 4 } }, context);
|
||||
expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { EditorState, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { ViewPlugin, type EditorView } from "@codemirror/view";
|
||||
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import {
|
||||
intentionProviders,
|
||||
intentionsChanged,
|
||||
quickFixProviders,
|
||||
registerEditorContribution,
|
||||
whenSynced,
|
||||
type ContributedIntention,
|
||||
type EditorDocContext,
|
||||
} from "@/editor/api";
|
||||
import { setFileDiagnostics } from "@/editor/lsp/diagnostics";
|
||||
import { offsetsToRange } from "@/editor/lsp/positions";
|
||||
import { call, commands, isTauri, nextRequestId, type DependencyVersions, type RequestId } from "@/platform/ipc";
|
||||
import {
|
||||
DEPENDENCY_INSPECTION,
|
||||
DEPENDENCY_PRODUCER,
|
||||
inspectionProfile,
|
||||
subscribeInspectionProfile,
|
||||
} from "@/platform/inspections";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const setVersions = StateEffect.define<DependencyVersions[]>();
|
||||
const versionsField = StateField.define<DependencyVersions[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
if (tr.docChanged) return [];
|
||||
for (const effect of tr.effects) if (effect.is(setVersions)) return effect.value;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
const viewers = new Map<string, number>();
|
||||
|
||||
const enabled = () => inspectionProfile()[DEPENDENCY_INSPECTION]?.enabled !== false;
|
||||
|
||||
export function dependencyFixes(view: EditorView, from: number, to: number): ContributedIntention[] {
|
||||
if (!enabled() || view.state.readOnly) return [];
|
||||
const text = view.state.doc;
|
||||
return (view.state.field(versionsField, false) ?? []).flatMap(({ dependency, update }) => {
|
||||
if (!update || from > dependency.to || to < dependency.from) return [];
|
||||
const { replacement } = update;
|
||||
return [
|
||||
{
|
||||
id: `dependency.update:${dependency.from}`,
|
||||
label: tx`Update to ${replacement}`,
|
||||
quickFix: true,
|
||||
run: () => {
|
||||
if (view.state.doc !== text) return;
|
||||
view.dispatch({
|
||||
changes: { from: dependency.from, to: dependency.to, insert: replacement },
|
||||
userEvent: "input.dependency",
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function versionCompletions(item: DependencyVersions, ctx: CompletionContext): CompletionResult {
|
||||
const { dependency, versions } = item;
|
||||
const typed = ctx.state.sliceDoc(dependency.from, ctx.pos);
|
||||
const prefix = /^[\s^~<>=]*/.exec(typed)?.[0] ?? "";
|
||||
const filter = typed.slice(prefix.length);
|
||||
return {
|
||||
from: dependency.from,
|
||||
to: dependency.to,
|
||||
filter: false,
|
||||
options: versions
|
||||
.filter((v) => ctx.explicit || v.startsWith(filter))
|
||||
.map((version) => ({
|
||||
label: version,
|
||||
type: "constant",
|
||||
detail: dependency.package,
|
||||
apply: prefix + version,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function extension(doc: EditorDocContext): Extension {
|
||||
const plugin = ViewPlugin.define((view) => {
|
||||
if (!isTauri()) return {};
|
||||
viewers.set(doc.path, (viewers.get(doc.path) ?? 0) + 1);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let request: RequestId | null = null;
|
||||
let sequence = 0;
|
||||
let disposed = false;
|
||||
const clear = () => setFileDiagnostics(doc.path, DEPENDENCY_PRODUCER, []);
|
||||
const cancel = () => {
|
||||
sequence++;
|
||||
if (request !== null) void commands.cancelRequest(request);
|
||||
request = null;
|
||||
};
|
||||
const run = async () => {
|
||||
if (disposed || !enabled()) return;
|
||||
const mine = sequence;
|
||||
const text = view.state.doc;
|
||||
const root = useProject.getState().project?.root;
|
||||
const id = nextRequestId();
|
||||
request = id;
|
||||
try {
|
||||
await whenSynced(view);
|
||||
if (mine !== sequence || disposed) return;
|
||||
const items = await call(commands.dependencyVersions(doc.doc, null, id));
|
||||
if (mine !== sequence || disposed || view.state.doc !== text || useProject.getState().project?.root !== root)
|
||||
return;
|
||||
view.dispatch({ effects: [setVersions.of(items), intentionsChanged.of(null)] });
|
||||
setFileDiagnostics(
|
||||
doc.path,
|
||||
DEPENDENCY_PRODUCER,
|
||||
items.flatMap(({ dependency, update }) => {
|
||||
if (!update) return [];
|
||||
const { latest } = update;
|
||||
return [
|
||||
{
|
||||
range: offsetsToRange(text, dependency.from, dependency.to),
|
||||
severity: "info",
|
||||
message: tx`A newer version is available: ${latest}`,
|
||||
source: "Cargo",
|
||||
code: null,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Offline registries and cancelled checks never interrupt editing.
|
||||
} finally {
|
||||
if (request === id) request = null;
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
cancel();
|
||||
clear();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void run(), 500);
|
||||
};
|
||||
schedule();
|
||||
const offProfile = subscribeInspectionProfile(() => {
|
||||
schedule();
|
||||
view.dispatch({ effects: intentionsChanged.of(null) });
|
||||
});
|
||||
const offProject = useProject.subscribe((state, previous) => {
|
||||
if (state.project?.root !== previous.project?.root) {
|
||||
disposed = true;
|
||||
cancel();
|
||||
clearTimeout(timer);
|
||||
clear();
|
||||
}
|
||||
});
|
||||
return {
|
||||
update: (u) => {
|
||||
if (u.docChanged) schedule();
|
||||
},
|
||||
destroy: () => {
|
||||
disposed = true;
|
||||
cancel();
|
||||
clearTimeout(timer);
|
||||
offProfile();
|
||||
offProject();
|
||||
const remaining = (viewers.get(doc.path) ?? 1) - 1;
|
||||
if (remaining) viewers.set(doc.path, remaining);
|
||||
else {
|
||||
viewers.delete(doc.path);
|
||||
clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
const completion = async (ctx: CompletionContext): Promise<CompletionResult | null> => {
|
||||
if (!isTauri()) return null;
|
||||
const id = nextRequestId();
|
||||
const text = ctx.state.doc;
|
||||
const root = useProject.getState().project?.root;
|
||||
ctx.addEventListener("abort", () => void commands.cancelRequest(id));
|
||||
try {
|
||||
// The completion context is immutable; only use it while its document is current.
|
||||
const view = ctx.view;
|
||||
if (!view) return null;
|
||||
await whenSynced(view);
|
||||
if (ctx.aborted || view.state.doc !== text) return null;
|
||||
const items = await call(commands.dependencyVersions(doc.doc, ctx.pos, id));
|
||||
if (ctx.aborted || view.state.doc !== text || useProject.getState().project?.root !== root) return null;
|
||||
const item = items.find(({ dependency }) => dependency.from <= ctx.pos && ctx.pos <= dependency.to);
|
||||
return item ? versionCompletions(item, ctx) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return [
|
||||
versionsField,
|
||||
plugin,
|
||||
intentionProviders.of((view) => {
|
||||
const selection = view.state.selection.main;
|
||||
return dependencyFixes(view, selection.from, selection.to);
|
||||
}),
|
||||
quickFixProviders.of(dependencyFixes),
|
||||
EditorState.languageData.of(() => [{ openideaCompletion: completion }]),
|
||||
];
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
return registerEditorContribution({
|
||||
id: "dependency-versions",
|
||||
extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []),
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { activate as editorLsp } from "@/editor/lsp";
|
||||
import { activate as welcome } from "./welcome";
|
||||
import { activate as projectTree } from "./project-tree";
|
||||
import { activate as search } from "./search";
|
||||
import { activate as dependencies } from "./dependencies";
|
||||
import { activate as codeInsight } from "./code-insight";
|
||||
import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs";
|
||||
import { activate as terminal } from "./terminal";
|
||||
@@ -55,6 +56,7 @@ const features: Array<[string, Activate]> = [
|
||||
["project-tree", projectTree],
|
||||
["search", search],
|
||||
["code-insight", codeInsight],
|
||||
["dependencies", dependencies],
|
||||
["vcs", vcs],
|
||||
["terminal", terminal],
|
||||
["run", run],
|
||||
|
||||
Reference in new issue
Заблокувати користувача