Skip to content
Merged
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
9 changes: 6 additions & 3 deletions packages/sel-editor-react/src/sel-editor.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,13 @@ describe("src/sel-editor.tsx", () => {
expect(container.querySelector(".cm-editor")).toBeTruthy();
});

it("accepts validate prop for error highlighting", () => {
const validate = vi.fn().mockReturnValue([]);
it("accepts checkerOptions prop", () => {
const { container } = render(
<SELEditor schema={testSchema} value="test" validate={validate} />,
<SELEditor
schema={testSchema}
value="test"
checkerOptions={{ rules: [] }}
/>,
);
expect(container.querySelector(".cm-editor")).toBeTruthy();
});
Expand Down
29 changes: 8 additions & 21 deletions packages/sel-editor-react/src/sel-editor.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SELChecker, rules } from "@seljs/checker";
import { rules } from "@seljs/checker";
import { buildSchema } from "@seljs/env";
import { parseAbi } from "viem";

Expand Down Expand Up @@ -93,56 +93,43 @@ const WithValidation: Story = {
},
};

const checkerWithRules = new SELChecker(erc20Schema, {
rules: rules.builtIn,
});

const conditionChecker = new SELChecker(erc20Schema, {
rules: [rules.requireType("bool"), ...rules.builtIn],
});

const WithLintRules: Story = {
name: "Lint Rules (Redundant Bool)",
args: {
value: "erc20.balanceOf(user) > 0 == true",
validate: (expression: string) =>
checkerWithRules.check(expression).diagnostics,
checkerOptions: { rules: rules.builtIn },
},
};

const WithLintSelfComparison: Story = {
name: "Lint Rules (Self Comparison)",
args: {
value: "erc20.totalSupply() == erc20.totalSupply()",
validate: (expression: string) =>
checkerWithRules.check(expression).diagnostics,
checkerOptions: { rules: rules.builtIn },
},
};

const WithLintConstantCondition: Story = {
name: "Lint Rules (Constant Condition)",
args: {
value: "true && erc20.balanceOf(user) > 0",
validate: (expression: string) =>
checkerWithRules.check(expression).diagnostics,
checkerOptions: { rules: rules.builtIn },
},
};

const RequireTypeBoolPass: Story = {
name: "Require Type Bool (Pass)",
args: {
value: "erc20.balanceOf(user) > 0",
validate: (expression: string) =>
conditionChecker.check(expression).diagnostics,
checkerOptions: { rules: [rules.requireType("bool"), ...rules.builtIn] },
},
};

const RequireTypeBoolFail: Story = {
name: "Require Type Bool (Fail)",
args: {
value: "erc20.balanceOf(user)",
validate: (expression: string) =>
conditionChecker.check(expression).diagnostics,
checkerOptions: { rules: [rules.requireType("bool"), ...rules.builtIn] },
},
};

Expand Down Expand Up @@ -197,15 +184,15 @@ const WithTypeDisplay: Story = {
name: "Type Display",
args: {
value: "erc20.balanceOf(user) > 0",
showType: true,
features: { typeDisplay: true },
},
};

const TypeDisplayDark: Story = {
name: "Type Display (Dark)",
args: {
value: "erc20.balanceOf(user)",
showType: true,
features: { typeDisplay: true },
dark: true,
},
decorators: [
Expand Down
2 changes: 1 addition & 1 deletion packages/sel-editor-react/src/use-sel-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe("src/use-sel-editor.ts", () => {
});
});

expect(onChange).toHaveBeenCalledWith("hello");
expect(onChange).toHaveBeenCalledWith("hello", expect.any(Boolean));

document.body.removeChild(container);
});
Expand Down
9 changes: 6 additions & 3 deletions packages/sel-editor-react/src/use-sel-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface UseSELEditorResult {
ref: (node: HTMLElement | null) => void;
view: EditorView | null;
value: string;
valid: boolean;
}

