mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
fix(http): recognize request lines and highlight JSON bodies (#2142)
`.http` and `.rest` files left gRPC, WebSocket and custom-method requests unhighlighted: the parser waited for one of nine HTTP verbs, so the headers and body after any other request line stayed plain, and JSON bodies had only partial token colours.
The parser now recognises these request lines and bare URL targets, and gives JSON keys, values and punctuation their editor roles, including `{{variables}}` inside strings.
Siblings covered: an upper-case word that is not a request line stays plain text, and JSON highlighting does not reach form, XML, text or GraphQL bodies. Run gutter positions are unchanged.
Co-authored-by: shohruh-genius <[email protected]>
This commit is contained in:
1 parent
c1e5db9511
commit
99100e48bd
5 files changed
+290
-11
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import "../src/styles/tokens.css";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import { registerLanguageMode } from "../src/editor/api";
|
||||
import { openideaHighlightStyle } from "../src/editor/core/cm/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages";
|
||||
import { httpLanguageDescription } from "../src/features/http-client/language";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
document.documentElement.dataset.theme = params.get("theme") ?? "dark";
|
||||
document.body.style.backgroundColor = "var(--bg-editor)";
|
||||
document.body.style.color = "var(--fg-editor)";
|
||||
registerLanguageMode(httpLanguageDescription);
|
||||
const file = params.has("rest") ? "requests.rest" : "requests.http";
|
||||
const support = await loadSupport(findLanguageDescription("HTTP", file)!);
|
||||
const http = document.createElement("div");
|
||||
http.id = "http";
|
||||
document.body.append(http);
|
||||
new EditorView({
|
||||
parent: http,
|
||||
doc: `### gRPC request with a JSON body
|
||||
GRPC localhost:9003/example.Greeter/SayHello
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "World",
|
||||
"count": 12,
|
||||
"enabled": true,
|
||||
"empty": null,
|
||||
"items": [1, 2, 3]
|
||||
}
|
||||
|
||||
### WebSocket message
|
||||
WEBSOCKET ws://localhost:8080/events
|
||||
|
||||
{"message": "hello", "ready": false}
|
||||
|
||||
### Regular HTTP request
|
||||
POST https://example.test/messages
|
||||
|
||||
{"message": "HTTP still works", "count": 1}`,
|
||||
extensions: [support!, syntaxHighlighting(openideaHighlightStyle)],
|
||||
});
|
||||
|
||||
const json = document.createElement("div");
|
||||
json.id = "json";
|
||||
document.body.append(json);
|
||||
new EditorView({
|
||||
parent: json,
|
||||
doc: '{"name": "World", "count": 12, "enabled": true, "ready": false, "empty": null, "items": [1]}',
|
||||
extensions: [
|
||||
await loadSupport(findLanguageDescription("JSON", "body.json")!),
|
||||
syntaxHighlighting(openideaHighlightStyle),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./httpHighlight.fixture.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const extension of ["http", "rest"] as const) {
|
||||
test(`highlights request lines and JSON bodies in ${extension} files (${theme})`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(`/e2e/httpHighlight.html?theme=${theme}${extension === "rest" ? "&rest" : ""}`);
|
||||
const token = (text: string) =>
|
||||
page
|
||||
.locator("#http .cm-line span")
|
||||
.filter({ hasText: new RegExp(`^${text}$`) })
|
||||
.first();
|
||||
const keyword = theme === "light" ? "rgb(0, 51, 179)" : "rgb(207, 142, 109)";
|
||||
const property = theme === "light" ? "rgb(135, 16, 148)" : "rgb(199, 125, 187)";
|
||||
const string = theme === "light" ? "rgb(6, 125, 23)" : "rgb(106, 171, 115)";
|
||||
for (const method of ["GRPC", "WEBSOCKET", "POST"]) await expect(token(method)).toHaveCSS("color", keyword);
|
||||
await expect(token('"name"')).toHaveCSS("color", property);
|
||||
await expect(token('"World"')).toHaveCSS("color", string);
|
||||
await expect(token("12")).toHaveCSS("color", theme === "light" ? "rgb(23, 80, 235)" : "rgb(42, 172, 184)");
|
||||
for (const literal of ["true", "false", "null"]) await expect(token(literal)).toHaveCSS("color", keyword);
|
||||
await expect(token("Content-Type")).toHaveCSS("color", property);
|
||||
for (const text of ['"name"', '"World"', "12", "true", "false", "null", "{", "[", ":", ","]) {
|
||||
const color = async (editor: string) =>
|
||||
page.locator(`${editor} .cm-line`).evaluateAll((lines, target) => {
|
||||
for (const line of lines) {
|
||||
if (!/^\s*["{[]/.test(line.textContent ?? "")) continue;
|
||||
const index = line.textContent?.indexOf(target) ?? -1;
|
||||
if (index < 0) continue;
|
||||
const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT);
|
||||
let offset = 0;
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (offset + (node.textContent?.length ?? 0) > index)
|
||||
return getComputedStyle(node.parentElement!).color;
|
||||
offset += node.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, text);
|
||||
const jsonColor = await color("#json");
|
||||
expect(jsonColor, `JSON color for ${text}`).not.toBeNull();
|
||||
expect(await color("#http"), `HTTP color for ${text}`).toBe(jsonColor);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,137 @@ describe("http highlighting", () => {
|
||||
return out;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["GRPC", "localhost:9003/example.Greeter/SayHello"],
|
||||
["WEBSOCKET", "ws://localhost:8080/events"],
|
||||
["PROPFIND", "https://example.test/collection"],
|
||||
["M-SEARCH", "http://example.test/devices"],
|
||||
])("highlights %s request lines and their JSON bodies", (method, url) => {
|
||||
const t = tokens(
|
||||
`### Request with body\n${method} ${url}\nContent-Type: application/json\n\n{"name": "World", "count": 12, "enabled": true}\n`,
|
||||
);
|
||||
expect(t).toContainEqual(["keyword", method]);
|
||||
expect(t).toContainEqual(["link", url]);
|
||||
expect(t).toContainEqual(["propertyName", "Content-Type"]);
|
||||
expect(t).toContainEqual(["propertyName", '"name"']);
|
||||
expect(t).toContainEqual(["string", '"World"']);
|
||||
expect(t).toContainEqual(["number", "12"]);
|
||||
expect(t).toContainEqual(["bool", "true"]);
|
||||
});
|
||||
|
||||
it.each(["https://example.test/items", "/items", "{{endpoint}}", "{{host}}/items"])(
|
||||
"recognizes a bare request target %s before entering the body",
|
||||
(url) => {
|
||||
const t = tokens(`${url}\n\n{"count": 12, "enabled": false}\n`);
|
||||
expect(t[0]?.[0]).toContain(url.startsWith("{{") ? "variableName" : "link");
|
||||
expect(t).toContainEqual(["propertyName", '"count"']);
|
||||
expect(t).toContainEqual(["number", "12"]);
|
||||
expect(t).toContainEqual(["bool", "false"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("resets request state across protocol changes and keeps variable declarations", () => {
|
||||
const t = tokens(
|
||||
'@host = localhost\n### RPC\nGRPC {{host}}:9003/example.Greeter/SayHello\n\n{"count": 1}\n### HTTP\nPOST https://example.test/messages\n\n{"count": 2}\n### Socket\nWEBSOCKET ws://{{host}}/events\n\n{"count": 3}',
|
||||
);
|
||||
expect(t.filter(([name]) => name === "keyword").map(([, text]) => text)).toEqual(["GRPC", "POST", "WEBSOCKET"]);
|
||||
expect(t.filter(([name]) => name === "number").map(([, text]) => text)).toEqual(["1", "2", "3"]);
|
||||
expect(t.some(([name, text]) => name.includes("variableName") && text === "@host")).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes JSON keys, values, arrays and primitive literals", () => {
|
||||
const t = tokens(
|
||||
'POST https://example.test/items\n\n{"name": "World", "fraction": -1.25e+3, "enabled": true, "empty": null, "items": [1, false, {"nested": "yes"}]}',
|
||||
);
|
||||
expect(t).toContainEqual(["propertyName", '"name"']);
|
||||
expect(t).toContainEqual(["string", '"World"']);
|
||||
expect(t).toContainEqual(["propertyName", '"nested"']);
|
||||
expect(t).toContainEqual(["number", "-1.25e+3"]);
|
||||
expect(t).toContainEqual(["bool", "true"]);
|
||||
expect(t).toContainEqual(["bool", "false"]);
|
||||
expect(t).toContainEqual(["null", "null"]);
|
||||
expect(t.some(([name, text]) => name === "brace" && text.includes("{"))).toBe(true);
|
||||
expect(t).toContainEqual(["squareBracket", "["]);
|
||||
expect(t.some(([name, text]) => name === "separator" && text.includes(":"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["TODO something", "HOST: x", "WORD", "TODO", "TODO ", "TODO something\nHOST: x"])(
|
||||
"ignores non-request preamble lines: %s",
|
||||
(preamble) => {
|
||||
const t = tokens(
|
||||
`${preamble}\nPOST https://example.test/items HTTP/1.1\nContent-Type: application/json\n\n{"ok": true}`,
|
||||
);
|
||||
expect(t).toContainEqual(["keyword", "POST"]);
|
||||
expect(t).not.toContainEqual(["keyword", "TODO"]);
|
||||
expect(t).toContainEqual(["meta", " HTTP/1.1"]);
|
||||
expect(t).toContainEqual(["propertyName", "Content-Type"]);
|
||||
expect(t).toContainEqual(["propertyName", '"ok"']);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps comments, continued queries and separator resets", () => {
|
||||
const t = tokens(
|
||||
"TODO something\n### Request\n# comment\n// comment\n{{host}}/items HTTP/1.1\n ?q={{query}}\n &limit=12\n# header comment\nAccept: application/json\n\n{}\n### Next\nGET /next\n\n[]",
|
||||
);
|
||||
expect(t).toContainEqual(["meta", " HTTP/1.1"]);
|
||||
expect(t).toContainEqual(["link", "/items"]);
|
||||
expect(t.some(([name, text]) => name === "link" && text.includes("&limit=12"))).toBe(true);
|
||||
expect(t).toContainEqual(["comment", "# header comment"]);
|
||||
expect(t).toContainEqual(["propertyName", "Accept"]);
|
||||
expect(t).toContainEqual(["keyword", "GET"]);
|
||||
expect(t).toContainEqual(["link", "/next"]);
|
||||
});
|
||||
|
||||
it("highlights variables inside JSON strings and unquoted values", () => {
|
||||
const t = tokens('POST /items\n\n{"name": "Hello {{name}}!", "count": {{count}}, "escaped": "a\\\"b"}');
|
||||
for (const name of ["name", "count"]) expect(t).toContainEqual(["variableName.special", `{{${name}}}`]);
|
||||
expect(t).toContainEqual(["string", '!"']);
|
||||
expect(t).toContainEqual(["propertyName", '"escaped"']);
|
||||
});
|
||||
|
||||
it("keeps advancing while a JSON string ends with an unfinished escape", () => {
|
||||
expect(() => tokens('POST /items\n\n{"name": "unfinished' + "\\")).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["application/x-www-form-urlencoded", "name=World&items=[one,two]&enabled=true"],
|
||||
["application/xml", '<item name="World">text: [one,two]</item>'],
|
||||
["text/plain", "text: [one,two]"],
|
||||
["application/graphql", "{\n user(id: 12) { name }\n}"],
|
||||
["", "query { user(id: 12) { name } }"],
|
||||
])("keeps non-JSON %s bodies out of JSON punctuation highlighting", (type, body) => {
|
||||
const headers = type ? `Content-Type: ${type}\n` : "";
|
||||
const t = tokens(`POST /items\n${headers}\n${body}`);
|
||||
expect(t.filter(([name]) => ["brace", "squareBracket", "separator"].includes(name))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["GET relative/path", "OPTIONS *", "CONNECT example.test:443"])(
|
||||
"preserves standard request targets: %s",
|
||||
(request) => expect(tokens(`${request}\n\n{}`).some(([name]) => name === "keyword")).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["application/json", "application/problem+json; charset=utf-8"])(
|
||||
"recognizes JSON content type %s and resets it at separators",
|
||||
(type) => {
|
||||
const t = tokens(
|
||||
`POST /one\ncontent-type: ${type}\n\ntrue\n### Two\nPOST /two\nContent-Type: text/plain\n\ntext: [one,two]`,
|
||||
);
|
||||
expect(t).toContainEqual(["bool", "true"]);
|
||||
expect(t.filter(([name]) => ["brace", "squareBracket", "separator"].includes(name))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps input files and response scripts out of JSON highlighting", () => {
|
||||
const t = tokens(
|
||||
'POST /items\n\n< ./payload.json\n> {%\nclient.test("status", function () { client.assert(true); });\n%}\n### Next\nPOST /next\n\n{"ok": true}',
|
||||
);
|
||||
expect(t).toContainEqual(["meta", "< ./payload.json"]);
|
||||
expect(t).toContainEqual(["meta", "> {%"]);
|
||||
expect(t).toContainEqual(["comment", 'client.test("status", function () { client.assert(true); });']);
|
||||
expect(t).toContainEqual(["meta", "%}"]);
|
||||
expect(t).toContainEqual(["propertyName", '"ok"']);
|
||||
});
|
||||
|
||||
it("tags separators, methods, URLs, headers, variables and comments", () => {
|
||||
const t = tokens('### Users\n# @name list\nGET https://{{host}}/users\nAccept: json\n\n{"a": 1}\n');
|
||||
const has = (name: string, text: string) => t.some(([n, s]) => n.includes(name) && s === text);
|
||||
@@ -88,7 +219,7 @@ describe("http highlighting", () => {
|
||||
expect(has("variableName", "{{host}}")).toBe(true);
|
||||
expect(has("propertyName", "Accept")).toBe(true);
|
||||
expect(has("string", " json")).toBe(true);
|
||||
expect(has("string", '"a"')).toBe(true);
|
||||
expect(has("propertyName", '"a"')).toBe(true);
|
||||
expect(has("number", "1")).toBe(true);
|
||||
expect(has("meta", "# @name")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ interface State {
|
||||
phase: "preamble" | "headers" | "body";
|
||||
/** Inside a `> {% … %}` handler script. */
|
||||
handler: boolean;
|
||||
json: boolean | null;
|
||||
string: "propertyName" | "string" | null;
|
||||
/** Position within the current line's structure. */
|
||||
part: "start" | "url" | "headerValue" | "rest";
|
||||
}
|
||||
@@ -27,7 +29,7 @@ function variable(stream: Parameters<StreamParser<State>["token"]>[0]): string |
|
||||
|
||||
export const httpParser: StreamParser<State> = {
|
||||
name: "http",
|
||||
startState: () => ({ phase: "preamble", handler: false, part: "start" }),
|
||||
startState: () => ({ phase: "preamble", handler: false, json: null, string: null, part: "start" }),
|
||||
copyState: (s) => ({ ...s }),
|
||||
blankLine(state) {
|
||||
if (state.phase === "headers" || (state.phase === "preamble" && state.part === "url")) state.phase = "body";
|
||||
@@ -37,10 +39,12 @@ export const httpParser: StreamParser<State> = {
|
||||
// The line after the request line starts the headers.
|
||||
if (state.phase === "preamble" && state.part === "url") state.phase = "headers";
|
||||
state.part = "start";
|
||||
state.string = null;
|
||||
if (stream.match(/^\s*###/)) {
|
||||
stream.skipToEnd();
|
||||
state.phase = "preamble";
|
||||
state.handler = false;
|
||||
state.json = null;
|
||||
return "heading";
|
||||
}
|
||||
}
|
||||
@@ -53,7 +57,10 @@ export const httpParser: StreamParser<State> = {
|
||||
return "comment";
|
||||
}
|
||||
const v = variable(stream);
|
||||
if (v) return v;
|
||||
if (v) {
|
||||
if (state.phase === "preamble" && state.part === "start") state.part = "url";
|
||||
return v;
|
||||
}
|
||||
|
||||
if (state.phase === "preamble") {
|
||||
if (state.part === "start") {
|
||||
@@ -70,17 +77,18 @@ export const httpParser: StreamParser<State> = {
|
||||
state.part = "rest";
|
||||
return "variableName.definition";
|
||||
}
|
||||
const method = stream.match(new RegExp(`^(${METHODS.join("|")})(?=\\s)`));
|
||||
const method =
|
||||
stream.match(new RegExp(`^(${METHODS.join("|")})(?=\\s)`)) ||
|
||||
stream.match(/^[A-Z][A-Z0-9_-]*(?=\s+(?:[\w+.-]+:\/\/|[\w.-]+:\d+(?:[/?#]|\s|$)|\/|\{\{))/);
|
||||
if (method) {
|
||||
state.part = "url";
|
||||
return "keyword";
|
||||
}
|
||||
if (stream.match(/^(https?:\/\/|\/)/, false)) {
|
||||
state.part = "url";
|
||||
if (stream.match(/^(https?:\/\/|\/)/, false)) state.part = "url";
|
||||
else {
|
||||
stream.skipToEnd();
|
||||
return null;
|
||||
}
|
||||
stream.skipToEnd();
|
||||
return null;
|
||||
}
|
||||
if (state.part === "url") {
|
||||
if (stream.match(/^\s+HTTP\/[\d.]+\s*$/)) return "meta";
|
||||
@@ -104,6 +112,8 @@ export const httpParser: StreamParser<State> = {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
const contentType = /^Content-Type:\s*([^;\s]+)/i.exec(stream.string.slice(stream.pos));
|
||||
if (contentType) state.json = /(?:\/|\+)json$/i.test(contentType[1]!);
|
||||
if (stream.match(/^[^:\s]+(?=:)/)) {
|
||||
state.part = "headerValue";
|
||||
return "propertyName";
|
||||
@@ -134,10 +144,36 @@ export const httpParser: StreamParser<State> = {
|
||||
return "meta";
|
||||
}
|
||||
}
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) return "string";
|
||||
if (state.json === null) {
|
||||
if (stream.eatSpace()) return null;
|
||||
const start = stream.string.slice(stream.pos);
|
||||
state.json = /^(?:\[|\{\s*(?:"|}|$)|"|(?:true|false|null|-?\d[\d.eE+-]*)\s*$)/.test(start);
|
||||
}
|
||||
if (!state.json) {
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) return "string";
|
||||
if (stream.match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?\b/)) return "number";
|
||||
if (stream.match(/^(true|false|null)\b/)) return "atom";
|
||||
if (!stream.match(/^[^"{\d\-tfn]+/)) stream.next();
|
||||
return null;
|
||||
}
|
||||
if (!state.string && stream.peek() === '"') {
|
||||
state.string = stream.match(/^"(?:[^"\\]|\\.)*"\s*:/, false) ? "propertyName" : "string";
|
||||
stream.next();
|
||||
}
|
||||
if (state.string) {
|
||||
const style = state.string;
|
||||
stream.match(/^(?:[^"\\{]|\\.|{(?!{))*/);
|
||||
if (stream.eat('"')) state.string = null;
|
||||
if (stream.pos === stream.start) stream.next();
|
||||
return style;
|
||||
}
|
||||
if (stream.match(/^[{}]/)) return "brace";
|
||||
if (stream.match(/^[[\]]/)) return "squareBracket";
|
||||
if (stream.match(/^[,:]/)) return "separator";
|
||||
if (stream.match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?\b/)) return "number";
|
||||
if (stream.match(/^(true|false|null)\b/)) return "atom";
|
||||
if (!stream.match(/^[^"{\d\-tfn]+/)) stream.next();
|
||||
if (stream.match(/^(true|false)\b/)) return "bool";
|
||||
if (stream.match(/^null\b/)) return "null";
|
||||
if (!stream.match(/^[^"{}[\],:\d\-tfn]+/)) stream.next();
|
||||
return null;
|
||||
},
|
||||
languageData: { commentTokens: { line: "#" } },
|
||||
|
||||
Reference in new issue
Block a user