From e1a5da89b7f97f4337449f0867c0f50d21aa7bf7 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 17:29:02 +0000 Subject: [PATCH] feat(rust): the extension API (buildEditorFromExtensions), with a JS-recorded scripts/extensions vector ## Description The Rust port had `register_*` functions for each package but no extension API, so nothing could build an editor from extensions the way `buildEditorFromExtensions` does, and the extension-shaped parts of upstream (`InitialStateExtension`, `ClearEditorExtension`, signals) had no counterpart. This ports it behind the existing `extension` feature: - `lexical`'s `extension-core`: `defineExtension`, `configExtension`, `declarePeerDependency`, `safeCast`, `shallowMergeConfig` and the `LexicalExtension` types, plus `mergeRegister`. - `@lexical/extension`'s builder: `buildEditorFromExtensions` and `LexicalBuilder` (dependency and peer resolution, the duplicate-name and conflict invariants, config merging, the init/build/register phases in JS's order, disposal), `ExtensionRep`, and the dependency lookups (`getExtensionDependencyFromEditor`, `getPeerDependencyFromEditor` and their `OrThrow` forms). - A port of `@preact/signals-core` 1.14.4 (`signal`, `computed`, `effect`, `batch`, `untracked`), with `namedSignals` and `watchedSignal`. - `InitialStateExtension`, `ClearEditorExtension`, `EditorStateExtension` and `DecoratorTextExtension`'s extension. `packages/lexical-conformance` gains JSON-described test extensions (`ExtensionSpec`) that log each phase with their merged config, and a `scripts/extensions` vector with 26 cases: a dependency diamond, duplicate and conflicting extensions, peers present and absent, peers read before they are configured or built, every form of `$initialEditorState`, node registration, `ClearEditorExtension`, `EditorStateExtension` as a signal, `namedSignals` toggling a registration, and disposal. Builder errors are recorded in JS's wording, and both Rust and Swift reproduce it. Swift matched every case, so no Swift source changes. Not ported, each listed in PARITY.md: the editor options a headless Rust editor lacks (`namespace`, `theme`, `html`, `editable`, `onError`, `parentEditor`, `dom`...), node replacements, `HMRExtension`, `HorizontalRuleExtension` and the other DOM- or keyboard-bound extensions, and the packages' own extensions (`RichTextExtension`, `HistoryExtension`, ...), whose `register_*` functions are already ported. ## Test plan ### Before ``` $ cargo run -q --locked --release -p lexical-conformance | **Total** | 631 | 631 | 0 | 0 | 0 | | ``` (No `scripts/extensions` row.) ### After ``` $ cargo run -q --locked --release -p lexical-conformance | `property/sequences` | 81 | 81 | 0 | 0 | 0 | pass | | `scripts/extensions` | 26 | 26 | 0 | 0 | 0 | pass | | `scripts/text` | 13 | 13 | 0 | 0 | 0 | pass | | **Total** | 657 | 657 | 0 | 0 | 0 | | ``` Mutation checks (each reverted): `merge_register` cleaning up in forward order fails 4 cases; root `configExtension` overrides not merged fails 4; batch snapshots never reconciled fails 1; an extension recorded before its dependencies fails 2; in Swift, no array concatenation in the test `mergeConfig` fails 7. `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`, `cargo test --workspace --all-features --locked`, `cargo doc` with `-D warnings`, `cargo +1.88 check -p lexical-editor --all-features`, `--no-default-features` alone and with `extension`, and the Rune app's clippy all pass. `UPDATE_VECTORS=1 pnpm exec vitest run src/scripts.test.ts`: 20 passed, and no vector changed (md5). Swift: `swift build --build-tests` and `swift test --skip-build --filter ConformanceTests`, 33 tests in 13 suites passed. Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d --- packages/lexical-conformance/src/ops.ts | 567 ++++++++++++- .../lexical-conformance/src/scripts.test.ts | 277 +++++++ .../vectors/scripts/extensions.json | 28 + rust/DESIGN.md | 5 +- rust/PARITY.md | 64 +- rust/conformance/src/scripts.rs | 134 +++- rust/conformance/src/scripts/extensions.rs | 536 +++++++++++++ rust/conformance/src/suites/mod.rs | 5 + rust/lexical-editor/Cargo.toml | 8 +- rust/lexical-editor/src/editor.rs | 6 + .../src/extension/clear_editor_extension.rs | 133 +++ rust/lexical-editor/src/extension/config.rs | 31 + .../src/extension/decorator_text_extension.rs | 13 + .../src/extension/editor_state_extension.rs | 46 ++ .../src/extension/extension_rep.rs | 383 +++++++++ .../src/extension/get_extension_dependency.rs | 42 + .../get_extension_dependency_from_editor.rs | 26 + .../get_peer_dependency_from_editor.rs | 41 + .../src/extension/initial_state_extension.rs | 165 ++++ .../src/extension/lexical_builder.rs | 417 ++++++++++ rust/lexical-editor/src/extension/mod.rs | 65 +- .../src/extension/named_signals.rs | 42 + rust/lexical-editor/src/extension/signals.rs | 758 ++++++++++++++++++ .../src/extension/watched_signal.rs | 36 + .../src/extension_core/define_extension.rs | 49 ++ rust/lexical-editor/src/extension_core/mod.rs | 23 + .../src/extension_core/safe_cast.rs | 8 + .../extension_core/shallow_merge_config.rs | 16 + .../src/extension_core/types.rs | 649 +++++++++++++++ rust/lexical-editor/src/lib.rs | 15 + rust/lexical-editor/src/merge_register.rs | 14 + rust/lexical-editor/src/ordered.rs | 18 + .../Tests/ConformanceTests/ExtensionOps.swift | 332 ++++++++ swift/Tests/ConformanceTests/Ops.swift | 89 +- .../Tests/ConformanceTests/ScriptTests.swift | 18 +- 35 files changed, 4962 insertions(+), 97 deletions(-) create mode 100644 packages/lexical-conformance/vectors/scripts/extensions.json create mode 100644 rust/conformance/src/scripts/extensions.rs create mode 100644 rust/lexical-editor/src/extension/clear_editor_extension.rs create mode 100644 rust/lexical-editor/src/extension/config.rs create mode 100644 rust/lexical-editor/src/extension/editor_state_extension.rs create mode 100644 rust/lexical-editor/src/extension/extension_rep.rs create mode 100644 rust/lexical-editor/src/extension/get_extension_dependency.rs create mode 100644 rust/lexical-editor/src/extension/get_extension_dependency_from_editor.rs create mode 100644 rust/lexical-editor/src/extension/get_peer_dependency_from_editor.rs create mode 100644 rust/lexical-editor/src/extension/initial_state_extension.rs create mode 100644 rust/lexical-editor/src/extension/lexical_builder.rs create mode 100644 rust/lexical-editor/src/extension/named_signals.rs create mode 100644 rust/lexical-editor/src/extension/signals.rs create mode 100644 rust/lexical-editor/src/extension/watched_signal.rs create mode 100644 rust/lexical-editor/src/extension_core/define_extension.rs create mode 100644 rust/lexical-editor/src/extension_core/mod.rs create mode 100644 rust/lexical-editor/src/extension_core/safe_cast.rs create mode 100644 rust/lexical-editor/src/extension_core/shallow_merge_config.rs create mode 100644 rust/lexical-editor/src/extension_core/types.rs create mode 100644 rust/lexical-editor/src/merge_register.rs create mode 100644 swift/Tests/ConformanceTests/ExtensionOps.swift diff --git a/packages/lexical-conformance/src/ops.ts b/packages/lexical-conformance/src/ops.ts index 170c8eea967..69687e8c451 100644 --- a/packages/lexical-conformance/src/ops.ts +++ b/packages/lexical-conformance/src/ops.ts @@ -13,9 +13,21 @@ import { registerCodeIndentation, } from '@lexical/code-core'; import { + batch, + buildEditorFromExtensions, + ClearEditorExtension, + computed, + DecoratorTextExtension, DecoratorTextNode, + EditorStateExtension, + effect, + getExtensionDependencyFromEditor, + getKnownTypesAndNodes, + getPeerDependencyFromEditor, HorizontalRuleNode, namedSignals, + type NamedSignalsOutput, + untracked, } from '@lexical/extension'; import {HashtagNode, registerLexicalHashtag} from '@lexical/hashtag'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; @@ -65,6 +77,7 @@ import { $createNodeSelection, $createParagraphNode, $createRangeSelection, + $createTextNode, $getCaretRange, $getChildCaret, $getNodeByKey, @@ -87,6 +100,8 @@ import { $setSelectionFromCaretRange, $setState, type AnyLexicalCommand, + type AnyLexicalExtension, + type AnyLexicalExtensionArgument, type AnyStateConfig, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, @@ -94,10 +109,12 @@ import { CLEAR_EDITOR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_EDITOR, + configExtension, CONTROLLED_TEXT_INSERTION_COMMAND, createCommand, createEditor, createState, + declarePeerDependency, DELETE_CHARACTER_COMMAND, DELETE_LINE_COMMAND, DELETE_WORD_COMMAND, @@ -110,6 +127,8 @@ import { INSERT_TAB_COMMAND, type LexicalCommand, type LexicalEditor, + type LexicalEditorWithDispose, + type LexicalExtension, type LexicalNode, type NodeCaret, OUTDENT_CONTENT_COMMAND, @@ -120,8 +139,10 @@ import { resetRandomKey, RootNode, SELECT_ALL_COMMAND, + type SerializedEditorState, type SerializedLexicalNode, SET_TEXT_FORMAT_COMMAND, + shallowMergeConfig, TextNode, UNDO_COMMAND, } from 'lexical'; @@ -241,8 +262,127 @@ export type HandlerSpec = { export type Step = ( | {update: Action[]; tag?: string[]} | {dispatch: string; payload?: Json} + /** `editor.dispose()` of an editor built from `extensions`. */ + | {dispose: true} + /** + * In one `batch`, `output[signal].value = value` for each + * `[extension, signal, value]`, where `output` is + * `getExtensionDependencyFromEditor(editor, extension).output` of an + * extension of `extensionDefs` with `signals` (its `namedSignals`). + */ + | {setSignals: [string, string, Json][]} + /** + * Logs `['dependency', key, config]`: + * `getExtensionDependencyFromEditor(editor, extension).config` of an + * extension of `extensionDefs`. + */ + | {logDependency: string} + /** + * Logs `['peer', name, config]`: the config of + * `getPeerDependencyFromEditor(editor, name)`, or `null` without one. + */ + | {logPeer: string} ) & {now?: number}; +/** A config of an extension of `extensionDefs`, or overrides of one. */ +export type ExtensionConfig = {[key: string]: Json}; + +/** + * An extension, by its key in `extensionDefs` or its name in + * REAL_EXTENSIONS, alone or with config overrides + * (`configExtension(extension, ...overrides)`). + */ +export type ExtensionArg = string | [string, ...ExtensionConfig[]]; + +/** The phases an extension of `extensionDefs` defines. */ +export type ExtensionPhase = + | 'init' + | 'build' + | 'register' + | 'afterRegistration'; + +/** + * A test extension, made by `testExtension` from JSON so that every runner + * can make the same one. Each phase it defines (all four by default) logs + * its name and merged config: `['init', name, config]`, + * `['build', name, config]`, `['register', name, config, text]` and + * `['afterRegistration', name, config, text]`, where `text` is the root + * text of `editor.getEditorState()`. `init` returns `{init: name}`. The + * clean-ups that `register` and `afterRegistration` return log + * `['unregister', name]` and `['afterUnregister', name]`. Unless it has + * `signals`, `register` also handles `EXTENSION_COMMAND` (a command the + * script makes), logging `['handled', name, payload]` and returning false. + */ +export type ExtensionSpec = { + name: string; + /** The default config; without it, the builder's default is `{}`. */ + config?: ExtensionConfig; + dependencies?: ExtensionArg[]; + /** `declarePeerDependency(name, config?)` each. */ + peerDependencies?: [string, ExtensionConfig?][]; + conflictsWith?: string[]; + /** Node types, of `@lexical/extension`'s decorators (NODES). */ + nodes?: string[]; + /** + * `$initialEditorState`: `null`, an editor state serialized with + * `JSON.stringify` (`json`), or a function that appends a paragraph + * with `text` to the root. + */ + initialEditorState?: null | {json: SerializedEditorState} | {text: string}; + /** + * `mergeConfig` that merges with `shallowMergeConfig`, then concatenates + * every array of the overrides to the config's array of the same key. + */ + mergeConfig?: 'concat'; + phases?: ExtensionPhase[]; + /** + * `init` logs `['initPeer', name, peer, config]` for `state.getPeer(peer)` + * of each, `null` when it is absent. + */ + initPeers?: string[]; + /** `build` logs `['buildPeer', name, peer, config]`, as `initPeers`. */ + buildPeers?: string[]; + /** + * `init` logs `['initDependency', name, key, config]` and `build` + * `['buildDependency', name, key, config]` for + * `state.getDependency(extension)` of each key of `extensionDefs`. + */ + lookups?: string[]; + /** `register` logs `['initResult', name, state.getInitResult()]`. */ + initResult?: boolean; + /** + * `init` logs `['knownTypes', name, types]` of + * `getKnownTypesAndNodes(editorConfig)`, `['dependents', name, names]` of + * `state.getDirectDependentNames()` and `['peerNames', name, names]` of + * `state.getPeerNameSet()`. + */ + inspect?: boolean; + /** `register` logs `['aborted', name]` when `state.getSignal()` aborts. */ + abort?: boolean; + /** + * `register` registers an update listener that logs + * `['update', name, tags]` (sorted). + */ + listen?: boolean; + /** + * `build` returns `namedSignals(config)`, of a config with a boolean + * `disabled` and a string `label`; `register` runs two effects. One logs + * `['summary', name, summary]`, where `summary` is a `computed` of + * `'off'` when disabled and `on: