Current section
Files
Jump to
Current section
Files
src/yog_io@tgf.erl
-module(yog_io@tgf).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/yog_io/tgf.gleam").
-export([default_options/0, options_with/2, serialize_with/2, serialize/1, to_string/1, write/2, write_with/3, parse_with/4, parse/2, read/2, read_with/4]).
-export_type([tgf_options/2, tgf_error/0, tgf_result/2]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Trivial Graph Format (TGF) serialization support.\n"
"\n"
" Provides functions to serialize and deserialize graphs in TGF format,\n"
" a very simple text-based format suitable for quick graph exchange and debugging.\n"
"\n"
" ## Format Overview\n"
"\n"
" TGF consists of three parts:\n"
" 1. **Node section**: Each line is `node_id node_label`\n"
" 2. **Separator**: A single `#` character on its own line\n"
" 3. **Edge section**: Each line is `source_id target_id [edge_label]`\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" import yog/model.{Directed}\n"
" import yog_io/tgf\n"
"\n"
" // Create a simple graph\n"
" let graph =\n"
" model.new(Directed)\n"
" |> model.add_node(1, \"Alice\")\n"
" |> model.add_node(2, \"Bob\")\n"
"\n"
" let assert Ok(graph) = model.add_edge(graph, from: 1, to: 2, with: \"follows\")\n"
"\n"
" // Serialize to TGF\n"
" let tgf_string = tgf.serialize(graph)\n"
"\n"
" // Write to file\n"
" let assert Ok(Nil) = tgf.write(\"graph.tgf\", graph)\n"
" ```\n"
"\n"
" ## Output Format\n"
"\n"
" ```\n"
" 1 Alice\n"
" 2 Bob\n"
" #\n"
" 1 2 follows\n"
" ```\n"
"\n"
" ## Characteristics\n"
"\n"
" - **Human-readable**: Simple text format, easy to understand\n"
" - **Compact**: Minimal syntax overhead\n"
" - **No metadata**: Does not preserve graph type (directed/undirected)\n"
" - **Line-oriented**: One element per line\n"
"\n"
" ## Parsing Behavior\n"
"\n"
" When parsing TGF files, the following behaviors apply:\n"
"\n"
" - **Auto-node creation**: If an edge references a node ID that was not declared\n"
" in the node section, a node is automatically created with the ID as its label.\n"
" This provides a lenient parsing mode that accepts minimal TGF files.\n"
"\n"
" - **Empty labels**: Nodes without labels default to using their ID as the label\n"
" string. Edges without labels receive an empty string `\"\"` which is passed to\n"
" the edge parser.\n"
"\n"
" - **Whitespace handling**: Multiple consecutive spaces in labels are collapsed\n"
" to single spaces. Leading and trailing whitespace on lines is trimmed.\n"
"\n"
" - **Malformed lines**: Lines that cannot be parsed are skipped and collected\n"
" as warnings in the `TgfResult`, rather than causing the entire parse to fail.\n"
"\n"
" ## References\n"
"\n"
" - [TGF on Wikipedia](https://en.wikipedia.org/wiki/Trivial_Graph_Format)\n"
" - [yEd TGF Import](https://yed.yworks.com/support/manual/tgf.html)\n"
).
-type tgf_options(AGIC, AGID) :: {tgf_options,
fun((AGIC) -> binary()),
fun((AGID) -> gleam@option:option(binary()))}.
-type tgf_error() :: empty_input |
{invalid_node_line, integer(), binary()} |
{invalid_edge_line, integer(), binary()} |
{invalid_node_id, integer(), binary()} |
{invalid_edge_endpoint, integer(), binary()} |
{duplicate_node_id, integer(), integer()} |
{read_error, binary(), binary()} |
{write_error, binary(), binary()}.
-type tgf_result(AGIE, AGIF) :: {tgf_result,
yog@model:graph(AGIE, AGIF),
list({integer(), binary()})}.
-file("src/yog_io/tgf.gleam", 102).
?DOC(
" Default TGF serialization options.\n"
"\n"
" Default configuration:\n"
" - Node labels: Uses the node data's string representation\n"
" - Edge labels: None (edges are just `source target`)\n"
).
-spec default_options() -> tgf_options(binary(), binary()).
default_options() ->
{tgf_options, fun(Data) -> Data end, fun(_) -> none end}.
-file("src/yog_io/tgf.gleam", 118).
?DOC(
" Creates TGF options with custom node and edge label functions.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let options = tgf.options_with(\n"
" node_label: fn(person) { person.name },\n"
" edge_label: fn(weight) { Some(int.to_string(weight)) },\n"
" )\n"
" ```\n"
).
-spec options_with(
fun((AGII) -> binary()),
fun((AGIJ) -> gleam@option:option(binary()))
) -> tgf_options(AGII, AGIJ).
options_with(Node_label, Edge_label) ->
{tgf_options, Node_label, Edge_label}.
-file("src/yog_io/tgf.gleam", 188).
?DOC(
" Serializes a graph to TGF format with custom label functions.\n"
"\n"
" This function allows you to control how node and edge data are converted\n"
" to TGF labels.\n"
"\n"
" **Time Complexity:** O(V + E)\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" import yog/model.{Directed}\n"
" import yog_io/tgf\n"
"\n"
" type Person {\n"
" Person(name: String, age: Int)\n"
" }\n"
"\n"
" let graph =\n"
" model.new(Directed)\n"
" |> model.add_node(1, Person(\"Alice\", 30))\n"
" |> model.add_node(2, Person(\"Bob\", 25))\n"
" |> model.add_edge(from: 1, to: 2, with: \"follows\")\n"
"\n"
" let options = tgf.options_with(\n"
" node_label: fn(p) { p.name },\n"
" edge_label: fn(label) { Some(label) },\n"
" )\n"
"\n"
" let tgf_string = tgf.serialize_with(options, graph)\n"
" ```\n"
).
-spec serialize_with(tgf_options(AGIN, AGIO), yog@model:graph(AGIN, AGIO)) -> binary().
serialize_with(Options, Graph) ->
Builder = gleam@string_tree:new(),
Builder@1 = gleam@dict:fold(
erlang:element(3, Graph),
Builder,
fun(B, Id, Data) ->
Label = (erlang:element(2, Options))(Data),
_pipe = B,
_pipe@1 = gleam@string_tree:append(
_pipe,
erlang:integer_to_binary(Id)
),
_pipe@2 = gleam@string_tree:append(_pipe@1, <<" "/utf8>>),
_pipe@3 = gleam@string_tree:append(_pipe@2, Label),
gleam@string_tree:append(_pipe@3, <<"\n"/utf8>>)
end
),
Builder@2 = gleam@string_tree:append(Builder@1, <<"#\n"/utf8>>),
Builder@3 = gleam@dict:fold(
erlang:element(4, Graph),
Builder@2,
fun(B@1, From_id, Targets) ->
gleam@dict:fold(
Targets,
B@1,
fun(Inner_b, To_id, Weight) -> case erlang:element(2, Graph) of
undirected when From_id > To_id ->
Inner_b;
_ ->
Base = <<<<(erlang:integer_to_binary(From_id))/binary,
" "/utf8>>/binary,
(erlang:integer_to_binary(To_id))/binary>>,
Line = case (erlang:element(3, Options))(Weight) of
{some, Label@1} ->
<<<<<<Base/binary, " "/utf8>>/binary,
Label@1/binary>>/binary,
"\n"/utf8>>;
none ->
<<Base/binary, "\n"/utf8>>
end,
gleam@string_tree:append(Inner_b, Line)
end end
)
end
),
unicode:characters_to_binary(Builder@3).
-file("src/yog_io/tgf.gleam", 236).
?DOC(
" Serializes a graph to TGF format.\n"
"\n"
" Convenience function for graphs with String node and edge data.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let tgf_string = tgf.serialize(graph)\n"
" ```\n"
).
-spec serialize(yog@model:graph(binary(), binary())) -> binary().
serialize(Graph) ->
serialize_with(default_options(), Graph).
-file("src/yog_io/tgf.gleam", 243).
?DOC(
" Converts a graph to a TGF string.\n"
"\n"
" Alias for `serialize` for consistency with other modules.\n"
).
-spec to_string(yog@model:graph(binary(), binary())) -> binary().
to_string(Graph) ->
serialize(Graph).
-file("src/yog_io/tgf.gleam", 258).
?DOC(
" Writes a graph to a TGF file.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let assert Ok(Nil) = tgf.write(\"graph.tgf\", graph)\n"
" ```\n"
).
-spec write(binary(), yog@model:graph(binary(), binary())) -> {ok, nil} |
{error, simplifile:file_error()}.
write(Path, Graph) ->
Content = serialize(Graph),
simplifile:write(Path, Content).
-file("src/yog_io/tgf.gleam", 278).
?DOC(
" Writes a graph to a TGF file with custom label functions.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let options = tgf.options_with(\n"
" node_label: fn(p) { p.name },\n"
" edge_label: fn(w) { Some(int.to_string(w)) },\n"
" )\n"
"\n"
" let assert Ok(Nil) = tgf.write_with(\"graph.tgf\", options, graph)\n"
" ```\n"
).
-spec write_with(binary(), tgf_options(AGJB, AGJC), yog@model:graph(AGJB, AGJC)) -> {ok,
nil} |
{error, simplifile:file_error()}.
write_with(Path, Options, Graph) ->
Content = serialize_with(Options, Graph),
simplifile:write(Path, Content).
-file("src/yog_io/tgf.gleam", 373).
-spec do_split(list({integer(), binary()}), list({integer(), binary()})) -> {list({integer(),
binary()}),
list({integer(), binary()})}.
do_split(Lines, Acc) ->
case Lines of
[] ->
{lists:reverse(Acc), []};
[{_, <<"#"/utf8>>} | Rest] ->
{lists:reverse(Acc), Rest};
[Line | Rest@1] ->
do_split(Rest@1, [Line | Acc])
end.
-file("src/yog_io/tgf.gleam", 367).
?DOC(" Split lines into node section and edge section at the # separator.\n").
-spec split_at_separator(list({integer(), binary()})) -> {list({integer(),
binary()}),
list({integer(), binary()})}.
split_at_separator(Lines) ->
do_split(Lines, []).
-file("src/yog_io/tgf.gleam", 421).
?DOC(" Parse a single node line: \"id label\" or just \"id\"\n").
-spec parse_node_line(binary(), integer(), fun((integer(), binary()) -> AGKR)) -> {ok,
{integer(), AGKR}} |
{error, gleam@option:option(tgf_error())}.
parse_node_line(Line, Line_num, Node_parser) ->
Parts = begin
_pipe = Line,
_pipe@1 = gleam@string:split(_pipe, <<" "/utf8>>),
gleam@list:filter(
_pipe@1,
fun(S) -> gleam@string:trim(S) /= <<""/utf8>> end
)
end,
case Parts of
[] ->
{error, none};
[Id_str | Rest] ->
case gleam_stdlib:parse_int(gleam@string:trim(Id_str)) of
{error, _} ->
{error, {some, {invalid_node_id, Line_num, Id_str}}};
{ok, Id} ->
Label = case Rest of
[] ->
Id_str;
_ ->
gleam@string:join(Rest, <<" "/utf8>>)
end,
{ok, {Id, Node_parser(Id, Label)}}
end
end.
-file("src/yog_io/tgf.gleam", 392).
-spec do_parse_nodes(
list({integer(), binary()}),
fun((integer(), binary()) -> AGKK),
list({integer(), AGKK}),
list({integer(), binary()})
) -> {ok, {list({integer(), AGKK}), list({integer(), binary()})}} |
{error, tgf_error()}.
do_parse_nodes(Lines, Node_parser, Acc, Warnings) ->
case Lines of
[] ->
{ok, {Acc, Warnings}};
[{Line_num, Line} | Rest] ->
case parse_node_line(Line, Line_num, Node_parser) of
{ok, {Id, Data}} ->
case gleam@list:any(
Acc,
fun(Pair) -> erlang:element(1, Pair) =:= Id end
) of
true ->
{error, {duplicate_node_id, Line_num, Id}};
false ->
do_parse_nodes(
Rest,
Node_parser,
[{Id, Data} | Acc],
Warnings
)
end;
{error, none} ->
do_parse_nodes(
Rest,
Node_parser,
Acc,
[{Line_num, Line} | Warnings]
);
{error, {some, E}} ->
{error, E}
end
end.
-file("src/yog_io/tgf.gleam", 385).
?DOC(" Parse node lines: \"id label\"\n").
-spec parse_nodes(
list({integer(), binary()}),
fun((integer(), binary()) -> AGKE)
) -> {ok, {list({integer(), AGKE}), list({integer(), binary()})}} |
{error, tgf_error()}.
parse_nodes(Lines, Node_parser) ->
do_parse_nodes(Lines, Node_parser, [], []).
-file("src/yog_io/tgf.gleam", 449).
?DOC(" Create a graph with the parsed nodes.\n").
-spec create_graph_with_nodes(yog@model:graph_type(), list({integer(), AGKV})) -> yog@model:graph(AGKV, any()).
create_graph_with_nodes(Gtype, Nodes) ->
gleam@list:fold(
Nodes,
yog@model:new(Gtype),
fun(Graph, Pair) ->
{Id, Data} = Pair,
yog@model:add_node(Graph, Id, Data)
end
).
-file("src/yog_io/tgf.gleam", 545).
?DOC(" Ensure both source and target nodes exist in the graph.\n").
-spec ensure_nodes_exist(
yog@model:graph(AGME, AGMF),
integer(),
integer(),
fun((integer(), binary()) -> AGME)
) -> yog@model:graph(AGME, AGMF).
ensure_nodes_exist(Graph, Src, Tgt, Node_parser) ->
Graph_with_src = case gleam@dict:has_key(erlang:element(3, Graph), Src) of
true ->
Graph;
false ->
yog@model:add_node(
Graph,
Src,
Node_parser(Src, erlang:integer_to_binary(Src))
)
end,
case gleam@dict:has_key(erlang:element(3, Graph_with_src), Tgt) of
true ->
Graph_with_src;
false ->
yog@model:add_node(
Graph_with_src,
Tgt,
Node_parser(Tgt, erlang:integer_to_binary(Tgt))
)
end.
-file("src/yog_io/tgf.gleam", 499).
?DOC(" Parse a single edge line: \"source target [label]\"\n").
-spec parse_edge_line(
binary(),
integer(),
fun((binary()) -> AGLV),
yog@model:graph(AGLW, AGLV),
fun((integer(), binary()) -> AGLW)
) -> {ok, yog@model:graph(AGLW, AGLV)} |
{error, gleam@option:option(tgf_error())}.
parse_edge_line(Line, Line_num, Edge_parser, Graph, Node_parser) ->
Parts = begin
_pipe = Line,
_pipe@1 = gleam@string:split(_pipe, <<" "/utf8>>),
gleam@list:filter(
_pipe@1,
fun(S) -> gleam@string:trim(S) /= <<""/utf8>> end
)
end,
case Parts of
[] ->
{error, none};
[_] ->
{error, none};
[Src_str, Tgt_str | Rest] ->
case {gleam_stdlib:parse_int(gleam@string:trim(Src_str)),
gleam_stdlib:parse_int(gleam@string:trim(Tgt_str))} of
{{ok, Src}, {ok, Tgt}} ->
Edge_data = case Rest of
[] ->
Edge_parser(<<""/utf8>>);
_ ->
Edge_parser(gleam@string:join(Rest, <<" "/utf8>>))
end,
Graph_with_nodes = ensure_nodes_exist(
Graph,
Src,
Tgt,
Node_parser
),
case yog@model:add_edge(
Graph_with_nodes,
Src,
Tgt,
Edge_data
) of
{ok, New_graph} ->
{ok, New_graph};
{error, _} ->
{error, none}
end;
{{error, _}, _} ->
{error, {some, {invalid_edge_endpoint, Line_num, Src_str}}};
{_, {error, _}} ->
{error, {some, {invalid_edge_endpoint, Line_num, Tgt_str}}}
end
end.
-file("src/yog_io/tgf.gleam", 469).
-spec do_parse_edges(
list({integer(), binary()}),
fun((binary()) -> AGLL),
yog@model:graph(AGLM, AGLL),
list({integer(), binary()}),
fun((integer(), binary()) -> AGLM)
) -> {ok, {yog@model:graph(AGLM, AGLL), list({integer(), binary()})}} |
{error, tgf_error()}.
do_parse_edges(Lines, Edge_parser, Graph, Warnings, Node_parser) ->
case Lines of
[] ->
{ok, {Graph, Warnings}};
[{Line_num, Line} | Rest] ->
case parse_edge_line(
Line,
Line_num,
Edge_parser,
Graph,
Node_parser
) of
{ok, New_graph} ->
do_parse_edges(
Rest,
Edge_parser,
New_graph,
Warnings,
Node_parser
);
{error, none} ->
do_parse_edges(
Rest,
Edge_parser,
Graph,
[{Line_num, Line} | Warnings],
Node_parser
);
{error, {some, E}} ->
{error, E}
end
end.
-file("src/yog_io/tgf.gleam", 460).
?DOC(" Parse edge lines: \"source target [label]\"\n").
-spec parse_edges(
list({integer(), binary()}),
fun((binary()) -> AGLB),
yog@model:graph(AGLC, AGLB),
fun((integer(), binary()) -> AGLC)
) -> {ok, {yog@model:graph(AGLC, AGLB), list({integer(), binary()})}} |
{error, tgf_error()}.
parse_edges(Lines, Edge_parser, Graph, Node_parser) ->
do_parse_edges(Lines, Edge_parser, Graph, [], Node_parser).
-file("src/yog_io/tgf.gleam", 338).
-spec do_parse(
list({integer(), binary()}),
yog@model:graph_type(),
fun((integer(), binary()) -> AGJQ),
fun((binary()) -> AGJR)
) -> {ok, tgf_result(AGJQ, AGJR)} | {error, tgf_error()}.
do_parse(Lines, Gtype, Node_parser, Edge_parser) ->
{Node_lines, Edge_lines} = split_at_separator(Lines),
case parse_nodes(Node_lines, Node_parser) of
{error, E} ->
{error, E};
{ok, {Nodes, Node_warnings}} ->
Graph = create_graph_with_nodes(Gtype, Nodes),
case parse_edges(Edge_lines, Edge_parser, Graph, Node_parser) of
{error, E@1} ->
{error, E@1};
{ok, {Final_graph, Edge_warnings}} ->
All_warnings = lists:append(Node_warnings, Edge_warnings),
{ok, {tgf_result, Final_graph, All_warnings}}
end
end.
-file("src/yog_io/tgf.gleam", 316).
?DOC(
" Parses a TGF string into a graph with custom parsers.\n"
"\n"
" The graph type (directed/undirected) must be specified since TGF\n"
" doesn't encode this information.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let tgf_string = \"1 Alice\\n2 Bob\\n#\\n1 2 follows\"\n"
"\n"
" let result = tgf.parse_with(\n"
" tgf_string,\n"
" graph_type: Directed,\n"
" node_parser: fn(id, label) { label },\n"
" edge_parser: fn(label) { label },\n"
" )\n"
"\n"
" case result {\n"
" Ok(tgf.TgfResult(graph, warnings)) -> {\n"
" // Use the graph\n"
" process_graph(graph)\n"
" }\n"
" Error(e) -> handle_error(e)\n"
" }\n"
" ```\n"
).
-spec parse_with(
binary(),
yog@model:graph_type(),
fun((integer(), binary()) -> AGJJ),
fun((binary()) -> AGJK)
) -> {ok, tgf_result(AGJJ, AGJK)} | {error, tgf_error()}.
parse_with(Input, Gtype, Node_parser, Edge_parser) ->
Lines = begin
_pipe = Input,
_pipe@1 = gleam@string:split(_pipe, <<"\n"/utf8>>),
_pipe@2 = gleam@list:index_map(
_pipe@1,
fun(Line, Idx) -> {Idx + 1, gleam@string:trim(Line)} end
),
gleam@list:filter(
_pipe@2,
fun(Pair) ->
{_, Content} = Pair,
Content /= <<""/utf8>>
end
)
end,
case Lines of
[] ->
{error, empty_input};
_ ->
do_parse(Lines, Gtype, Node_parser, Edge_parser)
end.
-file("src/yog_io/tgf.gleam", 580).
?DOC(
" Parses a TGF string into a graph with String labels.\n"
"\n"
" Convenience function for the common case where both node and edge data\n"
" are just Strings.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let tgf_string = \"1 Alice\\n2 Bob\\n#\\n1 2 follows\"\n"
"\n"
" case tgf.parse(tgf_string, Directed) {\n"
" Ok(result) -> {\n"
" // result.graph is Graph(String, String)\n"
" process_graph(result.graph)\n"
" }\n"
" Error(e) -> handle_error(e)\n"
" }\n"
" ```\n"
).
-spec parse(binary(), yog@model:graph_type()) -> {ok,
tgf_result(binary(), binary())} |
{error, tgf_error()}.
parse(Input, Gtype) ->
parse_with(
Input,
Gtype,
fun(_, Label) -> Label end,
fun(Label@1) -> Label@1 end
).
-file("src/yog_io/tgf.gleam", 613).
?DOC(
" Reads a graph from a TGF file.\n"
"\n"
" Convenience function that reads node and edge data as strings.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let result = tgf.read(\"graph.tgf\", Directed)\n"
"\n"
" case result {\n"
" Ok(tgf.TgfResult(graph, warnings)) -> {\n"
" // Use the graph\n"
" process_graph(graph)\n"
" }\n"
" Error(e) -> handle_error(e)\n"
" }\n"
" ```\n"
).
-spec read(binary(), yog@model:graph_type()) -> {ok,
tgf_result(binary(), binary())} |
{error, tgf_error()}.
read(Path, Gtype) ->
case simplifile:read(Path) of
{ok, Content} ->
parse(Content, Gtype);
{error, E} ->
{error, {read_error, Path, simplifile:describe_error(E)}}
end.
-file("src/yog_io/tgf.gleam", 635).
?DOC(
" Reads a graph from a TGF file with custom parsers.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let result = tgf.read_with(\n"
" \"graph.tgf\",\n"
" graph_type: Directed,\n"
" node_parser: fn(id, label) { Person(id, label) },\n"
" edge_parser: fn(label) { String.to_int(label) },\n"
" )\n"
" ```\n"
).
-spec read_with(
binary(),
yog@model:graph_type(),
fun((integer(), binary()) -> AGMS),
fun((binary()) -> AGMT)
) -> {ok, tgf_result(AGMS, AGMT)} | {error, tgf_error()}.
read_with(Path, Gtype, Node_parser, Edge_parser) ->
case simplifile:read(Path) of
{ok, Content} ->
parse_with(Content, Gtype, Node_parser, Edge_parser);
{error, E} ->
{error, {read_error, Path, simplifile:describe_error(E)}}
end.