Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(playground): Implement codemirror based template string editor #4943

Merged
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f1f93e2
feat(playground): Implement codemirror based template string editor
cephalization Oct 10, 2024
1bbf1f8
Implement mustacheLike Language grammar
cephalization Oct 10, 2024
8ef11cb
Fix top level language definitions
cephalization Oct 10, 2024
f448731
feat(playground): Support template escaping and nesting in fstring te…
cephalization Oct 10, 2024
6f8a0d3
feat(playground): Improve mustache grammar and implement mustache for…
cephalization Oct 10, 2024
e7e6fd1
fix(playground): Allow Enter key within codemirror editor
cephalization Oct 11, 2024
e656c34
refactor(playground): Improve comments for mustache like lang
cephalization Oct 11, 2024
f5c7920
refactor(playground): Refactor variable extraction and lang format fu…
cephalization Oct 11, 2024
6cd2feb
test(playground): Test FStringTemplate and MustacheTemplate languages
cephalization Oct 11, 2024
d1792fa
refactor(playground): Remove debug logging
cephalization Oct 11, 2024
46c9615
fix(playground): Correctly parse empty templates, escape slashes, tri…
cephalization Oct 12, 2024
65adba3
fix(playground): Apply parsing fixes to fstring as well
cephalization Oct 12, 2024
511330d
refactor(playground): Remove debug
cephalization Oct 12, 2024
7be950b
docs(playground): Add comments to lexer grammars
cephalization Oct 14, 2024
337d6a1
docs(playground): Add comments to debug fns
cephalization Oct 14, 2024
de8a835
docs(playground): Improve comment formatting
cephalization Oct 14, 2024
d72c345
refactor(playground): Use named object arguments for variable extractor
cephalization Oct 14, 2024
7bcedb8
refactor(playground): rename lang directories
cephalization Oct 14, 2024
f21d943
fix(playground): Add a lightmode theme to template editor
cephalization Oct 14, 2024
ff8a18e
refactor(playground): More detailed typing to variable format record
cephalization Oct 14, 2024
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
4 changes: 4 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-json": "6.0.1",
"@codemirror/lang-python": "6.1.3",
"@codemirror/language": "^6.10.3",
"@codemirror/lint": "^6.8.1",
"@codemirror/view": "^6.28.5",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@react-three/drei": "^9.108.4",
"@react-three/fiber": "8.0.12",
"@tanstack/react-table": "^8.19.3",
Expand Down Expand Up @@ -55,6 +58,7 @@
},
"devDependencies": {
"@emotion/react": "^11.11.4",
"@lezer/generator": "^1.7.1",
"@playwright/test": "^1.48.0",
"@types/d3-format": "^3.0.4",
"@types/d3-scale-chromatic": "^3.0.3",
Expand Down
328 changes: 107 additions & 221 deletions app/pnpm-lock.yaml

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions app/src/components/templateEditor/TemplateEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { useMemo } from "react";
import { nord } from "@uiw/codemirror-theme-nord";
import CodeMirror, { ReactCodeMirrorProps } from "@uiw/react-codemirror";

import { useTheme } from "@phoenix/contexts";
import { assertUnreachable } from "@phoenix/typeUtils";

import { FStringTemplating } from "./language/fStringTemplating";
import { MustacheLikeTemplating } from "./language/mustacheLikeTemplating";

export const TemplateLanguages = {
FString: "f-string", // {variable}
Mustache: "mustache", // {{variable}}
} as const;

type TemplateLanguage =
(typeof TemplateLanguages)[keyof typeof TemplateLanguages];

type TemplateEditorProps = ReactCodeMirrorProps & {
templateLanguage: TemplateLanguage;
};
cephalization marked this conversation as resolved.
Show resolved Hide resolved

export const TemplateEditor = ({
templateLanguage,
...props
}: TemplateEditorProps) => {
const { theme } = useTheme();
const codeMirrorTheme = theme === "light" ? undefined : nord;
const extensions = useMemo(() => {
const ext: TemplateEditorProps["extensions"] = [];
switch (templateLanguage) {
case TemplateLanguages.FString:
ext.push(FStringTemplating());
break;
case TemplateLanguages.Mustache:
ext.push(MustacheLikeTemplating());
break;
default:
assertUnreachable(templateLanguage);
}
return ext;
}, [templateLanguage]);

return (
<CodeMirror theme={codeMirrorTheme} extensions={extensions} {...props} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the ...props so I get that you are trying to keep this generic. Maybe you can expose under /code a version that doesn't have things like line numbers since I think that's the primary use-case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to tweak any defaults you want for TemplateEditor, including removing line numbers and setting a default height.

I tend to opt for exposing all props on components so that I don't have to add them later but I'm happy with leaving extra props opaque until they're actually needed to be drilled through

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

No more gutter, line numbers, or line highlight by default

);
};
1 change: 1 addition & 0 deletions app/src/components/templateEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./TemplateEditor";
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { formatFString, FStringTemplatingLanguage } from "../fStringTemplating";
import { extractVariables } from "../languageUtils";
import {
formatMustacheLike,
MustacheLikeTemplatingLanguage,
} from "../mustacheLikeTemplating";

describe("language utils", () => {
it("should extract variable names from a mustache-like template", () => {
const tests = [
{ input: "{{name}}", expected: ["name"] },
// TODO: add support for triple mustache escaping or at least use the inner most mustache as value
// { input: "{{name}} {{{age}}}", expected: ["name"] },
{
input:
"Hi I'm {{name}} and I'm {{age}} years old and I live in {{city}}",
expected: ["name", "age", "city"],
},
{
input: `
hi there {{name}}
how are you?

can you help with this json?

{ "name": "John", "age": {{age}} }`,
expected: ["name", "age"],
},
] as const;
tests.forEach(({ input, expected }) => {
expect(
extractVariables(MustacheLikeTemplatingLanguage.parser, input)
).toEqual(expected);
});
});

it("should extract variable names from a f-string template", () => {
const tests = [
{ input: "{name}", expected: ["name"] },
{ input: "{name} {age}", expected: ["name", "age"] },
{ input: "{name} {{age}}", expected: ["name"] },
{
input: "Hi I'm {name} and I'm {age} years old and I live in {city}",
expected: ["name", "age", "city"],
},
{
input: `
hi there {name}
how are you?

can you help with this json?

{{ "name": "John", "age": {age} }}`,
expected: ["name", "age"],
},
] as const;
tests.forEach(({ input, expected }) => {
expect(extractVariables(FStringTemplatingLanguage.parser, input)).toEqual(
expected
);
});
});

it("should format a mustache-like template", () => {
const tests = [
{
input: "{{name}}",
variables: { name: "John" },
expected: "John",
},
{
input: "{{name}} {{age}}",
variables: { name: "John", age: 30 },
expected: "John 30",
},
{
input: "{{name}} {age} {{city}}",
variables: { name: "John", city: "New York" },
expected: "John {age} New York",
},
{
input: `
hi there {{name}}
how are you?

can you help with this json?

{ "name": "John", "age": {{age}} }`,
variables: { name: "John", age: 30 },
expected: `
hi there John
how are you?

can you help with this json?

{ "name": "John", "age": 30 }`,
},
] as const;
tests.forEach(({ input, variables, expected }) => {
expect(formatMustacheLike({ text: input, variables })).toEqual(expected);
cephalization marked this conversation as resolved.
Show resolved Hide resolved
});
});

it("should format a f-string template", () => {
const tests = [
{
input: "{name}",
variables: { name: "John" },
expected: "John",
},
{
input: "{name} {age}",
variables: { name: "John", age: 30 },
expected: "John 30",
},
{
input: "{name} {{age}}",
variables: { name: "John", age: 30 },
expected: "John {age}",
},
{
input: `
hi there {name}
how are you?

can you help with this json?

{{ "name": "John", "age": {age} }}`,
variables: { name: "John", age: 30 },
expected: `
hi there John
how are you?

can you help with this json?

{ "name": "John", "age": 30 }`,
},
] as const;
tests.forEach(({ input, variables, expected }) => {
expect(formatFString({ text: input, variables })).toEqual(expected);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@top FStringTemplate {(Template | Text | LEscape | REscape)*}

@skip {} {
Template { LBrace Variable+ RBrace }
}

@local tokens {
RBrace { "}" }
cephalization marked this conversation as resolved.
Show resolved Hide resolved
@else Variable
}

@skip { space }

@tokens {
LEscape { "{{" }
REscape { "}}" }
LBrace { "{" }
Text { ![{}]+ }
"}"
char { $[\n\r\t\u{20}\u{21}\u{23}-\u{5b}\u{5d}-\u{10ffff}]+ | "\\" esc }
esc { $["\\\/bfnrt] | "u" hex hex hex hex }
hex { $[0-9a-fA-F] }
space { @whitespace }
@precedence { space, LEscape, REscape, LBrace, Text, char, esc }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { LRParser } from "@lezer/lr";

export declare const parser: LRParser;
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { LanguageSupport, LRLanguage } from "@codemirror/language";
cephalization marked this conversation as resolved.
Show resolved Hide resolved
import { styleTags, tags as t } from "@lezer/highlight";

import { format } from "../languageUtils";

import { parser } from "./fStringTemplating.syntax.grammar";

// https://codemirror.net/examples/lang-package/
/**
* Define the language for the FString templating system
*
* @example
* ```
* {question}
*
* {{
* "answer": {answer}
* }}
* ```
* In this example, the variables are `question` and `answer`.
* Double braces are not considered as variables, and will be converted to a single brace on format.
*/
export const FStringTemplatingLanguage = LRLanguage.define({
parser: parser.configure({
props: [
// https://lezer.codemirror.net/docs/ref/#highlight.styleTags
styleTags({
// style the opening brace of a template, not floating braces
"Template/LBrace": t.quote,
// style the closing brace of a template, not floating braces
"Template/RBrace": t.quote,
// style variables (stuff inside {})
"Template/Variable": t.variableName,
// style invalid stuff, undefined tokens will be highlighted
"⚠": t.invalid,
}),
],
}),
languageData: {},
});

export const debugParser = (text: string) => {
const tree = FStringTemplatingLanguage.parser.parse(text);
return tree.toString();
};
cephalization marked this conversation as resolved.
Show resolved Hide resolved

/**
* Formats an FString template with the given variables.
*/
export const formatFString = ({
text,
variables,
}: Omit<Parameters<typeof format>[0], "parser" | "postFormat">) =>
format({
parser: FStringTemplatingLanguage.parser,
text,
variables,
postFormat: (text) => text.replaceAll("{{", "{").replaceAll("}}", "}"),
});

export function FStringTemplating() {
return new LanguageSupport(FStringTemplatingLanguage);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./fStringTemplating";
86 changes: 86 additions & 0 deletions app/src/components/templateEditor/language/languageUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { LRParser } from "@lezer/lr";

/**
* Extracts all variables from a templated string.
*
* @param parser - The parser for the templating language.
* The parser should be a language parser that emits Variable nodes.
* @param text - The text to extract variables from.
*
* @returns An array of variable names.
*/
export const extractVariables = (parser: LRParser, text: string) => {
cephalization marked this conversation as resolved.
Show resolved Hide resolved
const tree = parser.parse(text);
const variables: string[] = [];
const cur = tree.cursor();
do {
if (cur.name === "Variable") {
variables.push(text.slice(cur.node.from, cur.node.to));
}
} while (cur.next());
cephalization marked this conversation as resolved.
Show resolved Hide resolved
return variables;
};

/**
* Formats a templated string with the given variables.
*
* The parser should be a language parser that emits Variable nodes as children of some parent node.
*/
export const format = ({
parser,
text,
variables,
postFormat,
}: {
/**
* The parser for the templating language.
*
* Should be MustacheLikeTemplatingLanguage or FStringTemplatingLanguage.
cephalization marked this conversation as resolved.
Show resolved Hide resolved
*
* format assumes that the language produces a structure where Variable nodes
* are children of some parent node, in this case Template.
*/
parser: LRParser;
/**
* The text to format.
*/
text: string;
/**
* A mapping of variable names to their values.
*
* If a variable is not found in this object, it will be left as is.
*/
variables: Record<string, string | number | boolean>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: your check below avoids this, but sometimes prefer writing records as Record<string, string | number | undefined> just because it's a little more honest and catches unset keys

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my opinion we should just enable noUncheckedIndexedAccess in our tsconfig if we are concerned about unset keys

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

/**
* Runs after formatting the text but just before returning the result
*
* Useful for doing post-parse processing, like replacing double braces with single braces,
* or trimming whitespace.
*/
postFormat?: (text: string) => string;
}) => {
if (!text) return "";
let result = text;
let tree = parser.parse(result);
let cur = tree.cursor();
do {
if (cur.name === "Variable") {
// grab the content inside of the braces
const variable = result.slice(cur.node.from, cur.node.to);
// grab the position of the content including the braces
const Template = cur.node.parent!;
if (variable in variables) {
// replace the content (including braces) with the variable value
result = `${result.slice(0, Template.from)}${variables[variable]}${result.slice(Template.to)}`;
// reparse the result so that positions are updated
tree = parser.parse(result);
// reset the cursor to the start of the new tree
cur = tree.cursor();
}
}
} while (cur.next());
if (postFormat) {
result = postFormat(result);
}
return result;
};
Loading
Loading