export function useSELEditor(
Expand All @@ -19,6 +20,7 @@ export function useSELEditor(
const viewRef = useRef<EditorView | null>(null);
const [view, setView] = useState<EditorView | null>(null);
const [value, setValue] = useState(config.value ?? "");
const [valid, setValid] = useState(true);
const configRef = useRef(config);
configRef.current = config;

Expand All @@ -38,9 +40,10 @@ export function useSELEditor(
const editor = createSELEditor({
...currentConfig,
parent: node,
onChange: (newValue) => {
onChange: (newValue, isValid) => {
setValue(newValue);
currentConfig.onChange?.(newValue);
setValid(isValid);
currentConfig.onChange?.(newValue, isValid);
},
});
viewRef.current = editor;
Expand All @@ -56,5 +59,5 @@ export function useSELEditor(
};
}, []);

return { ref, view, value };
return { ref, view, value, valid };
}
6 changes: 4 additions & 2 deletions packages/sel-editor-react/src/whatsabi-explorer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,10 @@ function WhatsAbiExplorer({
schema={status.schema}
value={expression}
placeholder={`Try ${contractName.trim() || "contract"}.<method>(...)`}
onChange={setExpression}
showType
onChange={(value) => {
setExpression(value);
}}
features={{ typeDisplay: true }}
/>

<div style={{ marginTop: 12 }}>
Expand Down
27 changes: 18 additions & 9 deletions packages/sel-editor/src/editor/create-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe("src/editor/create-editor.ts", () => {
view.destroy();
});

it("onChange fires with current value on document changes", () => {
it("onChange fires with current value and validity on document changes", () => {
const onChange = vi.fn();
const parent = createParent();
const view = createSELEditor({
Expand All @@ -103,7 +103,7 @@ describe("src/editor/create-editor.ts", () => {
changes: { from: 0, insert: "test" },
});

expect(onChange).toHaveBeenCalledWith("test");
expect(onChange).toHaveBeenCalledWith("test", expect.any(Boolean));
view.destroy();
});

Expand All @@ -123,17 +123,15 @@ describe("src/editor/create-editor.ts", () => {
view.destroy();
});

it("passing validate enables the lint extension", () => {
const validate = vi.fn().mockReturnValue([]);
it("passing checkerOptions configures the internal checker", () => {
const parent = createParent();
const view = createSELEditor({
parent,
schema: testSchema,
value: "test",
validate,
checkerOptions: { rules: [] },
});

// Linter extension should be present (no errors thrown)
expect(view.state).toBeDefined();
view.destroy();
});
Expand Down Expand Up @@ -195,14 +193,25 @@ describe("src/editor/create-editor.ts", () => {
view2.destroy();
});

it("accepts additional extensions", () => {
it("features can disable linting", () => {
const parent = createParent();
const view = createSELEditor({
parent,
schema: testSchema,
features: { linting: false },
});

expect(view).toBeDefined();
view.destroy();
});

// Pass an empty array of extensions (no-op but shouldn't break)
it("features can enable type display", () => {
const parent = createParent();
const view = createSELEditor({
parent,
schema: testSchema,
extensions: [],
value: "erc20.balanceOf(user)",
features: { typeDisplay: true },
});

expect(view).toBeDefined();
Expand Down
4 changes: 2 additions & 2 deletions packages/sel-editor/src/editor/create-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildExtensions } from "./editor-config";

import type { SELEditorConfig } from "./types";

export function createSELEditor(config: SELEditorConfig): EditorView {
export const createSELEditor = (config: SELEditorConfig): EditorView => {
const extensions = buildExtensions(config);

const state = EditorState.create({
Expand All @@ -17,4 +17,4 @@ export function createSELEditor(config: SELEditorConfig): EditorView {
state,
parent: config.parent,
});
}
};
63 changes: 35 additions & 28 deletions packages/sel-editor/src/editor/editor-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,44 @@ import { bracketMatching } from "@codemirror/language";
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view";
import { celLanguageSupport } from "@seljs/cel-lezer";
import { SELChecker, rules } from "@seljs/checker";
import { SELChecker } from "@seljs/checker";

import { selDarkTheme, selLightTheme } from "./theme";
import { createTypeDisplay } from "./type-display";
import { createSchemaCompletion } from "../completion/schema-completion";
import { createSchemaCompletion } from "../completion";
import { createTokenizerConfig } from "../language";
import { createSemanticHighlighter } from "../language/semantic-highlighter";
import { createTokenizerConfig } from "../language/tokenizer-config";
import { createSELLinter } from "../linting/sel-linter";
import { createSELLinter } from "../linting";

import type { SELEditorConfig } from "./types";
import type { SELEditorConfig, SELEditorFeatures } from "./types";

const resolveFeatures = (features?: SELEditorFeatures) => ({
linting: features?.linting ?? true,
autocomplete: features?.autocomplete ?? true,
semanticHighlighting: features?.semanticHighlighting ?? true,
typeDisplay: features?.typeDisplay ?? false,
});

export const buildExtensions = (config: SELEditorConfig): Extension[] => {
const checker = new SELChecker(config.schema, { rules: [...rules.builtIn] });
const checker = new SELChecker(config.schema, config.checkerOptions);
const resolved = resolveFeatures(config.features);
const extensions: Extension[] = [];

// Language support (includes syntax highlighting)
extensions.push(celLanguageSupport(config.dark));
extensions.push(bracketMatching());

// Semantic highlighting (schema-aware identifier coloring)
const tokenizerConfig = createTokenizerConfig(config.schema);
extensions.push(createSemanticHighlighter(tokenizerConfig, config.dark));
if (resolved.semanticHighlighting) {
const tokenizerConfig = createTokenizerConfig(config.schema);
extensions.push(createSemanticHighlighter(tokenizerConfig, config.dark));
}

// Autocomplete (type-aware via checker)
extensions.push(createSchemaCompletion(config.schema, checker));
extensions.push(closeBrackets());
if (resolved.autocomplete) {
extensions.push(createSchemaCompletion(config.schema, checker));
extensions.push(closeBrackets());
}

// Keybindings
extensions.push(
Expand All @@ -40,24 +52,24 @@ export const buildExtensions = (config: SELEditorConfig): Extension[] => {
// Theme
extensions.push(config.dark ? selDarkTheme : selLightTheme);

// Validation / linting (built-in checker used when no validate callback provided)
const validate =
config.validate ??
((expression: string) => checker.check(expression).diagnostics);
extensions.push(
createSELLinter({
validate,
delay: config.validateDelay,
}),
);
// Validation / linting
if (resolved.linting) {
extensions.push(
createSELLinter({
validate: (expression: string) => checker.check(expression).diagnostics,
}),
);
}

// onChange listener
// onChange listener with validity
if (config.onChange) {
const onChange = config.onChange;
extensions.push(
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
const value = update.state.doc.toString();
const result = checker.check(value);
onChange(value, result.valid);
}
Comment on lines +64 to 73
}),
);
Expand All @@ -74,14 +86,9 @@ export const buildExtensions = (config: SELEditorConfig): Extension[] => {
}

// Type display panel
if (config.showType) {
if (resolved.typeDisplay) {
extensions.push(createTypeDisplay(checker, config.dark ?? false));
}

// User-provided extensions (last, so they can override)
if (config.extensions) {
extensions.push(...config.extensions);
}

return extensions;
};
3 changes: 1 addition & 2 deletions packages/sel-editor/src/editor/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { createSELEditor } from "./create-editor";
export { buildExtensions } from "./editor-config";
export { selLightTheme, selDarkTheme } from "./theme";
export type { SELEditorConfig } from "./types";
export type { SELEditorConfig, SELEditorFeatures } from "./types";
export type { EditorView } from "@codemirror/view";
17 changes: 10 additions & 7 deletions packages/sel-editor/src/editor/type-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ const typeField = StateField.define<string | null>({
},
});

function createTypePanel(dark: boolean): (view: EditorView) => Panel {
return (view: EditorView) => {
const createTypePanel =
(dark: boolean): ((view: EditorView) => Panel) =>
(view: EditorView) => {
const dom = document.createElement("div");
dom.className = "sel-type-display";
dom.style.cssText = [
Expand Down Expand Up @@ -60,16 +61,18 @@ function createTypePanel(dark: boolean): (view: EditorView) => Panel {
},
};
};
}

export function createTypeDisplay(
export const createTypeDisplay = (
checker: SELChecker,
dark: boolean,
): Extension {
): Extension => {
let debounceTimer: ReturnType<typeof setTimeout> | undefined;

const plugin = EditorView.updateListener.of((update) => {
if (!update.docChanged && !update.startState.field(typeField, false)) {
if (
!update.docChanged &&
update.startState.field(typeField, false) !== null
) {
return;
}

Expand All @@ -93,4 +96,4 @@ export function createTypeDisplay(
});

return [typeField, plugin, showPanel.of(createTypePanel(dark))];
}
};
Loading
Loading