diff --git a/devel/213_25.md b/devel/213_25.md new file mode 100644 index 0000000000..5d12f3e4bc --- /dev/null +++ b/devel/213_25.md @@ -0,0 +1,27 @@ +# [213_25] Add JSON serialization for modification (collaborative editing transport) + +Web-based collaborative editing requires transmitting OT operations between +clients and server over WebSocket. Currently, modifications can only be +serialized to a debug text format via `operator<<`. This PR adds a proper +JSON serialization/deserialization layer for `modification` in the `moebius` +library. + +## New Files +- `moebius/moebius/data/json_serde.hpp` — public API +- `moebius/moebius/data/json_serde.cpp` — implementation +- `moebius/tests/moebius/data/json_serde_test.cpp` — round-trip tests + +## Design Decisions +1. **JSON format**: `{"type":"assign","path":[0,1],"tree":"(document \"a\" \"b\")"}`. + Simple, flat, easy to parse in any language (JS/TS, Python, C++). +2. **Tree transport**: Uses existing scheme serialization (`tree_to_scheme` / + `scheme_to_tree`) as the tree payload format, avoiding reinvention. +3. **Path format**: JSON array of integers, e.g. `[0,1,3]`. Native JSON type, + no custom parsing needed on the receiver side. +4. **Minimal JSON parser**: A lightweight `json_extract_value` function is used + instead of pulling in a full JSON library, keeping `moebius` dependency-light. + +## Why This Matters +This is a foundational building block for the GSoC 2026 Web-Based Collaborative +Editing Core project. With this module, the WASM-compiled moebius library can +exchange OT operations with a JavaScript frontend via simple JSON messages. diff --git a/moebius/moebius/data/json_serde.cpp b/moebius/moebius/data/json_serde.cpp new file mode 100644 index 0000000000..74ee8b9c66 --- /dev/null +++ b/moebius/moebius/data/json_serde.cpp @@ -0,0 +1,227 @@ +/****************************************************************************** + * MODULE : json_serde.cpp + * DESCRIPTION: JSON serialization/deserialization for modification and patch, + * enabling network transport for collaborative editing + * COPYRIGHT : (C) 2026 cc-fuyu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#include "moebius/data/json_serde.hpp" +#include "moebius/data/scheme.hpp" +#include "path.hpp" +#include "tree.hpp" + +namespace moebius { +namespace data { + +/****************************************************************************** + * Path serialization + ******************************************************************************/ + +string +path_to_json_string (path p) { + string r; + r << "["; + bool first= true; + path cur = p; + while (!is_nil (cur)) { + if (!first) r << ","; + r << as_string (cur->item); + first= false; + cur = cur->next; + } + r << "]"; + return r; +} + +path +json_string_to_path (string s) { + int n= N (s); + if (n < 2 || s[0] != '[' || s[n - 1] != ']') return path (); + string inner= s (1, n - 1); + if (N (inner) == 0) return path (); + path head; + path* tail= &head; + int i = 0; + int in = N (inner); + while (i < in) { + // skip whitespace + while (i < in && inner[i] == ' ') + i++; + // parse integer + int start= i; + if (i < in && inner[i] == '-') i++; + while (i < in && inner[i] >= '0' && inner[i] <= '9') + i++; + string num_str= inner (start, i); + int num = as_int (num_str); + *tail = path (num); + tail = &((*tail)->next); + // skip comma + while (i < in && (inner[i] == ',' || inner[i] == ' ')) + i++; + } + return head; +} + +/****************************************************************************** + * Tree serialization (using scheme format as transport) + ******************************************************************************/ + +string +tree_to_json_string (tree t) { + return tree_to_scheme (t); +} + +tree +json_string_to_tree (string s) { + return scheme_to_tree (s); +} + +/****************************************************************************** + * JSON string escaping helpers + ******************************************************************************/ + +static string +json_escape (string s) { + int i, n= N (s); + string r; + for (i= 0; i < n; i++) { + char c= s[i]; + if (c == '"') r << "\\\""; + else if (c == '\\') r << "\\\\"; + else if (c == '\n') r << "\\n"; + else if (c == '\r') r << "\\r"; + else if (c == '\t') r << "\\t"; + else r << c; + } + return r; +} + +static string +json_unescape (string s) { + int i, n= N (s); + string r; + for (i= 0; i < n; i++) { + if (s[i] == '\\' && i + 1 < n) { + i++; + if (s[i] == '"') r << '"'; + else if (s[i] == '\\') r << '\\'; + else if (s[i] == 'n') r << '\n'; + else if (s[i] == 'r') r << '\r'; + else if (s[i] == 't') r << '\t'; + else { + r << '\\'; + r << s[i]; + } + } + else r << s[i]; + } + return r; +} + +/****************************************************************************** + * Modification to JSON + ******************************************************************************/ + +string +modification_to_json (modification mod) { + string type_str= get_type (mod); + string path_str= path_to_json_string (mod->p); + string tree_str= tree_to_json_string (mod->t); + + string r; + r << "{\"type\":\"" << json_escape (type_str) << "\""; + r << ",\"path\":" << path_str; + r << ",\"tree\":\"" << json_escape (tree_str) << "\""; + r << "}"; + return r; +} + +/****************************************************************************** + * JSON to Modification - minimal parser + ******************************************************************************/ + +// Extract value for a given key from a simple flat JSON object +static string +json_extract_value (string json, string key) { + string search; + search << "\"" << key << "\":\""; + int pos= -1; + int n = N (json); + int sn = N (search); + for (int i= 0; i + sn <= n; i++) { + bool match= true; + for (int j= 0; j < sn; j++) { + if (json[i + j] != search[j]) { + match= false; + break; + } + } + if (match) { + pos= i + sn; + break; + } + } + if (pos < 0) return ""; + + // Find closing quote (handling escapes) + string val; + for (int i= pos; i < n; i++) { + if (json[i] == '\\' && i + 1 < n) { + val << json[i]; + val << json[i + 1]; + i++; + } + else if (json[i] == '"') break; + else val << json[i]; + } + return json_unescape (val); +} + +// Extract a JSON array value (e.g. [0,1,3]) for a given key +static string +json_extract_array (string json, string key) { + string search; + search << "\"" << key << "\":["; + int pos= -1; + int n = N (json); + int sn = N (search); + for (int i= 0; i + sn <= n; i++) { + bool match= true; + for (int j= 0; j < sn; j++) { + if (json[i + j] != search[j]) { + match= false; + break; + } + } + if (match) { + pos= i + sn - 1; // point to '[' + break; + } + } + if (pos < 0) return "[]"; + // Find matching ']' + for (int i= pos + 1; i < n; i++) { + if (json[i] == ']') { + return json (pos, i + 1); + } + } + return "[]"; +} + +modification +json_to_modification (string s) { + string type_str= json_extract_value (s, "type"); + string path_str= json_extract_array (s, "path"); + string tree_str= json_extract_value (s, "tree"); + path p = json_string_to_path (path_str); + tree t = json_string_to_tree (tree_str); + return make_modification (type_str, p, t); +} + +} // namespace data +} // namespace moebius diff --git a/moebius/moebius/data/json_serde.hpp b/moebius/moebius/data/json_serde.hpp new file mode 100644 index 0000000000..e31777064b --- /dev/null +++ b/moebius/moebius/data/json_serde.hpp @@ -0,0 +1,86 @@ +/****************************************************************************** + * MODULE : json_serde.hpp + * DESCRIPTION: JSON serialization/deserialization for modification and patch, + * enabling network transport for collaborative editing + * COPYRIGHT : (C) 2026 cc-fuyu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ +#pragma once +#include "modification.hpp" +#include "patch.hpp" + +namespace moebius { +namespace data { + +/** + * @brief Serialize a modification to a JSON-formatted string. + * + * The output format is: + * {"type":"assign","path":[0,1],"tree":"..."} + * + * This is designed for transmitting OT operations over WebSocket + * in a collaborative editing session. + * + * @param mod The modification to serialize. + * @return A JSON string representing the modification. + */ +string modification_to_json (modification mod); + +/** + * @brief Deserialize a modification from a JSON-formatted string. + * + * @param s A JSON string produced by modification_to_json. + * @return The deserialized modification. + */ +modification json_to_modification (string s); + +/** + * @brief Serialize a path to a JSON array string. + * + * Uses JSON array of integers, e.g. "[0,1,3]". + * An empty path is represented as "[]". + * + * @param p The path to serialize. + * @return A JSON array string representation. + */ +string path_to_json_string (path p); + +/** + * @brief Deserialize a path from a JSON array string. + * + * @param s A JSON array string, e.g. "[0,1,3]". + * @return The deserialized path. + */ +path json_string_to_path (string s); + +/** + * @brief Deserialize a path from a dot-separated string. + * + * @param s A dot-separated string, e.g. "0.1.3". + * @return The deserialized path. + */ +path json_string_to_path (string s); + +/** + * @brief Serialize a tree to a JSON-compatible string. + * + * Uses the existing scheme serialization as the transport format. + * + * @param t The tree to serialize. + * @return A scheme-formatted string representation. + */ +string tree_to_json_string (tree t); + +/** + * @brief Deserialize a tree from a scheme-formatted string. + * + * @param s A scheme-formatted string. + * @return The deserialized tree. + */ +tree json_string_to_tree (string s); + +} // namespace data +} // namespace moebius diff --git a/moebius/tests/moebius/data/json_serde_test.cpp b/moebius/tests/moebius/data/json_serde_test.cpp new file mode 100644 index 0000000000..9ba552aa61 --- /dev/null +++ b/moebius/tests/moebius/data/json_serde_test.cpp @@ -0,0 +1,115 @@ +#include "modification.hpp" +#include "moe_doctests.hpp" +#include "moebius/data/json_serde.hpp" +#include "tree.hpp" + +using moebius::data::json_string_to_path; +using moebius::data::json_string_to_tree; +using moebius::data::json_to_modification; +using moebius::data::modification_to_json; +using moebius::data::path_to_json_string; +using moebius::data::tree_to_json_string; + +/****************************************************************************** + * Path round-trip + ******************************************************************************/ + +TEST_CASE ("path_to_json_string empty path") { + path p= path (); + string s= path_to_json_string (p); + CHECK (s == "[]"); +} + +TEST_CASE ("path round-trip single element") { + path p= path (3); + string s= path_to_json_string (p); + path q= json_string_to_path (s); + CHECK (p == q); +} + +TEST_CASE ("path round-trip multi element") { + path p= path (0, path (1, path (2))); + string s= path_to_json_string (p); + path q= json_string_to_path (s); + CHECK (p == q); +} + +/****************************************************************************** + * Tree round-trip + ******************************************************************************/ + +TEST_CASE ("tree round-trip atomic") { + tree t= tree ("hello world"); + string s= tree_to_json_string (t); + tree u= json_string_to_tree (s); + CHECK (t == u); +} + +TEST_CASE ("tree round-trip compound") { + tree t= tree (DOCUMENT, "line1", "line2"); + string s= tree_to_json_string (t); + tree u= json_string_to_tree (s); + CHECK (t == u); +} + +/****************************************************************************** + * Modification round-trip + ******************************************************************************/ + +TEST_CASE ("modification round-trip assign") { + modification m= mod_assign (path (0), tree ("new content")); + string s= modification_to_json (m); + modification n= json_to_modification (s); + CHECK (m == n); +} + +TEST_CASE ("modification round-trip insert") { + modification m= mod_insert (path (0), 3, tree ("inserted")); + string s= modification_to_json (m); + modification n= json_to_modification (s); + CHECK (m == n); +} + +TEST_CASE ("modification round-trip remove") { + modification m= mod_remove (path (1), 2, 5); + string s= modification_to_json (m); + modification n= json_to_modification (s); + CHECK (m == n); +} + +TEST_CASE ("modification round-trip split") { + modification m= mod_split (path (), 1, 3); + string s= modification_to_json (m); + modification n= json_to_modification (s); + CHECK (m == n); +} + +TEST_CASE ("modification round-trip join") { + modification m= mod_join (path (0), 2); + string s= modification_to_json (m); + modification n= json_to_modification (s); + CHECK (m == n); +} + +TEST_CASE ("modification round-trip assign_node") { + modification m= mod_assign_node (path (0, path (1)), DOCUMENT); + string s= modification_to_json (m); + modification n= json_to_modification (s); + CHECK (m == n); +} + +TEST_CASE ("modification round-trip set_cursor") { + modification m= mod_set_cursor (path (0), 5, tree ("cursor_data")); + string s= modification_to_json (m); + modification n= json_to_modification (s); + CHECK (m == n); +} + +TEST_CASE ("modification json contains expected keys") { + modification m= mod_assign (path (0), tree ("test")); + string s= modification_to_json (m); + // Verify the JSON string contains the expected structure + CHECK (N (s) > 0); + CHECK (s[0] == '{'); + CHECK (s[N (s) - 1] == '}'); +}