diff --git a/docs/go.md b/docs/go.md new file mode 100644 index 00000000..0e06f3cd --- /dev/null +++ b/docs/go.md @@ -0,0 +1,66 @@ +# Go lang struct exporter + +This exporter produces type struct definitions for the [go](https://go.dev/) programming language. + +## Exporter specific arguments + +### `--package` + +The name of the package the generated sources (output and types output) will have. + +# Example + +Input model: +```yaml +# model.vspec +Vehicle: + type: branch + description: Vehicle +Vehicle.Speed: + type: sensor + description: Speed + datatype: uint16 +Vehicle.Location: + type: sensor + description: Location + datatype: Types.GPSLocation +``` + +Input type definitions: +```yaml +# types.vspec +Types: + type: branch + description: Custom Types +Types.GPSLocation: + type: struct + description: GPS Location +Types.GPSLocation.Longitude: + type: property + description: Longitude + datatype: float +Types.GPSLocation.Latitude: + type: property + description: Latitude + datatype: float +``` + +Generator call: +```bash +vspec export go --vspec model.vspec --types types.vspec --package vss --output vss.go +``` + +Generated file: +```go +// vss.go +package vss + +type Vehicle struct { + Speed uint16 + Location GPSLocation +} +type GPSLocation struct { + Longitude float32 + Latitude float32 +} +``` diff --git a/docs/vspec.md b/docs/vspec.md index 66fe735f..93d399c0 100644 --- a/docs/vspec.md +++ b/docs/vspec.md @@ -52,6 +52,7 @@ vspec export json --vspec spec/VehicleSignalSpecification.vspec --output vss.jso - [id](./id.md) - [protobuf](./protobuf.md) - [samm](./samm.md) +- [go](./go.md) ## Argument Explanations diff --git a/src/vss_tools/vspec/cli.py b/src/vss_tools/vspec/cli.py index 939e60b3..873eb16d 100644 --- a/src/vss_tools/vspec/cli.py +++ b/src/vss_tools/vspec/cli.py @@ -48,6 +48,7 @@ def cli(ctx: click.Context, log_level: str, log_file: Path): "yaml": "vss_tools.vspec.vssexporters.vss2yaml:cli", "tree": "vss_tools.vspec.vssexporters.vss2tree:cli", "samm": "vss_tools.vspec.vssexporters.vss2samm.vss2samm:cli", + "go": "vss_tools.vspec.vssexporters.vss2go:cli", }, ) @click.pass_context diff --git a/src/vss_tools/vspec/vssexporters/vss2go.py b/src/vss_tools/vspec/vssexporters/vss2go.py new file mode 100644 index 00000000..90cafa97 --- /dev/null +++ b/src/vss_tools/vspec/vssexporters/vss2go.py @@ -0,0 +1,326 @@ +# Copyright (c) 2024 Contributors to COVESA +# +# This program and the accompanying materials are made available under the +# terms of the Mozilla Public License 2.0 which is available at +# https://www.mozilla.org/en-US/MPL/2.0/ +# +# SPDX-License-Identifier: MPL-2.0 + + +from __future__ import annotations + +import re +from pathlib import Path + +import rich_click as click +from anytree import PreOrderIter + +import vss_tools.vspec.cli_options as clo +from vss_tools import log +from vss_tools.vspec.datatypes import Datatypes, is_array +from vss_tools.vspec.main import get_trees +from vss_tools.vspec.model import VSSDataBranch, VSSDataDatatype, VSSDataStruct +from vss_tools.vspec.tree import VSSNode + +datatype_map = { + Datatypes.INT8_ARRAY[0]: "[]int8", + Datatypes.INT16_ARRAY[0]: "[]int16", + Datatypes.INT32_ARRAY[0]: "[]int32", + Datatypes.INT64_ARRAY[0]: "[]int64", + Datatypes.UINT8_ARRAY[0]: "[]uint8", + Datatypes.UINT16_ARRAY[0]: "[]uint16", + Datatypes.UINT32_ARRAY[0]: "[]uint32", + Datatypes.UINT64_ARRAY[0]: "[]uint64", + Datatypes.FLOAT[0]: "float32", + Datatypes.FLOAT_ARRAY[0]: "[]float32", + Datatypes.DOUBLE[0]: "float64", + Datatypes.DOUBLE_ARRAY[0]: "[]float64", + Datatypes.STRING_ARRAY[0]: "[]string", + Datatypes.NUMERIC[0]: "float64", + Datatypes.NUMERIC_ARRAY[0]: "[]float64", + Datatypes.BOOLEAN_ARRAY[0]: "[]bool", + Datatypes.BOOLEAN[0]: "bool", +} + + +class NoInstanceRootException(Exception): + pass + + +def get_instance_root(root: VSSNode, depth: int = 1) -> tuple[VSSNode, int]: + """ + Getting the root node of a given instance node. + Going the tree upwards + """ + if root.parent is None: + raise NoInstanceRootException() + if isinstance(root.parent.data, VSSDataBranch): + if root.parent.data.is_instance: + return get_instance_root(root.parent, depth + 1) + else: + return root.parent, depth + else: + raise NoInstanceRootException() + + +def add_children_map_entries(root: VSSNode, fqn: str, replace: str, map: dict[str, str]) -> None: + """ + Adding rename map entries for children of a given node + """ + child: VSSNode + for child in root.children: + child_fqn = child.get_fqn() + new_name = child_fqn.replace(fqn, replace) + map[child_fqn] = new_name + add_children_map_entries(child, fqn, replace, map) + + +def get_instance_mapping(root: VSSNode | None) -> dict[str, str]: + """ + Constructing a rename map of fqn->new_name. + The new name has instances stripped and appending "I" instead + where N is the depth of the instance + """ + if root is None: + return {} + instance_map: dict[str, str] = {} + for node in PreOrderIter(root): + if isinstance(node.data, VSSDataBranch): + if node.data.is_instance: + instance_root, depth = get_instance_root(node) + new_name = instance_root.get_fqn() + "." + "I" + str(depth) + fqn = node.get_fqn() + instance_map[fqn] = new_name + add_children_map_entries(node, fqn, instance_root.get_fqn(), instance_map) + return instance_map + + +def get_datatype(node: VSSNode) -> str | None: + """ + Gets the datatype string of a node. + """ + datatype = None + if isinstance(node.data, VSSDataDatatype): + if node.data.datatype in datatype_map: + return datatype_map[node.data.datatype] + d = Datatypes.get_type(node.data.datatype) + if d: + datatype = d[0] + # Struct type + d_raw = node.data.datatype + array = is_array(d_raw) + struct_datatype = node.data.datatype.rstrip("[]") + if array: + struct_datatype = f"[]{struct_datatype}" + datatype = struct_datatype + return datatype + + +class GoStructMember: + def __init__(self, name: str, datatype: str) -> None: + self.name = name + self.datatype = datatype + + +class GoStruct: + def __init__(self, name: str) -> None: + self.name = name + self.members: list[GoStructMember] = [] + + def __str__(self) -> str: + r = f"type {self.name.replace('.', '')} struct {{\n" + for member in self.members: + r += f"\t{member.name} {member.datatype.replace('.', '')}\n" + r += "}\n" + return r + + def __eq__(self, other: object) -> bool: + if not isinstance(other, GoStruct): + return False + return self.name == other.name + + +def get_struct_name(fqn: str, map: dict[str, str]) -> str: + if fqn in map: + return map[fqn] + else: + return fqn + + +def get_go_structs(root: VSSNode | None, map: dict[str, str], type_tree: bool = False) -> dict[str, GoStruct]: + structs: dict[str, GoStruct] = {} + if root is None: + return structs + for node in PreOrderIter(root): + if isinstance(node.data, VSSDataBranch) or isinstance(node.data, VSSDataStruct): + struct = GoStruct(get_struct_name(node.get_fqn(), map)) + for child in node.children: + datatype = get_datatype(child) + if not datatype: + datatype = get_struct_name(child.get_fqn(), map) + member = GoStructMember(child.name, datatype) + struct.members.append(member) + if type_tree and isinstance(node.data, VSSDataBranch): + pass + else: + structs[struct.name] = struct + return structs + + +def get_prefixes(structs: dict[str, GoStruct]) -> list[str]: + """ + Gets all current prefixes from the given structs. + Example: + + Vehicle.Cabin + Something.Else + + -> [Vehicle, Something] + """ + prefixes: dict[str, int] = {} + for struct in structs.values(): + split = struct.name.split(".") + if len(split) == 1: + continue + prefix = split[0] + if prefix in prefixes: + prefixes[prefix] += 1 + else: + prefixes[prefix] = 1 + return [p for p in prefixes.keys()] + + +def get_prefix_strip_conflicts(prefix: str, structs: dict[str, GoStruct]) -> int: + """ + Finds conflicts if we would strip the given prefix from structs + """ + structs_new: list[str] = [] + for struct in structs.values(): + split = struct.name.split(".") + sp = split[0] + if len(split) == 1: + structs_new.append(struct.name) + else: + if sp != prefix: + structs_new.append(struct.name) + else: + log.debug(f"Stripping, {prefix=}, {struct.name=}") + structs_new.append(".".join(split[1:])) + log.debug(f"New name: {structs_new[-1]}") + + return len(structs_new) - len(set(structs_new)) + + +def is_only_instance(s: str) -> bool: + """ + Whether a string is only an instance ID. + We do not want to end up in struct names only having "I1" or "I2" + """ + pattern = r"^I\d+$" + match = re.match(pattern, s) + if match: + return True + return False + + +def strip_structs_prefix(prefix: str, structs: dict[str, GoStruct]) -> int: + """ + Left strips all structs from the given prefix + Returns the number of changed struct names + """ + stripped = 0 + for struct in structs.values(): + split = struct.name.split(".") + if len(split) > 1: + if split[0] == prefix: + new_name = ".".join(split[1:]) + if is_only_instance(new_name): + log.debug(f"Struct, not stripping, would be Instance id only: {struct.name}") + else: + struct.name = new_name + stripped += 1 + for member in struct.members: + dtsplit = member.datatype.split(".") + if len(dtsplit) > 1: + array_member = dtsplit[0].startswith("[]") + content = dtsplit[0].lstrip("[]") + if content == prefix: + new_name = ".".join(dtsplit[1:]) + if is_only_instance(new_name): + log.debug(f"Member, not stripping, would be Instance id only: {member.datatype}") + else: + member.datatype = new_name + if array_member: + member.datatype = "[]" + member.datatype + return stripped + + +@click.command() +@clo.vspec_opt +@clo.output_required_opt +@clo.include_dirs_opt +@clo.extended_attributes_opt +@clo.strict_opt +@clo.aborts_opt +@clo.overlays_opt +@clo.quantities_opt +@clo.units_opt +@clo.types_opt +@click.option("--package", default="vss", help="Go package name", show_default=True) +@click.option("--short-names/--no-short-names", default=True, show_default=True, help="Shorten struct names") +def cli( + vspec: Path, + output: Path, + include_dirs: tuple[Path], + extended_attributes: tuple[str], + strict: bool, + aborts: tuple[str], + overlays: tuple[Path], + quantities: tuple[Path], + units: tuple[Path], + types: tuple[Path], + package: str, + short_names: bool, +): + """ + Export as Go structs. + """ + tree, datatype_tree = get_trees( + vspec=vspec, + include_dirs=include_dirs, + aborts=aborts, + strict=strict, + extended_attributes=extended_attributes, + quantities=quantities, + units=units, + types=types, + overlays=overlays, + ) + instance_map = get_instance_mapping(tree) + structs = get_go_structs(tree, instance_map) + log.info(f"Structs, amount={len(structs)}") + datatype_structs = get_go_structs(datatype_tree, instance_map, True) + log.info(f"Datatype structs, amount={len(datatype_structs)}") + structs.update(datatype_structs) + + if short_names: + rounds = 0 + while True: + prefixes = get_prefixes(structs) + log.debug(f"{prefixes=}") + stripped = 0 + for prefix in prefixes: + conflicts = get_prefix_strip_conflicts(prefix, structs) + log.debug(f"Struct name conflicts, prefix={prefix}, conflicts={conflicts}") + if conflicts == 0: + stripped += strip_structs_prefix(prefix, structs) + log.info(f"Stripping '{prefix}', round={rounds}, {stripped=}") + if stripped == 0: + break + else: + rounds += 1 + + with open(output, "w") as f: + f.write(f"package {package}\n\n") + for struct in structs.values(): + f.write(str(struct)) diff --git a/tests/vspec/test_datatypes/expected.go b/tests/vspec/test_datatypes/expected.go new file mode 100644 index 00000000..bf08f526 --- /dev/null +++ b/tests/vspec/test_datatypes/expected.go @@ -0,0 +1,15 @@ +package vss + +type A struct { + UInt8 uint8 + Int8 int8 + UInt16 uint16 + Int16 int16 + UInt32 uint16 + Int32 int32 + UInt64 uint64 + Int64 int64 + IsBoolean bool + Float float32 + Double float64 +} diff --git a/tests/vspec/test_exporter_go/expected.go b/tests/vspec/test_exporter_go/expected.go new file mode 100644 index 00000000..054e5782 --- /dev/null +++ b/tests/vspec/test_exporter_go/expected.go @@ -0,0 +1,42 @@ +package vss + +type Vehicle struct { + Struct Struct + StructA []Struct + AllTypes AllTypes +} +type AllTypes struct { + Uint8 uint8 + Uint8A []uint8 + Uint16 uint16 + Uint16A []uint16 + Uint32 uint32 + Uint32A []uint32 + Uint64 uint64 + Uint64A []uint64 + Int8 int8 + Int8A []int8 + Int16 int16 + Int16A []int16 + Int32 int32 + Int32A []int32 + Int64 int64 + Int64A []int64 + Bool bool + BoolA []bool + Float float32 + FloatA []float32 + Double float64 + DoubleA []float64 + String string + StringA []string + Numeric float64 + NumericA []float64 +} +type Struct struct { + x StructEmbedded + y uint8 +} +type StructEmbedded struct { + z []uint8 +} diff --git a/tests/vspec/test_exporter_go/test.vspec b/tests/vspec/test_exporter_go/test.vspec new file mode 100644 index 00000000..8f0fada9 --- /dev/null +++ b/tests/vspec/test_exporter_go/test.vspec @@ -0,0 +1,148 @@ +Vehicle: + type: branch + description: Vehicle + +Vehicle.Struct: + type: attribute + datatype: Types.Struct + description: Type X + +Vehicle.StructA: + type: attribute + datatype: Types.Struct[] + description: Type X + +Vehicle.AllTypes: + type: branch + description: All Types + +Vehicle.AllTypes.Uint8: + type: attribute + datatype: uint8 + description: Type X + +Vehicle.AllTypes.Uint8A: + type: attribute + datatype: uint8[] + description: Type X + +Vehicle.AllTypes.Uint16: + type: attribute + datatype: uint16 + description: Type X + +Vehicle.AllTypes.Uint16A: + type: attribute + datatype: uint16[] + description: Type X + +Vehicle.AllTypes.Uint32: + type: attribute + datatype: uint32 + description: Type X + +Vehicle.AllTypes.Uint32A: + type: attribute + datatype: uint32[] + description: Type X + +Vehicle.AllTypes.Uint64: + type: attribute + datatype: uint64 + description: Type X + +Vehicle.AllTypes.Uint64A: + type: attribute + datatype: uint64[] + description: Type X + +Vehicle.AllTypes.Int8: + type: attribute + datatype: int8 + description: Type X + +Vehicle.AllTypes.Int8A: + type: attribute + datatype: int8[] + description: Type X + +Vehicle.AllTypes.Int16: + type: attribute + datatype: int16 + description: Type X + +Vehicle.AllTypes.Int16A: + type: attribute + datatype: int16[] + description: Type X + + +Vehicle.AllTypes.Int32: + type: attribute + datatype: int32 + description: Type X + +Vehicle.AllTypes.Int32A: + type: attribute + datatype: int32[] + description: Type X + +Vehicle.AllTypes.Int64: + type: attribute + datatype: int64 + description: Type X + +Vehicle.AllTypes.Int64A: + type: attribute + datatype: int64[] + description: Type X + +Vehicle.AllTypes.Bool: + type: attribute + datatype: boolean + description: Type X + +Vehicle.AllTypes.BoolA: + type: attribute + datatype: boolean[] + description: Type X + +Vehicle.AllTypes.Float: + type: attribute + datatype: float + description: Type X + +Vehicle.AllTypes.FloatA: + type: attribute + datatype: float[] + description: Type X + +Vehicle.AllTypes.Double: + type: attribute + datatype: double + description: Type X + +Vehicle.AllTypes.DoubleA: + type: attribute + datatype: double[] + description: Type X + +Vehicle.AllTypes.String: + type: attribute + datatype: string + description: Type X + +Vehicle.AllTypes.StringA: + type: attribute + datatype: string[] + description: Type X + +Vehicle.AllTypes.Numeric: + type: attribute + datatype: numeric + description: Type X + +Vehicle.AllTypes.NumericA: + type: attribute + datatype: numeric[] + description: Type X diff --git a/tests/vspec/test_exporter_go/types.vspec b/tests/vspec/test_exporter_go/types.vspec new file mode 100644 index 00000000..80ed9388 --- /dev/null +++ b/tests/vspec/test_exporter_go/types.vspec @@ -0,0 +1,26 @@ +Types: + type: branch + description: Types + +Types.Struct: + type: struct + description: Special + +Types.Struct.x: + type: property + datatype: StructEmbedded + description: X + +Types.Struct.y: + type: property + datatype: uint8 + description: Y + +Types.StructEmbedded: + type: struct + description: Bar + +Types.StructEmbedded.z: + type: property + datatype: uint8[] + description: Z diff --git a/tests/vspec/test_generic.py b/tests/vspec/test_generic.py index 230bd60d..2d6c80ed 100644 --- a/tests/vspec/test_generic.py +++ b/tests/vspec/test_generic.py @@ -36,16 +36,19 @@ def idfn(directory: pathlib.PosixPath): def run_exporter(directory, exporter, tmp_path): vspec = directory / "test.vspec" + types = directory / "types.vspec" output = tmp_path / f"out.{exporter}" expected = directory / f"expected.{exporter}" + if not expected.exists(): + return cmd = f"vspec export {exporter} -u {TEST_UNITS} -q {TEST_QUANT} --vspec {vspec} " + if types.exists(): + cmd += f" --types {types}" if exporter in ["apigear"]: - cmd += f"--output-dir {output}" + cmd += f" --output-dir {output}" else: - cmd += f"--output {output}" + cmd += f" --output {output}" subprocess.run(cmd.split(), check=True) - print(output) - print(expected) if exporter in ["apigear"]: dcmp = filecmp.dircmp(output, expected) assert not (dcmp.diff_files or dcmp.left_only or dcmp.right_only) @@ -57,8 +60,7 @@ def run_exporter(directory, exporter, tmp_path): def test_exporters(directory, tmp_path): # Run all "supported" exporters, i.e. not those in contrib # Exception is "binary", as it is assumed output may vary depending on target - exporters = ["apigear", "json", "jsonschema", "ddsidl", - "csv", "yaml", "franca", "graphql"] + exporters = ["apigear", "json", "jsonschema", "ddsidl", "csv", "yaml", "franca", "graphql", "go"] for exporter in exporters: run_exporter(directory, exporter, tmp_path) diff --git a/tests/vspec/test_min_max/expected.go b/tests/vspec/test_min_max/expected.go new file mode 100644 index 00000000..e43d00fa --- /dev/null +++ b/tests/vspec/test_min_max/expected.go @@ -0,0 +1,18 @@ +package vss + +type A struct { + IntNoMinMax int8 + IntOnlyMax int8 + IntOnlyMin int8 + IntMinMax int8 + IntMaxZero int8 + IntMinZero int8 + FloatNoMinMax float32 + FloatOnlyMax float32 + FloatOnlyMin float32 + FloatMinMax float32 + FloatMaxZero float32 + FloatMinZero float32 + FloatMaxZeroInt float32 + FloatMinZeroInt float32 +}