feat(http-client): Generate requests from Spring Web mappings (#104)

Spring MVC/WebFlux mapping methods (Java and Kotlin) in @RestController / @Controller classes get a gutter icon with "Generate Request in HTTP Client", and the class line gets "Generate Requests for Controller"; both are also actions (`http.generateSpringRequest`, `http.generateSpringControllerRequests`) in Find Action, the editor menu and ⌥↵. Requests are appended to the `generated-requests.http` scratch and opened at the new request.

The URL combines class and method paths ({id} and {id:regex} → {{id}}), with host, port and context path from application.properties / .yml (default http://localhost:8080). @RequestParam, @RequestHeader, params/headers, consumes/produces and @RequestBody (JSON skeleton from the body type, nested one level) are mapped; same-file constants are resolved. Adds an `intentionProviders` editor facet so features can contribute ⌥↵ items.

Fixes #100

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai
2026-10-27 20:54:48 +08:00
committed by GitHub
parent a065189516
commit 14f72fc307
9 changed files with 1824 additions and 7 deletions
+22
View File
@@ -283,6 +283,28 @@ export function contributedAt<T>(state: EditorState, key: string, pos: number):
return state.languageDataAt<T>(key, pos)[0];
}
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */
export interface ContributedIntention {
id: string;
label: string;
icon?: string;
run: () => void | Promise<void>;
}
/** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */
export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>();
export function contributedIntentions(view: EditorView): ContributedIntention[] {
return view.state.facet(intentionProviders).flatMap((p) => {
try {
return p(view);
} catch (e) {
console.error("intention provider failed", e);
return [];
}
});
}
// --- navigation history ------------------------------------------------------
/** One entry of the Back/Forward (⌘[ / ⌘]) history. 0-based line, UTF-16 col. */
+7 -6
View File
@@ -7,7 +7,7 @@ import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { forEachDiagnostic } from "@codemirror/lint";
import { commands, isCancelled, type CodeAction } from "@/platform/ipc";
import { whenSynced } from "@/editor/api";
import { contributedIntentions, whenSynced } from "@/editor/api";
import { shortcutLabel } from "@/platform/keymap";
import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
import { offsetsToRange } from "./positions";
@@ -207,8 +207,8 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
/** ⌥↵ */
export async function showIntentions(view: EditorView) {
try {
// Typo fixes come first and need no language server.
const local = spellingIntentions(view);
// Typo fixes and feature-contributed actions come first and need no language server.
const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)];
let actions: CodeAction[] = [];
let failure: { error: unknown } | null = null;
try {
@@ -338,20 +338,21 @@ const bulbPlugin = ViewPlugin.fromClass(
if (!doc || doc.readOnly || !this.view.dom.isConnected) return;
const state = this.view.state;
const typoFix = spellingIntentions(this.view).length > 0;
const local = typoFix || contributedIntentions(this.view).length > 0;
const backoff = serverBackoff.active(doc.doc);
if (backoff && !typoFix) return;
if (backoff && !local) return;
let actions: CodeAction[] = [];
if (!backoff) {
try {
actions = await fetchIntentions(this.view);
} catch (e) {
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
if (!typoFix) return;
if (!local) return;
}
}
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return;
const line = state.doc.lineAt(state.selection.main.head).number;
const any = actions.length > 0 || typoFix;
const any = actions.length > 0 || local;
this.view.dispatch({
effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null),
});
+31 -1
View File
@@ -1,7 +1,8 @@
/**
* HTTP Client (IDEA's `.http` / `.rest` files): highlighting, ▶ gutter per
* request, environments, Run All, and a tool window with the responses and
* their history. Parsing, variable resolution and sending happen in Rust
* their history, and requests generated from Spring Web mappings in Java /
* Kotlin sources (`spring/`). Parsing, variable resolution and sending happen in Rust
* (`ide-http`).
*/
import { activeEditor, registerEditorContribution, registerLanguageMode } from "@/editor/api";
@@ -19,6 +20,15 @@ import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } f
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
import { httpLanguageDescription } from "./language";
import { SERVICE_VIEW, resetHttpClient, runAll, runRequest } from "./store";
import {
GENERATE_CONTROLLER_REQUESTS,
GENERATE_REQUEST,
generateAtCaret,
menuGroupItems,
springEditorExtension,
} from "./spring/editor";
const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin";
export const NEW_REQUEST_TEMPLATE = `### Example request
# Variables come from http-client.env.json / http-client.private.env.json
@@ -105,6 +115,8 @@ export function activate(): () => void {
}),
registerLanguageMode(httpLanguageDescription),
registerEditorContribution({ id: "http.requests", order: 56, extensions: httpEditorExtension }),
registerEditorContribution({ id: "http.spring", order: 57, extensions: springEditorExtension }),
registerMenuGroup({ id: "http.spring", menus: ["editor"], order: 40, items: (_menu, ctx) => menuGroupItems(ctx) }),
registerServiceView({
id: SERVICE_VIEW,
title: "HTTP Client",
@@ -156,6 +168,24 @@ export function activate(): () => void {
else if (isTauri()) await newRequest(directory ?? undefined);
},
},
{
id: GENERATE_REQUEST,
title: "Generate Request in HTTP Client",
category: "Tools",
icon: "file-http",
keywords: ["spring", "request mapping", "rest", "endpoint", "http client"],
when: SPRING_WHEN,
run: () => generateAtCaret("endpoint"),
},
{
id: GENERATE_CONTROLLER_REQUESTS,
title: "Generate Requests for Controller",
category: "Tools",
icon: "file-http",
keywords: ["spring", "request mapping", "rest", "endpoints", "http client"],
when: SPRING_WHEN,
run: () => generateAtCaret("controller"),
},
{
id: "http.showToolWindow",
title: "HTTP Client",
+346
View File
@@ -0,0 +1,346 @@
/**
* Spring Web in Java / Kotlin editors: a gutter icon on every request mapping
* and controller, context actions and editor menu items that generate the
* requests into the `generated-requests.http` scratch and open it.
*/
import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import {
activeEditor,
editorsFor,
intentionProviders,
useEditorRegistry,
type ContributedIntention,
type EditorDocContext,
type TextEditorHandle,
} from "@/editor/api";
import { tx } from "@/platform/i18n";
import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { iconMarkup, showContextMenu, type MenuItem } from "@/ui";
import { editorService, notifications, type MenuGroupContext } from "@/workbench/contributions";
import { springAt, springControllers, type Controller, type Endpoint } from "./endpoints";
import { appendRequests, bodyLookup, findHost, renderRequest, skeleton, type SourceReader } from "./request";
import { parseSource } from "./source";
import styles from "../HttpClient.module.css";
export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
export const GENERATED_REQUESTS = "generated-requests.http";
export const GENERATE_REQUEST = "http.generateSpringRequest";
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
const reader: SourceReader = {
read: async (path) => {
try {
return await call(commands.fsReadText(path));
} catch {
return null;
}
},
};
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
export interface Target {
endpoint: Endpoint;
path?: string;
}
async function scratchFile(): Promise<{ path: string; created: boolean }> {
const { appDataDir } = await import("@tauri-apps/api/path");
const dir = `${(await appDataDir()).replace(/[\\/]+$/, "")}/scratches`;
const ignoreExisting = (e: unknown) => {
if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e;
};
await call(commands.fsCreateDir(dir)).catch(ignoreExisting);
const path = `${dir}/${GENERATED_REQUESTS}`;
let created = true;
await call(commands.fsCreateFile(path)).catch((e) => {
ignoreExisting(e);
created = false;
});
return { path, created };
}
/** The text editor of `path`, once the just opened tab has created it. */
function editorFor(path: string, timeoutMs = 5000): Promise<TextEditorHandle | null> {
const find = () => editorsFor(path).find((x) => x === activeEditor()) ?? editorsFor(path)[0] ?? null;
const now = find();
if (now) return Promise.resolve(now);
return new Promise((resolve) => {
const done = (e: TextEditorHandle | null) => {
clearTimeout(timer);
unsubscribe();
resolve(e);
};
const timer = setTimeout(() => done(null), timeoutMs);
const unsubscribe = useEditorRegistry.subscribe(() => {
const e = find();
if (e) done(e);
});
});
}
/** Renders `targets` of the source at `sourcePath`, appends them to the scratch and opens it at the first one. */
export async function generateRequests(sourcePath: string, text: string, targets: Target[]): Promise<void> {
if (!targets.length) return;
try {
const root = useProject.getState().project?.root ?? null;
const [host, lookup] = await Promise.all([
findHost(sourcePath, root, reader),
bodyLookup(
sourcePath,
parseSource(text),
targets.map((t) => t.endpoint),
reader,
),
]);
const blocks = targets.map(({ endpoint, path }) =>
renderRequest(endpoint, {
host,
path,
body: endpoint.bodyType ? skeleton(endpoint.bodyType, lookup) : undefined,
}),
);
const file = await scratchFile();
if (file.created) emitLocalIdeEvent({ type: "fsChanged", created: [file.path], modified: [], removed: [] });
await editorService().openFile(file.path, { focus: true });
const e = await editorFor(file.path);
if (!e) throw new Error(`${tx("Cannot open file")}: ${file.path}`);
const { insert, from, caret } = appendRequests(e.view.state.doc.toString(), blocks);
e.view.dispatch({
changes: { from, insert },
selection: { anchor: caret },
effects: EditorView.scrollIntoView(caret, { y: "center" }),
userEvent: "input",
});
} catch (err) {
notifications().notify({
level: "error",
title: "Cannot generate the HTTP request",
body: err instanceof Error ? err.message : String(err),
key: "http-generate",
});
}
}
function endpointItems(sourcePath: string, text: string, e: Endpoint): MenuItem[] {
const generate = (path?: string) => void generateRequests(sourcePath, text, [{ endpoint: e, path }]);
return [
e.paths.length > 1
? {
label: "Generate Request in HTTP Client",
icon: "file-http",
submenu: e.paths.map((p) => ({ label: `${e.httpMethod} ${p}`, verbatim: true, run: () => generate(p) })),
}
: { label: "Generate Request in HTTP Client", icon: "file-http", run: () => generate() },
];
}
function controllerItems(sourcePath: string, text: string, c: Controller): MenuItem[] {
return c.endpoints.length
? [
{
label: "Generate Requests for Controller",
icon: "file-http",
run: () =>
void generateRequests(
sourcePath,
text,
c.endpoints.map((endpoint) => ({ endpoint })),
),
},
]
: [];
}
/** Editor context menu items for the caret at `offset`: the generate actions (a path choice for several paths). */
export function springMenuItems(sourcePath: string, text: string, offset: number): MenuItem[] {
const { controller, endpoint } = springAt(springControllers(text), offset);
const items: MenuItem[] = [];
if (endpoint)
items.push(
endpoint.paths.length > 1
? endpointItems(sourcePath, text, endpoint)[0]!
: { kind: "action", action: GENERATE_REQUEST, icon: "file-http" },
);
if (controller?.endpoints.length)
items.push({ kind: "action", action: GENERATE_CONTROLLER_REQUESTS, icon: "file-http" });
return items;
}
export function menuGroupItems(ctx: MenuGroupContext): MenuItem[] {
const path = ctx.paths[0];
if (!path || ctx.text === undefined || ctx.line === undefined || !/\.(java|kt)$/.test(path)) return [];
// The actions work at the caret: only the active editor's menu offers them.
const e = activeEditor();
if (!e || e.ctx.path !== path || !SPRING_LANGUAGES.has(e.ctx.language)) return [];
return springMenuItems(path, ctx.text, e.view.state.selection.main.head);
}
/** The generate actions at the caret of the active editor. */
export async function generateAtCaret(scope: "endpoint" | "controller"): Promise<void> {
const e = activeEditor();
if (!e || !SPRING_LANGUAGES.has(e.ctx.language)) return;
const text = e.view.state.doc.toString();
const { controller, endpoint } = springAt(springControllers(text), e.view.state.selection.main.head);
const endpoints = scope === "endpoint" ? (endpoint ? [endpoint] : []) : (controller?.endpoints ?? []);
if (!endpoints.length) {
notifications().notify({
level: "info",
title: scope === "endpoint" ? "No Spring request mapping at the caret" : "No Spring controller at the caret",
key: "http-generate",
});
return;
}
await generateRequests(
e.ctx.path,
text,
endpoints.map((x) => ({ endpoint: x })),
);
}
// --- gutter ----------------------------------------------------------------------
interface Mark {
pos: number;
/** Endpoint key or the controller's name. */
key: string;
title: string;
items: (path: string, text: string) => MenuItem[];
}
const setMarks = StateEffect.define<Mark[]>();
const marksField = StateField.define<Mark[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setMarks)) return e.value;
if (!tr.docChanged || !value.length) return value;
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
},
});
function computeMarks(state: EditorState): Mark[] {
const doc = state.doc;
const marks: Mark[] = [];
const lineStart = (offset: number) => doc.lineAt(Math.min(offset, doc.length)).from;
for (const c of springControllers(doc.toString())) {
if (c.endpoints.length)
marks.push({
pos: lineStart(c.offset),
key: `class:${c.name}`,
title: "Generate Requests for Controller",
items: (path, text) => controllerItems(path, text, c),
});
for (const e of c.endpoints)
marks.push({
pos: lineStart(e.offset),
key: `${e.httpMethod} ${e.paths.join(",")}`,
title: `${e.httpMethod} ${e.paths[0] ?? "/"}`,
items: (path, text) => endpointItems(path, text, e),
});
}
// One icon per line (the first wins).
return marks.filter((m, i) => marks.findIndex((x) => x.pos === m.pos) === i);
}
class SpringMarker extends GutterMarker {
constructor(readonly mark: Mark) {
super();
}
override eq(other: GutterMarker): boolean {
return other instanceof SpringMarker && other.mark.key === this.mark.key && other.mark.title === this.mark.title;
}
override toDOM(): Node {
const el = document.createElement("div");
el.className = styles.runMarker ?? "";
el.title = this.mark.title;
el.setAttribute("role", "button");
el.setAttribute("aria-label", this.mark.title);
el.innerHTML = iconMarkup("file-http", 14) ?? "";
return el;
}
}
const DEBOUNCE = 400;
export function springEditorExtension(ctx: EditorDocContext): Extension {
if (!SPRING_LANGUAGES.has(ctx.language) || ctx.virtual || isVirtualUri(ctx.path)) return [];
const path = ctx.path;
const detector = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | undefined;
constructor(readonly view: EditorView) {
this.schedule(0);
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule(DEBOUNCE);
}
schedule(delay: number) {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
const found = computeMarks(this.view.state);
const cur = this.view.state.field(marksField);
const same =
cur.length === found.length && cur.every((m, i) => m.pos === found[i]!.pos && m.key === found[i]!.key);
if (!same) this.view.dispatch({ effects: setMarks.of(found) });
}, delay);
}
destroy() {
clearTimeout(this.timer);
}
},
);
const intentions = (view: EditorView): ContributedIntention[] => {
// Only files that have mappings (the gutter found some) are parsed again here.
if (!view.state.field(marksField, false)?.length) return [];
const text = view.state.doc.toString();
const { controller, endpoint } = springAt(springControllers(text), view.state.selection.main.head);
const out: ContributedIntention[] = [];
if (endpoint)
out.push({
id: GENERATE_REQUEST,
label: "Generate Request in HTTP Client",
icon: "file-http",
run: () => generateRequests(path, text, [{ endpoint }]),
});
if (controller?.endpoints.length)
out.push({
id: GENERATE_CONTROLLER_REQUESTS,
label: "Generate Requests for Controller",
icon: "file-http",
run: () =>
generateRequests(
path,
text,
controller.endpoints.map((e) => ({ endpoint: e })),
),
});
return out;
};
return [
marksField,
detector,
intentionProviders.of(intentions),
gutter({
class: `${styles.runGutter} cm-openidea-gutter-spring`,
markers: (view) =>
RangeSet.of(
view.state.field(marksField).map((m) => new SpringMarker(m).range(m.pos)),
true,
),
domEventHandlers: {
mousedown: (view, block, event) => {
const mark = view.state.field(marksField).find((m) => m.pos === block.from);
if (!mark || (event as MouseEvent).button !== 0) return false;
const el = (event.target as HTMLElement).closest?.(`.${styles.runMarker}`) as HTMLElement | null;
const rect = (el ?? (event.target as HTMLElement)).getBoundingClientRect();
event.preventDefault();
showContextMenu({ x: rect.left, y: rect.bottom + 2 }, mark.items(path, view.state.doc.toString()));
return true;
},
},
}),
];
}
@@ -0,0 +1,297 @@
/**
* Spring Web request mappings of a Java / Kotlin source: `@RequestMapping` /
* `@GetMapping`… methods of `@RestController` / `@Controller` classes, with
* the class-level mapping applied. Constants are resolved within the file.
*/
import {
allClasses,
parseSource,
type Annotation,
type ClassDecl,
type Expr,
type Param,
type SourceFile,
} from "./source";
export interface QueryParam {
name: string;
/** `defaultValue`, if any. */
value: string | null;
}
export interface Endpoint {
className: string;
methodName: string;
/** Offset of the method name (gutter line). */
offset: number;
/** The method declaration, annotations included. */
range: { from: number; to: number };
/** Offset of the controller class name. */
classOffset: number;
httpMethod: string;
/** Class × method paths (`/` joined, `{var}` kept), first one first. */
paths: string[];
query: QueryParam[];
headers: { name: string; value: string }[];
/** `consumes` (first). */
consumes: string | null;
/** `produces` (first). */
produces: string | null;
/** `@RequestBody` parameter type as written, if any. */
bodyType: string | null;
}
export interface Controller {
name: string;
offset: number;
/** The class declaration, annotations included. */
range: { from: number; to: number };
endpoints: Endpoint[];
}
const SHORTCUTS: Record<string, string> = {
GetMapping: "GET",
PostMapping: "POST",
PutMapping: "PUT",
DeleteMapping: "DELETE",
PatchMapping: "PATCH",
};
const CONTROLLER = new Set(["RestController", "Controller"]);
const MEDIA_TYPES: Record<string, string> = {
ALL: "*/*",
APPLICATION_JSON: "application/json",
APPLICATION_JSON_UTF8: "application/json;charset=UTF-8",
APPLICATION_XML: "application/xml",
APPLICATION_ATOM_XML: "application/atom+xml",
APPLICATION_FORM_URLENCODED: "application/x-www-form-urlencoded",
APPLICATION_OCTET_STREAM: "application/octet-stream",
APPLICATION_PDF: "application/pdf",
APPLICATION_PROBLEM_JSON: "application/problem+json",
APPLICATION_PROBLEM_XML: "application/problem+xml",
APPLICATION_NDJSON: "application/x-ndjson",
APPLICATION_STREAM_JSON: "application/stream+json",
APPLICATION_CBOR: "application/cbor",
APPLICATION_GRAPHQL_RESPONSE: "application/graphql-response+json",
IMAGE_GIF: "image/gif",
IMAGE_JPEG: "image/jpeg",
IMAGE_PNG: "image/png",
MULTIPART_FORM_DATA: "multipart/form-data",
MULTIPART_MIXED: "multipart/mixed",
TEXT_EVENT_STREAM: "text/event-stream",
TEXT_HTML: "text/html",
TEXT_MARKDOWN: "text/markdown",
TEXT_PLAIN: "text/plain",
TEXT_XML: "text/xml",
};
/** Evaluates string-valued annotation arguments within one file. */
export class Constants {
private readonly fields = new Map<string, Expr>();
constructor(file: SourceFile) {
for (const f of [...file.fields, ...allClasses(file).flatMap((c) => c.fields)])
if (f.init && !this.fields.has(f.name)) this.fields.set(f.name, f.init);
}
/** Every element of an array (`{a, b}`, `[a, b]`, `arrayOf(a, b)`) or the single value; unresolvable ones dropped. */
strings(expr: Expr | undefined, enums = false): string[] {
if (!expr?.length) return [];
const first = expr[0]!;
let inner: Expr | null = null;
if (first.kind === "p" && (first.text === "{" || first.text === "[")) inner = expr.slice(1, -1);
else if (first.kind === "id" && /^(arrayOf|listOf|setOf)$/.test(first.text) && expr[1]?.text === "(")
inner = expr.slice(2, -1);
if (!inner) {
const v = this.value(expr, enums, new Set());
return v === null ? [] : [v];
}
return splitTop(inner)
.map((e) => this.value(e, enums, new Set()))
.filter((v): v is string => v !== null);
}
string(expr: Expr | undefined, enums = false): string | null {
return this.strings(expr, enums)[0] ?? null;
}
private value(expr: Expr, enums: boolean, seen: Set<string>): string | null {
let out = "";
for (const part of splitPlus(expr)) {
const v = this.part(part, enums, seen);
if (v === null) return null;
out += v;
}
return out;
}
private part(part: Expr, enums: boolean, seen: Set<string>): string | null {
// Kotlin templates of constants; other `${…}` are property placeholders, kept.
if (part.length === 1 && part[0]!.kind === "str")
return (part[0]!.value ?? "").replace(
/\$\{(\w+)\}|\$([A-Za-z_]\w*)/g,
(m, a: string | undefined, b: string | undefined) => {
const name = (a ?? b)!;
const init = seen.has(name) ? undefined : this.fields.get(name);
return init ? (this.value(init, false, new Set([...seen, name])) ?? m) : m;
},
);
if (part.length === 1 && part[0]!.kind === "num") return part[0]!.text;
if (part[0]?.text === "(" && part[part.length - 1]?.text === ")") return this.value(part.slice(1, -1), enums, seen);
// `NAME`, `Paths.NAME`, `MediaType.APPLICATION_JSON_VALUE`, `RequestMethod.GET`.
if (!part.every((t, i) => (i % 2 ? t.kind === "p" && t.text === "." : t.kind === "id"))) return null;
const names = part.filter((t) => t.kind === "id").map((t) => t.text);
const last = names[names.length - 1]!;
const qualifier = names[names.length - 2];
if (qualifier === "MediaType") return MEDIA_TYPES[last.replace(/_VALUE$/, "")] ?? null;
const init = this.fields.get(last);
if (init && !seen.has(last)) {
seen.add(last);
return this.value(init, enums, seen);
}
return enums ? last : null;
}
}
function splitTop(expr: Expr): Expr[] {
const parts: Expr[] = [];
let cur: Expr = [];
let depth = 0;
for (const t of expr) {
if (t.kind === "p" && "([{".includes(t.text)) depth++;
if (t.kind === "p" && ")]}".includes(t.text)) depth--;
if (depth === 0 && t.kind === "p" && t.text === ",") {
parts.push(cur);
cur = [];
} else cur.push(t);
}
if (cur.length) parts.push(cur);
return parts;
}
function splitPlus(expr: Expr): Expr[] {
const parts: Expr[] = [];
let cur: Expr = [];
let depth = 0;
for (const t of expr) {
if (t.kind === "p" && "([{".includes(t.text)) depth++;
if (t.kind === "p" && ")]}".includes(t.text)) depth--;
if (depth === 0 && t.kind === "p" && t.text === "+") {
parts.push(cur);
cur = [];
} else cur.push(t);
}
parts.push(cur);
return parts;
}
function mapping(annotations: Annotation[]): { ann: Annotation; method: string | null } | null {
for (const ann of annotations) {
if (SHORTCUTS[ann.name]) return { ann, method: SHORTCUTS[ann.name]! };
if (ann.name === "RequestMapping") return { ann, method: null };
}
return null;
}
function mappingPaths(ann: Annotation | undefined, c: Constants): string[] {
if (!ann) return [];
return c.strings(ann.args.get("path") ?? ann.args.get("value"));
}
export function joinPaths(base: string, path: string): string {
const joined = `/${base}/${path}`.replace(/\/{2,}/g, "/");
return joined.length > 1 ? joined.replace(/\/$/, "") : "/";
}
function annotation(p: Param, name: string): Annotation | undefined {
return p.annotations.find((a) => a.name === name);
}
/** Map-typed `@RequestParam` / `@RequestHeader` collect all of them: nothing to generate. */
const isMap = (type: string) =>
/^(java\.util\.)?(Map|MultiValueMap|HttpHeaders|LinkedMultiValueMap|HashMap)\b/.test(type);
function endpoint(cls: ClassDecl, method: ClassDecl["methods"][number], c: Constants): Endpoint | null {
const m = mapping(method.annotations);
if (!m) return null;
const classMapping = cls.annotations.find((a) => a.name === "RequestMapping");
const httpMethod =
m.method ??
c.string(m.ann.args.get("method"), true) ??
(classMapping && c.string(classMapping.args.get("method"), true)) ??
"GET";
const bases = mappingPaths(classMapping, c);
const own = mappingPaths(m.ann, c);
const paths: string[] = [];
for (const b of bases.length ? bases : [""]) for (const p of own.length ? own : [""]) paths.push(joinPaths(b, p));
const query: QueryParam[] = [];
const headers: { name: string; value: string }[] = [];
let bodyType: string | null = null;
for (const p of method.params) {
const rp = annotation(p, "RequestParam");
const rh = annotation(p, "RequestHeader");
if (rp && !isMap(p.type)) {
const name = c.string(rp.args.get("value")) || c.string(rp.args.get("name")) || p.name;
query.push({ name, value: c.string(rp.args.get("defaultValue")) });
} else if (rh && !isMap(p.type)) {
const name = c.string(rh.args.get("value")) || c.string(rh.args.get("name")) || p.name;
headers.push({ name, value: c.string(rh.args.get("defaultValue")) ?? `{{${name}}}` });
} else if (annotation(p, "RequestBody")) bodyType = p.type || "Object";
}
// `params = "a=1"` / `headers = "X-Api=1"` narrow the mapping: the request must carry them.
for (const cond of [...c.strings(classMapping?.args.get("params")), ...c.strings(m.ann.args.get("params"))]) {
const [name, value] = cond.split("=", 2);
if (name && !cond.includes("!") && !query.some((q) => q.name === name.trim()))
query.push({ name: name.trim(), value: value?.trim() ?? null });
}
for (const cond of [...c.strings(classMapping?.args.get("headers")), ...c.strings(m.ann.args.get("headers"))]) {
const [name, value] = cond.split("=", 2);
if (name && !cond.includes("!") && value !== undefined) headers.push({ name: name.trim(), value: value.trim() });
}
const first = (key: string) =>
c.string(m.ann.args.get(key)) ?? (classMapping && c.string(classMapping.args.get(key))) ?? null;
return {
className: cls.name,
methodName: method.name,
offset: method.nameStart,
range: { from: method.start, to: method.end },
classOffset: cls.nameStart,
httpMethod: httpMethod.toUpperCase(),
paths,
query,
headers,
consumes: first("consumes"),
produces: first("produces"),
bodyType,
};
}
export function controllersOf(file: SourceFile): Controller[] {
const constants = new Constants(file);
const out: Controller[] = [];
for (const cls of allClasses(file)) {
if (!cls.annotations.some((a) => CONTROLLER.has(a.name))) continue;
const endpoints = cls.methods.map((m) => endpoint(cls, m, constants)).filter((e): e is Endpoint => !!e);
out.push({ name: cls.name, offset: cls.nameStart, range: { from: cls.start, to: cls.end }, endpoints });
}
return out;
}
/** Controllers of a Java / Kotlin source, with their endpoints. */
export function springControllers(text: string): Controller[] {
// Cheap guard: most files are not controllers.
if (!/@(?:[\w.]+\.)?(?:Rest)?Controller\b/.test(text)) return [];
return controllersOf(parseSource(text));
}
/** The controller and endpoint whose declaration holds `offset`. */
export function springAt(
controllers: Controller[],
offset: number,
): { controller: Controller | null; endpoint: Endpoint | null } {
// Innermost controller (nested classes come after their outer one).
const controller = controllers.filter((c) => c.range.from <= offset && offset <= c.range.to).pop() ?? null;
const endpoint = controller?.endpoints.find((e) => e.range.from <= offset && offset <= e.range.to) ?? null;
return { controller, endpoint };
}
+254
View File
@@ -0,0 +1,254 @@
/**
* Spring endpoints → `.http` requests: the server address from
* `application.properties` / `application.yml`, `{var}` → `{{var}}`, query
* parameters, headers and a JSON skeleton of the `@RequestBody` type.
*/
import type { Endpoint } from "./endpoints";
import { allClasses, parseSource, type ClassDecl, type SourceFile } from "./source";
export const DEFAULT_HOST = "http://localhost:8080";
/** Flat `key → value` of a `.properties` file (`=`, `:` or space separated; `#` / `!` comments). */
export function parseProperties(text: string): Map<string, string> {
const out = new Map<string, string>();
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
let line = lines[i]!.trim();
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
while (line.endsWith("\\") && i + 1 < lines.length) line = line.slice(0, -1) + lines[++i]!.trim();
const m = /^((?:\\.|[^=:\s])+)\s*[=:\s]\s*(.*)$/.exec(line);
if (m && !out.has(m[1]!)) out.set(m[1]!.replace(/\\(.)/g, "$1"), m[2]!.trim());
}
return out;
}
/** Flat dotted keys of a YAML file's first document (block mappings and scalars only; enough for `server.*`). */
export function parseYaml(text: string): Map<string, string> {
const out = new Map<string, string>();
const stack: { indent: number; key: string }[] = [];
let started = false;
for (const raw of text.split(/\r?\n/)) {
if (/^---/.test(raw)) {
if (started) break;
continue;
}
const line = raw.replace(/\s+#.*$/, "");
if (!line.trim() || line.trim().startsWith("#") || line.trim().startsWith("- ")) continue;
const m = /^(\s*)("[^"]*"|'[^']*'|[^:]+?)\s*:(?:\s+(.*))?$/.exec(line);
if (!m) continue;
started = true;
const indent = m[1]!.length;
while (stack.length && stack[stack.length - 1]!.indent >= indent) stack.pop();
const key = m[2]!.replace(/^["']|["']$/g, "");
const path = [...stack.map((s) => s.key), key].join(".");
const value = m[3]?.trim();
if (value && value !== "|" && value !== ">") {
if (!out.has(path)) out.set(path, value.replace(/^"(.*)"$|^'(.*)'$/, "$1$2"));
} else stack.push({ indent, key });
}
return out;
}
/** `${PORT:8081}` → `8081`; a placeholder without a default is unknown. */
function resolved(value: string | undefined): string | null {
if (value === undefined) return null;
let unknown = false;
const out = value.replace(/\$\{[^}:]*(?::([^}]*))?\}/g, (_m, def: string | undefined) => {
if (def === undefined) unknown = true;
return def ?? "";
});
return unknown ? null : out;
}
/** `http://localhost:<server.port><context path>` from Spring Boot configuration (first file with a value wins). */
export function hostFromConfig(configs: Map<string, string>[]): string {
const get = (...keys: string[]) => {
for (const c of configs)
for (const k of keys) {
const v = resolved(c.get(k));
if (v) return v;
}
return null;
};
const port = get("server.port");
const ssl = /^true$/i.test(get("server.ssl.enabled") ?? "") || !!get("server.ssl.key-store");
const scheme = ssl ? "https" : "http";
const portNum = port && /^\d+$/.test(port) && port !== "0" ? Number(port) : 8080;
const context = [
get("server.servlet.context-path", "server.context-path", "spring.webflux.base-path") ?? "",
get("spring.mvc.servlet.path") ?? "",
]
.join("/")
.replace(/\/{2,}/g, "/")
.replace(/\/$/, "");
const ctx = context && !context.startsWith("/") ? `/${context}` : context;
return `${scheme}://localhost:${portNum}${ctx}`;
}
const NUMBER =
/^(byte|short|int|long|float|double|Byte|Short|Integer|Long|Float|Double|Int|BigDecimal|BigInteger|Number|UByte|UShort|UInt|ULong)\??$/;
const BOOLEAN = /^(boolean|Boolean)\??$/;
const STRING =
/^(String|CharSequence|char|Character|Char|UUID|LocalDate|LocalDateTime|LocalTime|Instant|OffsetDateTime|ZonedDateTime|Date|Duration|URI|URL)\??$/;
const COLLECTION =
/^(List|ArrayList|LinkedList|Set|HashSet|TreeSet|LinkedHashSet|Collection|Iterable|MutableList|MutableSet|Flux|Array|.*Array)(<.*>)?\??$|\[\]$|\.\.\.$/;
const MAP = /^(Map|HashMap|LinkedHashMap|TreeMap|MutableMap|MultiValueMap|JsonNode|ObjectNode)(<.*>)?\??$/;
/** Unwraps `Mono<T>`, `Optional<T>`, `HttpEntity<T>`, `@Valid`-less qualified names and nullability. */
export function bareType(type: string): string {
let t = type.trim().replace(/\?$/, "");
for (;;) {
const m = /^(?:[\w.]*\.)?(Mono|Optional|HttpEntity|RequestEntity|Deferred|CompletableFuture)<(.*)>$/.exec(t);
if (!m) break;
t = m[2]!.trim();
}
return t.replace(/^(?:[a-z_$][\w$]*\.)+/, "");
}
export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
/** Placeholder value for a field / body of `type`; classes found by `lookup` get their properties (one level deep for nested ones). */
export function skeleton(type: string, lookup: (name: string) => ClassDecl | null, depth = 0): Json {
const t = bareType(type);
if (NUMBER.test(t)) return 0;
if (BOOLEAN.test(t)) return false;
if (STRING.test(t)) return "";
if (COLLECTION.test(t)) return [];
if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {};
const cls = lookup(t.replace(/<.*$/, ""));
if (!cls || depth > 1) return {};
const obj: { [k: string]: Json } = {};
for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
return obj;
}
const pathVariables = (path: string) => path.replace(/\{\*?([\w.-]+)(?::[^{}]*(?:\{[^{}]*\}[^{}]*)*)?\}/g, "{{$1}}");
export interface RenderOptions {
host: string;
/** Path to use (one of `endpoint.paths`); default the first. */
path?: string;
/** JSON body of the `@RequestBody` type. */
body?: Json;
}
/** One `### Name` request block, ending with a line break. */
export function renderRequest(e: Endpoint, opts: RenderOptions): string {
const path = pathVariables(opts.path ?? e.paths[0] ?? "/");
const query = e.query.map((q) => `${q.name}=${q.value ?? `{{${q.name}}}`}`).join("&");
const lines = [
`### ${e.className}.${e.methodName}`,
`${e.httpMethod} ${opts.host}${path}${query ? `?${query}` : ""}`,
];
const headers = [...e.headers];
const contentType = e.consumes ?? (e.bodyType ? "application/json" : null);
if (e.produces) headers.unshift({ name: "Accept", value: e.produces });
if (contentType && (e.bodyType || e.consumes)) headers.push({ name: "Content-Type", value: contentType });
for (const h of headers) lines.push(`${h.name}: ${h.value}`);
if (e.bodyType) {
lines.push("");
const body = opts.body ?? {};
const form =
contentType === "application/x-www-form-urlencoded" && body && typeof body === "object" && !Array.isArray(body);
lines.push(
form
? Object.keys(body)
.map((k) => `${k}=`)
.join("&")
: JSON.stringify(body, null, 2),
);
}
return `${lines.join("\n")}\n`;
}
/** Text to append to a request file holding `existing`, and the offset (in the new text) of the first request line. */
export function appendRequests(existing: string, blocks: string[]): { insert: string; from: number; caret: number } {
const from = existing.length;
let sep = "";
if (existing.trim()) sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
const insert = sep + blocks.join("\n");
const firstLine = blocks[0]?.indexOf("\n") ?? -1;
return { insert, from, caret: from + sep.length + (firstLine >= 0 ? firstLine + 1 : 0) };
}
// --- project lookups -------------------------------------------------------------
export interface SourceReader {
/** File text, null when missing. */
read(path: string): Promise<string | null>;
}
const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, "");
/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null): string[] {
const norm = sourcePath.replace(/\\/g, "/");
const at = norm.lastIndexOf("/src/main/");
const roots = at >= 0 ? [norm.slice(0, at)] : [];
if (projectRoot && !roots.includes(projectRoot)) roots.push(projectRoot.replace(/\\/g, "/").replace(/\/$/, ""));
return roots.flatMap((r) =>
["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`),
);
}
export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise<string> {
const configs: Map<string, string>[] = [];
for (const path of configCandidates(sourcePath, projectRoot)) {
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return hostFromConfig(configs);
}
/**
* Body types of `endpoints`: classes of the controller's file, then
* `<Type>.java|.kt` beside it or where its import points (same source root).
*/
export async function bodyLookup(
sourcePath: string,
file: SourceFile,
endpoints: Endpoint[],
fs: SourceReader,
): Promise<(name: string) => ClassDecl | null> {
const known = new Map<string, ClassDecl>();
const add = (f: SourceFile) => {
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
};
add(file);
const sourceRoot = (path: string, f: SourceFile) => {
let root = dirname(path);
for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root);
return root;
};
const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"];
/** Loads `name` as seen from `from` (its imports, then its directory). */
const load = async (name: string, from: { path: string; file: SourceFile }) => {
if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null;
const imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
const dir = dirname(from.path);
const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`;
for (const d of pkgDir ? [pkgDir, dir] : [dir])
for (const x of ext) {
const path = `${d}/${name}${x}`;
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
const loaded = parseSource(text);
add(loaded);
return { path, file: loaded };
}
return null;
};
const typeName = (t: string) => bareType(t).replace(/<.*$/, "");
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
for (const n of names) {
const from = (await load(n, origin)) ?? origin;
// Property types one level down, as seen from the body type's file.
const c = known.get(n);
for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : [])
await load(typeName(p.type), from);
}
return (name) => known.get(name) ?? null;
}
+560
View File
@@ -0,0 +1,560 @@
/**
* A declaration-level reader for Java and Kotlin sources: classes, methods,
* fields and their annotations with unevaluated argument expressions. Enough
* to read Spring Web mappings (no types, no statements); comments, strings and
* bracket nesting are handled exactly, the rest is skipped.
*/
export interface Tok {
kind: "id" | "str" | "num" | "p";
text: string;
/** String literals: the contents (escapes decoded). */
value?: string;
start: number;
end: number;
/** A line break precedes the token. */
nl: boolean;
}
/** Unevaluated expression: its tokens. */
export type Expr = Tok[];
export interface Annotation {
/** Simple name (`GetMapping` of `@org.springframework…GetMapping`). */
name: string;
/** Arguments by name; a positional argument is `value`. */
args: Map<string, Expr>;
}
export interface Param {
name: string;
/** Type as written, without spaces (`List<String>`); "" when unknown. */
type: string;
annotations: Annotation[];
}
export interface Field {
name: string;
type: string;
isStatic: boolean;
init: Expr | null;
}
export interface Method {
name: string;
annotations: Annotation[];
params: Param[];
/** Offset of the name. */
nameStart: number;
/** Whole declaration, annotations included. */
start: number;
end: number;
}
export interface ClassDecl {
name: string;
annotations: Annotation[];
nameStart: number;
/** Whole declaration, annotations included. */
start: number;
end: number;
/** Kotlin `object` / companion: its properties are static. */
isObject: boolean;
methods: Method[];
fields: Field[];
/** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */
ctorParams: Param[];
classes: ClassDecl[];
}
export interface SourceFile {
packageName: string;
imports: string[];
classes: ClassDecl[];
/** Top-level properties (Kotlin). */
fields: Field[];
}
const ID_START = /[A-Za-z_$]/;
const ID_PART = /[\w$]/;
export function tokenize(text: string): Tok[] {
const out: Tok[] = [];
let i = 0;
let nl = false;
const n = text.length;
while (i < n) {
const c = text[i]!;
if (c === "\n") {
nl = true;
i++;
continue;
}
if (c === " " || c === "\t" || c === "\r" || c === "\f") {
i++;
continue;
}
if (c === "/" && text[i + 1] === "/") {
while (i < n && text[i] !== "\n") i++;
continue;
}
if (c === "/" && text[i + 1] === "*") {
const close = text.indexOf("*/", i + 2);
const end = close < 0 ? n : close + 2;
if (text.slice(i, end).includes("\n")) nl = true;
i = end;
continue;
}
const start = i;
if (c === '"' && text.startsWith('"""', i)) {
const close = text.indexOf('"""', i + 3);
const end = close < 0 ? n : close + 3;
const body = text.slice(i + 3, close < 0 ? n : close);
// Java text blocks start after a line break; Kotlin raw strings keep it.
out.push({ kind: "str", text: text.slice(i, end), value: body.replace(/^[ \t]*\r?\n/, ""), start, end, nl });
i = end;
} else if (c === '"' || c === "'") {
let j = i + 1;
let value = "";
while (j < n && text[j] !== c && text[j] !== "\n") {
if (text[j] === "\\" && j + 1 < n) {
const e = text[j + 1]!;
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
j += 2;
} else value += text[j++];
}
const end = Math.min(n, j + 1);
out.push({ kind: "str", text: text.slice(i, end), value, start, end, nl });
i = end;
} else if (c === "`") {
const close = text.indexOf("`", i + 1);
const end = close < 0 || text.slice(i, close).includes("\n") ? i + 1 : close + 1;
out.push({ kind: "id", text: end > i + 1 ? text.slice(i + 1, end - 1) : "`", start, end, nl });
i = end;
} else if (ID_START.test(c)) {
let j = i + 1;
while (j < n && ID_PART.test(text[j]!)) j++;
out.push({ kind: "id", text: text.slice(i, j), start, end: j, nl });
i = j;
} else if (/\d/.test(c)) {
let j = i + 1;
while (j < n && /[\w.]/.test(text[j]!)) j++;
out.push({ kind: "num", text: text.slice(i, j), start, end: j, nl });
i = j;
} else {
out.push({ kind: "p", text: c, start, end: i + 1, nl });
i++;
}
nl = false;
}
return out;
}
const OPEN: Record<string, string> = { "(": ")", "[": "]", "{": "}" };
/** Index of the matching closing bracket for every opening one (unbalanced: the end). */
function matchBrackets(toks: Tok[]): Int32Array {
const match = new Int32Array(toks.length).fill(-1);
const stack: number[] = [];
toks.forEach((t, i) => {
if (t.kind !== "p") return;
if (OPEN[t.text]) stack.push(i);
else if (t.text === ")" || t.text === "]" || t.text === "}") {
// Pop to the matching opener (unclosed ones inside end here too); stray closers are ignored.
for (let k = stack.length - 1; k >= 0; k--) {
if (OPEN[toks[stack[k]!]!.text] === t.text) {
for (const o of stack.splice(k)) match[o] = i;
break;
}
}
}
});
for (const o of stack) match[o] = toks.length;
return match;
}
const CLASS_KW = new Set(["class", "interface", "enum", "record", "object"]);
const MODIFIERS = new Set([
"public",
"private",
"protected",
"internal",
"static",
"final",
"abstract",
"open",
"override",
"const",
"data",
"sealed",
"inline",
"suspend",
"lateinit",
"default",
"synchronized",
"native",
"transient",
"volatile",
"strictfp",
"operator",
"infix",
"external",
"annotation",
"inner",
"value",
"companion",
"fun",
"val",
"var",
]);
const NOT_METHOD = new Set(["if", "for", "while", "switch", "catch", "synchronized", "return", "new", "throw", "when"]);
class Reader {
readonly match: Int32Array;
constructor(readonly toks: Tok[]) {
this.match = matchBrackets(toks);
}
is(i: number, text: string): boolean {
const t = this.toks[i];
return !!t && t.text === text && (t.kind === "p" || t.kind === "id");
}
/** Kotlin: a token on a new line that starts a member ends the previous one. */
startsMember(i: number): boolean {
const t = this.toks[i];
if (!t || !t.nl) return false;
return t.text === "@" || t.text === "}" || (t.kind === "id" && (MODIFIERS.has(t.text) || CLASS_KW.has(t.text)));
}
/** `@Name(args)` at `i` (the `@`); returns the annotation and the next index. */
annotation(i: number): [Annotation, number] | null {
if (!this.is(i, "@") || this.toks[i + 1]?.kind !== "id" || this.is(i + 1, "interface")) return null;
let j = i + 1;
let name = this.toks[j]!.text;
// Kotlin use-site targets: `@field:JsonProperty`.
if (this.is(j + 1, ":") && this.toks[j + 2]?.kind === "id") {
j += 2;
name = this.toks[j]!.text;
}
while (this.is(j + 1, ".") && this.toks[j + 2]?.kind === "id") {
j += 2;
name = this.toks[j]!.text;
}
j++;
const args = new Map<string, Expr>();
if (this.is(j, "(") && !this.toks[j]!.nl) {
const close = this.match[j]!;
const positional: Tok[][] = [];
for (const part of this.split(j + 1, close)) {
if (!part.length) continue;
if (part[0]!.kind === "id" && part[1]?.text === "=" && part[1].kind === "p" && part[2]?.text !== "=")
args.set(part[0]!.text, part.slice(2));
else positional.push(part);
}
// Kotlin `vararg value`: `@GetMapping("/a", "/b")` is an array.
if (positional.length === 1) args.set("value", positional[0]!);
else if (positional.length > 1) {
const p = (text: string, at: Tok): Tok => ({ kind: "p", text, start: at.start, end: at.start, nl: false });
const first = positional[0]![0]!;
args.set("value", [
p("[", first),
...positional.flatMap((x, k) => (k ? [p(",", x[0]!), ...x] : x)),
p("]", first),
]);
}
j = close + 1;
}
return [{ name, args }, j];
}
/** Splits `[from, to)` at top-level commas (brackets and generics nest). */
split(from: number, to: number): Tok[][] {
const parts: Tok[][] = [];
let cur: Tok[] = [];
let angle = 0;
for (let i = from; i < to; i++) {
const t = this.toks[i]!;
if (t.kind === "p" && OPEN[t.text]) {
const close = Math.min(this.match[i]!, to - 1);
for (let k = i; k <= close; k++) cur.push(this.toks[k]!);
i = close;
continue;
}
if (t.kind === "p" && t.text === "<") angle++;
if (t.kind === "p" && t.text === ">" && angle > 0) angle--;
if (t.kind === "p" && t.text === "," && angle === 0) {
parts.push(cur);
cur = [];
continue;
}
cur.push(t);
}
parts.push(cur);
return parts.filter((p, idx) => p.length || idx < parts.length - 1);
}
params(open: number): Param[] {
const close = this.match[open]!;
const out: Param[] = [];
for (const part of this.split(open + 1, close)) {
const annotations: Annotation[] = [];
const rest: Tok[] = [];
const sub = new Reader(part);
for (let i = 0; i < part.length;) {
const a = sub.annotation(i);
if (a) {
annotations.push(a[0]);
i = a[1];
} else rest.push(part[i++]!);
}
const colon = rest.findIndex((t) => t.kind === "p" && t.text === ":");
if (colon >= 0) {
// Kotlin `name: Type = default`.
const head = rest
.slice(0, colon)
.filter((t) => t.kind === "id" && !MODIFIERS.has(t.text) && t.text !== "vararg");
const eq = rest.findIndex((t, k) => k > colon && t.kind === "p" && t.text === "=");
const name = head[head.length - 1]?.text;
if (name) out.push({ name, type: joined(rest.slice(colon + 1, eq < 0 ? undefined : eq)), annotations });
} else {
const toks = rest.filter((t) => !(t.kind === "id" && t.text === "final"));
const last = toks[toks.length - 1];
if (last?.kind === "id") out.push({ name: last.text, type: joined(toks.slice(0, -1)), annotations });
}
}
return out;
}
/** Members of a body `[from, to)`; nested declarations recurse. */
body(from: number, to: number, into: ClassDecl): void {
let annotations: Annotation[] = [];
let header: Tok[] = [];
let declStart = -1;
const reset = () => {
annotations = [];
header = [];
declStart = -1;
};
const flushField = (init: Expr | null) => {
const ids = header.filter((t) => t.kind === "id");
const kw = header.findIndex((t) => t.kind === "id" && (t.text === "val" || t.text === "var"));
const isStatic = into.isObject || ids.some((t) => t.text === "static" || t.text === "const");
if (kw >= 0) {
const name = header[kw + 1];
const colon = header.findIndex((t, k) => k > kw && t.text === ":" && t.kind === "p");
if (name?.kind === "id")
into.fields.push({
name: name.text,
type: colon >= 0 ? joined(header.slice(colon + 1)) : "",
isStatic,
init,
});
} else {
const typeToks = header.filter((t) => !(t.kind === "id" && MODIFIERS.has(t.text)));
const name = typeToks[typeToks.length - 1];
if (name?.kind === "id" && typeToks.length >= 2)
into.fields.push({ name: name.text, type: joined(typeToks.slice(0, -1)), isStatic, init });
}
};
let i = from;
while (i < to) {
const t = this.toks[i]!;
if (header.length && this.startsMember(i) && !this.is(i, "}")) {
flushField(null);
reset();
}
const a = this.annotation(i);
if (a) {
if (declStart < 0) declStart = t.start;
annotations.push(a[0]);
i = a[1];
continue;
}
if (t.kind === "p" && t.text === ";") {
flushField(null);
reset();
i++;
continue;
}
if (declStart < 0) declStart = t.start;
const companion = t.kind === "id" && t.text === "object" && this.is(i + 1, "{");
if (t.kind === "id" && CLASS_KW.has(t.text) && (this.toks[i + 1]?.kind === "id" || companion)) {
const nameTok = companion ? t : this.toks[i + 1]!;
const cls: ClassDecl = {
name: companion ? "Companion" : nameTok.text,
annotations,
nameStart: nameTok.start,
start: declStart,
end: nameTok.end,
isObject: t.text === "object",
methods: [],
fields: [],
ctorParams: [],
classes: [],
};
let j = companion ? i + 1 : i + 2;
if (this.is(j, "<")) {
let depth = 0;
for (; j < to; j++) {
if (this.is(j, "<")) depth++;
else if (this.is(j, ">") && --depth === 0) break;
}
j++;
}
// Kotlin `class A @Inject constructor(…)`.
while (j < to && (this.is(j, "@") || this.toks[j]?.kind === "id") && !this.toks[j]!.nl) {
const ann = this.annotation(j);
if (ann) j = ann[1];
else if (this.toks[j]!.kind === "id" && (MODIFIERS.has(this.toks[j]!.text) || this.is(j, "constructor"))) j++;
else break;
}
if (this.is(j, "(")) {
cls.ctorParams = this.params(j);
j = this.match[j]! + 1;
}
for (; j < to; j++) {
if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break;
if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!;
}
if (this.is(j, "{")) {
const close = Math.min(this.match[j]!, to);
this.body(j + 1, close, cls);
cls.end = this.toks[close]?.end ?? this.toks[to - 1]!.end;
j = close + 1;
} else cls.end = this.toks[j - 1]!.end;
into.classes.push(cls);
reset();
i = j;
continue;
}
if (
t.kind === "id" &&
this.is(i + 1, "(") &&
!NOT_METHOD.has(t.text) &&
!header.some((h) => h.kind === "p" && (h.text === "=" || h.text === "."))
) {
const params = this.params(i + 1);
let j = this.match[i + 1]! + 1;
for (; j < to; j++) {
if (this.is(j, "{")) {
j = this.match[j]! + 1;
break;
}
if (this.is(j, ";")) {
j++;
break;
}
if (this.startsMember(j)) break;
if (this.toks[j]!.kind === "p" && OPEN[this.toks[j]!.text]) j = this.match[j]!;
}
const end = this.toks[Math.min(j, this.toks.length) - 1]?.end ?? t.end;
into.methods.push({ name: t.text, annotations, params, nameStart: t.start, start: declStart, end });
reset();
i = j;
continue;
}
if (t.kind === "p" && t.text === "=") {
const init: Tok[] = [];
let j = i + 1;
for (; j < to; j++) {
if (this.is(j, ";") || this.is(j, "}")) break;
const prev = this.toks[j - 1]!;
const operand = prev.kind === "p" && /[+.(,=]/.test(prev.text);
if (j > i + 1 && this.startsMember(j) && !operand) break;
if (j > i + 1 && this.toks[j]!.nl && !operand && !/^[+.]$/.test(this.toks[j]!.text)) break;
const tok = this.toks[j]!;
if (tok.kind === "p" && OPEN[tok.text]) {
const close = Math.min(this.match[j]!, to - 1);
for (let k = j; k <= close; k++) init.push(this.toks[k]!);
j = close;
} else init.push(tok);
}
flushField(init);
reset();
i = this.is(j, ";") ? j + 1 : j;
continue;
}
if (t.kind === "p" && OPEN[t.text]) {
// Initializer blocks, enum constant arguments…
i = this.match[i]! + 1;
if (t.text === "{") reset();
continue;
}
if (t.kind === "p" && t.text === ",") {
// Enum constants / `int a, b;`.
reset();
i++;
continue;
}
header.push(t);
i++;
}
if (header.length) flushField(null);
}
}
function joined(toks: Tok[]): string {
return toks.map((t) => t.text).join("");
}
export function parseSource(text: string): SourceFile {
const toks = tokenize(text);
const r = new Reader(toks);
let packageName = "";
const imports: string[] = [];
let i = 0;
// `package a.b;` / `import a.b.C;` (Kotlin: no semicolons; `import a.b.C as D`).
for (;;) {
const t = toks[i];
if (!t || t.kind !== "id" || (t.text !== "package" && t.text !== "import")) {
if (t && r.is(i, "@") && toks[i + 1]?.text === "file") {
const a = r.annotation(i);
if (a) {
i = a[1];
continue;
}
}
break;
}
let j = i + 1;
let name = "";
while (j < toks.length && !toks[j]!.nl && !r.is(j, ";")) {
if (toks[j]!.kind === "id" && toks[j]!.text === "as") break;
name += toks[j]!.text;
j++;
}
while (j < toks.length && !toks[j]!.nl && !r.is(j, ";")) j++;
if (r.is(j, ";")) j++;
if (t.text === "package") packageName = name;
else imports.push(name.replace(/^static/, ""));
i = j;
}
const root: ClassDecl = {
name: "",
annotations: [],
nameStart: 0,
start: 0,
end: text.length,
isObject: true,
methods: [],
fields: [],
ctorParams: [],
classes: [],
};
r.body(i, toks.length, root);
return { packageName, imports, classes: root.classes, fields: root.fields };
}
/** Every class of the file, nested ones included. */
export function allClasses(file: SourceFile): ClassDecl[] {
const out: ClassDecl[] = [];
const walk = (cs: ClassDecl[]) => {
for (const c of cs) {
out.push(c);
walk(c.classes);
}
};
walk(file.classes);
return out;
}
@@ -0,0 +1,302 @@
import { describe, expect, it } from "vitest";
import { springAt, springControllers, type Endpoint } from "./endpoints";
import {
appendRequests,
bodyLookup,
findHost,
hostFromConfig,
parseProperties,
parseYaml,
renderRequest,
skeleton,
type SourceReader,
} from "./request";
import { parseSource } from "./source";
const JAVA = `package com.example.demo.web;
import com.example.demo.dto.CreateUser;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
/** Users { not a brace } */
@RestController
@RequestMapping(UserController.BASE + "/users")
public class UserController {
static final String BASE = "/api";
private static final String BY_ID = "/{id}";
private final UserService service;
public UserController(UserService service) { this.service = service; }
@GetMapping
public List<User> list(@RequestParam(defaultValue = "0") int page, @RequestParam("q") String query,
@RequestParam Map<String, String> all) {
return service.list(); // "}" in a comment
}
@GetMapping(BY_ID)
public User get(@PathVariable Long id, @RequestHeader("X-Trace") String trace) {
if (id == null) { throw new IllegalArgumentException("{"); }
return service.get(id);
}
@PostMapping(value = {"", "/new"}, consumes = MediaType.APPLICATION_JSON_VALUE, produces = "application/json")
public User create(@Valid @RequestBody CreateUser body) { return service.create(body); }
@RequestMapping(path = "/{id:\\\\d+}/avatar", method = RequestMethod.PUT)
public void avatar(@PathVariable("id") long id, @RequestBody byte[] data) {}
@DeleteMapping(path = BY_ID, params = "force=true", headers = "X-Api=1")
public void delete(@PathVariable Long id) {}
@org.springframework.web.bind.annotation.PatchMapping("/{id}/name")
public void rename(@PathVariable Long id, @RequestBody Rename rename) {}
public void helper() {}
record Rename(String name, boolean notify) {}
}
`;
const KOTLIN = `package com.example.demo.web
import org.springframework.web.bind.annotation.*
const val API = "/api/v2"
@RestController
@RequestMapping(API)
class OrderController(private val service: OrderService) {
companion object {
const val ORDERS = "/orders"
}
@GetMapping("\${ORDERS}/{id}", ORDERS + "/by-id/{id}")
fun get(@PathVariable id: Long): Order = service.get(id)
@PostMapping(ORDERS)
suspend fun create(@RequestBody order: NewOrder, @RequestParam(required = false) dryRun: Boolean?): Order {
return service.create(order)
}
@RequestMapping(value = [ORDERS + "/search"], method = [RequestMethod.POST, RequestMethod.GET])
fun search(@RequestParam("q") query: String) = service.search(query)
}
data class NewOrder(val item: String, val quantity: Int, val tags: List<String> = emptyList())
`;
function byMethod(endpoints: Endpoint[], name: string): Endpoint {
const e = endpoints.find((x) => x.methodName === name);
if (!e) throw new Error(`no endpoint ${name}`);
return e;
}
describe("Spring mappings (Java)", () => {
const [ctrl] = springControllers(JAVA);
const eps = ctrl!.endpoints;
it("finds the controller and its mapped methods only", () => {
expect(ctrl!.name).toBe("UserController");
expect(eps.map((e) => e.methodName)).toEqual(["list", "get", "create", "avatar", "delete", "rename"]);
expect(JAVA.slice(byMethod(eps, "get").offset).startsWith("get(")).toBe(true);
expect(JAVA.slice(ctrl!.offset).startsWith("UserController {")).toBe(true);
});
it("combines class and method paths, resolving constants of the file", () => {
expect(byMethod(eps, "list").paths).toEqual(["/api/users"]);
expect(byMethod(eps, "get").paths).toEqual(["/api/users/{id}"]);
expect(byMethod(eps, "create").paths).toEqual(["/api/users", "/api/users/new"]);
expect(byMethod(eps, "rename").paths).toEqual(["/api/users/{id}/name"]);
});
it("reads HTTP methods from shortcuts and RequestMapping#method", () => {
expect(eps.map((e) => e.httpMethod)).toEqual(["GET", "GET", "POST", "PUT", "DELETE", "PATCH"]);
});
it("reads request parameters, headers, bodies, consumes and produces", () => {
expect(byMethod(eps, "list").query).toEqual([
{ name: "page", value: "0" },
{ name: "q", value: null },
]);
expect(byMethod(eps, "get").headers).toEqual([{ name: "X-Trace", value: "{{X-Trace}}" }]);
const create = byMethod(eps, "create");
expect(create).toMatchObject({
bodyType: "CreateUser",
consumes: "application/json",
produces: "application/json",
});
expect(byMethod(eps, "delete")).toMatchObject({
query: [{ name: "force", value: "true" }],
headers: [{ name: "X-Api", value: "1" }],
});
});
it("renders requests with path variables, query and JSON body", () => {
const host = "http://localhost:8080";
expect(renderRequest(byMethod(eps, "list"), { host })).toBe(
"### UserController.list\nGET http://localhost:8080/api/users?page=0&q={{q}}\n",
);
expect(renderRequest(byMethod(eps, "get"), { host })).toBe(
"### UserController.get\nGET http://localhost:8080/api/users/{{id}}\nX-Trace: {{X-Trace}}\n",
);
expect(renderRequest(byMethod(eps, "avatar"), { host })).toBe(
"### UserController.avatar\nPUT http://localhost:8080/api/users/{{id}}/avatar\nContent-Type: application/json\n\n{}\n",
);
const file = parseSource(JAVA);
const lookup = (n: string) => file.classes.flatMap((c) => c.classes).find((c) => c.name === n) ?? null;
const rename = byMethod(eps, "rename");
expect(renderRequest(rename, { host, body: skeleton(rename.bodyType!, lookup) })).toBe(
[
"### UserController.rename",
"PATCH http://localhost:8080/api/users/{{id}}/name",
"Content-Type: application/json",
"",
"{",
' "name": "",',
' "notify": false',
"}",
"",
].join("\n"),
);
expect(renderRequest(byMethod(eps, "create"), { host, path: "/api/users/new" })).toBe(
[
"### UserController.create",
"POST http://localhost:8080/api/users/new",
"Accept: application/json",
"Content-Type: application/json",
"",
"{}",
"",
].join("\n"),
);
});
});
describe("Spring mappings (Kotlin)", () => {
const [ctrl] = springControllers(KOTLIN);
const eps = ctrl!.endpoints;
it("finds mappings with companion and top-level constants, arrays and suspend functions", () => {
expect(ctrl!.name).toBe("OrderController");
expect(eps.map((e) => [e.methodName, e.httpMethod, e.paths])).toEqual([
["get", "GET", ["/api/v2/orders/{id}", "/api/v2/orders/by-id/{id}"]],
["create", "POST", ["/api/v2/orders"]],
["search", "POST", ["/api/v2/orders/search"]],
]);
expect(byMethod(eps, "create")).toMatchObject({ bodyType: "NewOrder", query: [{ name: "dryRun", value: null }] });
expect(byMethod(eps, "search").query).toEqual([{ name: "q", value: null }]);
});
it("builds the body from a data class of the file", async () => {
const file = parseSource(KOTLIN);
const lookup = await bodyLookup("/p/src/main/kotlin/com/example/demo/web/OrderController.kt", file, eps, {
read: async () => null,
});
expect(skeleton("NewOrder", lookup)).toEqual({ item: "", quantity: 0, tags: [] });
});
});
describe("non-controllers", () => {
it("ignores classes that are not controllers and files without any", () => {
expect(springControllers('@Service class A { @GetMapping("/x") void a() {} }')).toEqual([]);
expect(springControllers("class A {}")).toEqual([]);
expect(
springControllers('@FeignClient interface C { @GetMapping("/x") String a(); }\n@Controller class B {}'),
).toEqual([{ name: "B", offset: expect.any(Number), range: expect.any(Object), endpoints: [] }]);
});
it("uses GET for a RequestMapping without method and the class-level method otherwise", () => {
const [c] = springControllers(
'@Controller @RequestMapping(value="/a", method=RequestMethod.POST) class A { @RequestMapping("/b") String b() { return ""; } }',
);
expect(c!.endpoints[0]).toMatchObject({ httpMethod: "POST", paths: ["/a/b"] });
const [d] = springControllers('@RestController class D { @RequestMapping("/b") String b() { return ""; } }');
expect(d!.endpoints[0]).toMatchObject({ httpMethod: "GET", paths: ["/b"] });
});
});
describe("server address", () => {
it("reads server.port and the context path from properties", () => {
const props = parseProperties(
"# comment\nserver.port=9090\nserver.servlet.context-path = /shop/\nspring.application.name: demo\n",
);
expect(hostFromConfig([props])).toBe("http://localhost:9090/shop");
});
it("reads nested and flattened keys from YAML's first document", () => {
const yml = parseYaml(
'spring:\n application:\n name: demo # x\nserver:\n port: ${PORT:8181}\n servlet:\n context-path: "/api"\n---\nserver:\n port: 1\n',
);
expect(hostFromConfig([yml])).toBe("http://localhost:8181/api");
expect(hostFromConfig([parseYaml("server.port: 7070\n")])).toBe("http://localhost:7070");
});
it("defaults to localhost:8080 and keeps unknown placeholders out", () => {
expect(hostFromConfig([])).toBe("http://localhost:8080");
expect(hostFromConfig([parseProperties("server.port=${PORT}\n")])).toBe("http://localhost:8080");
expect(hostFromConfig([parseProperties("server.port=0\n")])).toBe("http://localhost:8080");
expect(hostFromConfig([parseProperties("server.port=8443\nserver.ssl.enabled=true\n")])).toBe(
"https://localhost:8443",
);
expect(hostFromConfig([parseYaml("spring:\n webflux:\n base-path: /rx\n")])).toBe("http://localhost:8080/rx");
});
it("looks in the module's src/main/resources", async () => {
const files: Record<string, string> = {
"/ws/api/src/main/resources/application.yml": "server:\n port: 8001\n",
"/ws/src/main/resources/application.properties": "server.port=9000\n",
};
const fs: SourceReader = { read: async (p) => files[p] ?? null };
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:8001");
expect(await findHost("/ws/other/A.java", "/ws", fs)).toBe("http://localhost:9000");
});
});
describe("body types from other files", () => {
it("follows the import to the same source root, then the controller's directory", async () => {
const files: Record<string, string> = {
"/p/src/main/java/com/example/demo/dto/CreateUser.java":
"package com.example.demo.dto;\npublic class CreateUser {\n public static final int MAX = 3;\n private String name;\n private int age;\n private Address address;\n private List<String> roles;\n}\n",
"/p/src/main/java/com/example/demo/dto/Address.java":
"package com.example.demo.dto;\npublic record Address(String city, String zip) {}\n",
};
const fs: SourceReader = { read: async (p) => files[p] ?? null };
const file = parseSource(JAVA);
const eps = springControllers(JAVA)[0]!.endpoints;
const lookup = await bodyLookup("/p/src/main/java/com/example/demo/web/UserController.java", file, eps, fs);
expect(skeleton("CreateUser", lookup)).toEqual({ name: "", age: 0, address: { city: "", zip: "" }, roles: [] });
expect(skeleton("Mono<CreateUser>", lookup)).toMatchObject({ name: "" });
expect(skeleton("List<CreateUser>", lookup)).toEqual([]);
expect(skeleton("Unknown", lookup)).toEqual({});
});
});
describe("appending to the request file", () => {
const block = "### A.a\nGET http://localhost:8080/a\n";
it("starts an empty file with the request", () => {
expect(appendRequests("", [block])).toEqual({ insert: block, from: 0, caret: 8 });
});
it("separates from existing requests with a blank line before ###", () => {
const existing = "### X\nGET http://x\n";
const r = appendRequests(existing, [block, block]);
expect(existing + r.insert).toBe(`${existing}\n${block}\n${block}`);
expect((existing + r.insert).slice(r.caret).startsWith("GET http://localhost:8080/a")).toBe(true);
expect(appendRequests("GET http://x", [block]).insert.startsWith("\n\n###")).toBe(true);
expect(appendRequests("GET http://x\n\n", [block]).insert.startsWith("###")).toBe(true);
});
});
describe("caret lookup", () => {
it("finds the endpoint whose declaration (annotations included) holds the caret, and its controller", () => {
const controllers = springControllers(JAVA);
const at = (needle: string) => springAt(controllers, JAVA.indexOf(needle));
expect(at("@GetMapping(BY_ID)").endpoint?.methodName).toBe("get");
expect(at("service.get(id)").endpoint?.methodName).toBe("get");
expect(at("public void helper").endpoint).toBeNull();
expect(at("public void helper").controller?.name).toBe("UserController");
expect(springAt(controllers, 0)).toEqual({ controller: null, endpoint: null });
});
});
+5
View File
@@ -194,6 +194,7 @@
"Cannot convert encoding": "无法转换编码",
"Cannot create scratch file": "无法创建临时文件",
"Cannot create the HTTP request file": "无法创建 HTTP 请求文件",
"Cannot generate the HTTP request": "无法生成 HTTP 请求",
"Cannot duplicate the data source": "无法复制数据源",
"Cannot execute": "无法执行",
"Cannot execute on {target}": "无法在 {target} 上执行",
@@ -790,6 +791,8 @@
"Generate": "生成",
"Generate DDL to Clipboard": "生成 DDL 到剪贴板",
"Generate DDL to Query Console": "生成 DDL 到查询控制台",
"Generate Request in HTTP Client": "在 HTTP 客户端中生成请求",
"Generate Requests for Controller": "为控制器生成请求",
"Generate…": "生成…",
"Get from Version Control": "从版本控制获取",
"Get from Version Control…": "从版本控制获取…",
@@ -1167,6 +1170,8 @@
"No GitHub repository": "没有 GitHub 仓库",
"No Gradle projects": "没有 Gradle 项目",
"No HTTP request at the caret": "光标处没有 HTTP 请求",
"No Spring controller at the caret": "光标处没有 Spring 控制器",
"No Spring request mapping at the caret": "光标处没有 Spring 请求映射",
"No Maven projects": "没有 Maven 项目",
"No profile for '{name}'": "没有 '{name}' 的分析数据",
"No Python found": "未找到 Python",