Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 13 additions & 196 deletions packages/react-components/src/components/code-editor.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CodeEditor> = {
title: "Component/Editor",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -227,7 +66,7 @@ const PathInput = React.memo(function PathInput({
}) {
return (
<div className="flex justify-center items-baseline">
{"Path: "}
Path:
<Input
type="text"
className="inline"
Expand All @@ -239,14 +78,8 @@ const PathInput = React.memo(function PathInput({
});

function ComplexComp() {
const [rawQuery, setRawQuery] = React.useState("");
const parsed = React.useMemo(() => 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<EditorView | null>(null);

Expand All @@ -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);
},
[],
);
Expand All @@ -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);
},
[],
);
Expand All @@ -310,9 +125,11 @@ function ComplexComp() {
<CodeEditor
mode="http"
onUpdate={(update) => {
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;
Expand Down
121 changes: 118 additions & 3 deletions packages/react-components/src/components/code-editor/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HttpLine>({
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 };
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// HTTP language parser for integration with CodeMirror.