diff --git a/packages/react-components/src/components/code-editor.stories.tsx b/packages/react-components/src/components/code-editor.stories.tsx index 6c1e92c..623bcc6 100644 --- a/packages/react-components/src/components/code-editor.stories.tsx +++ b/packages/react-components/src/components/code-editor.stories.tsx @@ -3,6 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import React from "react"; import { Input } from "#shadcn/components/ui/input.js"; import { CodeEditor } from "./code-editor"; +import * as http from "./code-editor/http"; const meta: Meta = { title: "Component/Editor", @@ -36,168 +37,6 @@ export const Default: Story = { ), }; -type ParsedHeader = { - name: string; - nameTrivia: string; - value: string; - valueTrivia: string; -}; - -type Parsed = { - method: string; - methodTrivia: string; - path: string; - pathTrivia: string; - headers: ParsedHeader[]; - headersTrivia: string; -}; - -function parse(query: string): Parsed { - let hi = 0; - let lo = 0; - const res: Parsed = { - method: "", - methodTrivia: "", - path: "", - pathTrivia: "", - headers: [], - headersTrivia: "", - }; - - // Note that we iterate by code units, but it doesn't change correctness. - // method - for (hi = 0; hi < query.length; ++hi) { - const c = query[hi]; - if (c === " " || c === "\t" || c === "\n") { - break; - } - } - res.method = query.substring(lo, hi); - lo = hi; - if (lo >= query.length) { - return res; - } - - // method trivia - for (hi = lo; hi < query.length; ++hi) { - const c = query[hi]; - if (!(c === " " || c === "\t")) { - break; - } - } - res.methodTrivia = query.substring(lo, hi); - lo = hi; - if (lo >= query.length) { - return res; - } - - // path - for (hi = lo; hi < query.length; ++hi) { - const c = query[hi]; - if (c === "\n") { - break; - } - } - res.path = query.substring(lo, hi); - lo = hi; - if (lo >= query.length) { - return res; - } - - // path trivia - if (query[hi] === "\n") { - hi += 1; - res.pathTrivia = query.substring(lo, hi); - } - lo = hi; - if (lo >= query.length) { - return res; - } - - // headers - let header: ParsedHeader = { - name: "", - nameTrivia: "", - value: "", - valueTrivia: "", - }; - let headerReady = false; - // SAFETY: don't decrease hi inside this loop. - for (hi = lo; hi < query.length; ++hi) { - if (headerReady) { - res.headers.push(header); - } - header = { name: "", nameTrivia: "", value: "", valueTrivia: "" }; - headerReady = false; - - if (query[hi] === "\n") { - // end of headers - break; - } - - // header name - for (lo = hi; hi <= query.length; ++hi) { - const c = query[hi]; - if (c === " " || c === "\t" || c === "\n" || c === ":") { - break; - } - } - headerReady = true; - header.name = query.substring(lo, hi); - lo = hi; - if (lo >= query.length) { - break; - } - - // header name trivia - let colonFound = false; - for (lo = hi; hi <= query.length; ++hi) { - const c = query[hi]; - if (c === ":" && !colonFound) { - colonFound = true; - } else if (!(c === " " || c === "\t")) { - break; - } - } - header.nameTrivia = query.substring(lo, hi); - lo = hi; - if (lo >= query.length) { - break; - } - - // header value - for (hi = lo; hi < query.length; ++hi) { - const c = query[hi]; - if (c === "\n") { - break; - } - } - header.value = query.substring(lo, hi); - lo = hi; - if (lo >= query.length) { - break; - } - - // header value trivia - if (query[hi] === "\n") { - header.valueTrivia = query.substring(lo, hi + 1); - } - lo = hi + 1; - if (lo >= query.length) { - break; - } - } - if (headerReady) { - res.headers.push(header); - } - - if (query[hi] === "\n") { - res.headersTrivia = query.substring(lo, hi + 1); - } - - return res; -} - const MethodInput = React.memo(function MethodInput({ method, onMethodChange, @@ -227,7 +66,7 @@ const PathInput = React.memo(function PathInput({ }) { return (
- {"Path: "} + Path: parse(rawQuery), [rawQuery]); - const parsedRef = React.useRef(parsed); - React.useEffect(() => { - parsedRef.current = parsed; - }, [parsed]); - const method = React.useMemo(() => parsed.method, [parsed.method]); - const path = React.useMemo(() => parsed.path, [parsed.path]); + const [path, setPath] = React.useState(""); + const [method, setMethod] = React.useState(""); const viewRef = React.useRef(null); @@ -263,16 +96,8 @@ function ComplexComp() { return null; } - const from = 0; - const to = parsedRef.current.method.length; - - view.dispatch({ - changes: { - from: from, - to: to, - insert: newVal, - }, - }); + http.setMethod(view, newVal); + setMethod(newVal); }, [], ); @@ -286,18 +111,8 @@ function ComplexComp() { return null; } - const from = - parsedRef.current.method.length + parsedRef.current.methodTrivia.length; - const to = from + parsedRef.current.path.length; - console.log(from, to); - - view.dispatch({ - changes: { - from: from, - to: to, - insert: newVal, - }, - }); + http.setPath(view, newVal); + setPath(newVal); }, [], ); @@ -310,9 +125,11 @@ function ComplexComp() { { - if (update.docChanged) { - setRawQuery(update.state.doc.toString()); - } + const method = http.getMethod(update.view); + const path = http.getPath(update.view); + + setMethod(method); + setPath(path); }} viewCallback={(view) => { viewRef.current = view; diff --git a/packages/react-components/src/components/code-editor/http/index.ts b/packages/react-components/src/components/code-editor/http/index.ts index b8e9304..693284a 100644 --- a/packages/react-components/src/components/code-editor/http/index.ts +++ b/packages/react-components/src/components/code-editor/http/index.ts @@ -5,10 +5,12 @@ import { LanguageSupport, LRLanguage, } from "@codemirror/language"; +import { StateField, type Text } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; import { parseMixed } from "@lezer/common"; import { styleTags, tags } from "@lezer/highlight"; import type { LRParser } from "@lezer/lr"; -import { parser } from "./grammar/http"; +import { parser } from "./lezer/http"; function makeParser( bodyLanguages: (contentType: string) => Language | null, @@ -78,10 +80,123 @@ function makeParser( }); } +function isSpace(c: string | undefined) { + return c === "\t" || c === " "; +} + +type Component = { + from: number; + to: number; + value: string; +}; + +type HttpLine = { + method: Component; + path: Component; +}; + +function extractHttpLine(doc: Text): HttpLine { + const firstLine = doc.line(1); + const firstLineString = firstLine.text; + + let methodFrom = 0; + let methodTo = 0; + + for (methodTo = methodFrom; methodTo < firstLineString.length; ++methodTo) { + const c = firstLineString[methodTo]; + if (isSpace(c)) { + break; + } + } + + let pathFrom = methodTo; + for (pathFrom = methodTo; pathFrom < firstLineString.length; ++pathFrom) { + const c = firstLineString[pathFrom]; + if (!isSpace(c)) { + break; + } + } + + // A bag of special cases to make behavior more pretty + if (methodFrom === methodTo) { + methodFrom = 0; + methodTo = 0; + } + + const pathValue = firstLineString.slice(pathFrom).trim(); + const pathTo = firstLineString.length; + + if (pathFrom === pathTo && pathFrom > methodTo + 1) { + pathFrom = methodTo + 1; + } + + return { + method: { + from: methodFrom, + to: methodTo, + value: firstLineString.slice(methodFrom, methodTo).trim(), + }, + path: { + from: pathFrom, + to: firstLineString.length, + value: pathValue, + }, + }; +} + +const lineStateField = StateField.define({ + create: (state) => { + return extractHttpLine(state.doc); + }, + + update: (prev, transaction) => { + if (transaction.docChanged) { + return extractHttpLine(transaction.newDoc); + } else { + return prev; + } + }, +}); + +function getMethod(view: EditorView): string { + const currentLine = view.state.field(lineStateField); + return currentLine.method.value; +} + +function setMethod(view: EditorView, method: string) { + const currentLine = view.state.field(lineStateField); + view.dispatch({ + changes: { + from: currentLine.method.from, + to: currentLine.method.to, + insert: method, + }, + }); +} + +function getPath(view: EditorView): string { + const currentLine = view.state.field(lineStateField); + return currentLine.path.value; +} + +function setPath(view: EditorView, path: string) { + const currentLine = view.state.field(lineStateField); + + const shouldAddSpace = currentLine.method.to === currentLine.path.from; + + view.dispatch({ + changes: { + from: currentLine.path.from, + to: currentLine.path.to, + insert: shouldAddSpace ? ` ${path}` : path, + }, + }); +} + function http(bodyLanguages: (contentType: string) => Language | null) { const parser = makeParser(bodyLanguages); const language = LRLanguage.define({ parser: parser }); - return new LanguageSupport(language, []); + return new LanguageSupport(language, [lineStateField.extension]); } -export { http }; +export { http, getMethod, setMethod, getPath, setPath }; diff --git a/packages/react-components/src/components/code-editor/http/grammar/http.grammar b/packages/react-components/src/components/code-editor/http/lezer/http.grammar similarity index 100% rename from packages/react-components/src/components/code-editor/http/grammar/http.grammar rename to packages/react-components/src/components/code-editor/http/lezer/http.grammar diff --git a/packages/react-components/src/components/code-editor/http/grammar/http.terms.ts b/packages/react-components/src/components/code-editor/http/lezer/http.terms.ts similarity index 100% rename from packages/react-components/src/components/code-editor/http/grammar/http.terms.ts rename to packages/react-components/src/components/code-editor/http/lezer/http.terms.ts diff --git a/packages/react-components/src/components/code-editor/http/grammar/http.test.ts b/packages/react-components/src/components/code-editor/http/lezer/http.test.ts similarity index 100% rename from packages/react-components/src/components/code-editor/http/grammar/http.test.ts rename to packages/react-components/src/components/code-editor/http/lezer/http.test.ts diff --git a/packages/react-components/src/components/code-editor/http/grammar/http.ts b/packages/react-components/src/components/code-editor/http/lezer/http.ts similarity index 100% rename from packages/react-components/src/components/code-editor/http/grammar/http.ts rename to packages/react-components/src/components/code-editor/http/lezer/http.ts diff --git a/packages/react-components/src/components/code-editor/http/parser.ts b/packages/react-components/src/components/code-editor/http/parser.ts new file mode 100644 index 0000000..94d7214 --- /dev/null +++ b/packages/react-components/src/components/code-editor/http/parser.ts @@ -0,0 +1 @@ +/// HTTP language parser for integration with CodeMirror.