diff --git a/gleam.toml b/gleam.toml index e383e6e..72222b4 100644 --- a/gleam.toml +++ b/gleam.toml @@ -20,6 +20,7 @@ redraw_dom = ">= 19.2.2 and < 20.0.0" lustre = ">= 5.7.1 and < 6.0.0" mendraw = ">= 2.0.0 and < 3.0.0" simplifile = ">= 2.6.0 and < 3.0.0" +xmlm = ">= 1.0.1 and < 2.0.0" [dev_dependencies] gleeunit = ">= 1.11.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 9bb98fe..7986719 100644 --- a/manifest.toml +++ b/manifest.toml @@ -24,6 +24,7 @@ packages = [ { name = "redraw", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "redraw", source = "hex", outer_checksum = "B8CEEB74E8846CE10B8360B924DAD22441B61D947F9449854164F0686C4B8661" }, { name = "redraw_dom", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_stdlib", "redraw"], otp_app = "redraw_dom", source = "hex", outer_checksum = "80278296AD6E3D4457D6FF6A14FEA3E90696284BEA297BDE10E430FC4CE726B8" }, { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "xmlm", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "xmlm", source = "hex", outer_checksum = "F23155B6F0B22CB8E09DAD41BB6B1CD036725FD0A80EE25B1D1B14138683A81D" }, ] [requirements] @@ -37,3 +38,4 @@ mendraw = { version = ">= 2.0.0 and < 3.0.0" } redraw = { version = ">= 19.2.2 and < 20.0.0" } redraw_dom = { version = ">= 19.2.2 and < 20.0.0" } simplifile = { version = ">= 2.6.0 and < 3.0.0" } +xmlm = { version = ">= 1.0.1 and < 2.0.0" } diff --git a/src/glendix/define.gleam b/src/glendix/define.gleam index 8fe4c93..8b71884 100644 --- a/src/glendix/define.gleam +++ b/src/glendix/define.gleam @@ -12,6 +12,7 @@ import gleam/list import gleam/option import gleam/result import gleam/string +import glendix/define/document import glendix/define/file_boundary import glendix/define/model import glendix/define/ui @@ -29,64 +30,72 @@ pub fn main() -> Nil { io.println("\n " <> style.red(file_error_message(error))) Nil } - Ok(xml_content) -> { - let #(meta, groups) = parse_widget_xml(xml_content) - let state = - DefineState( - xml_path: xml_path, - widget_meta: meta, - groups: groups, - cursor: 0, - collapsed: [], - view_mode: TreeView, - dirty: False, - status_msg: option.None, - scroll_offset: 0, - add_target_group: 0, - selected_type_idx: 0, - edit_group_idx: 0, - edit_item_idx: 0, - ) - case is_tty() { - True -> { - case enter_tui() { - Error(error) -> { - io.println( - "\n " - <> style.red( - "Unable to enable terminal raw mode: " - <> string.inspect(error), - ), - ) - Nil - } - Ok(Nil) -> { - { - use _final <- promise.await(tui_loop(state)) - case exit_tui() { - Ok(Nil) -> Nil - Error(error) -> - io.println( - "\n " - <> style.red( - "Unable to restore terminal mode: " - <> string.inspect(error), - ), - ) + Ok(xml_content) -> + case document.parse(xml_content) { + Error(error) -> { + io.println("\n " <> style.red(document.error_message(error))) + Nil + } + Ok(#(meta, groups)) -> { + let state = + DefineState( + xml_path: xml_path, + widget_meta: meta, + groups: groups, + cursor: 0, + collapsed: [], + view_mode: TreeView, + dirty: False, + status_msg: option.None, + scroll_offset: 0, + add_target_group: 0, + selected_type_idx: 0, + edit_group_idx: 0, + edit_item_idx: 0, + ) + case is_tty() { + True -> { + case enter_tui() { + Error(error) -> { + io.println( + "\n " + <> style.red( + "Unable to enable terminal raw mode: " + <> string.inspect(error), + ), + ) + Nil + } + Ok(Nil) -> { + { + use _final <- promise.await(tui_loop(state)) + case exit_tui() { + Ok(Nil) -> Nil + Error(error) -> + io.println( + "\n " + <> style.red( + "Unable to restore terminal mode: " + <> string.inspect(error), + ), + ) + } + exit_process() + promise.resolve(Nil) + } + Nil } - exit_process() - promise.resolve(Nil) } + } + False -> { + io.println( + "\n " <> style.yellow("TTY가 아닙니다. 대화형 모드가 필요합니다."), + ) Nil } } } - False -> { - io.println("\n " <> style.yellow("TTY가 아닙니다. 대화형 모드가 필요합니다.")) - Nil - } } - } } } } @@ -2035,7 +2044,7 @@ fn return_from_multi_select( } fn save_xml(state: DefineState) -> DefineState { - let xml = serialize_widget_xml(state.widget_meta, state.groups) + let xml = document.serialize(state.widget_meta, state.groups) case file_boundary.write(state.xml_path, xml) { Ok(Nil) -> DefineState( @@ -2074,17 +2083,6 @@ fn exit_process() -> Nil @external(javascript, "./define_ffi.mjs", "terminal_size") fn terminal_size() -> #(Int, Int) -@external(javascript, "./define_ffi.mjs", "parse_widget_xml") -fn parse_widget_xml( - xml: String, -) -> #(model.WidgetMeta, List(model.PropertyGroup)) - -@external(javascript, "./define_ffi.mjs", "serialize_widget_xml") -fn serialize_widget_xml( - meta: model.WidgetMeta, - groups: List(model.PropertyGroup), -) -> String - @external(javascript, "./define_ffi.mjs", "poll_key_raw") fn poll_key_raw(timeout_ms: Int) -> promise.Promise(#(Int, String)) diff --git a/src/glendix/define/document.gleam b/src/glendix/define/document.gleam new file mode 100644 index 0000000..1c9f160 --- /dev/null +++ b/src/glendix/define/document.gleam @@ -0,0 +1,636 @@ +//// Parses and serializes Mendix widget definition XML. +//// + +import gleam/list +import gleam/option +import gleam/result +import gleam/string +import glendix/define/model +import xmlm + +/// Describes a widget definition XML parsing failure. +@internal +pub type WidgetXmlError { + /// The input is not a well-formed XML document. + MalformedWidgetXml(reason: String) + /// The XML document root is not a Mendix widget element. + WidgetRootWasExpected(actual: String) +} + +/// Parses one complete Mendix widget definition document. +/// +/// Malformed XML and documents whose root is not `widget` return a descriptive +/// error rather than a partial widget model. +@internal +pub fn parse( + source source: String, +) -> Result(#(model.WidgetMeta, List(model.PropertyGroup)), WidgetXmlError) { + use #(_, root, input) <- result.try( + xmlm.from_string(source) + |> xmlm.document_tree( + element_callback: xml_node, + data_callback: fn(data: String) -> XmlNode { XmlText(data) }, + ) + |> result.map_error(xml_error), + ) + use #(at_end, _) <- result.try(xmlm.eoi(input) |> result.map_error(xml_error)) + + case at_end { + False -> Error(MalformedWidgetXml(reason: "unexpected trailing XML data")) + True -> parse_widget_root(root) + } +} + +/// Returns a human-readable explanation of a widget XML parsing failure. +@internal +pub fn error_message(error error: WidgetXmlError) -> String { + case error { + MalformedWidgetXml(reason) -> "Unable to parse widget XML: " <> reason + WidgetRootWasExpected(actual) -> + "Unable to parse widget XML: expected root, found <" + <> actual + <> ">." + } +} + +/// Serializes a widget definition using the stable Glendix XML layout. +@internal +pub fn serialize( + meta meta: model.WidgetMeta, + groups groups: List(model.PropertyGroup), +) -> String { + let attributes = [ + xml_attribute("id", meta.id), + bool_attribute("pluginWidget", meta.plugin_widget), + bool_attribute("needsEntityContext", meta.needs_entity_context), + bool_attribute("offlineCapable", meta.offline_capable), + xml_attribute("supportedPlatform", meta.supported_platform), + xml_attribute("xmlns", mendix_widget_namespace), + xml_attribute("xmlns:xsi", xml_schema_instance_namespace), + xml_attribute("xsi:schemaLocation", schema_location), + ] + let lines = [ + "", + " string.join(attributes, " ") <> ">", + " " <> escape_xml(meta.name) <> "", + " " <> escape_xml(meta.description) <> "", + ] + let lines = case meta.icon { + "" -> lines + icon -> list.append(lines, [" " <> icon <> ""]) + } + let lines = + append_optional_element( + lines, + "studioProCategory", + meta.studio_pro_category, + ) + let lines = append_optional_element(lines, "helpUrl", meta.help_url) + let lines = append_optional_element(lines, "prompt", meta.prompt) + let lines = list.append(lines, [" "]) + let lines = + list.append(lines, list.map(groups, serialize_group(_, " "))) + let lines = list.append(lines, [" ", ""]) + + string.join(lines, "\n") <> "\n" +} + +const mendix_widget_namespace = "http://www.mendix.com/widget/1.0/" + +const xml_schema_instance_namespace = "http://www.w3.org/2001/XMLSchema-instance" + +const schema_location = "http://www.mendix.com/widget/1.0/ ../node_modules/mendix/custom_widget.xsd" + +type XmlAttribute { + XmlAttribute(local_name: String, value: String) +} + +type XmlNode { + XmlElement( + namespace_uri: String, + local_name: String, + attributes: List(XmlAttribute), + children: List(XmlNode), + ) + XmlText(String) +} + +fn xml_node(tag: xmlm.Tag, children: List(XmlNode)) -> XmlNode { + let xmlm.Tag(name, attributes) = tag + let xmlm.Name(namespace_uri, local_name) = name + XmlElement( + namespace_uri: namespace_uri, + local_name: local_name, + attributes: list.map(attributes, fn(attribute: xmlm.Attribute) { + let xmlm.Attribute(name, value) = attribute + let xmlm.Name(_, local_name) = name + XmlAttribute(local_name: local_name, value: value) + }), + children: children, + ) +} + +fn xml_error(error: xmlm.InputError) -> WidgetXmlError { + MalformedWidgetXml(reason: xmlm.input_error_to_string(error)) +} + +fn parse_widget_root( + root: XmlNode, +) -> Result(#(model.WidgetMeta, List(model.PropertyGroup)), WidgetXmlError) { + case root { + XmlText(_) -> Error(WidgetRootWasExpected(actual: "text")) + XmlElement(_, "widget", attributes, children) -> { + let studio_pro_category = case child_text(children, "studioProCategory") { + option.Some(value) -> option.Some(value) + option.None -> child_text(children, "studioCategory") + } + let meta = + model.WidgetMeta( + id: attribute_or(attributes, "id", ""), + plugin_widget: bool_attribute_or(attributes, "pluginWidget", False), + offline_capable: bool_attribute_or( + attributes, + "offlineCapable", + False, + ), + supported_platform: attribute_or( + attributes, + "supportedPlatform", + "Web", + ), + needs_entity_context: bool_attribute_or( + attributes, + "needsEntityContext", + False, + ), + name: child_text_or(children, "name", "Unknown"), + description: child_text_or(children, "description", "") + |> string.trim, + studio_pro_category: studio_pro_category, + help_url: child_text(children, "helpUrl"), + icon: child_text_or(children, "icon", ""), + prompt: child_text(children, "prompt") |> option.map(string.trim), + ) + let groups = case child_element(children, "properties") { + option.Some(XmlElement(_, _, _, property_children)) -> + parse_groups(property_children) + option.Some(XmlText(_)) | option.None -> [] + } + Ok(#(meta, groups)) + } + XmlElement(_, actual, _, _) -> Error(WidgetRootWasExpected(actual: actual)) + } +} + +fn parse_groups(children: List(XmlNode)) -> List(model.PropertyGroup) { + children + |> list.filter_map(fn(node: XmlNode) -> Result(model.PropertyGroup, Nil) { + case node { + XmlElement(_, "propertyGroup", attributes, group_children) -> + Ok(model.PropertyGroup( + caption: attribute_or(attributes, "caption", ""), + items: parse_items(group_children), + )) + XmlElement(_, _, _, _) | XmlText(_) -> Error(Nil) + } + }) +} + +fn parse_items(children: List(XmlNode)) -> List(model.PropertyItem) { + children + |> list.filter_map(fn(node: XmlNode) -> Result(model.PropertyItem, Nil) { + case node { + XmlElement(_, "property", attributes, property_children) -> + Ok(model.PropItem(parse_property(attributes, property_children))) + XmlElement(_, "systemProperty", attributes, _) -> + Ok( + model.SysPropItem( + model.SystemProperty(key: attribute_or(attributes, "key", "")), + ), + ) + XmlElement(_, _, _, _) | XmlText(_) -> Error(Nil) + } + }) +} + +fn parse_property( + attributes: List(XmlAttribute), + children: List(XmlNode), +) -> model.Property { + let key = attribute_or(attributes, "key", "") + let property_type = + attribute_or(attributes, "type", "string") + |> model.string_to_type + |> result.unwrap(model.TypeString) + let return_type = case child_element(children, "returnType") { + option.Some(XmlElement(_, _, return_attributes, _)) -> + option.Some(model.ReturnType( + type_name: attribute_or(return_attributes, "type", ""), + assignable_to: attribute(return_attributes, "assignableTo"), + )) + option.Some(XmlText(_)) | option.None -> option.None + } + + model.Property( + key: key, + type_: property_type, + caption: child_text_or(children, "caption", key), + description: child_text_or(children, "description", ""), + required: optional_bool_attribute(attributes, "required"), + default_value: attribute(attributes, "defaultValue"), + multiline: optional_bool_attribute(attributes, "multiline"), + is_list: optional_bool_attribute(attributes, "isList"), + data_source: attribute(attributes, "dataSource"), + allow_upload: optional_bool_attribute(attributes, "allowUpload"), + on_change: attribute(attributes, "onChange"), + set_label: optional_bool_attribute(attributes, "setLabel"), + return_type: return_type, + enumeration_values: parse_enumeration_values(children), + attribute_types: parse_named_types( + children, + container_name: "attributeTypes", + item_name: "attributeType", + ), + association_types: parse_named_types( + children, + container_name: "associationTypes", + item_name: "associationType", + ), + selection_types: parse_named_types( + children, + container_name: "selectionTypes", + item_name: "selectionType", + ), + default_type: attribute(attributes, "defaultType"), + selectable_objects: attribute(attributes, "selectableObjects"), + sub_properties: case child_element(children, "properties") { + option.Some(XmlElement(_, _, _, property_children)) -> + parse_groups(property_children) + option.Some(XmlText(_)) | option.None -> [] + }, + ) +} + +fn parse_enumeration_values(children: List(XmlNode)) -> List(model.EnumValue) { + case child_element(children, "enumerationValues") { + option.Some(XmlElement(_, _, _, enumeration_children)) -> + enumeration_children + |> list.filter_map(fn(node: XmlNode) -> Result(model.EnumValue, Nil) { + case node { + XmlElement(_, "enumerationValue", attributes, _) -> + Ok(model.EnumValue( + key: attribute_or(attributes, "key", ""), + caption: node_text(node), + )) + XmlElement(_, _, _, _) | XmlText(_) -> Error(Nil) + } + }) + option.Some(XmlText(_)) | option.None -> [] + } +} + +fn parse_named_types( + children: List(XmlNode), + container_name container_name: String, + item_name item_name: String, +) -> List(String) { + case child_element(children, container_name) { + option.Some(XmlElement(_, _, _, type_children)) -> + type_children + |> list.filter_map(fn(node: XmlNode) -> Result(String, Nil) { + case node { + XmlElement(_, local_name, attributes, _) if local_name == item_name -> + Ok(attribute_or(attributes, "name", "")) + XmlElement(_, _, _, _) | XmlText(_) -> Error(Nil) + } + }) + option.Some(XmlText(_)) | option.None -> [] + } +} + +fn child_element( + children: List(XmlNode), + local_name: String, +) -> option.Option(XmlNode) { + case children { + [] -> option.None + [child, ..rest] -> + case child { + XmlElement(_, child_name, _, _) if child_name == local_name -> + option.Some(child) + XmlElement(_, _, _, _) | XmlText(_) -> child_element(rest, local_name) + } + } +} + +fn child_text( + children: List(XmlNode), + local_name: String, +) -> option.Option(String) { + child_element(children, local_name) + |> option.map(node_text) +} + +fn child_text_or( + children: List(XmlNode), + local_name: String, + default: String, +) -> String { + case child_text(children, local_name) { + option.Some(value) -> value + option.None -> default + } +} + +fn node_text(node: XmlNode) -> String { + case node { + XmlText(data) -> data + XmlElement(_, _, _, children) -> + children + |> list.map(node_text) + |> string.concat + } +} + +fn attribute( + attributes: List(XmlAttribute), + local_name: String, +) -> option.Option(String) { + case attributes { + [] -> option.None + [XmlAttribute(attribute_name, value), ..rest] -> + case attribute_name == local_name { + True -> option.Some(value) + False -> attribute(rest, local_name) + } + } +} + +fn attribute_or( + attributes: List(XmlAttribute), + local_name: String, + default: String, +) -> String { + case attribute(attributes, local_name) { + option.Some(value) -> value + option.None -> default + } +} + +fn bool_attribute_or( + attributes: List(XmlAttribute), + local_name: String, + default: Bool, +) -> Bool { + case attribute(attributes, local_name) { + option.Some("true") -> True + option.Some(_) -> False + option.None -> default + } +} + +fn optional_bool_attribute( + attributes: List(XmlAttribute), + local_name: String, +) -> option.Option(Bool) { + case attribute(attributes, local_name) { + option.Some("true") -> option.Some(True) + option.Some(_) -> option.Some(False) + option.None -> option.None + } +} + +fn append_optional_element( + lines: List(String), + name: String, + value: option.Option(String), +) -> List(String) { + case value { + option.Some(content) -> + list.append(lines, [ + " <" <> name <> ">" <> escape_xml(content) <> " name <> ">", + ]) + option.None -> lines + } +} + +fn serialize_group(group: model.PropertyGroup, indent: String) -> String { + let child_indent = indent <> " " + let lines = [ + indent <> " escape_xml(group.caption) <> "\">", + ] + let lines = + list.append( + lines, + list.map(group.items, fn(item: model.PropertyItem) -> String { + case item { + model.SysPropItem(property) -> + child_indent + <> " escape_xml(property.key) + <> "\" />" + model.PropItem(property) -> serialize_property(property, child_indent) + } + }), + ) + let lines = list.append(lines, [indent <> ""]) + string.join(lines, "\n") +} + +fn serialize_property(property: model.Property, indent: String) -> String { + let child_indent = indent <> " " + let attributes = [ + xml_attribute("key", property.key), + xml_attribute("type", model.type_to_string(property.type_)), + ] + let attributes = + append_optional_bool_attribute(attributes, "required", property.required) + let attributes = + append_optional_attribute( + attributes, + "defaultValue", + property.default_value, + ) + let attributes = + append_optional_bool_attribute(attributes, "multiline", property.multiline) + let attributes = + append_optional_bool_attribute(attributes, "isList", property.is_list) + let attributes = + append_optional_attribute(attributes, "dataSource", property.data_source) + let attributes = + append_optional_bool_attribute( + attributes, + "allowUpload", + property.allow_upload, + ) + let attributes = + append_optional_attribute(attributes, "onChange", property.on_change) + let attributes = + append_optional_bool_attribute(attributes, "setLabel", property.set_label) + let attributes = + append_optional_attribute(attributes, "defaultType", property.default_type) + let attributes = + append_optional_attribute( + attributes, + "selectableObjects", + property.selectable_objects, + ) + let lines = [ + indent <> " string.join(attributes, " ") <> ">", + child_indent <> "" <> escape_xml(property.caption) <> "", + child_indent + <> "" + <> escape_xml(property.description) + <> "", + ] + let lines = case property.return_type { + option.Some(return_type) -> { + let attributes = [xml_attribute("type", return_type.type_name)] + let attributes = + append_optional_attribute( + attributes, + "assignableTo", + return_type.assignable_to, + ) + list.append(lines, [ + child_indent <> " string.join(attributes, " ") <> " />", + ]) + } + option.None -> lines + } + let lines = + append_value_container( + lines, + child_indent, + container_name: "enumerationValues", + values: list.map(property.enumeration_values, serialize_enumeration_value), + ) + let lines = + append_value_container( + lines, + child_indent, + container_name: "attributeTypes", + values: list.map(property.attribute_types, serialize_named_type( + _, + "attributeType", + )), + ) + let lines = + append_value_container( + lines, + child_indent, + container_name: "associationTypes", + values: list.map(property.association_types, serialize_named_type( + _, + "associationType", + )), + ) + let lines = + append_value_container( + lines, + child_indent, + container_name: "selectionTypes", + values: list.map(property.selection_types, serialize_named_type( + _, + "selectionType", + )), + ) + let lines = case property.sub_properties { + [] -> lines + sub_properties -> { + let nested_groups = + list.map(sub_properties, serialize_group(_, child_indent <> " ")) + let nested_lines = [child_indent <> "", ..nested_groups] + let nested_lines = + list.append(nested_lines, [child_indent <> ""]) + list.append(lines, nested_lines) + } + } + let lines = list.append(lines, [indent <> ""]) + string.join(lines, "\n") +} + +fn serialize_enumeration_value(value: model.EnumValue) -> String { + " escape_xml(value.key) + <> "\">" + <> escape_xml(value.caption) + <> "" +} + +fn serialize_named_type(value: String, name: String) -> String { + "<" <> name <> " name=\"" <> escape_xml(value) <> "\" />" +} + +fn append_value_container( + lines: List(String), + indent: String, + container_name container_name: String, + values values: List(String), +) -> List(String) { + case values { + [] -> lines + values -> { + let value_lines = + list.map(values, fn(value: String) -> String { + indent <> " " <> value + }) + let container_lines = [ + indent <> "<" <> container_name <> ">", + ..value_lines + ] + let container_lines = + list.append(container_lines, [ + indent <> " container_name <> ">", + ]) + list.append(lines, container_lines) + } + } +} + +fn append_optional_attribute( + attributes: List(String), + name: String, + value: option.Option(String), +) -> List(String) { + case value { + option.Some(content) -> + list.append(attributes, [xml_attribute(name, content)]) + option.None -> attributes + } +} + +fn append_optional_bool_attribute( + attributes: List(String), + name: String, + value: option.Option(Bool), +) -> List(String) { + case value { + option.Some(content) -> + list.append(attributes, [bool_attribute(name, content)]) + option.None -> attributes + } +} + +fn xml_attribute(name: String, value: String) -> String { + name <> "=\"" <> escape_xml(value) <> "\"" +} + +fn bool_attribute(name: String, value: Bool) -> String { + xml_attribute(name, bool_to_xml(value)) +} + +fn bool_to_xml(value: Bool) -> String { + case value { + True -> "true" + False -> "false" + } +} + +fn escape_xml(value: String) -> String { + value + |> string.replace("&", "&") + |> string.replace("<", "<") + |> string.replace(">", ">") + |> string.replace("\"", """) +} diff --git a/src/glendix/define_ffi.mjs b/src/glendix/define_ffi.mjs index cdaf056..22036b6 100644 --- a/src/glendix/define_ffi.mjs +++ b/src/glendix/define_ffi.mjs @@ -1,16 +1,5 @@ // FFI adapter for the Mendix widget property TUI editor. import { Ok, Error as GleamError } from "../gleam.mjs"; -import { Some, None } from "../../gleam_stdlib/gleam/option.mjs"; -import { toList } from "../gleam.mjs"; -import { - PropertyGroup, PropItem, SysPropItem, SystemProperty, - Property, EnumValue, ReturnType, - TypeString, TypeBoolean, TypeInteger, TypeDecimal, TypeEnumeration, - TypeIcon, TypeImage, TypeWidgets, TypeFile, - TypeExpression, TypeTextTemplate, TypeAction, TypeAttribute, - TypeAssociation, TypeObject, TypeDatasource, TypeSelection, - WidgetMeta, -} from "./define/model.mjs"; export function is_tty() { return !!process.stdin.isTTY; } @@ -42,305 +31,6 @@ export function set_terminal_raw_mode(enabled) { export function terminal_mode_error_message(error) { return error; } -function escXml(s) { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} -function unesc(s) { - return s - .replace(/"/g, '"') - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/&/g, "&"); -} -function attr(tag, name) { - const m = tag.match(new RegExp(`${name}="([^"]*)"`)); - return m ? m[1] : null; -} -function findClose(xml, tag, from) { - const close = ``; - let depth = 1, pos = from; - while (depth > 0 && pos < xml.length) { - const nc = xml.indexOf(close, pos); - if (nc === -1) return xml.length; - const seg = xml.substring(pos, nc); - const re = new RegExp(`<${tag}[\\s>]`, "g"); - let m; - while ((m = re.exec(seg)) !== null) { - const gt = seg.indexOf(">", m.index); - if (gt !== -1 && seg[gt - 1] !== "/") depth++; - } - depth--; - pos = nc + close.length; - } - return pos; -} -function findElements(xml, tag) { - const results = []; - const re = new RegExp(`<${tag}(\\s[^>]*)?>`, "g"); - let m; - while ((m = re.exec(xml)) !== null) { - const attrs = (m[1] || "").trim(); - if (m[0].endsWith("/>")) { - results.push({ attrs: attrs.replace(/\/$/, "").trim(), content: "", pos: m.index }); - continue; - } - const openEnd = m.index + m[0].length; - const closeEnd = findClose(xml, tag, openEnd); - const closeTag = ``; - const content = xml.substring(openEnd, closeEnd - closeTag.length); - results.push({ attrs, content, pos: m.index }); - re.lastIndex = closeEnd; - } - return results; -} -const TYPE_MAP = { - string: TypeString, boolean: TypeBoolean, integer: TypeInteger, - decimal: TypeDecimal, enumeration: TypeEnumeration, - icon: TypeIcon, image: TypeImage, widgets: TypeWidgets, file: TypeFile, - expression: TypeExpression, textTemplate: TypeTextTemplate, - action: TypeAction, attribute: TypeAttribute, association: TypeAssociation, - object: TypeObject, datasource: TypeDatasource, selection: TypeSelection, -}; -function toType(s) { - const T = TYPE_MAP[s]; - return T ? new T() : new TypeString(); -} -function parseProp(el) { - const a = el.attrs; - const c = el.content; - const key = attr(a, "key") || ""; - const type_ = toType(attr(a, "type") || "string"); - const capM = c.match(/([^<]*)<\/caption>/); - const caption = capM ? unesc(capM[1]) : key; - const descM = c.match(/([^<]*)<\/description>/); - const description = descM ? unesc(descM[1]) : ""; - const opt = (n) => { const v = attr(a, n); return v !== null ? new Some(v) : new None(); }; - const optBool = (n) => { const v = attr(a, n); return v !== null ? new Some(v === "true") : new None(); }; - // returnType - const rtM = c.match(/]*>([^<]*)<\/enumerationValue>/g; - let ev; - while ((ev = evRe.exec(c)) !== null) enums.push(new EnumValue(ev[1], unesc(ev[2]))); - // attributeTypes - const attrTypes = []; - const atRe = /([\s\S]*)<\/properties>/); - if (subM) { - for (const g of parseGroups(subM[1])) subGroups.push(g); - } - return new Property( - key, type_, caption, description, - optBool("required"), opt("defaultValue"), - optBool("multiline"), optBool("isList"), - opt("dataSource"), optBool("allowUpload"), - opt("onChange"), optBool("setLabel"), - return_type, - toList(enums), toList(attrTypes), toList(assocTypes), toList(selTypes), - opt("defaultType"), - opt("selectableObjects"), - toList(subGroups), - ); -} -function parseGroups(propertiesXml) { - const groupEls = findElements(propertiesXml, "propertyGroup"); - return groupEls.map(gel => { - const caption = unesc(attr(gel.attrs, "caption") || ""); - const items = []; - const propEls = findElements(gel.content, "property"); - const sysEls = findElements(gel.content, "systemProperty"); - const all = [ - ...propEls.map(e => ({ ...e, kind: "prop" })), - ...sysEls.map(e => ({ ...e, kind: "sys" })), - ].sort((a, b) => a.pos - b.pos); - for (const el of all) { - if (el.kind === "sys") { - const k = attr(el.attrs, "key") || ""; - items.push(new SysPropItem(new SystemProperty(k))); - } else { - items.push(new PropItem(parseProp(el))); - } - } - return new PropertyGroup(caption, toList(items)); - }); -} -// -- Parse Widget XML -- -export function parse_widget_xml(xmlString) { - const widgetTagM = xmlString.match(/]*?)>/s); - const widgetAttrs = widgetTagM ? widgetTagM[1] : ""; - const id = attr(widgetAttrs, "id") || ""; - const pluginWidget = attr(widgetAttrs, "pluginWidget") === "true"; - const offlineCapable = attr(widgetAttrs, "offlineCapable") === "true"; - const supportedPlatform = attr(widgetAttrs, "supportedPlatform") || "Web"; - const needsEntityContext = attr(widgetAttrs, "needsEntityContext") === "true"; - const nameM = xmlString.match(/([^<]*)<\/name>/); - const name = nameM ? nameM[1] : "Unknown"; - const afterName = xmlString.indexOf(""); - const descStart = afterName > 0 ? xmlString.indexOf("", afterName) : -1; - const descEnd = descStart > 0 ? xmlString.indexOf("", descStart) : -1; - const propsStart = xmlString.indexOf(""); - const desc = (descStart > 0 && descEnd > 0 && (propsStart < 0 || descStart < propsStart)) - ? xmlString.substring(descStart + 13, descEnd).trim() - : ""; - const iconM = xmlString.match(/([\s\S]*?)<\/icon>/); - const icon = iconM ? iconM[1] : ""; - const studioCatM = xmlString.match(/([^<]*)<\/studioCategory>/); - const studioProCatM = xmlString.match(/([^<]*)<\/studioProCategory>/); - const studioProCategory = studioProCatM - ? new Some(studioProCatM[1]) - : (studioCatM ? new Some(studioCatM[1]) : new None()); - const helpUrlM = xmlString.match(/([^<]*)<\/helpUrl>/); - const helpUrl = helpUrlM ? new Some(helpUrlM[1]) : new None(); - const promptM = xmlString.match(/([\s\S]*?)<\/prompt>/); - const widgetPrompt = promptM ? new Some(promptM[1].trim()) : new None(); - const meta = new WidgetMeta( - id, pluginWidget, offlineCapable, supportedPlatform, needsEntityContext, - name, desc, studioProCategory, helpUrl, icon, widgetPrompt, - ); - const propsM = xmlString.match(/([\s\S]*)<\/properties>/); - const groups = propsM ? parseGroups(propsM[1]) : []; - return [meta, toList(groups)]; -} -function serializeProperty(prop, indent) { - const i = indent; - const i2 = indent + " "; - const attrs = []; - attrs.push(`key="${escXml(prop.key)}"`); - attrs.push(`type="${typeToStr(prop.type_)}"`); - if (prop.required instanceof Some) attrs.push(`required="${prop.required[0]}"`); - if (prop.default_value instanceof Some) attrs.push(`defaultValue="${escXml(prop.default_value[0])}"`); - if (prop.multiline instanceof Some) attrs.push(`multiline="${prop.multiline[0]}"`); - if (prop.is_list instanceof Some) attrs.push(`isList="${prop.is_list[0]}"`); - if (prop.data_source instanceof Some) attrs.push(`dataSource="${escXml(prop.data_source[0])}"`); - if (prop.allow_upload instanceof Some) attrs.push(`allowUpload="${prop.allow_upload[0]}"`); - if (prop.on_change instanceof Some) attrs.push(`onChange="${escXml(prop.on_change[0])}"`); - if (prop.set_label instanceof Some) attrs.push(`setLabel="${prop.set_label[0]}"`); - if (prop.default_type instanceof Some) attrs.push(`defaultType="${escXml(prop.default_type[0])}"`); - if (prop.selectable_objects instanceof Some) attrs.push(`selectableObjects="${escXml(prop.selectable_objects[0])}"`); - const children = []; - children.push(`${i2}${escXml(prop.caption)}`); - children.push(`${i2}${escXml(prop.description)}`); - // returnType - if (prop.return_type instanceof Some) { - const rt = prop.return_type[0]; - let rtTag = `${i2} 0) { - children.push(`${i2}`); - for (const ev of evArr) { - children.push(`${i2} ${escXml(ev.caption)}`); - } - children.push(`${i2}`); - } - // attributeTypes - const atArr = prop.attribute_types.toArray(); - if (atArr.length > 0) { - children.push(`${i2}`); - for (const t of atArr) children.push(`${i2} `); - children.push(`${i2}`); - } - // associationTypes - const asArr = prop.association_types.toArray(); - if (asArr.length > 0) { - children.push(`${i2}`); - for (const t of asArr) children.push(`${i2} `); - children.push(`${i2}`); - } - // selectionTypes - const stArr = prop.selection_types.toArray(); - if (stArr.length > 0) { - children.push(`${i2}`); - for (const t of stArr) children.push(`${i2} `); - children.push(`${i2}`); - } - const subArr = prop.sub_properties.toArray(); - if (subArr.length > 0) { - children.push(`${i2}`); - for (const g of subArr) children.push(serializeGroup(g, i2 + " ")); - children.push(`${i2}`); - } - return `${i}\n${children.join("\n")}\n${i}`; -} -function typeToStr(t) { - for (const [k, C] of Object.entries(TYPE_MAP)) { - if (t instanceof C) return k; - } - return "string"; -} -function serializeGroup(group, indent) { - const i = indent; - const i2 = indent + " "; - const lines = [`${i}`]; - for (const item of group.items.toArray()) { - if (item instanceof SysPropItem) { - lines.push(`${i2}`); - } else if (item instanceof PropItem) { - lines.push(serializeProperty(item[0], i2)); - } - } - lines.push(`${i}`); - return lines.join("\n"); -} -export function serialize_widget_xml(meta, groups) { - const lines = []; - lines.push(''); - const widgetAttrs = [ - `id="${escXml(meta.id)}"`, - `pluginWidget="${meta.plugin_widget}"`, - `needsEntityContext="${meta.needs_entity_context}"`, - `offlineCapable="${meta.offline_capable}"`, - `supportedPlatform="${escXml(meta.supported_platform)}"`, - `xmlns="http://www.mendix.com/widget/1.0/"`, - `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`, - `xsi:schemaLocation="http://www.mendix.com/widget/1.0/ ../node_modules/mendix/custom_widget.xsd"`, - ]; - lines.push(``); - lines.push(` ${escXml(meta.name)}`); - lines.push(` ${escXml(meta.description)}`); - if (meta.icon) lines.push(` ${meta.icon}`); - if (meta.studio_pro_category instanceof Some) { - lines.push(` ${escXml(meta.studio_pro_category[0])}`); - } - if (meta.help_url instanceof Some) { - lines.push(` ${escXml(meta.help_url[0])}`); - } - if (meta.prompt instanceof Some) { - lines.push(` ${escXml(meta.prompt[0])}`); - } - lines.push(" "); - for (const g of groups.toArray()) { - lines.push(serializeGroup(g, " ")); - } - lines.push(" "); - lines.push(""); - return lines.join("\n") + "\n"; -} let stdinActive = false; let keyQueue = []; let keyResolver = null; diff --git a/test/document_test.gleam b/test/document_test.gleam new file mode 100644 index 0000000..1fff700 --- /dev/null +++ b/test/document_test.gleam @@ -0,0 +1,264 @@ +//// Exercises Mendix widget definition XML parsing and serialization. +//// + +import gleam/option +import gleam/string +import gleeunit/should +import glendix/define/document +import glendix/define/model + +/// Verifies namespace-aware parsing preserves the complete widget model. +pub fn widget_xml_structural_round_trip_test() -> Nil { + document.parse(representative_xml()) + |> should.equal(Ok(#(representative_meta(), representative_groups()))) +} + +/// Verifies serialization retains the established byte-exact Glendix layout. +pub fn widget_xml_byte_exact_serialization_test() -> Nil { + document.serialize(representative_meta(), representative_groups()) + |> should.equal(representative_serialized_xml()) +} + +/// Verifies generated XML parses back to the same model. +pub fn widget_xml_generated_document_round_trip_test() -> Nil { + let meta = representative_meta() + let groups = representative_groups() + + document.serialize(meta, groups) + |> document.parse + |> should.equal(Ok(#(meta, groups))) +} + +/// Verifies compatibility defaults and unknown property type handling. +pub fn widget_xml_empty_and_default_values_test() -> Nil { + let source = + "Fallback" + let expected_meta = + model.WidgetMeta( + id: "", + plugin_widget: False, + offline_capable: False, + supported_platform: "Web", + needs_entity_context: False, + name: "Unknown", + description: "", + studio_pro_category: option.None, + help_url: option.None, + icon: "", + prompt: option.None, + ) + let expected_property = + model.Property( + ..model.default_property("fallback", model.TypeString), + caption: "Fallback", + required: option.None, + default_value: option.None, + multiline: option.None, + ) + + document.parse(source) + |> should.equal( + Ok( + #(expected_meta, [ + model.PropertyGroup("", [model.PropItem(expected_property)]), + ]), + ), + ) +} + +/// Verifies malformed XML produces a descriptive domain error. +pub fn widget_xml_malformed_document_error_test() -> Nil { + case document.parse("") { + Error(document.MalformedWidgetXml(reason)) -> + reason + |> string.contains("ERROR Position") + |> should.be_true + Error(document.WidgetRootWasExpected(_)) | Ok(_) -> should.fail() + } +} + +/// Verifies a well-formed non-widget document is rejected explicitly. +pub fn widget_xml_wrong_root_error_test() -> Nil { + document.parse("") + |> should.equal(Error(document.WidgetRootWasExpected("package"))) +} + +fn representative_xml() -> String { + string.join( + [ + "", + "", + "", + " Namespaced & Widget", + " & exact ]]>", + " ", + " Fallback & Category", + " https://example.com/help?a=1&b=2", + " ]]>", + " ", + " ", + " ", + " ", + " ", + " ]]>", + " Choose & continue", + " ", + " ", + " First & Best", + " ]]>", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Nested", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + "\n", + ) +} + +fn representative_meta() -> model.WidgetMeta { + model.WidgetMeta( + id: "example.widget", + plugin_widget: True, + offline_capable: True, + supported_platform: "Web", + needs_entity_context: False, + name: "Namespaced & Widget", + description: "Description & exact", + studio_pro_category: option.Some("Fallback & Category"), + help_url: option.Some("https://example.com/help?a=1&b=2"), + icon: "icon-data", + prompt: option.Some("Prompt "), + ) +} + +fn representative_groups() -> List(model.PropertyGroup) { + [ + model.PropertyGroup(caption: "General & Data", items: [ + model.SysPropItem(model.SystemProperty("Visibility")), + model.PropItem( + model.Property( + key: "choice", + type_: model.TypeEnumeration, + caption: "Choice ", + description: "Choose & continue", + required: option.Some(True), + default_value: option.Some("one & two"), + multiline: option.Some(False), + is_list: option.Some(True), + data_source: option.Some("../items"), + allow_upload: option.Some(False), + on_change: option.Some("refresh"), + set_label: option.Some(True), + return_type: option.Some(model.ReturnType( + "String", + option.Some("Example.Entity"), + )), + enumeration_values: [ + model.EnumValue("first", "First & Best"), + model.EnumValue("second", "Second "), + ], + attribute_types: ["String", "Integer"], + association_types: ["Reference"], + selection_types: ["Single", "Multi"], + default_type: option.Some("String"), + selectable_objects: option.Some("CurrentObject"), + sub_properties: [ + model.PropertyGroup("Nested", [ + model.PropItem( + model.Property( + ..model.default_property("nested", model.TypeBoolean), + caption: "Nested", + description: "", + required: option.None, + default_value: option.None, + ), + ), + ]), + ], + ), + ), + model.SysPropItem(model.SystemProperty("Name")), + ]), + ] +} + +fn representative_serialized_xml() -> String { + string.join( + [ + "", + "", + " Namespaced & Widget", + " Description <safe> & exact", + " icon-data", + " Fallback & Category", + " https://example.com/help?a=1&b=2", + " Prompt <value>", + " ", + " ", + " ", + " ", + " Choice <caption>", + " Choose & continue", + " ", + " ", + " First & Best", + " Second <Value>", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Nested", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + "", + ], + "\n", + ) +}