diff --git a/e2e/httpHighlight.fixture.ts b/e2e/httpHighlight.fixture.ts new file mode 100644 index 000000000..568db9312 --- /dev/null +++ b/e2e/httpHighlight.fixture.ts @@ -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), + ], +}); diff --git a/e2e/httpHighlight.html b/e2e/httpHighlight.html new file mode 100644 index 000000000..fab1a8bb3 --- /dev/null +++ b/e2e/httpHighlight.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/e2e/httpHighlight.spec.ts b/e2e/httpHighlight.spec.ts new file mode 100644 index 000000000..3ae6e7678 --- /dev/null +++ b/e2e/httpHighlight.spec.ts @@ -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([]); + }); + } +} diff --git a/src/features/http-client/httpClient.test.ts b/src/features/http-client/httpClient.test.ts index 5bb4ba7c2..52a1114f5 100644 --- a/src/features/http-client/httpClient.test.ts +++ b/src/features/http-client/httpClient.test.ts @@ -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", 'text: [one,two]'], + ["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); }); diff --git a/src/features/http-client/language.ts b/src/features/http-client/language.ts index bba6cac1a..a701a7b0a 100644 --- a/src/features/http-client/language.ts +++ b/src/features/http-client/language.ts @@ -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["token"]>[0]): string | export const httpParser: StreamParser = { 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 = { // 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 = { 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.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 = { 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 = { 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: "#" } },