Repository: pydot/pydot

Issue:
Parser: Missing support for +-concatenated quoted strings
The GraphViz language documentation says, in the ["Comments and optional formatting" section](https://graphviz.org/doc/info/lang.html#comments-and-optional-formatting)):

> In addition, double-quoted strings can be concatenated using a '+' operator.

Our parser does not currently have any support for that syntax.

_Adapted from a comment originally posted by @ferdnyc in [#483](https://github.com/pydot/pydot/issues/483#issuecomment-2961183272)_

(Granted, I've never seen anyone actually _use_ that syntax, but I've tried it and it does work in `dot`, so we should probably support it as well.)

Hints from issue comments:

Solution patch:
diff --git a/src/pydot/dot_parser.py b/src/pydot/dot_parser.py
index 5f9e0ec..d3d9609 100644
--- a/src/pydot/dot_parser.py
+++ b/src/pydot/dot_parser.py
@@ -36,8 +36,8 @@ from pyparsing import (
     cStyleComment,
     lineno,
     nums,
-    pyparsing_unicode,
     restOfLine,
+    unicode,
 )
 
 import pydot.core
@@ -180,6 +180,28 @@ def push_dbl_quoted(toks: ParseResults) -> str:
     return s
 
 
+def push_ID(toks: ParseResults) -> str:
+    """Join multiple string pieces into a single ID string."""
+    if "concat" in toks:
+        out = "".join(s[1:-1] for s in toks.concat)
+        return f'"{out}"'
+    if "dbl_quoted" in toks:
+        return str(toks.dbl_quoted)
+    if "ident" in toks:
+        return str(toks.ident)
+    # (by process of elimination, HTML)
+    assert "html" in toks and isinstance(toks.html, str)
+    return toks.html
+
+
+def push_node_id(toks: ParseResults) -> str:
+    out = []
+    for group in toks:
+        assert "id_part" in group
+        out.append(str(group.id_part))
+    return ":".join(out)
+
+
 def push_graph_stmt(toks: ParseResults) -> pydot.core.Subgraph:
     g = pydot.core.Subgraph("")
     g.obj_dict["show_keyword"] = False
@@ -258,25 +280,33 @@ class GraphParser:
     edge_ = CaselessLiteral("edge")
 
     # token definitions
-    identifier = Word(
-        pyparsing_unicode.BasicMultilingualPlane.alphanums + "_."
+    identifier = Word(unicode.BasicMultilingualPlane.alphanums + "_.")
+
+    double_quoted = (
+        QuotedString('"', multiline=True, unquote_results=False, esc_char="\\")
+        .set_results_name("dbl_quoted")
+        .set_parse_action(push_dbl_quoted)
     )
 
-    double_quoted_string = QuotedString(
-        '"', multiline=True, unquote_results=False, esc_char="\\"
+    concat_string = DelimitedList(
+        double_quoted, delim="+", min=2, combine=False
     )
 
     ID = (
-        identifier
-        | HTML()
-        | double_quoted_string("dbl_quoted").set_parse_action(push_dbl_quoted)
-    )
+        concat_string("concat")
+        | double_quoted
+        | identifier("ident")
+        | HTML().set_results_name("html")
+    ).set_parse_action(push_ID)
 
     float_number = Combine(Optional(minus) + OneOrMore(Word(nums + ".")))
 
     righthand_id = float_number | ID
 
-    node_id = DelimitedList(ID, delim=":", min=1, max=3, combine=True)
+    node_id = DelimitedList(
+        Group(ID("id_part")), delim=":", min=1, max=3, combine=False
+    ).set_parse_action(push_node_id)
+
     a_list = OneOrMore(
         ID + Optional(equals + righthand_id) + Optional(comma.suppress())
     )
@@ -363,8 +393,7 @@ def parse_dot_data(s: str) -> list[pydot.core.Dot] | None:
     @rtype: `list` of `pydot.core.Dot`
     """
     try:
-        graphparser = GraphParser.parser
-        tokens = graphparser.parse_string(s)
+        tokens = GraphParser.parser.parse_string(s)
         return list(tokens)
     except ParseException as err:
         print(err.line)


Test patch:
diff --git a/test/test_parser.py b/test/test_parser.py
index 4140ed9..cd94acb 100644
--- a/test/test_parser.py
+++ b/test/test_parser.py
@@ -117,3 +117,39 @@ def test_backslash_continuations() -> None:
     assert nodes[0].get_name() == '"my very long node name"'
     assert nodes[1].get_name() == '"my indented and wrapped     node name"'
     assert nodes[2].get_name() == '"my node name containing \\ backslash"'
+
+
+def test_plus_concatenation() -> None:
+    src = textwrap.dedent(r"""
+        digraph G {
+            "my" + "concatenated" + "name";
+            "myconcatenated" + " with " + "ports" [color="r" + "ed"];
+            "my\
+        concatenated" + " with ports":p45:sw -> "my\
+        concatenated\
+        name":ne [penwidth=5, arrows="b" + "o" + "t" + "h"];
+        "con" + "catenated" [label="this is a long\
+        long label" + " that just goes on \
+        and on and on"];
+        }""")
+    res = dot_parser.parse_dot_data(src)
+    assert isinstance(res, list)
+    assert len(res) == 1
+    graph = res[0]
+    nodes = graph.get_nodes()
+    edges = graph.get_edges()
+
+    assert len(nodes) == 3
+    assert nodes[0].get_name() == '"myconcatenatedname"'
+    assert nodes[1].get_name() == '"myconcatenated with ports"'
+    assert nodes[1].get("color") == '"red"'
+    assert nodes[2].get_name() == '"concatenated"'
+    assert nodes[2].get("label") == (
+        '"this is a longlong label that just goes on and on and on"'
+    )
+
+    assert len(edges) == 1
+    edge = edges[0]
+    assert edge.get_source() == f"{nodes[1].get_name()}:p45:sw"
+    assert edge.get_destination() == '"myconcatenatedname":ne'
+    assert edge.get("arrows") == '"both"'