mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(http-client): Generate HTTP requests from Spring Web mappings
Java and Kotlin controllers (@RestController / @Controller) get a gutter
icon on every request mapping and on the class. Its menu, the editor
context menu and Show Context Actions offer "Generate Request in HTTP
Client" (http.generateSpringRequest) and "Generate Requests for
Controller" (http.generateSpringControllerRequests).
Requests are appended to the generated-requests.http scratch (created on
first use, ### separated), which opens with the caret on the new request:
- class + method paths (several paths: a choice in the gutter menu),
@RequestMapping#method or the shortcut annotations, {var} -> {{var}};
- @RequestParam -> query, @RequestHeader / headers= -> headers, params=;
- consumes / produces -> Content-Type / Accept, @RequestBody -> JSON body
with the fields of the body type (same file, or the imported / sibling
source file, one nested level);
- server.port, context path and SSL from application.properties / .yml of
the module, else http://localhost:8080;
- static final / const val constants of the same file, Kotlin templates.
Sources are read by a small declaration-level Java/Kotlin reader
(annotations, classes, methods, fields), no new dependencies. Features can
now contribute context actions to the intentions popup through the
intentionProviders facet.
Fixes #100
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
@@ -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,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),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user