diff --git a/launch/launch/substitutions/string_join_substitution.py b/launch/launch/substitutions/string_join_substitution.py
index be3a101d8..50dda3a27 100644
--- a/launch/launch/substitutions/string_join_substitution.py
+++ b/launch/launch/substitutions/string_join_substitution.py
@@ -14,14 +14,16 @@
"""Module for the StringJoinSubstitution substitution."""
-from typing import Iterable, List, Text
+from typing import Any, Dict, Iterable, List, Sequence, Text, Tuple, Type
+from ..frontend.expose import expose_substitution
from ..launch_context import LaunchContext
from ..some_substitutions_type import SomeSubstitutionsType
from ..substitution import Substitution
from ..utilities import perform_substitutions
+@expose_substitution('string-join')
class StringJoinSubstitution(Substitution):
"""
Substitution that joins strings and/or other substitutions.
@@ -34,13 +36,31 @@ class StringJoinSubstitution(Substitution):
.. code-block:: python
- StringJoinSubstitution(
- [['https', '://'], LaunchConfiguration('subdomain')], 'ros', 'org'],
+ subdomain = LaunchConfiguration(variable_name='subdomain', default='docs')
+ url = StringJoinSubstitution(
+ [['https', '://', subdomain], 'ros', 'org'],
delimiter='.'
)
- If the ``subdomain`` launch configuration was set to ``docs``
- and the ``delimiter`` to ``.``, this would result in a string equal to
+ .. code-block:: xml
+
+
+
+
+
+
+ .. code-block:: yaml
+
+ launch:
+ - arg:
+ name: subdomain
+ default: "docs"
+ - let:
+ name: url
+ value: "$(string-join . https://$(var subdomain) ros org)"
+
+ If the ``subdomain`` launch configuration was set to ``docs`` and the ``delimiter`` to ``.``,
+ then any of the above launch descriptions would result in a string equal to
.. code-block:: python
@@ -76,6 +96,18 @@ def delimiter(self) -> List[Substitution]:
"""Getter for delimiter."""
return self.__delimiter
+ @classmethod
+ def parse(
+ cls, data: Sequence[SomeSubstitutionsType]
+ ) -> Tuple[Type['StringJoinSubstitution'], Dict[str, Any]]:
+ """Parse `StringJoinSubstitution` substitution."""
+ if len(data) < 2:
+ raise TypeError(
+ 'string-join substitution expects at least 2 arguments: '
+ '1 delimiter + at least 1 component'
+ )
+ return cls, {'delimiter': data[0], 'substitutions': data[1:]}
+
def __repr__(self) -> Text:
"""Return a description of this substitution as a string."""
string_components = [
diff --git a/launch_xml/test/launch_xml/test_string_join_substitution.py b/launch_xml/test/launch_xml/test_string_join_substitution.py
new file mode 100644
index 000000000..4a4b3561c
--- /dev/null
+++ b/launch_xml/test/launch_xml/test_string_join_substitution.py
@@ -0,0 +1,71 @@
+# Copyright 2025 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Test parsing a StringJoinSubstitution in XML launch file."""
+
+import io
+import textwrap
+
+from launch.actions import DeclareLaunchArgument, SetLaunchConfiguration
+from launch.frontend import Parser
+from launch.launch_context import LaunchContext
+from launch.substitutions import StringJoinSubstitution
+
+
+def test_nested():
+ xml_file = textwrap.dedent(
+ """
+
+
+
+
+ """
+ )
+ root_entity, parser = Parser.load(io.StringIO(xml_file))
+ ld = parser.parse_description(root_entity)
+
+ assert len(ld.entities) == 2
+ assert isinstance(ld.entities[0], DeclareLaunchArgument)
+ assert isinstance(ld.entities[1], SetLaunchConfiguration)
+
+ lc = LaunchContext()
+ ld.entities[0].visit(lc)
+
+ let = ld.entities[1]
+ assert isinstance(let.value[0], StringJoinSubstitution)
+ assert let.value[0].perform(lc) == 'https://wiki.ros.org'
+
+
+def test_delimiter():
+ yaml_file = textwrap.dedent(
+ """
+
+
+
+
+ """
+ )
+ root_entity, parser = Parser.load(io.StringIO(yaml_file))
+ ld = parser.parse_description(root_entity)
+
+ assert len(ld.entities) == 2
+ assert isinstance(ld.entities[0], DeclareLaunchArgument)
+ assert isinstance(ld.entities[1], SetLaunchConfiguration)
+
+ lc = LaunchContext()
+ ld.entities[0].visit(lc)
+
+ let = ld.entities[1]
+ assert isinstance(let.value[0], StringJoinSubstitution)
+ assert let.value[0].perform(lc) == 'a(^_^)b(^_^)c'
diff --git a/launch_yaml/test/launch_yaml/test_string_join_substitution.py b/launch_yaml/test/launch_yaml/test_string_join_substitution.py
new file mode 100644
index 000000000..761dbd1ee
--- /dev/null
+++ b/launch_yaml/test/launch_yaml/test_string_join_substitution.py
@@ -0,0 +1,77 @@
+# Copyright 2025 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Test parsing a StringJoinSubstitution in yaml launch file."""
+
+import io
+import textwrap
+
+from launch.actions import DeclareLaunchArgument, SetLaunchConfiguration
+from launch.frontend import Parser
+from launch.launch_context import LaunchContext
+from launch.substitutions import StringJoinSubstitution
+
+
+def test_nested():
+ yaml_file = textwrap.dedent(
+ """
+ launch:
+ - arg:
+ name: subdomain
+ default: "wiki"
+ - let:
+ name: url
+ value: "$(string-join . https://$(var subdomain) ros org)"
+ """
+ )
+ root_entity, parser = Parser.load(io.StringIO(yaml_file))
+ ld = parser.parse_description(root_entity)
+
+ assert len(ld.entities) == 2
+ assert isinstance(ld.entities[0], DeclareLaunchArgument)
+ assert isinstance(ld.entities[1], SetLaunchConfiguration)
+
+ lc = LaunchContext()
+ ld.entities[0].visit(lc)
+
+ let = ld.entities[1]
+ assert isinstance(let.value[0], StringJoinSubstitution)
+ assert let.value[0].perform(lc) == 'https://wiki.ros.org'
+
+
+def test_delimiter():
+ yaml_file = textwrap.dedent(
+ """
+ launch:
+ - arg:
+ name: delimiter
+ default: "^_^"
+ - let:
+ name: text
+ value: "$(string-join '($(var delimiter))' a b c)"
+ """
+ )
+ root_entity, parser = Parser.load(io.StringIO(yaml_file))
+ ld = parser.parse_description(root_entity)
+
+ assert len(ld.entities) == 2
+ assert isinstance(ld.entities[0], DeclareLaunchArgument)
+ assert isinstance(ld.entities[1], SetLaunchConfiguration)
+
+ lc = LaunchContext()
+ ld.entities[0].visit(lc)
+
+ let = ld.entities[1]
+ assert isinstance(let.value[0], StringJoinSubstitution)
+ assert let.value[0].perform(lc) == 'a(^_^)b(^_^)c'