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:
Shohruh Ismoilovandshohruh-genius authored and GitHub committed 2026-11-08 21:52:18 +08:00
1 parent c1e5db9511
commit 99100e48bd
5 files changed
+290 -11

No files matched your search

+55
View File
@@ -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),
],
});
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module" src="./httpHighlight.fixture.ts"></script>
</body>
</html>
+48
View File
@@ -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([]);
});
}
}
+132 -1
View File
@@ -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);
});
+46 -10
View File
@@ -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: "#" } },