дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02: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]>
Цей коміт міститься в:
1 джерело
c1e5db9511
коміт
99100e48bd
5 files changed
+290
-11
No files matched your search
@@ -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
Заблокувати користувача