From 8e5e66c6c8c9ded37fda1c597daebb8088cb9a33 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Tue, 10 Dec 2024 01:05:10 -0600 Subject: [PATCH 01/14] Add support for native xml parsing. --- .github/scripts/build-posix.sh | 2 + dune-project | 1 + liquidsoap-lang.opam | 1 + src/lang/builtins_string.ml | 10 ++++ src/lang/builtins_xml.ml | 83 ++++++++++++++++++++++++++++++ src/lang/dune | 2 + src/lang/lexer.ml | 1 + src/lang/parser.mly | 2 + src/lang/parser_helper.ml | 2 + src/lang/parser_helper.mli | 1 + src/lang/term/parsed_term.ml | 1 + src/lang/term/term_preprocessor.ml | 1 + src/lang/term/term_reducer.ml | 12 +++++ src/tooling/parsed_json.ml | 1 + 14 files changed, 120 insertions(+) create mode 100644 src/lang/builtins_xml.ml diff --git a/.github/scripts/build-posix.sh b/.github/scripts/build-posix.sh index 21422ac1a9..f48ebf65eb 100755 --- a/.github/scripts/build-posix.sh +++ b/.github/scripts/build-posix.sh @@ -39,6 +39,8 @@ echo "::endgroup::" echo "::group::Setting up specific dependencies" +opam install -y xml-light + cd /tmp/liquidsoap-full/liquidsoap ./.github/scripts/checkout-deps.sh diff --git a/dune-project b/dune-project index 187428b7b0..b1f1a11f16 100644 --- a/dune-project +++ b/dune-project @@ -156,6 +156,7 @@ (ppx_hash :build) (sedlex (>= 3.2)) (menhir (>= 20240715)) + xml-light ) (sites (share libs) (share bin) (share cache) (lib_root lib_root)) (synopsis "Liquidsoap language library")) diff --git a/liquidsoap-lang.opam b/liquidsoap-lang.opam index 91b7b8979b..afc9905e8e 100644 --- a/liquidsoap-lang.opam +++ b/liquidsoap-lang.opam @@ -17,6 +17,7 @@ depends: [ "ppx_hash" {build} "sedlex" {>= "3.2"} "menhir" {>= "20240715"} + "xml-light" "odoc" {with-doc} ] build: [ diff --git a/src/lang/builtins_string.ml b/src/lang/builtins_string.ml index 8acfebf15f..337571ab05 100644 --- a/src/lang/builtins_string.ml +++ b/src/lang/builtins_string.ml @@ -29,6 +29,16 @@ let _ = let s2 = Lang.to_string (Lang.assoc "" 2 p) in Lang.string (s1 ^ s2)) +let _ = + Lang.add_builtin ~base:string "compare" ~category:`String + ~descr:"Compare strings in lexicographical order." + [("", Lang.string_t, None, None); ("", Lang.string_t, None, None)] + Lang.int_t + (fun p -> + let s1 = Lang.to_string (Lang.assoc "" 1 p) in + let s2 = Lang.to_string (Lang.assoc "" 2 p) in + Lang.int (String.compare s1 s2)) + let _ = Lang.add_builtin ~base:string "digest" ~category:`String ~descr:"Return an MD5 digest for the given string." diff --git a/src/lang/builtins_xml.ml b/src/lang/builtins_xml.ml new file mode 100644 index 0000000000..b232a292c8 --- /dev/null +++ b/src/lang/builtins_xml.ml @@ -0,0 +1,83 @@ +let rec methods_of_xml = function + | Xml.PCData s -> ("text", Lang.string s) + | Xml.Element (name, params, ([Xml.PCData s] as children)) -> + (name, Lang.meth (Lang.string s) (xml_node ~params ~children)) + | Xml.Element (name, params, children) -> + ( name, + Lang.record + (Methods.bindings + (List.fold_left + (fun methods el -> + let name, v = methods_of_xml el in + let v = + match Methods.find_opt name methods with + | None -> v + | Some (Value.Tuple { value }) -> Lang.tuple (value @ [v]) + | Some value -> Lang.tuple [value; v] + in + Methods.add name v methods) + (Methods.from_list (xml_node ~params ~children)) + children)) ) + +and xml_node ~params ~children = + [ + ( "xml_params", + Lang.record (List.map (fun (k, v) -> (k, Lang.string v)) params) ); + ("xml_children", Lang.tuple (List.map (fun v -> value_of_xml v) children)); + ] + +and value_of_xml v = Lang.record [methods_of_xml v] + +let rec check_value v ty = + let typ_meths, ty = Type.split_meths ty in + let meths, v = Value.split_meths v in + let v = + match (v, ty.Type.descr) with + | Value.Tuple { value = [] }, Type.Nullable _ -> Lang.null + | Value.String { value = s }, Type.Int -> Lang.int (int_of_string s) + | Value.String { value = s }, Type.Float -> Lang.float (float_of_string s) + | Value.String { value = s }, Type.Bool -> Lang.bool (bool_of_string s) + | Value.String _, Type.Nullable ty -> check_value v ty + | _, Type.Var _ + | Value.Tuple { value = [] }, Type.Tuple [] + | Value.String _, Type.String -> + v + | _ -> assert false + in + let meths = + List.fold_left + (fun meths (name, v) -> + match List.find_opt (fun { Type.meth } -> meth = name) typ_meths with + | None -> meths + | Some { Type.scheme = _, ty } -> (name, check_value v ty) :: meths) + [] meths + in + let v = Lang.meth v meths in + v + +let _ = + Lang.add_builtin "_internal_xml_parser_" ~category:`String ~flags:[`Hidden] + ~descr:"Internal xml parser" + [ + ("type", Value.RuntimeType.t, None, Some "Runtime type"); + ("", Lang.string_t, None, None); + ] + (Lang.univ_t ()) + (fun p -> + let s = Lang.to_string (List.assoc "" p) in + let ty = Value.RuntimeType.of_value (List.assoc "type" p) in + let ty = Type.fresh ty in + try + let xml = Xml.parse_string s in + let value = value_of_xml xml in + check_value value ty + with exn -> ( + let bt = Printexc.get_raw_backtrace () in + match exn with + | _ -> + Runtime_error.raise ~bt ~pos:(Lang.pos p) + ~message: + (Printf.sprintf + "Parse error: xml value cannot be parsed as type: %s" + (Type.to_string ty)) + "xml")) diff --git a/src/lang/dune b/src/lang/dune index a9eac7fe13..c8093d6ad3 100644 --- a/src/lang/dune +++ b/src/lang/dune @@ -94,6 +94,7 @@ str unix menhirLib + xml-light (select liqmemtrace.ml from @@ -113,6 +114,7 @@ builtins_regexp builtins_string builtins_yaml + builtins_xml builtins_ref cache doc diff --git a/src/lang/lexer.ml b/src/lang/lexer.ml index 75a1900dce..a8810a7096 100644 --- a/src/lang/lexer.ml +++ b/src/lang/lexer.ml @@ -213,6 +213,7 @@ let rec token lexbuf = | "let", Plus skipped, "yaml.parse", Plus skipped -> LET `Yaml_parse | "let", Plus skipped, "sqlite.row", Plus skipped -> LET `Sqlite_row | "let", Plus skipped, "sqlite.query", Plus skipped -> LET `Sqlite_query + | "let", Plus skipped, "xml.parse", Plus skipped -> LET `Xml_parse | "let" -> LET `None | "fun" -> FUN | '=' -> GETS diff --git a/src/lang/parser.mly b/src/lang/parser.mly index 520bb222e5..d7401b2966 100644 --- a/src/lang/parser.mly +++ b/src/lang/parser.mly @@ -292,6 +292,8 @@ ty: | LPAR argsty RPAR YIELDS ty { `Arrow ($2,$5) } | LCUR record_ty RCUR { `Record $2 } | ty DOT VAR { `Invoke ($1, $3) } + | ty QUESTION_DOT LCUR record_ty RCUR + { `Method (`Nullable $1, $4) } | ty DOT LCUR record_ty RCUR { `Method ($1, $4) } | ty_source { `Source $1 } diff --git a/src/lang/parser_helper.ml b/src/lang/parser_helper.ml index 51dec784c7..14d620efb5 100644 --- a/src/lang/parser_helper.ml +++ b/src/lang/parser_helper.ml @@ -36,6 +36,7 @@ type lexer_let_decoration = | `Eval | `Json_parse | `Yaml_parse + | `Xml_parse | `Sqlite_row | `Sqlite_query ] @@ -185,6 +186,7 @@ type let_opt_el = string * Term.t let let_decoration_of_lexer_let_decoration = function | `Json_parse -> `Json_parse [] | `Yaml_parse -> `Yaml_parse + | `Xml_parse -> `Xml_parse | `Sqlite_query -> `Sqlite_query | `Sqlite_row -> `Sqlite_row | `Eval -> `Eval diff --git a/src/lang/parser_helper.mli b/src/lang/parser_helper.mli index fea009cf66..8178a3428b 100644 --- a/src/lang/parser_helper.mli +++ b/src/lang/parser_helper.mli @@ -35,6 +35,7 @@ type lexer_let_decoration = | `Recursive | `Replaces | `Yaml_parse + | `Xml_parse | `Sqlite_row | `Sqlite_query ] diff --git a/src/lang/term/parsed_term.ml b/src/lang/term/parsed_term.ml index d6a842db44..8144ac2aba 100644 --- a/src/lang/term/parsed_term.ml +++ b/src/lang/term/parsed_term.ml @@ -123,6 +123,7 @@ and let_decoration = | `Sqlite_query | `Sqlite_row | `Yaml_parse + | `Xml_parse | `Json_parse of (string * t) list ] and _let = { diff --git a/src/lang/term/term_preprocessor.ml b/src/lang/term/term_preprocessor.ml index 98d4dc8571..85f1d23978 100644 --- a/src/lang/term/term_preprocessor.ml +++ b/src/lang/term/term_preprocessor.ml @@ -93,6 +93,7 @@ and expand_term tm = | `Sqlite_query -> `Sqlite_query | `Sqlite_row -> `Sqlite_row | `Yaml_parse -> `Yaml_parse + | `Xml_parse -> `Xml_parse | `Json_parse l -> `Json_parse (List.map (fun (lbl, t) -> (lbl, expand_term t)) l) in diff --git a/src/lang/term/term_reducer.ml b/src/lang/term/term_reducer.ml index 38164aa2e0..2edcc8bd82 100644 --- a/src/lang/term/term_reducer.ml +++ b/src/lang/term/term_reducer.ml @@ -937,6 +937,14 @@ let mk_let_json_parse ~pos (args, pat, def, cast) body = let def = mk ~pos (`Cast { cast = def; typ = ty }) in pattern_reducer ~body ~pat def +let mk_let_xml_parse ~pos (pat, def, cast) body = + let ty = match cast with Some ty -> ty | None -> mk_var ~pos () in + let tty = Value.RuntimeType.to_term ty in + let parser = mk ~pos (`Var "_internal_xml_parser_") in + let def = mk ~pos (`App (parser, [("type", tty); ("", def)])) in + let def = mk ~pos (`Cast { cast = def; typ = ty }) in + pattern_reducer ~body ~pat def + let mk_let_yaml_parse ~pos (pat, def, cast) body = let ty = match cast with Some ty -> ty | None -> mk_var ~pos () in let tty = Value.RuntimeType.to_term ty in @@ -1019,6 +1027,7 @@ let string_of_let_decoration = function | `Sqlite_query -> "sqlite.query" | `Sqlite_row -> "sqlite.row" | `Yaml_parse -> "yaml.parse" + | `Xml_parse -> "xml.parse" | `Json_parse _ -> "json.parse" let mk_let ~env ~pos ~to_term ~comments @@ -1094,6 +1103,9 @@ let mk_let ~env ~pos ~to_term ~comments | None, `Yaml_parse -> let body = mk_body def in mk_let_yaml_parse ~pos (pat, def, cast) body + | None, `Xml_parse -> + let body = mk_body def in + mk_let_xml_parse ~pos (pat, def, cast) body | None, `Sqlite_row -> let body = mk_body def in mk_let_sqlite_row ~pos (pat, def, cast) body diff --git a/src/tooling/parsed_json.ml b/src/tooling/parsed_json.ml index ef4a055cfe..8447243e2e 100644 --- a/src/tooling/parsed_json.ml +++ b/src/tooling/parsed_json.ml @@ -302,6 +302,7 @@ let json_of_let_decoration ~to_json : Parsed_term.let_decoration -> Json.t = `Assoc (ast_node ~typ:"var" [("value", `String "sqlite.row")]) | `Yaml_parse -> `Assoc (ast_node ~typ:"var" [("value", `String "yaml.parse")]) + | `Xml_parse -> `Assoc (ast_node ~typ:"var" [("value", `String "xml.parse")]) | `Json_parse [] -> `Assoc (ast_node ~typ:"var" [("value", `String "json.parse")]) | `Json_parse args -> From f9c0d898b598e976a35ca446e1e073e19689dedd Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Tue, 10 Dec 2024 12:17:03 +0100 Subject: [PATCH 02/14] Also add flat params list. --- src/lang/builtins_xml.ml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lang/builtins_xml.ml b/src/lang/builtins_xml.ml index b232a292c8..1e73c0710c 100644 --- a/src/lang/builtins_xml.ml +++ b/src/lang/builtins_xml.ml @@ -23,6 +23,11 @@ and xml_node ~params ~children = [ ( "xml_params", Lang.record (List.map (fun (k, v) -> (k, Lang.string v)) params) ); + ( "xml_params_list", + Lang.list + (List.map + (fun (k, v) -> Lang.product (Lang.string k) (Lang.string v)) + params) ); ("xml_children", Lang.tuple (List.map (fun v -> value_of_xml v) children)); ] From e22d5d33706500243c691084a83acfa8c52c4551 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Tue, 10 Dec 2024 12:18:09 +0100 Subject: [PATCH 03/14] Combine. --- src/lang/builtins_xml.ml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lang/builtins_xml.ml b/src/lang/builtins_xml.ml index 1e73c0710c..05c8360cc4 100644 --- a/src/lang/builtins_xml.ml +++ b/src/lang/builtins_xml.ml @@ -22,12 +22,12 @@ let rec methods_of_xml = function and xml_node ~params ~children = [ ( "xml_params", - Lang.record (List.map (fun (k, v) -> (k, Lang.string v)) params) ); - ( "xml_params_list", - Lang.list - (List.map - (fun (k, v) -> Lang.product (Lang.string k) (Lang.string v)) - params) ); + Lang.meth + (Lang.list + (List.map + (fun (k, v) -> Lang.product (Lang.string k) (Lang.string v)) + params)) + (List.map (fun (k, v) -> (k, Lang.string v)) params) ); ("xml_children", Lang.tuple (List.map (fun v -> value_of_xml v) children)); ] From 74d4fa54ab3a9210cc2d47b26efe1c1c80ef7088 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 07:09:17 +0100 Subject: [PATCH 04/14] Bla --- src/lang/builtins_xml.ml | 81 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/src/lang/builtins_xml.ml b/src/lang/builtins_xml.ml index 05c8360cc4..f28610d3d3 100644 --- a/src/lang/builtins_xml.ml +++ b/src/lang/builtins_xml.ml @@ -1,7 +1,7 @@ let rec methods_of_xml = function | Xml.PCData s -> ("text", Lang.string s) | Xml.Element (name, params, ([Xml.PCData s] as children)) -> - (name, Lang.meth (Lang.string s) (xml_node ~params ~children)) + (name, Lang.meth (Lang.string s) (xml_node ~params ~children name)) | Xml.Element (name, params, children) -> ( name, Lang.record @@ -16,11 +16,12 @@ let rec methods_of_xml = function | Some value -> Lang.tuple [value; v] in Methods.add name v methods) - (Methods.from_list (xml_node ~params ~children)) + (Methods.from_list (xml_node ~params ~children name)) children)) ) -and xml_node ~params ~children = +and xml_node ~params ~children name = [ + ("xml_node", Lang.string name); ( "xml_params", Lang.meth (Lang.list @@ -86,3 +87,77 @@ let _ = "Parse error: xml value cannot be parsed as type: %s" (Type.to_string ty)) "xml")) + +let xml = Lang.add_module "xml" + +let string_of_ground v = + match v with + | Value.String _ | Value.Bool _ | Value.Float _ | Value.Int _ -> + Value.to_string (Lang.demeth v) + | _ -> assert false + +let params_of_xml_params v = + match Lang.split_meths v with + | [], Value.Tuple { value = params } -> + List.map + (function + | Value.Tuple { value = [Value.String { value = s }; v] } -> + (s, string_of_ground v) + | _ -> assert false) + params + | params, Value.Tuple { value = [] } -> + List.map (fun (s, v) -> (s, string_of_ground v)) params + | _ -> assert false + +let params_of_optional_params = function + | None -> [] + | Some params -> params_of_xml_params params + +let rec xml_of_value v = + match Lang.split_meths v with + | meths, Value.Tuple { value = [] } -> xml_of_meths meths + | _ -> assert false + +and xml_of_meths meths = + let xml_name = List.assoc_opt "xml_name" meths in + let xml_nodes = List.assoc_opt "xml_nodes" meths in + let xml_params = List.assoc_opt "xml_params" meths in + let meths = + List.filter + (fun (k, _) -> not (List.mem k ["xml_name"; "xml_nodes"; "xml_params"])) + meths + in + match (xml_name, xml_params, xml_nodes, meths) with + | ( Some (Value.String { value = name }), + params, + Some (Value.Tuple { value = nodes }), + [] ) + | None, params, None, [(name, Value.Tuple { value = nodes, methods = [] })] -> + Xml.Element + (name, params_of_optional_params params, List.map xml_of_value nodes) + | None, params, None, + +let _ = + Lang.add_builtin ~base:xml "stringify" ~category:`String + ~descr: + "Convert a value to XML. If the value cannot be represented as XML (for \ + instance a function), a `error.xml` exception is raised." + [ + ( "compact", + Lang.bool_t, + Some (Lang.bool false), + Some "Output compact text." ); + ("", Lang.univ_t (), None, None); + ] + Lang.string_t + (fun p -> + let v = List.assoc "" p in + let compact = Lang.to_bool (List.assoc "compact" p) in + try + let xml = xml_of_value v in + Lang.string + (if compact then Xml.to_string xml else Xml.to_string_fmt xml) + with _ -> + let bt = Printexc.get_raw_backtrace () in + Runtime_error.raise ~bt ~pos:(Lang.pos p) + ~message:"Value could not be converted to XML!" "xml") From e4ab65f9af2bf21e375e6d25136cbb00762acd80 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 10:50:01 +0100 Subject: [PATCH 05/14] Add test, almost there --- src/lang/builtins_xml.ml | 111 ++++++++++++++++++++++++------------ src/lang/value.ml | 10 ++-- tests/language/dune.inc | 12 ++++ tests/language/xml_test.liq | 108 +++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 41 deletions(-) create mode 100644 tests/language/xml_test.liq diff --git a/src/lang/builtins_xml.ml b/src/lang/builtins_xml.ml index f28610d3d3..f5442cb442 100644 --- a/src/lang/builtins_xml.ml +++ b/src/lang/builtins_xml.ml @@ -1,7 +1,10 @@ let rec methods_of_xml = function - | Xml.PCData s -> ("text", Lang.string s) + | Xml.PCData s -> + ( "xml_text", + Lang.meth (Lang.string s) (xml_node ~text:s ~params:[] ~children:[] ()) + ) | Xml.Element (name, params, ([Xml.PCData s] as children)) -> - (name, Lang.meth (Lang.string s) (xml_node ~params ~children name)) + (name, Lang.meth (Lang.string s) (xml_node ~text:s ~params ~children ())) | Xml.Element (name, params, children) -> ( name, Lang.record @@ -12,16 +15,18 @@ let rec methods_of_xml = function let v = match Methods.find_opt name methods with | None -> v + | Some (Value.Tuple { value = [] } as value) -> + Lang.tuple [value; v] | Some (Value.Tuple { value }) -> Lang.tuple (value @ [v]) | Some value -> Lang.tuple [value; v] in Methods.add name v methods) - (Methods.from_list (xml_node ~params ~children name)) + (Methods.from_list (xml_node ~params ~children ())) children)) ) -and xml_node ~params ~children name = +and xml_node ?text ~params ~children () = [ - ("xml_node", Lang.string name); + ("xml_text", match text with None -> Lang.null | Some s -> Lang.string s); ( "xml_params", Lang.meth (Lang.list @@ -29,10 +34,12 @@ and xml_node ~params ~children name = (fun (k, v) -> Lang.product (Lang.string k) (Lang.string v)) params)) (List.map (fun (k, v) -> (k, Lang.string v)) params) ); - ("xml_children", Lang.tuple (List.map (fun v -> value_of_xml v) children)); + ("xml_children", Lang.list (List.map (fun v -> value_of_xml v) children)); ] -and value_of_xml v = Lang.record [methods_of_xml v] +and value_of_xml v = + let name, methods = methods_of_xml v in + Lang.meth (Lang.tuple [Lang.string name; methods]) [(name, methods)] let rec check_value v ty = let typ_meths, ty = Type.split_meths ty in @@ -40,23 +47,25 @@ let rec check_value v ty = let v = match (v, ty.Type.descr) with | Value.Tuple { value = [] }, Type.Nullable _ -> Lang.null + | _, Type.Tuple [] -> Lang.tuple [] + | Value.Tuple { value }, Type.Tuple l -> + Lang.tuple + (List.mapi (fun idx v -> check_value v (List.nth l idx)) value) + | Value.List { value = l }, Type.List { t = ty } -> + Lang.list (List.map (fun v -> check_value v ty) l) | Value.String { value = s }, Type.Int -> Lang.int (int_of_string s) | Value.String { value = s }, Type.Float -> Lang.float (float_of_string s) | Value.String { value = s }, Type.Bool -> Lang.bool (bool_of_string s) | Value.String _, Type.Nullable ty -> check_value v ty - | _, Type.Var _ - | Value.Tuple { value = [] }, Type.Tuple [] - | Value.String _, Type.String -> - v + | _, Type.Var _ | Value.String _, Type.String -> v | _ -> assert false in let meths = List.fold_left - (fun meths (name, v) -> - match List.find_opt (fun { Type.meth } -> meth = name) typ_meths with - | None -> meths - | Some { Type.scheme = _, ty } -> (name, check_value v ty) :: meths) - [] meths + (fun checked_meths { Type.meth; scheme = _, ty } -> + let v = List.assoc meth meths in + (meth, check_value v ty) :: checked_meths) + [] typ_meths in let v = Lang.meth v meths in v @@ -92,13 +101,15 @@ let xml = Lang.add_module "xml" let string_of_ground v = match v with - | Value.String _ | Value.Bool _ | Value.Float _ | Value.Int _ -> - Value.to_string (Lang.demeth v) + | Value.String { value = s } -> s + | Value.Bool { value = b } -> string_of_bool b + | Value.Float { value = f } -> Utils.string_of_float f + | Value.Int { value = i; flags } -> Value.string_of_int_value ~flags i | _ -> assert false let params_of_xml_params v = match Lang.split_meths v with - | [], Value.Tuple { value = params } -> + | [], Value.List { value = params } -> List.map (function | Value.Tuple { value = [Value.String { value = s }; v] } -> @@ -110,32 +121,58 @@ let params_of_xml_params v = | _ -> assert false let params_of_optional_params = function - | None -> [] - | Some params -> params_of_xml_params params + | None -> [] + | Some params -> params_of_xml_params params -let rec xml_of_value v = - match Lang.split_meths v with - | meths, Value.Tuple { value = [] } -> xml_of_meths meths - | _ -> assert false +let rec xml_of_value = function + | Value.Tuple + { + value = + [Value.String { value = name }; Value.Tuple { value = []; methods }]; + } -> + xml_of_node ~name (Methods.bindings methods) + | Value.Tuple { value = []; methods } -> ( + match Methods.bindings methods with + | [(name, Value.Tuple { value = []; methods })] -> + xml_of_node ~name (Methods.bindings methods) + | [(name, Value.String { value = s; methods })] -> + xml_of_node ~xml_text:s ~name (Methods.bindings methods) + | _ -> assert false) + | _ -> assert false -and xml_of_meths meths = - let xml_name = List.assoc_opt "xml_name" meths in - let xml_nodes = List.assoc_opt "xml_nodes" meths in +and xml_of_node ?xml_text ~name meths = + let xml_text = + match xml_text with + | Some s -> Some s + | None -> Option.map Lang.to_string (List.assoc_opt "xml_text" meths) + in + let xml_children = + Option.map Lang.to_list (List.assoc_opt "xml_children" meths) + in let xml_params = List.assoc_opt "xml_params" meths in let meths = List.filter - (fun (k, _) -> not (List.mem k ["xml_name"; "xml_nodes"; "xml_params"])) + (fun (k, _) -> + not (List.mem k ["xml_text"; "xml_children"; "xml_params"])) meths in - match (xml_name, xml_params, xml_nodes, meths) with - | ( Some (Value.String { value = name }), - params, - Some (Value.Tuple { value = nodes }), - [] ) - | None, params, None, [(name, Value.Tuple { value = nodes, methods = [] })] -> + match (name, xml_params, xml_children, xml_text, meths) with + | "xml_text", None, None, Some s, [] -> Xml.PCData s + | name, xml_params, None, Some s, [] -> + Xml.Element (name, params_of_optional_params xml_params, [Xml.PCData s]) + | name, xml_params, Some nodes, None, [] -> + Xml.Element + ( name, + params_of_optional_params xml_params, + List.map xml_of_value nodes ) + | name, xml_params, None, None, nodes -> Xml.Element - (name, params_of_optional_params params, List.map xml_of_value nodes) - | None, params, None, + ( name, + params_of_optional_params xml_params, + List.map + (fun (name, value) -> xml_of_value (Lang.record [(name, value)])) + nodes ) + | _ -> assert false let _ = Lang.add_builtin ~base:xml "stringify" ~category:`String diff --git a/src/lang/value.ml b/src/lang/value.ml index 714168ab74..f1492170ed 100644 --- a/src/lang/value.ml +++ b/src/lang/value.ml @@ -215,13 +215,15 @@ let make ?pos ?(methods = Methods.empty) ?(flags = Flags.empty) : in_value -> t Fun { pos; methods; flags; fun_args; fun_env; fun_body } | `FFI { ffi_args; ffi_fn } -> FFI { pos; methods; flags; ffi_args; ffi_fn } +let string_of_int_value ~flags i = + if Flags.has flags Flags.octal_int then Printf.sprintf "0o%o" i + else if Flags.has flags Flags.hex_int then Printf.sprintf "0x%x" i + else string_of_int i + let rec to_string v = let base_string v = match v with - | Int { value = i; flags } -> - if Flags.has flags Flags.octal_int then Printf.sprintf "0o%o" i - else if Flags.has flags Flags.hex_int then Printf.sprintf "0x%x" i - else string_of_int i + | Int { value = i; flags } -> string_of_int_value ~flags i | Float { value = f } -> Utils.string_of_float f | Bool { value = b } -> string_of_bool b | String { value = s } -> Lang_string.quote_string s diff --git a/tests/language/dune.inc b/tests/language/dune.inc index ad336f2801..97e6172299 100644 --- a/tests/language/dune.inc +++ b/tests/language/dune.inc @@ -527,6 +527,18 @@ (:run_test ../run_test.exe)) (action (run %{run_test} various.liq liquidsoap %{test_liq} various.liq))) +(rule + (alias citest) + (package liquidsoap) + (deps + xml_test.liq + ../../src/bin/liquidsoap.exe + (package liquidsoap) + (source_tree ../../src/libs) + (:test_liq ../test.liq) + (:run_test ../run_test.exe)) + (action (run %{run_test} xml_test.liq liquidsoap %{test_liq} xml_test.liq))) + (rule (alias citest) (package liquidsoap) diff --git a/tests/language/xml_test.liq b/tests/language/xml_test.liq new file mode 100644 index 0000000000..ce95948e3a --- /dev/null +++ b/tests/language/xml_test.liq @@ -0,0 +1,108 @@ +def f() = + s = + ' +gni + +bla +' + + let xml.parse (x : + { + bla: { + foo: string.{ xml_params: {opt: float} }, + bar: (string? * string?.{ xml_params: [(string * string)] }), + xml_params: [(string * string)].{ bla: bool } + } + } + ) = s + + test.equal( + x, + { + bla= + { + xml_params=[("param", "1"), ("bla", "true")].{bla=true}, + bar=(null(), "bla".{xml_params=[("option", "aab")]}), + foo="gni".{xml_params={opt=12.3}} + } + } + ) + + test.equal( + xml.stringify( + { + bla= + { + xml_params=[("param", "1"), ("bla", "true")], + bar="bla".{xml_params=[("option", "aab")]}, + foo="gni".{xml_params={opt=12.3}} + } + } + ), + ' + bla + gni +' + ) + + let xml.parse (x : + ( + string + * + { + xml_params: [(string * string)], + xml_children: [ + ( + string + * + { + xml_params: [(string * string)], + xml_children: [(string * {xml_text: string?})] + } + ) + ] + } + ) + ) = s + + test.equal( + x, + ( + "bla", + { + xml_children= + [ + ( + "foo", + { + xml_children=[("xml_text", {xml_text="gni"})], + xml_params=[("opt", "12.3")] + } + ), + ("bar", {xml_children=[], xml_params=[]}), + ( + "bar", + { + xml_children=[("xml_text", {xml_text="bla"})], + xml_params=[("option", "aab")] + } + ) + ], + xml_params=[("param", "1"), ("bla", "true")] + } + ) + ) + + test.equal( + xml.stringify(x), + ' + gni + + bla +' + ) + + test.pass() +end + +test.check(f) From 492da1c3047a7b2b36293525cb7bf9144ab898d7 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 11:02:34 +0100 Subject: [PATCH 06/14] Test conversions. --- src/lang/builtins_xml.ml | 8 ++++++-- tests/language/xml_test.liq | 26 +++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/lang/builtins_xml.ml b/src/lang/builtins_xml.ml index f5442cb442..c36b883f74 100644 --- a/src/lang/builtins_xml.ml +++ b/src/lang/builtins_xml.ml @@ -135,8 +135,12 @@ let rec xml_of_value = function match Methods.bindings methods with | [(name, Value.Tuple { value = []; methods })] -> xml_of_node ~name (Methods.bindings methods) - | [(name, Value.String { value = s; methods })] -> - xml_of_node ~xml_text:s ~name (Methods.bindings methods) + | [(name, (Value.String { methods } as v))] + | [(name, (Value.Float { methods } as v))] + | [(name, (Value.Int { methods } as v))] + | [(name, (Value.Bool { methods } as v))] -> + xml_of_node ~xml_text:(string_of_ground v) ~name + (Methods.bindings methods) | _ -> assert false) | _ -> assert false diff --git a/tests/language/xml_test.liq b/tests/language/xml_test.liq index ce95948e3a..071bf3a658 100644 --- a/tests/language/xml_test.liq +++ b/tests/language/xml_test.liq @@ -4,6 +4,9 @@ def f() = gni bla +1.23 +false +123 ' let xml.parse (x : @@ -11,6 +14,9 @@ def f() = bla: { foo: string.{ xml_params: {opt: float} }, bar: (string? * string?.{ xml_params: [(string * string)] }), + blo: float, + blu: bool, + ble: int, xml_params: [(string * string)].{ bla: bool } } } @@ -22,6 +28,9 @@ def f() = bla= { xml_params=[("param", "1"), ("bla", "true")].{bla=true}, + ble=123, + blu=false, + blo=1.23, bar=(null(), "bla".{xml_params=[("option", "aab")]}), foo="gni".{xml_params={opt=12.3}} } @@ -57,7 +66,7 @@ def f() = * { xml_params: [(string * string)], - xml_children: [(string * {xml_text: string?})] + xml_children: [(string * {xml_text: string})] } ) ] @@ -86,6 +95,18 @@ def f() = xml_children=[("xml_text", {xml_text="bla"})], xml_params=[("option", "aab")] } + ), + ( + "blo", + {xml_children=[("xml_text", {xml_text="1.23"})], xml_params=[]} + ), + ( + "blu", + {xml_children=[("xml_text", {xml_text="false"})], xml_params=[]} + ), + ( + "ble", + {xml_children=[("xml_text", {xml_text="123"})], xml_params=[]} ) ], xml_params=[("param", "1"), ("bla", "true")] @@ -99,6 +120,9 @@ def f() = gni bla + 1.23 + false + 123 ' ) From 785032aa10d9031b4208b71ba9307a3f237499e5 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 11:12:53 +0100 Subject: [PATCH 07/14] Add this. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3282640fb8..4e148ace55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,7 @@ jobs: cd /tmp/liquidsoap-full/liquidsoap eval "$(opam config env)" opam update - opam install -y saturn_lockfree.0.4.1 + opam install -y xml-light dune build --profile release ./src/js/interactive_js.bc.js tree_sitter_parse: From 8a6a9c24604580a395d2a631e88c835632b21edf Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 11:24:49 +0100 Subject: [PATCH 08/14] Add xml-light-windows. --- .github/opam/liquidsoap-core-windows.opam | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/opam/liquidsoap-core-windows.opam b/.github/opam/liquidsoap-core-windows.opam index ffda9f1e75..8ae3bcb0a0 100644 --- a/.github/opam/liquidsoap-core-windows.opam +++ b/.github/opam/liquidsoap-core-windows.opam @@ -35,6 +35,7 @@ depends: [ "fileutils" "fileutils-windows" "curl-windows" + "xml-light-windows" "mem_usage-windows" {>= "0.1.1"} "metadata-windows" {>= "0.3.0"} "dune-site-windows" From 3012c2048c1e791ffc30b525eda5bd1179d03cf8 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 11:40:19 +0100 Subject: [PATCH 09/14] Revert this. --- src/libs/http.liq | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/http.liq b/src/libs/http.liq index cece16dd1b..1ef9cc529b 100644 --- a/src/libs/http.liq +++ b/src/libs/http.liq @@ -964,7 +964,7 @@ def http.headers.content_disposition(headers) = type: string, filename?: string, name?: string, - args: [(string * string?)] + args: [(string*string?)] } ) end, From d0ac31972a027fdc87a617cbb83abe25dfe3ef6b Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 12:12:11 +0100 Subject: [PATCH 10/14] Add doc. --- doc/content/xml.md | 150 +++++++++++++++++++++++++++++++++++++++++++++ doc/dune.inc | 129 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 doc/content/xml.md diff --git a/doc/content/xml.md b/doc/content/xml.md new file mode 100644 index 0000000000..adac640fb1 --- /dev/null +++ b/doc/content/xml.md @@ -0,0 +1,150 @@ +## Importing/exporting XML values + +Support for XML parsing and rendering was first added in liquidsoap `2.3.1`. + +You can parse XML strings using a decorator and type annotation. There are two different representations of XML you can use. + +### Record access representation + +This is the easiest representation. It is intended for quick access to parsed value via +record and tuples. + +Here's an example: + +```liquidsoap +s = +' + gni + + bla + 1.23 + false + 123 +' + +let xml.parse (x : +{ + bla: { + foo: string.{ xml_params: {opt: float} }, + bar: (unit * string), + blo: float, + blu: bool, + ble: int, + xml_params: { bla: bool } + } +} +) = s +``` + +Things to note: + +- The basic mappings are: `tag name -> tag content` +- Tag content maps tag parameters to `xml_params` +- When multiple tags are present, their values are collected as tuple (`bar` tag in the example) +- When a tag contains a single ground value (`string`, `bool`, `float` or `integer`), the mapping is from tag name to the corresponding value, with xml attributes attached as methods +- Tag parameters can be converted to ground values and omitted. + +The parsing is driven by the type annotation and is intended to be permissive. For instance, this will work: + +```liquidsoaop +s = 'foo' + +let xml.parse (x: { bla: unit }) = s +``` + +### Formal representation + +Because XML format can result in complex values, the parser can also use a generic representation. + +Here's an example: + +```liquidsoap +s = +' + gni + + bla + 1.23 + false + 123 +' + +let xml.parse (x : + ( + string + * + { + xml_params: [(string * string)], + xml_children: [ + ( + string + * + { + xml_params: [(string * string)], + xml_children: [(string * {xml_text: string})] + } + ) + ] + } + ) +) = s + +# x contains: +( + "bla", + { + xml_children= + [ + ( + "foo", + { + xml_children=[("xml_text", {xml_text="gni"})], + xml_params=[("opt", "12.3")] + } + ), + ("bar", {xml_children=[], xml_params=[]}), + ( + "bar", + { + xml_children=[("xml_text", {xml_text="bla"})], + xml_params=[("option", "aab")] + } + ), + ( + "blo", + {xml_children=[("xml_text", {xml_text="1.23"})], xml_params=[]} + ), + ( + "blu", + {xml_children=[("xml_text", {xml_text="false"})], xml_params=[]} + ), + ( + "ble", + {xml_children=[("xml_text", {xml_text="123"})], xml_params=[]} + ) + ], + xml_params=[("param", "1"), ("bla", "true")] + } +) +``` + +This representation is much less convenient to manipulate but allows an exact representation of all XML values. + +Things to note: + +- XML nodes are represented by a pair of the form: `(, )` +- `` contains the following: + - `xml_params`, represented as a list of pairs `(string * string)` + - `xml_children`, containing an array of the XML node's children. + - `xml_text`, present when the node is a text node. In this case, `xml_params` or `xm_children` are empty. +- By convention, text nodes are labelled `xml_text`. + +### Rendering XML values + +XML values can be converted back to strings using `xml.stringify`. + +Both the formal and record-access form can be rendered back into XML strings however, with the record-access representations, if a node has multiple children with the same tag, the conversion to XML string will fail. + +More generally, if the values you want to convert to XML strings are complex, for instance if they use several times the same tag as child node or if the order of child nodes matters, we recommend using the formal representation to make sure that children ordering is properly preserved. + +This is because record methods are not ordered in the language so we make no guarantee that the child nodes they represent be rendered in a specific order. diff --git a/doc/dune.inc b/doc/dune.inc index 0f1f2fdff0..acc89ed9f3 100644 --- a/doc/dune.inc +++ b/doc/dune.inc @@ -9281,6 +9281,134 @@ ) ) +(rule + (alias doc) + (package liquidsoap) + (enabled_if (not %{bin-available:pandoc})) + (deps (:no_pandoc no-pandoc)) + (target xml.html) + (action (run cp %{no_pandoc} %{target})) +) + +(rule + (alias doc) + (package liquidsoap) + (enabled_if %{bin-available:pandoc}) + (deps + liquidsoap.xml + language.dtd + template.html + content/liq/append-silence.liq + content/liq/archive-cleaner.liq + content/liq/basic-radio.liq + content/liq/beets-amplify.liq + content/liq/beets-protocol-short.liq + content/liq/beets-protocol.liq + content/liq/beets-source.liq + content/liq/blank-detect.liq + content/liq/blank-sorry.liq + content/liq/complete-case.liq + content/liq/cross.custom.liq + content/liq/crossfade.liq + content/liq/decoder-faad.liq + content/liq/decoder-flac.liq + content/liq/decoder-metaflac.liq + content/liq/dump-hourly.liq + content/liq/dump-hourly2.liq + content/liq/dynamic-source.liq + content/liq/external-output.file.liq + content/liq/fallback.liq + content/liq/ffmpeg-filter-dynamic-volume.liq + content/liq/ffmpeg-filter-flanger-highpass.liq + content/liq/ffmpeg-filter-hflip.liq + content/liq/ffmpeg-filter-hflip2.liq + content/liq/ffmpeg-filter-parallel-flanger-highpass.liq + content/liq/ffmpeg-live-switch.liq + content/liq/ffmpeg-relay-ondemand.liq + content/liq/ffmpeg-relay.liq + content/liq/ffmpeg-shared-encoding-rtmp.liq + content/liq/ffmpeg-shared-encoding.liq + content/liq/fixed-time1.liq + content/liq/fixed-time2.liq + content/liq/frame-size.liq + content/liq/harbor-auth.liq + content/liq/harbor-dynamic.liq + content/liq/harbor-insert-metadata.liq + content/liq/harbor-metadata.liq + content/liq/harbor-redirect.liq + content/liq/harbor-simple.liq + content/liq/harbor-usage.liq + content/liq/harbor.http.register.liq + content/liq/harbor.http.response.liq + content/liq/hls-metadata.liq + content/liq/hls-mp4.liq + content/liq/http-input.liq + content/liq/icy-update.liq + content/liq/input.mplayer.liq + content/liq/jingle-hour.liq + content/liq/json-ex.liq + content/liq/json-stringify.liq + content/liq/json1.liq + content/liq/live-switch.liq + content/liq/medialib-predicate.liq + content/liq/medialib.liq + content/liq/medialib.sqlite.liq + content/liq/multitrack-add-video-track.liq + content/liq/multitrack-add-video-track2.liq + content/liq/multitrack-default-video-track.liq + content/liq/multitrack.liq + content/liq/multitrack2.liq + content/liq/multitrack3.liq + content/liq/output.file.hls.liq + content/liq/playlists.liq + content/liq/prometheus-callback.liq + content/liq/prometheus-settings.liq + content/liq/radiopi.liq + content/liq/re-encode.liq + content/liq/regular.liq + content/liq/replaygain-metadata.liq + content/liq/replaygain-playlist.liq + content/liq/request.dynamic.liq + content/liq/rtmp.liq + content/liq/samplerate3.liq + content/liq/scheduling.liq + content/liq/seek-telnet.liq + content/liq/settings.liq + content/liq/shoutcast.liq + content/liq/single.liq + content/liq/source-cue.liq + content/liq/space_overhead.liq + content/liq/split-cue.liq + content/liq/sqlite.liq + content/liq/srt-receiver.liq + content/liq/srt-sender.liq + content/liq/switch-show.liq + content/liq/transcoding.liq + content/liq/video-anonymizer.liq + content/liq/video-bluescreen.liq + content/liq/video-canvas-example.liq + content/liq/video-default-canvas.liq + content/liq/video-in-video.liq + content/liq/video-logo.liq + content/liq/video-osc.liq + content/liq/video-simple.liq + content/liq/video-static.liq + content/liq/video-text.liq + content/liq/video-transition.liq + content/liq/video-weather.liq + content/liq/video-webcam.liq + (:md content/xml.md) + ) + (target xml.html) + (action + (pipe-stdout + (run pandoc %{md} -t json) + (run pandoc-include --directory content/liq) + (run pandoc -f json --syntax-definition=liquidsoap.xml --highlight=pygments --metadata pagetitle=xml --template=template.html -o %{target}) + ) + ) +) + (rule (alias doc) (package liquidsoap) @@ -10496,6 +10624,7 @@ (strings_encoding.html as html/strings_encoding.html) (video-static.html as html/video-static.html) (video.html as html/video.html) + (xml.html as html/xml.html) (yaml.html as html/yaml.html) ) ) From e49a6367e3af9c09f31c387bee8216621b5740fa Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Wed, 11 Dec 2024 12:42:26 +0100 Subject: [PATCH 11/14] Add changes entry. --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index 75e5a31809..a47e28636c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ New: +- Added support for parsing and rendering XML natively (#4252) - Added support for `WAVE_FORMAT_EXTENSIBLE` to the internal wav dexcoder. - Added optional `buffer_size` parameter to `input.alsa` and From 91d6b1c7c02fd3a7aca6915a4670543740065571 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Thu, 12 Dec 2024 10:36:45 +0100 Subject: [PATCH 12/14] Cleanup --- doc/content/xml.md | 22 ++++++++++++++++------ tests/language/xml_test.liq | 4 ++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/doc/content/xml.md b/doc/content/xml.md index adac640fb1..deda87965e 100644 --- a/doc/content/xml.md +++ b/doc/content/xml.md @@ -34,12 +34,14 @@ let xml.parse (x : } } ) = s + +print("The value for blu is: #{x.bla.ble}") ``` Things to note: -- The basic mappings are: `tag name -> tag content` -- Tag content maps tag parameters to `xml_params` +- The basic mappings are: ` -> ` +- Tag content maps tag parameters to a `xml_params` method. - When multiple tags are present, their values are collected as tuple (`bar` tag in the example) - When a tag contains a single ground value (`string`, `bool`, `float` or `integer`), the mapping is from tag name to the corresponding value, with xml attributes attached as methods - Tag parameters can be converted to ground values and omitted. @@ -49,7 +51,15 @@ The parsing is driven by the type annotation and is intended to be permissive. F ```liquidsoaop s = 'foo' +# Here, `foo` is omitted. let xml.parse (x: { bla: unit }) = s + +# x contains: { bla = () } + +# Here, `foo` is made optional +let xml.parse (x: { bla: string? }) = s + +# x contains: { bla = "foo" } ``` ### Formal representation @@ -133,11 +143,11 @@ This representation is much less convenient to manipulate but allows an exact re Things to note: - XML nodes are represented by a pair of the form: `(, )` -- `` contains the following: +- `` is a record containing the following methods: - `xml_params`, represented as a list of pairs `(string * string)` - - `xml_children`, containing an array of the XML node's children. - - `xml_text`, present when the node is a text node. In this case, `xml_params` or `xm_children` are empty. -- By convention, text nodes are labelled `xml_text`. + - `xml_children`, containing a list of the XML node's children. Each entry in the list is a node in the formal XML representation. + - `xml_text`, present when the node is a text node. In this case, `xml_params` and `xm_children` are empty. +- By convention, text nodes are labelled `xml_text` and are of the form: `{ xml_text: "node content" }` ### Rendering XML values diff --git a/tests/language/xml_test.liq b/tests/language/xml_test.liq index 071bf3a658..e242e945f9 100644 --- a/tests/language/xml_test.liq +++ b/tests/language/xml_test.liq @@ -44,13 +44,13 @@ def f() = { xml_params=[("param", "1"), ("bla", "true")], bar="bla".{xml_params=[("option", "aab")]}, - foo="gni".{xml_params={opt=12.3}} + foo=true.{xml_params={opt=12.3}} } } ), ' bla - gni + true ' ) From 3a1b4613f34cc7bfa7713e7936acfc500332baa6 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Thu, 12 Dec 2024 10:44:24 +0100 Subject: [PATCH 13/14] Update pre-commit. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3aa56da0e0..25878f4e98 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: exclude: dune.inc - repo: https://github.com/savonet/pre-commit-liquidsoap - rev: c5eab8dceed09fa985b3cf0ba3fe7f398fc00c04 + rev: 056cf2da9d985e1915a069679f126a461206504a hooks: - id: liquidsoap-prettier From cb092373601fb7994be59678a3ef70e31d66beda Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Thu, 12 Dec 2024 11:44:03 +0100 Subject: [PATCH 14/14] Revert this. --- src/libs/http.liq | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/http.liq b/src/libs/http.liq index 1ef9cc529b..cece16dd1b 100644 --- a/src/libs/http.liq +++ b/src/libs/http.liq @@ -964,7 +964,7 @@ def http.headers.content_disposition(headers) = type: string, filename?: string, name?: string, - args: [(string*string?)] + args: [(string * string?)] } ) end,