From 14f72fc3075cddba47741465ccd6a019bf905ff4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:54:48 +0800 Subject: [PATCH] feat(http-client): Generate requests from Spring Web mappings (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/editor/api.ts | 22 + src/editor/lsp/intentions.ts | 13 +- src/features/http-client/index.ts | 32 +- src/features/http-client/spring/editor.ts | 346 +++++++++++ src/features/http-client/spring/endpoints.ts | 297 ++++++++++ src/features/http-client/spring/request.ts | 254 ++++++++ src/features/http-client/spring/source.ts | 560 ++++++++++++++++++ .../http-client/spring/spring.test.ts | 302 ++++++++++ src/platform/locales/text/zh.json | 5 + 9 files changed, 1824 insertions(+), 7 deletions(-) create mode 100644 src/features/http-client/spring/editor.ts create mode 100644 src/features/http-client/spring/endpoints.ts create mode 100644 src/features/http-client/spring/request.ts create mode 100644 src/features/http-client/spring/source.ts create mode 100644 src/features/http-client/spring/spring.test.ts diff --git a/src/editor/api.ts b/src/editor/api.ts index 060ec4ac..78892027 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -283,6 +283,28 @@ export function contributedAt(state: EditorState, key: string, pos: number): return state.languageDataAt(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; +} + +/** 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. */ diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index f459e77c..7894f3c4 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -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), }); diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index 4910ce32..578ab673 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -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", diff --git a/src/features/http-client/spring/editor.ts b/src/features/http-client/spring/editor.ts new file mode 100644 index 00000000..4d31d13f --- /dev/null +++ b/src/features/http-client/spring/editor.ts @@ -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 { + 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 { + 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 { + 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(); + +const marksField = StateField.define({ + 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 | 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; + }, + }, + }), + ]; +} diff --git a/src/features/http-client/spring/endpoints.ts b/src/features/http-client/spring/endpoints.ts new file mode 100644 index 00000000..7a9ef125 --- /dev/null +++ b/src/features/http-client/spring/endpoints.ts @@ -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 = { + GetMapping: "GET", + PostMapping: "POST", + PutMapping: "PUT", + DeleteMapping: "DELETE", + PatchMapping: "PATCH", +}; + +const CONTROLLER = new Set(["RestController", "Controller"]); + +const MEDIA_TYPES: Record = { + 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(); + 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 | 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 | 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 }; +} diff --git a/src/features/http-client/spring/request.ts b/src/features/http-client/spring/request.ts new file mode 100644 index 00000000..6678a6b6 --- /dev/null +++ b/src/features/http-client/spring/request.ts @@ -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 { + const out = new Map(); + 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 { + const out = new Map(); + 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:` from Spring Boot configuration (first file with a value wins). */ +export function hostFromConfig(configs: Map[]): 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`, `Optional`, `HttpEntity`, `@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; +} + +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 { + const configs: Map[] = []; + 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 + * `.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(); + 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; +} diff --git a/src/features/http-client/spring/source.ts b/src/features/http-client/spring/source.ts new file mode 100644 index 00000000..068fc23d --- /dev/null +++ b/src/features/http-client/spring/source.ts @@ -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; +} + +export interface Param { + name: string; + /** Type as written, without spaces (`List`); "" 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 = { "(": ")", "[": "]", "{": "}" }; + +/** 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(); + 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; +} diff --git a/src/features/http-client/spring/spring.test.ts b/src/features/http-client/spring/spring.test.ts new file mode 100644 index 00000000..9f7bca8f --- /dev/null +++ b/src/features/http-client/spring/spring.test.ts @@ -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 list(@RequestParam(defaultValue = "0") int page, @RequestParam("q") String query, + @RequestParam Map 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 = 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 = { + "/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 = { + "/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 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", lookup)).toMatchObject({ name: "" }); + expect(skeleton("List", 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 }); + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index cb52c840..d2c6a1b2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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",