diff --git a/.github/workflows/PluginSkeletonGenerator.yml b/.github/workflows/PluginSkeletonGenerator.yml index 0fc240c4..026004dd 100644 --- a/.github/workflows/PluginSkeletonGenerator.yml +++ b/.github/workflows/PluginSkeletonGenerator.yml @@ -39,17 +39,89 @@ jobs: id: set-matrix shell: python run: | - import json, os + import json + import os + + def mk_answers(plugin_name, oop, custom_cfg, pre=None): + """ + Prompt order (interactive mode): + 1) plugin name + 2) output dir (Enter = current directory) + 3) out-of-process (Y/N) + 4) custom config (Y/N) + 5+) interface path(s) ... then blank line + next) subsystems (Y/N) + if subsystems=Y: + Preconditions list entries ... blank + Terminations list entries ... blank + Controls list entries ... blank + next) (optional, per header with >1 root) pick indices or Enter for ALL + next) include location per header (Enter for default) + """ + if pre is None: + pre = {"pre": [], "term": [], "ctrl": []} + + answers = [ + plugin_name, + "", # output dir: Enter => current directory + oop, + custom_cfg, + "__INTERFACES_H__", # interface path injected later + "", # finish interface list + ("Y" if (pre["pre"] or pre["term"] or pre["ctrl"]) else "N"), + ] + + if answers[-1] == "Y": + # Preconditions list + terminator + answers += list(pre["pre"]) + [""] + # Terminations list + terminator + answers += list(pre["term"]) + [""] + # Controls list + terminator + answers += list(pre["ctrl"]) + [""] + + # After subsystems collection, generator may ask: + # - (optional) interface selection (Enter=ALL) for multi-root headers + # - include location (Enter=default 'interfaces') + answers += ["", ""] + + return answers variants = [ - { "name": "InProcess", "header": "IMath.h", "answers": ["InProcess", "N", "N", "__INTERFACES_H__", "", "N", ""] }, - { "name": "OutOfProcess", "header": "IBrowser.h", "answers": ["OutOfProcess", "Y", "N", "__INTERFACES_H__", "", "", "N", ""] }, - { "name": "InProcessConfig", "header": "IPower.h", "answers": ["InProcessConfig", "N", "Y", "__INTERFACES_H__", "", "N", ""] }, - { "name": "OutOfProcessConfig", "header": "INetworkControl.h", "answers": ["OutOfProcessConfig", "Y", "Y", "__INTERFACES_H__", "", "N", ""] }, - { "name": "InProcessPreconditions", "header": "ITimeSync.h", "answers": ["InProcessPreconditions", "N", "N", "__INTERFACES_H__", "", "Y", "GRAPHICS", "", "NOT_GRAPHICS" , "", "TIME", "", ""] }, - { "name": "OutOfProcessPreconditions", "header": "IMessageControl.h", "answers": ["OutOfProcessPreconditions", "Y", "N", "__INTERFACES_H__", "", "Y", "GRAPHICS", "", "NOT_GRAPHICS" , "", "TIME", "", ""] }, - { "name": "InProcessConfigPreconditions", "header": "IDictionary.h", "answers": ["InProcessConfigPreconditions", "N", "Y", "__INTERFACES_H__", "", "Y", "GRAPHICS", "", "NOT_GRAPHICS" , "", "TIME", "", ""] }, - { "name": "OutOfProcessConfigPreconditions", "header": "IBluetooth.h", "answers": ["OutOfProcessConfigPreconditions", "Y", "Y", "__INTERFACES_H__", "", "", "Y", "GRAPHICS", "", "NOT_GRAPHICS" , "", "TIME", "", ""] } + { "name": "InProcess", "header": "IMath.h", + "answers": mk_answers("InProcess", "N", "N") }, + + { "name": "OutOfProcess", "header": "IBrowser.h", + "answers": mk_answers("OutOfProcess", "Y", "N") }, + + { "name": "InProcessConfig", "header": "IPower.h", + "answers": mk_answers("InProcessConfig", "N", "Y") }, + + { "name": "OutOfProcessConfig", "header": "INetworkControl.h", + "answers": mk_answers("OutOfProcessConfig", "Y", "Y") }, + + { "name": "InProcessPreconditions", "header": "ITimeSync.h", + "answers": mk_answers( + "InProcessPreconditions", "N", "N", + pre={"pre": ["GRAPHICS"], "term": ["NOT_GRAPHICS"], "ctrl": ["TIME"]} + ) }, + + { "name": "OutOfProcessPreconditions", "header": "IMessageControl.h", + "answers": mk_answers( + "OutOfProcessPreconditions", "Y", "N", + pre={"pre": ["GRAPHICS"], "term": ["NOT_GRAPHICS"], "ctrl": ["TIME"]} + ) }, + + { "name": "InProcessConfigPreconditions", "header": "IVolumeControl.h", + "answers": mk_answers( + "InProcessConfigPreconditions", "N", "Y", + pre={"pre": ["GRAPHICS"], "term": ["NOT_GRAPHICS"], "ctrl": ["TIME"]} + ) }, + + { "name": "OutOfProcessConfigPreconditions", "header": "IWifiControl.h", + "answers": mk_answers( + "OutOfProcessConfigPreconditions", "Y", "Y", + pre={"pre": ["GRAPHICS"], "term": ["NOT_GRAPHICS"], "ctrl": ["TIME"]} + ) }, ] include = [] @@ -430,6 +502,14 @@ jobs: Path("diffs/combined_ai.diff").write_text(text, encoding="utf-8") PY + - name: Upload combined diff artifact + if: github.event_name == 'pull_request' && steps.build_site.outputs.has_diff == 'true' + uses: actions/upload-artifact@v4 + with: + name: generated-plugin-diff + path: diffs/combined.diff + retention-days: 14 + - name: AI summary (GitHub Models) if: github.event_name == 'pull_request' && steps.build_site.outputs.has_diff == 'true' id: ai diff --git a/PluginSkeletonGenerator/CombinedPlugins.sh b/PluginSkeletonGenerator/CombinedPlugins.sh index 2f0fb0ff..e2ce8a63 100755 --- a/PluginSkeletonGenerator/CombinedPlugins.sh +++ b/PluginSkeletonGenerator/CombinedPlugins.sh @@ -48,6 +48,22 @@ fi TARGET_DIR="$TARGET_ROOT_ABS/CombinedPlugins" mkdir -p "$TARGET_DIR" +# Optional subsystem lists can be provided via environment variables: +# PSG_PRECONDITIONS (newline-separated) +# PSG_TERMINATIONS (newline-separated) +# PSG_CONTROLS (newline-separated) +# If any of these are set (non-empty), the script answers 'Y' to the subsystems prompt and +# feeds the lists (each terminated by a blank line). Otherwise it answers 'N'. +_psg_emit_list() { + local content="$1" + if [ -n "$content" ]; then + # Print as-is, ensure it ends with a newline + printf '%s\n' "$content" + fi + # terminating blank line + printf '\n' +} + # Work inside CombinedPlugins in a subshell so the parent shell CWD is unchanged ( cd "$TARGET_DIR" @@ -56,15 +72,25 @@ mkdir -p "$TARGET_DIR" for i in $(seq 1 "$COUNT"); do plugin="Plugin$i" - # Answers: - # 1) $plugin - # 2) N - # 3) N - # 4) $IFACE_ABS - # 5) - # 6) N - # 7) - printf '%s\nN\nN\n%s\n\nN\n\n' "$plugin" "$IFACE_ABS" | python3 "$START_DIR/PluginSkeletonGenerator.py" + if [ -n "${PSG_PRECONDITIONS:-}" ] || [ -n "${PSG_TERMINATIONS:-}" ] || [ -n "${PSG_CONTROLS:-}" ]; then + # Subsystems enabled + { + printf '%s\n' "$plugin" # name + printf '\n' # output dir (default) + printf 'N\n' # out-of-process + printf 'N\n' # custom config + printf '%s\n' "$IFACE_ABS"# interface path + printf '\n' # finish interface list + printf 'Y\n' # subsystems enabled + _psg_emit_list "${PSG_PRECONDITIONS:-}" # Preconditions + _psg_emit_list "${PSG_TERMINATIONS:-}" # Terminations + _psg_emit_list "${PSG_CONTROLS:-}" # Controls + printf '\n' # include location (default) + } | python3 "$START_DIR/PluginSkeletonGenerator.py" + else + # No subsystems + printf '%s\n\nN\nN\n%s\n\nN\n\n' "$plugin" "$IFACE_ABS" | python3 "$START_DIR/PluginSkeletonGenerator.py" + fi done # Create CMakeLists.txt with one add_subdirectory line per plugin diff --git a/PluginSkeletonGenerator/core/GeneratorCoordinator.py b/PluginSkeletonGenerator/core/GeneratorCoordinator.py index e52c8e72..3f53b5d9 100644 --- a/PluginSkeletonGenerator/core/GeneratorCoordinator.py +++ b/PluginSkeletonGenerator/core/GeneratorCoordinator.py @@ -1,3 +1,22 @@ +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' + from core.PluginBlueprint import PluginBlueprint from data.FileData import HeaderData, SourceData, CMakeData, JSONData, ConfData from generators.PluginRepositoryGenerator import PluginRepositoryGenerator @@ -12,7 +31,7 @@ class GenerationTask: class GeneratorCoordinator: def __init__(self, name, out_of_process, configuration, parsed_data, header_lookup, locations, - preconditions=None, terminations=None, controls=None): + preconditions=None, terminations=None, controls=None, output_dir=None): self.m_blueprint = PluginBlueprint( name=name, out_of_process=out_of_process, @@ -23,6 +42,7 @@ def __init__(self, name, out_of_process, configuration, parsed_data, header_look preconditions=preconditions, terminations=terminations, controls=controls, + output_dir=output_dir, ) def generateAll(self): diff --git a/PluginSkeletonGenerator/core/GlobalVariables.py b/PluginSkeletonGenerator/core/GlobalVariables.py index 02fd4ae0..ab9547e8 100644 --- a/PluginSkeletonGenerator/core/GlobalVariables.py +++ b/PluginSkeletonGenerator/core/GlobalVariables.py @@ -1,3 +1,22 @@ +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' + import os def findRepoRoot(filename="PluginSkeletonGenerator.py"): diff --git a/PluginSkeletonGenerator/core/PluginBlueprint.py b/PluginSkeletonGenerator/core/PluginBlueprint.py index 0d24c16b..a20673b9 100644 --- a/PluginSkeletonGenerator/core/PluginBlueprint.py +++ b/PluginSkeletonGenerator/core/PluginBlueprint.py @@ -1,5 +1,23 @@ -from typing import Dict, List, Tuple +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' +from typing import Dict, List, Tuple class PluginBlueprint: class ParsedPluginInfo: @@ -10,6 +28,7 @@ def __init__(self, parsed_data: Dict[str, Tuple]): self._jsonrpc_interfaces: List[str] = [] self._notification_interfaces: List[str] = [] self._notification_entries: List[Tuple[str, object]] = [] + self._event_notification_entries: List[Tuple[str, object]] = [] self.processInterfaces(parsed_data) @@ -25,11 +44,17 @@ def processInterfaces(self, parsed_data: Dict[str, Tuple]): json_name = f"J{root_name[1:]}" if root_name.startswith("I") else f"J{root_name}" self._jsonrpc_interfaces.append(json_name) - if self.hasNotification(cls_data): - self._notification_interfaces.append(root_name) - self.gatherNotificationEntries(full_name, cls_data) + all_roots: List[str] = [] + seen = set() + for fq, _ in self._notification_entries: + root = fq.split("::")[-2] + if root not in seen: + seen.add(root) + all_roots.append(root) + self._notification_interfaces = all_roots + @staticmethod def extractRootName(full_name: str) -> str: return full_name.split("::")[-1] @@ -53,6 +78,9 @@ def gatherNotificationEntries(self, full_name: str, cls_data): if "Notification" in current.m_name: fq = f"{exchange_ns}::{iface}::{current.m_name}" self._notification_entries.append((fq, current)) + # Only mark the notification struct itself is tagged @event? + if "event" in getattr(current, "m_tags", []): + self._event_notification_entries.append((fq, current)) for child in current.m_children.values(): stack.append((child, iface)) @@ -79,6 +107,10 @@ def file_interface_map(self) -> Dict[str, str]: def notification_entries(self) -> List[Tuple[str, object]]: return self._notification_entries + @property + def event_notification_entries(self) -> List[Tuple[str, object]]: + return self._event_notification_entries + def __init__(self, name, out_of_process, @@ -88,13 +120,15 @@ def __init__(self, locations, preconditions=None, terminations=None, - controls=None): + controls=None, + output_dir=None): self._name = name self._out_of_process = out_of_process self._configuration = configuration self._parsed_data = parsed_data self._header_lookup = header_lookup self._locations = locations + self._output_dir = output_dir preconditions = preconditions or [] terminations = terminations or [] @@ -111,6 +145,7 @@ def __init__(self, self._notification_interfaces = parsed_info.notification_interfaces self._file_interface_map = parsed_info.file_interface_map self._notification_entries = parsed_info.collectNotificationEntries() + self._event_notification_entries = parsed_info.event_notification_entries @property def name(self) -> str: @@ -156,6 +191,10 @@ def file_interface_map(self) -> Dict[str, str]: def notification_entries(self) -> List[Tuple[str, object]]: return self._notification_entries + @property + def event_notification_entries(self) -> List[Tuple[str, object]]: + return self._event_notification_entries + @property def preconditions(self) -> List[str]: return self._PRECONDITIONS @@ -168,5 +207,9 @@ def terminations(self) -> List[str]: def controls(self) -> List[str]: return self._CONTROLS + @property + def output_dir(self) -> str: + return self._output_dir + def isJsonRpcPlugin(self) -> bool: return any("json" in cls_data.m_tags for cls_data, _ in self._parsed_data.values()) \ No newline at end of file diff --git a/PluginSkeletonGenerator/data/FileData.py b/PluginSkeletonGenerator/data/FileData.py index 15787886..77d4be9b 100644 --- a/PluginSkeletonGenerator/data/FileData.py +++ b/PluginSkeletonGenerator/data/FileData.py @@ -1,3 +1,22 @@ +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' + from abc import ABC from enum import Enum from core.PluginBlueprint import PluginBlueprint @@ -11,6 +30,9 @@ from utils.CodeGenUtils import convertToBaseName, convertToCOMRPC, convertToJSONRPC class FileData(ABC): + _BLOCK_COMMENT_RE = r"/\*.*?\*/" + _CONST_PREFIX = "const " + def __init__(self, blueprint: PluginBlueprint) -> None: self.m_blueprint: PluginBlueprint = blueprint self.m_plugin_name: str = blueprint.name @@ -29,6 +51,20 @@ def __init__(self, blueprint: PluginBlueprint) -> None: self.m_preconditions = blueprint._PRECONDITIONS self.m_terminations = blueprint._TERMINATIONS self.m_controls = blueprint._CONTROLS + self.m_event_notification_entries = getattr(blueprint, "event_notification_entries", []) + self.m_warnings: List[str] = [] + + @staticmethod + def _dedupe_preserve_order(items: List[str]) -> List[str]: + """Return a new list with duplicates removed while preserving the original order. + """ + seen = set() + out: List[str] = [] + for item in items: + if item not in seen: + seen.add(item) + out.append(item) + return out def _extractStrippedNamespace(self, full_name: str) -> str: parts = full_name.split("::") @@ -37,7 +73,7 @@ def _extractStrippedNamespace(self, full_name: str) -> str: return "::".join(parts[:-1]) # for interfaces that might not have Thunder::, I don't know if they exist def resolveFullName(self, short_name: str): - # Basically a look up, needed for getting full name for namespaces... + """Resolve a short interface name to its parsed fully-qualified name.""" for full_name in self.m_parsed: if full_name.endswith(f"::{short_name}"): return full_name @@ -45,28 +81,181 @@ def resolveFullName(self, short_name: str): @staticmethod def commentParamnames(params: str): - if not params.strip(): + """Avoid unused parameter warnings.""" + if not params or not params.strip(): return "", [] + parts = [s.strip() for s in params.split(",")] - out_parts, names = [], [] + out_parts: List[str] = [] + names: List[str] = [] for chunk in parts: - # some params have tags like /* @out */... - no_comments = re.sub(r'/\*.*?\*/', '', chunk) - token = no_comments.strip().split() - if not token: + no_comments = re.sub(FileData._BLOCK_COMMENT_RE, "", chunk) + no_default = no_comments.split("=")[0].strip() + # Examples of named params: + # `const string& msg` + # `bool enabled` + # `Exchange::IFoo* foo` + # Examples of anonymous params: + # `const string&` + # `uint32_t` + tokens = no_default.split() + if len(tokens) < 2: + out_parts.append(chunk) + continue + + candidate = tokens[-1] + candidate_stripped = candidate.lstrip("*&").rstrip("&*") + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", candidate_stripped): out_parts.append(chunk) continue - name = token[-1] + name = candidate_stripped names.append(name) + last = chunk.rfind(name) if last != -1: - chunk = chunk[:last] + f"/* {name} */" + chunk[last+len(name):] + chunk = chunk[:last] + f"/* {name} */" + chunk[last + len(name):] out_parts.append(chunk) + return ", ".join(out_parts), names + def _collect_known_types_for_notification(self, notification_fq: str, notif_cls_data) -> Tuple[str, set, set]: + """ + Notification methods often reference nested enums/structs from: + 1) the notification scope itself (e.g. INotification::Source), and + 2) the owning interface scope (e.g. IAudioStream::streamstate). + + return both sets so downstream logic can choose the correct qualification target. + """ + fq_parts = notification_fq.split("::") + owner_iface_fq = "::".join(fq_parts[:-1]) + + notif_types = set(getattr(notif_cls_data, "m_usings", {}).keys()) + notif_types |= set(getattr(notif_cls_data, "m_children", {}).keys()) + notif_types |= set(getattr(notif_cls_data, "m_types", set())) + + owner_types: set = set() + owner_iface = fq_parts[-2] if len(fq_parts) >= 2 else None + if owner_iface: + owner_full = self.resolveFullName(owner_iface) + if owner_full and owner_full in self.m_parsed: + owner_cls, _ = self.m_parsed[owner_full] + owner_types |= set(getattr(owner_cls, "m_usings", {}).keys()) + owner_types |= set(getattr(owner_cls, "m_children", {}).keys()) + owner_types |= set(getattr(owner_cls, "m_types", set())) + + return owner_iface_fq, notif_types, owner_types + + def _qualify_known_type_token( + self, + token: str, + owner_iface_fq: str, + notification_fq: str, + notif_types: set, + owner_types: set, + *, + prefer_unqualified_owner: bool, + ) -> str: + """ + - Types declared in the notification scope are not visible from the generated sink class + unless fully qualified, so we always qualify those. + - Types declared in the owning interface may be visible via inheritance (implementation + classes) and can remain unqualified for readability unless the current generation + context requires explicit qualification. + """ + if token in notif_types: + return f"{notification_fq}::{token}" + if token in owner_types and not prefer_unqualified_owner: + return f"{owner_iface_fq}::{token}" + return token + + def _qualify_template_args(self, text: str, owner_iface_fq: str, notification_fq: str, notif_types: set, owner_types: set) -> str: + """ + This focuses on the common Thunder patterns like: + Core::OptionalType + where the nested type appears as a template argument and unqualified lookup might fail + depending on the surrounding scope. + """ + def repl_angle(m): + inner = m.group(1) + items = [x.strip() for x in inner.split(",")] + qualified_items: List[str] = [] + for x in items: + if "::" in x: + qualified_items.append(x) + continue + base = x.strip().strip("*&") + qualified = self._qualify_known_type_token( + base, owner_iface_fq, notification_fq, notif_types, owner_types, + prefer_unqualified_owner=False, # inside templates, qualify owner types for safety + ) + if qualified != base: + qualified_items.append(x.replace(base, qualified, 1)) + else: + qualified_items.append(x) + return "<" + ", ".join(qualified_items) + ">" + + return re.sub(r"<\s*([^>]+?)\s*>", repl_angle, text) + + def _qualify_first_type_token_in_param( + self, + param: str, + owner_iface_fq: str, + notification_fq: str, + notif_types: set, + owner_types: set, + *, + prefer_unqualified_owner: bool, + ) -> str: + """ + Parameter strings are preserved as much as possible; we only rewrite the leading + type identifier when it is a known nested type + """ + if "::" in param: + return param + + analysis = re.sub(FileData._BLOCK_COMMENT_RE, "", param) + analysis = analysis.split("=")[0].strip() + tokens = analysis.split() + qualifiers = {"const", "volatile", "signed", "unsigned", "struct", "class", "enum"} + + type_token = next((t for t in tokens if t not in qualifiers), None) + if not type_token: + return param + + stripped = type_token.strip("*&") + qualified = self._qualify_known_type_token( + stripped, owner_iface_fq, notification_fq, notif_types, owner_types, + prefer_unqualified_owner=prefer_unqualified_owner, + ) + if qualified == stripped: + return param + + return param.replace(type_token, qualified, 1) + + def _qualifyParamTypes(self, params: str, notification_fq: str, cls_data, *, prefer_unqualified_owner: bool = True) -> str: + if not params or not params.strip() or not cls_data: + return params + + owner_iface_fq, notif_types, owner_types = self._collect_known_types_for_notification(notification_fq, cls_data) + if not notif_types and not owner_types: + return params + + parts = [p.strip() for p in params.split(",") if p.strip()] + out_parts: List[str] = [] + + for p in parts: + p2 = self._qualify_template_args(p, owner_iface_fq, notification_fq, notif_types, owner_types) + p3 = self._qualify_first_type_token_in_param( + p2, owner_iface_fq, notification_fq, notif_types, owner_types, + prefer_unqualified_owner=prefer_unqualified_owner, + ) + out_parts.append(p3) + + return ", ".join(out_parts) + def dynamic_keys(self) -> Dict[str, str]: return { "{{PLUGIN_NAME}}": self.m_plugin_name, @@ -84,6 +273,92 @@ def prepare(self) -> None: self.m_keywords = {**self.dynamic_keys(), **self.static_keys()} self.m_keywords.update(self.extra_keys()) + def _iface_namespace(self, iface_short: str) -> str: + """Returns the stripped namespace for an interface (without the trailing type name)""" + full_name = self.resolveFullName(iface_short) + return self._extractStrippedNamespace(full_name) if full_name else "" + + def notification_registers(self, interface: str) -> str: + name = interface[1:].lower() if interface.startswith('I') else interface.lower() + return generateSimpleText([ + "ASSERT(notification != nullptr);", + "", + "_adminLock.Lock();", + f"auto item = std::find(_{name}Notification.begin(), _{name}Notification.end(), notification);", + f"ASSERT(item == _{name}Notification.end());", + "", + f"if (item == _{name}Notification.end()) {{", + " notification->AddRef();", + f" _{name}Notification.push_back(notification);", + "}", + "", + "_adminLock.Unlock();", + ], remove_empty=False, sep="\n") + + def notification_unregisters(self, interface: str) -> str: + name = interface[1:].lower() if interface.startswith('I') else interface.lower() + return generateSimpleText([ + "ASSERT(notification != nullptr);", + "", + "_adminLock.Lock();", + f"auto item = std::find(_{name}Notification.begin(), _{name}Notification.end(), notification);", + f"ASSERT(item != _{name}Notification.end());", + "", + f"if (item != _{name}Notification.end()) {{", + f" _{name}Notification.erase(item);", + " notification->Release();", + "}", + "_adminLock.Unlock();", + ], remove_empty=False, sep="\n") + + @staticmethod + def _defaultReturnStatement(return_type: str) -> List[str]: + rt = (return_type or "").strip() + if (rt == "void"): + return [] + + if rt == "string" or rt.endswith("::string"): + return [" return string();"] + + if rt == "bool": + return [" return false;"] + + if "*" in rt: + return [" return nullptr;"] + + if rt in ("Core::hresult", "uint32_t", "int32_t", "uint8_t", "uint16_t", "uint64_t", "int64_t", "long", "unsigned", "unsigned int"): + return [" return Core::ERROR_NONE;"] + + return [f" return {rt}();"] + + @staticmethod + def _first_param_is_const(params: str) -> bool: + if not params: + return False + p = re.sub(FileData._BLOCK_COMMENT_RE, "", params).strip() + first = p.split(",", 1)[0].strip() + return first.startswith(FileData._CONST_PREFIX) + + def _notif_unregister_param_is_const(self, iface_short: str) -> bool: + full_name = self.resolveFullName(iface_short) + if not full_name: + return True + cls_data, _ = self.m_parsed.get(full_name, (None, None)) + if not cls_data: + return True + + for m in getattr(cls_data, "m_methods", []): + if getattr(m, "m_name", None) == "Unregister": + return self._first_param_is_const(getattr(m, "m_params", "")) + return True + + def _maybe_warn_nonconst_unregister(self, iface_short: str) -> None: + if not self._notif_unregister_param_is_const(iface_short): + self.m_warnings.append( + f"[WARNING] {iface_short}::Unregister() does not take a const notification pointer. " + "Const should be enforced however the generator will const_cast where required to compile." + ) + class HeaderData(FileData): class HeaderType(Enum): HEADER = 1 @@ -105,8 +380,10 @@ def static_keys(self): "{{NOTIFICATION_ENTRY}}": self._generateNotifEntry(), "{{NOTIFICATION_FUNCTION}}": self._generateNotifFunction(), "{{HEADER_USING_CONTAINER}}": self._generateHeaderUsingContainer(), + "{{HEADER_USING_EXTERNAL}}": self._generateHeaderUsingExternal(), "{{ADD_PRIVATE_FIELD}}": self._generatePrivateField(), "{{ADD_PUBLIC_FIELD}}": self._generatePublicField(), + "{{ADD_PRIVATE_MEMBERS_FIELD}}": self._generatePrivateMembersField(), "{{HEADER_DEFINITIONS}}": self._generateHeaderDefinitions(), "{{HEADER_STATIC_TIMEOUT}}": self._generateHeaderTimeout(), "{{HEADER_INCLUDES}}": self._generateHeaderIncludes(), @@ -115,7 +392,8 @@ def static_keys(self): "{{HEADER_MEMBERS}}": self._generateHeaderMembers(), "{{HEADER_NOTIFY}}": self._generateHeaderNotify(), "{{HEADER_DEACTIVATE_METHOD}}": self._generateHeaderDeactivateMethod(), - "{{HEADER_DANGLING_METHOD}}": self._generateHeaderDanglingMethod() + "{{HEADER_DANGLING_METHOD}}": self._generateHeaderDanglingMethod(), + "{{CONFIGURE_OOP}}": self._generateConfigureOOPMethod(), } def extra_keys(self): @@ -131,75 +409,131 @@ def _generateHeaderDanglingMethod(self): return f'void Dangling(const Core::IUnknown* remote, const uint32_t interfaceId);' if self.m_out_of_process and self.m_notification_entries else '' def _generateNotifClass(self): - entries = self.m_blueprint.notification_entries - if not entries: + if not self.m_out_of_process: + return ": public RPC::IRemoteConnection::INotification" + + notif_entries = list(getattr(self.m_blueprint, "notification_entries", [])) + if not notif_entries: return ": public RPC::IRemoteConnection::INotification" - derived = [f"public {fq_name}" for fq_name, _ in entries] - return generateSimpleText([": public RPC::IRemoteConnection::INotification, public PluginHost::IShell::ICOMLink::INotification"] + derived, sep=", ") + + derived = [f"public {fq_name}" for fq_name, _ in notif_entries] + return generateSimpleText( + [": public RPC::IRemoteConnection::INotification, public PluginHost::IShell::ICOMLink::INotification"] + + derived, + sep=", ") def _generateNotifConstructor(self): - entries = self.m_blueprint.notification_entries - lines = [] - if entries: + if not self.m_out_of_process: + return "" + + notif_entries = list(getattr(self.m_blueprint, "notification_entries", [])) + lines: List[str] = [] + if notif_entries: lines.append(", PluginHost::IShell::ICOMLink::INotification()") - lines.extend([f", {fq_name}()" for fq_name, _ in entries]) + lines.extend([f", {fq_name}()" for fq_name, _ in notif_entries]) return generateSimpleText(lines) def _generateNotifEntry(self): - entries = self.m_blueprint.notification_entries - lines = [] - if entries: + if not self.m_out_of_process: + return "" + + notif_entries = list(getattr(self.m_blueprint, "notification_entries", [])) + lines: List[str] = [] + if notif_entries: lines.append('INTERFACE_ENTRY(PluginHost::IShell::ICOMLink::INotification)') - lines.extend([f"INTERFACE_ENTRY({fq_name})" for fq_name, _ in entries]) + lines.extend([f"INTERFACE_ENTRY({fq_name})" for fq_name, _ in notif_entries]) return generateSimpleText(lines) def _generateNotifFunction(self): - entries = self.m_blueprint.notification_entries - result = [] + if not self.m_out_of_process: + return "" - if entries: - result.append("void Dangling(const Core::IUnknown* remote, const uint32_t interfaceId) override {" - "\n_parent.Dangling(remote, interfaceId);" - "\n}") - - for fq_name, cls_data in entries: - namespace = fq_name.split("::")[1] - jprefix = f"J{namespace[1:]}" if namespace.startswith("I") else f"J{namespace}" + all_notif_entries = list(getattr(self.m_blueprint, "notification_entries", [])) + event_map = {fq: cls for fq, cls in (self.m_event_notification_entries if self.m_jsonrpc else [])} - for m in cls_data.m_methods: - _, param_names = self.commentParamnames(m.m_params) - param_str = ", ".join(param_names) - add_const = " const" if getattr(m, "m_is_const", False) else "" - result.extend([ - f"void {m.m_name}({m.m_params}) override {{", - f"Exchange::{jprefix}::Event::{m.m_name}(_parent{', ' + param_str if param_str else ''}){add_const};" if self.m_jsonrpc else '', - "}" - ]) - result.append("") # for spacing between functions + result: List[str] = [] + + if all_notif_entries: + result.append( + "void Dangling(const Core::IUnknown* remote, const uint32_t interfaceId) override {\n" + " _parent.Dangling(remote, interfaceId);\n" + "}" + ) + result.append("") + + for fq_name, cls_data in all_notif_entries: + is_event = fq_name in event_map + + if is_event and self.m_jsonrpc: + root_iface = fq_name.split("::")[-2] + jprefix = f"J{root_iface[1:]}" if root_iface.startswith("I") else f"J{root_iface}" + + for m in cls_data.m_methods: + qualified_params = self._qualifyParamTypes(m.m_params, fq_name, cls_data, prefer_unqualified_owner=False) + _, param_names = self.commentParamnames(qualified_params) + param_str = ", ".join(param_names) + add_const = " const" if getattr(m, "m_is_const", False) else "" + + result.extend([ + f"void {m.m_name}({qualified_params}) override {{", + f" Exchange::{jprefix}::Event::{m.m_name}(_parent{', ' + param_str if param_str else ''}){add_const};", + "}", + "", + ]) + else: + for m in cls_data.m_methods: + qualified_params = self._qualifyParamTypes(m.m_params, fq_name, cls_data, prefer_unqualified_owner=False) + params_no_names, _ = self.commentParamnames(qualified_params) + add_const = " const" if getattr(m, "m_is_const", False) else "" + + result.extend([ + f"void {m.m_name}({params_no_names}){add_const} override {{", + "}", + "", + ]) return generateSimpleText(result, remove_empty=True, sep="\n") def _generateHeaderDefinitions(self): return '#include ' if self.m_jsonrpc else '' + + def _generateConfigureOOPMethod(self): + if not self.m_plugin_config: + return '' + template = FileUtils.readFile(GlobalVariables.CONFIGURE_METHOD) + return template if template else '' def _generateHeaderNotify(self) -> str: - if not self.m_notification_interfaces: return '' - + + event_entries = list(self.m_event_notification_entries if self.m_jsonrpc else []) + if not event_entries: + return '' + + if self.m_type == self.HeaderType.HEADER and not self.m_out_of_process: + result = [] + for fq_name, cls_data in event_entries: + for m in cls_data.m_methods: + qualified_params = self._qualifyParamTypes(m.m_params, fq_name, cls_data, prefer_unqualified_owner=True) + paramsNoNames, _ = self.commentParamnames(qualified_params) + result.append(f"void Notify{m.m_name}({paramsNoNames}) const;") + return generateSimpleText(result) + if self.m_type == self.HeaderType.HEADER_IMPLEMENTATION: result = [] - for fq_name, cls_data in self.m_blueprint.notification_entries: + for fq_name, cls_data in event_entries: iface = fq_name.split("::")[-2] no_i = iface[1:] if iface.startswith("I") else iface container = f"_{no_i.lower()}Notification" - + for m in cls_data.m_methods: - _, param_names = self.commentParamnames(m.m_params) + qualified_params = self._qualifyParamTypes(m.m_params, fq_name, cls_data, prefer_unqualified_owner=True) + _, param_names = self.commentParamnames(qualified_params) param_str = ", ".join(param_names) result.extend([ - f"void Notify{m.m_name}({m.m_params}) const {{", + f"void Notify{m.m_name}({qualified_params}) const {{", " _adminLock.Lock();", f" for (auto* notification : {container}) {{", f" notification->{m.m_name}({param_str if param_str else ''});", @@ -224,11 +558,9 @@ def _generateHeaderInterfaceAggregate(self) -> str: lines = [] for iface in self.m_comrpc_interfaces: - full_name = self.resolveFullName(iface) - if full_name: - namespace = self._extractStrippedNamespace(full_name) - full_iface = f"{namespace}::{iface}" if namespace else iface - lines.append(f"INTERFACE_AGGREGATE({full_iface}, _impl{convertToBaseName(iface)})") + ns = self._iface_namespace(iface) + full_iface = f"{ns}::{iface}" if ns else iface + lines.append(f"INTERFACE_AGGREGATE({full_iface}, _impl{convertToBaseName(iface)})") return generateSimpleText(lines) @@ -240,10 +572,18 @@ def _generateHeaderJSONRPCEvent(self): def _generatePrivateField(self): if not self.m_out_of_process and not self.m_plugin_config: - return "private:" + return "" + return "private:" def _generatePublicField(self): + if not self.m_out_of_process and not self.m_plugin_config: + return "" return "public:" + + def _generatePrivateMembersField(self): + if not self.m_out_of_process and not self.m_plugin_config and not self.m_notification_entries: + return "" + return "private:" def _generateHeaderMembers(self) -> str: lines = [] @@ -254,9 +594,8 @@ def _generateHeaderMembers(self) -> str: lines.append("uint32_t _connectionId;") for iface in self.m_comrpc_interfaces: - full_name = self.resolveFullName(iface) - namespace = self._extractStrippedNamespace(full_name) - lines.append(f"{namespace}::{iface}* _impl{convertToBaseName(iface)};") + ns = self._iface_namespace(iface) + lines.append(f"{ns}::{iface}* _impl{convertToBaseName(iface)};") lines.append("Core::SinkType _notification;") @@ -282,15 +621,16 @@ def _generateHeaderInterfaceEntry(self) -> str: if self.m_jsonrpc: entries.append("INTERFACE_ENTRY(PluginHost::IDispatcher)") + if self.m_type == self.HeaderType.HEADER_IMPLEMENTATION and self.m_plugin_config: + entries.append(f"INTERFACE_ENTRY(Exchange::IConfiguration)") + if self.m_type == self.HeaderType.HEADER_IMPLEMENTATION or (not self.m_out_of_process and self.m_type == self.HeaderType.HEADER): for iface in self.m_comrpc_interfaces: - full_name = self.resolveFullName(iface) - namespace = self._extractStrippedNamespace(full_name) - entries.append(f"INTERFACE_ENTRY({namespace}::{iface})") + ns = self._iface_namespace(iface) + entries.append(f"INTERFACE_ENTRY({ns}::{iface})") return generateSimpleText(entries) - def _generateHeaderConfigClass(self): if not self.m_plugin_config: return '' @@ -306,50 +646,51 @@ def _generateHeaderConfigClass(self): return "\n".join(parts).strip() def _generateHeaderIncludes(self): - includes = [] - includes.append('#include "Module.h"') + includes: List[str] = ['#include "Module.h"'] - # Includes for all IDL Header files for iface in self.m_comrpc_interfaces: for filename, mapped_iface in self.m_file_interface_map.items(): if mapped_iface == iface: location = self.m_locations.get(iface, "interfaces") includes.append(f'#include <{location}/{filename}>') - # Includes JSON variant of IDL Header files if applicable - if self.m_type == self.HeaderType.HEADER and self.m_out_of_process: - for json_iface in self.m_jsonrpc_interfaces: - comrpc_iface = convertToCOMRPC(json_iface) - - if comrpc_iface in self.m_comrpc_interfaces: - # Find corresponding filename - filename = next( - (fname for fname, iface in self.m_file_interface_map.items() - if iface == comrpc_iface), - None - ) - if filename: - location = self.m_locations.get(comrpc_iface, "interfaces") - includes.append(f'#include <{location}/json/J{filename[1:]}>') + if self.m_type == self.HeaderType.HEADER and self.m_out_of_process and self.m_jsonrpc: + needed_roots: List[str] = [] + seen = set() + for fq_name, _ in getattr(self.m_blueprint, "event_notification_entries", []): + root_iface = fq_name.split("::")[-2] + if root_iface not in seen: + seen.add(root_iface) + needed_roots.append(root_iface) + + for iface in needed_roots: + json_header = f"J{iface[1:]}" if iface.startswith('I') else f"J{iface}" + location = self.m_locations.get(iface, "interfaces") + includes.append(f'#include <{location}/json/{json_header}.h>') - return generateSimpleText(includes) + if self.m_type == self.HeaderType.HEADER_IMPLEMENTATION and self.m_plugin_config: + includes.append(f"#include ") + + return generateSimpleText(self._dedupe_preserve_order(includes)) def _generateHeaderInheritedClasses(self) -> str: if self.m_type == self.HeaderType.HEADER_IMPLEMENTATION: parts = [] + + if self.m_plugin_config: + parts.append(f"public Exchange::IConfiguration") + for iface in self.m_comrpc_interfaces: - full_name = self.resolveFullName(iface) - namespace = self._extractStrippedNamespace(full_name) - parts.append(f"public {namespace}::{iface}") + ns = self._iface_namespace(iface) + parts.append(f"public {ns}::{iface}") else: parts = ["public PluginHost::IPlugin"] if self.m_jsonrpc: parts.append("public PluginHost::JSONRPC") if not self.m_out_of_process: for iface in self.m_comrpc_interfaces: - full_name = self.resolveFullName(iface) - namespace = self._extractStrippedNamespace(full_name) - parts.append(f"public {namespace}::{iface}") + ns = self._iface_namespace(iface) + parts.append(f"public {ns}::{iface}") return ": " + generateSimpleText(parts, sep=", ", remove_empty=True) @@ -357,10 +698,9 @@ def _generateHeaderConstructor(self) -> str: members = [] if self.m_type == self.HeaderType.HEADER_IMPLEMENTATION: - for i, iface in enumerate(self.m_comrpc_interfaces): - full_name = self.resolveFullName(iface) - namespace = self._extractStrippedNamespace(full_name) - members.append(f"{namespace}::{iface}()") + for iface in self.m_comrpc_interfaces: + ns = self._iface_namespace(iface) + members.append(f"{ns}::{iface}()") if self.m_notification_interfaces: members.append("_adminLock()") members.extend(f"_{convertToBaseName(iface).lower()}Notification()" for iface in self.m_notification_interfaces) @@ -370,10 +710,9 @@ def _generateHeaderConstructor(self) -> str: if self.m_jsonrpc: members.append("PluginHost::JSONRPC()") if not self.m_out_of_process and self.m_comrpc_interfaces: - for i, iface in enumerate(self.m_comrpc_interfaces): - full_name = self.resolveFullName(iface) - namespace = self._extractStrippedNamespace(full_name) - members.append(f"{namespace}::{iface}()") + for iface in self.m_comrpc_interfaces: + ns = self._iface_namespace(iface) + members.append(f"{ns}::{iface}()") if self.m_out_of_process: members.extend(["_service(nullptr)", "_connectionId(0)"]) members.extend(f"_impl{convertToBaseName(iface)}(nullptr)" for iface in self.m_comrpc_interfaces) @@ -399,70 +738,55 @@ def _generateHeaderInheritedMethod(self) -> str: lines.append(f"// {short_name} methods") for m in cls_data.m_methods: - add_const = " const" if getattr(m, "m_is_const", False) else "" + if is_header: - paramsNoNames, names = self.commentParamnames(m.m_params) - lines.append(f"{m.m_return_type} {m.m_name}({paramsNoNames}){add_const} override;") + if short_name in self.m_notification_interfaces and m.m_name in ("Register", "Unregister"): + is_unreg = (m.m_name == "Unregister") + is_const = self._notif_unregister_param_is_const(short_name) if is_unreg else False + if is_unreg and not is_const: + self._maybe_warn_nonconst_unregister(short_name) + const_kw = FileData._CONST_PREFIX if (is_unreg and is_const) else "" + lines.append( + f"{m.m_return_type} {m.m_name}({const_kw}{namespace}::{short_name}::INotification* const /* sink */){add_const} override;" + ) + else: + paramsNoNames, _ = self.commentParamnames(m.m_params) + lines.append(f"{m.m_return_type} {m.m_name}({paramsNoNames}){add_const} override;") + else: + paramsNoNames, _ = self.commentParamnames(m.m_params) + if short_name in self.m_notification_interfaces and m.m_name in ("Register", "Unregister"): + return_stmt = self._defaultReturnStatement(m.m_return_type) if m.m_name == "Register": lines.append(generateSimpleText([ f"{m.m_return_type} Register({namespace}::{short_name}::INotification* notification) override {{", self.notification_registers(short_name), + *return_stmt, "}" - ])) + ], remove_empty=False)) else: + is_const = self._notif_unregister_param_is_const(short_name) + if not is_const: + self._maybe_warn_nonconst_unregister(short_name) + const_kw = FileData._CONST_PREFIX if is_const else "" lines.append(generateSimpleText([ - f"{m.m_return_type} Unregister(const {namespace}::{short_name}::INotification* notification) override {{", + f"{m.m_return_type} Unregister({const_kw}{namespace}::{short_name}::INotification* notification) override {{", self.notification_unregisters(short_name), + *return_stmt, "}" - ])) + ], remove_empty=False)) else: - paramsNoNames, names = self.commentParamnames(m.m_params) - - lines.append(generateSimpleText([ + body = [ f"{m.m_return_type} {m.m_name}({paramsNoNames}){add_const} override {{", - " return Core::ERROR_NONE;", + *self._defaultReturnStatement(m.m_return_type), "}" - ])) + ] + lines.append(generateSimpleText(body, remove_empty=False)) return generateSimpleText(lines, sep="\n\n") - - def notification_registers(self, interface): - text = [] - text.append('\nASSERT(notification != nullptr);\n') - text.append('\n_adminLock.Lock();') - text.append(f'''\nauto item = std::find(_{interface[1:].lower()}Notification.begin(), _{interface[1:].lower()}Notification.end(), notification); - ASSERT(item == _{interface[1:].lower()}Notification.end()); - - if (item == _{interface[1:].lower()}Notification.end()) {{ - notification->AddRef(); - _{interface[1:].lower()}Notification.push_back(notification); - }} - - _adminLock.Unlock(); ''') - text.append('\n') - return ''.join(text) - - def notification_unregisters(self,interface): - text = [] - text.append(f'''\n - ASSERT(notification != nullptr); - - _adminLock.Lock(); - auto item = std::find(_{interface[1:].lower()}Notification.begin(), _{interface[1:].lower()}Notification.end(), notification); - ASSERT(item != _{interface[1:].lower()}Notification.end()); - - if (item != _{interface[1:].lower()}Notification.end()) {{ - _{interface[1:].lower()}Notification.erase(item); - notification->Release(); - }} - _adminLock.Unlock(); - ''') - return ''.join(text) - def _generateHeaderUsingContainer(self) -> str: cond = ( (self.m_out_of_process and self.m_type == self.HeaderType.HEADER_IMPLEMENTATION) @@ -483,8 +807,83 @@ def _generateHeaderUsingContainer(self) -> str: lines.append(container_typedef) return generateSimpleText(lines) + + def _patch_unqualified_ids(self, owner_full_name: str, alias_value: str) -> str: + patched = alias_value + if "ID_" not in patched or "::ID_" in patched: + return patched + owner_ns = self._extractStrippedNamespace(owner_full_name) + if not owner_ns: + return patched + return re.sub(r"\b(ID_[A-Z0-9_]+)\b", rf"{owner_ns}::\1", patched) + + def _generateHeaderUsingExternal(self) -> str: + if self.m_type != self.HeaderType.HEADER_IMPLEMENTATION: + return "" + + usings: Dict[str, str] = {} + for full_name, (cls_data, _) in self.m_parsed.items(): + for name, value in getattr(cls_data, "m_usings", {}).items(): + if name in usings and usings[name] != value: + print( + f"[WARN] Conflicting type alias '{name}': '{usings[name]}' vs '{value}'. " + "Keeping the first definition." + ) + continue + + usings.setdefault(name, self._patch_unqualified_ids(full_name, value)) + + if not usings: + return "" + + lines = ["// Type aliases copied from interface headers"] + for name in sorted(usings.keys()): + lines.append(f"using {name} = {usings[name]};") + + return generateSimpleText(lines, remove_empty=False) class SourceData(FileData): + _KNOWN_SUBSYSTEMS = { + "PLATFORM", + "SECURITY", + "NETWORK", + "IDENTIFIER", + "GRAPHICS", + "INTERNET", + "LOCATION", + "TIME", + "PROVISIONING", + "DECRYPTION", + "WEBSOURCE", + "STREAMING", + "BLUETOOTH", + "CRYPTOGRAPHY", + "INSTALLATION", + "NOT_PLATFORM", + "NOT_SECURITY", + "NOT_NETWORK", + "NOT_IDENTIFIER", + "NOT_GRAPHICS", + "NOT_INTERNET", + "NOT_LOCATION", + "NOT_TIME", + "NOT_PROVISIONING", + "NOT_DECRYPTION", + "NOT_WEBSOURCE", + "NOT_STREAMING", + "NOT_BLUETOOTH", + "NOT_CRYPTOGRAPHY", + "NOT_INSTALLATION", + } + + def _validateSubsystemEntries(self, entries: List[str], label: str) -> List[str]: + if not entries: + return [] + invalid = [e for e in entries if e.upper() not in self._KNOWN_SUBSYSTEMS] + for e in invalid: + print(f"[WARN] Unknown subsystem in {label}: '{e}'. It will still be emitted as subsystem::{e.upper()}.") + return [e.upper() for e in entries if e] + def static_keys(self) -> Dict[str, str]: return { "{{PLUGIN_NAME}}": self.m_plugin_name, @@ -502,6 +901,7 @@ def static_keys(self) -> Dict[str, str]: "{{DEINITIALIZE_OOP}}": self._generateDeinitializeOOP(), "{{INCLUDE_STATEMENTS}}": self._generateSourceIncludeStatements(), "{{SOURCE_METHOD_IMPL}}": self._generateSourcePluginMethods(), + "{{SOURCE_NOTIFY_IMPL}}": self._generateSourceNotify(), "{{PRECONDITIONS}}": self._generatePreconditions(), "{{TERMINATIONS}}": self._generateTerminations(), "{{CONTROLS}}": self._generateControls(), @@ -528,39 +928,46 @@ def _generateUnregisterNotification(self): def _generatePreconditions(self): if not self.m_preconditions: return '' - lines = [f" subsystem::{entry} " for entry in self.m_preconditions] - return generateSimpleText(lines, sep=",") + entries = self._validateSubsystemEntries(self.m_preconditions, "Preconditions") + lines = [f"subsystem::{entry}" for entry in entries] + return generateSimpleText(lines, sep=", ") def _generateTerminations(self): if not self.m_terminations: return '' - lines = [f" subsystem::{entry} " for entry in self.m_terminations] - return generateSimpleText(lines, sep=",") + entries = self._validateSubsystemEntries(self.m_terminations, "Terminations") + lines = [f"subsystem::{entry}" for entry in entries] + return generateSimpleText(lines, sep=", ") def _generateControls(self): if not self.m_controls: return '' - lines = [f" subsystem::{entry} " for entry in self.m_controls] - return generateSimpleText(lines, sep=",") + entries = self._validateSubsystemEntries(self.m_controls, "Controls") + lines = [f"subsystem::{entry}" for entry in entries] + return generateSimpleText(lines, sep=", ") def _generateSourceIncludeStatements(self): + """ + The source file is responsible for JSONRPC Register/Unregister calls for @json interfaces, + so it may need J* headers even when the header does not. + """ includes = [f'#include "{self.m_plugin_name}.h"'] if self.m_plugin_config and self.m_out_of_process: includes.append('#include ') - for json_iface in self.m_jsonrpc_interfaces: - base_name = convertToCOMRPC(json_iface) + for iface in self.m_comrpc_interfaces: + full_name = self.resolveFullName(iface) + if not full_name: + continue + cls_data, _ = self.m_parsed.get(full_name, (None, None)) + if not cls_data or "json" not in getattr(cls_data, "m_tags", []): + continue - header_filename = next( - (fname for fname, iface in self.m_file_interface_map.items() - if iface == base_name), - None - ) - if header_filename: - location = self.m_locations.get(base_name, "interfaces") - includes.append(f'#include <{location}/json/J{header_filename[1:]}>') + json_header = f"J{iface[1:]}" if iface.startswith('I') else f"J{iface}" + location = self.m_locations.get(iface, "interfaces") + includes.append(f'#include <{location}/json/{json_header}.h>') - return generateSimpleText(includes) + return generateSimpleText(self._dedupe_preserve_order(includes)) def _generateSourcePluginMethods(self) -> str: @@ -586,23 +993,38 @@ def _generateSourcePluginMethods(self) -> str: f"void {self.m_plugin_name}::Dangling(const Core::IUnknown* remote, const uint32_t interfaceId) {{\n" "ASSERT(remote != nullptr);\n" ) - - impl_var = self.m_comrpc_interfaces[0][1:] - impl_var_cpp = f"_impl{impl_var}" if impl_var else "_implementation" - + + # root interface -> implementation member (e.g. IBrowser -> _implBrowser) + impl_map = {iface: f"_impl{convertToBaseName(iface)}" for iface in self.m_comrpc_interfaces} + for fq_name, _ in entries: + root_iface = fq_name.split("::")[-2] + unregister_impl = impl_map.get(root_iface) + if not unregister_impl: + unregister_impl = impl_map.get(self.m_comrpc_interfaces[0]) + + unreg_const = self._notif_unregister_param_is_const(root_iface) + if not unreg_const: + self._maybe_warn_nonconst_unregister(root_iface) + + cast_expr = "revokedInterface" if unreg_const else f"const_cast<{fq_name}*>(revokedInterface)" + dangling.append( f" if (interfaceId == {fq_name}::ID) {{\n" f" auto* revokedInterface = remote->QueryInterface<{fq_name}>();\n" f" if (revokedInterface) {{\n" - f" {impl_var_cpp}->Unregister(revokedInterface);\n" + f" {unregister_impl}->Unregister({cast_expr});\n" f" revokedInterface->Release();\n" f" }}\n" f" }}\n" - f" }}\n" ) - - return "\n".join(method) + "\n".join(dangling) + + dangling.append("}\n") + + out = "\n".join(method) + "\n".join(dangling) + self._emit_warning_report() + return out + lines = [] for full_name, (cls_data, _) in self.m_blueprint.parsed_data.items(): @@ -610,15 +1032,39 @@ def _generateSourcePluginMethods(self) -> str: iface = parts[-1] base_name = convertToBaseName(iface) notify_var = f"_{base_name.lower()}Notification" + namespace = self._extractStrippedNamespace(full_name) for method in cls_data.m_methods: paramsNoNames, _ = self.commentParamnames(method.m_params) add_const = " const" if getattr(method, "m_is_const", False) else "" - lines.extend([ - f"{method.m_return_type} {self.m_plugin_name}::{method.m_name}({paramsNoNames}){add_const} {{", - f" return Core::ERROR_NONE;", - "}" - ]) + + # Handle Register and Unregister methods for notification interfaces + if iface in self.m_notification_interfaces and method.m_name in ("Register", "Unregister"): + return_stmt = self._defaultReturnStatement(method.m_return_type) + if method.m_name == "Register": + lines.extend([ + f"{method.m_return_type} {self.m_plugin_name}::Register({namespace}::{iface}::INotification* notification) {{", + self.notification_registers(iface).strip(), + *return_stmt, + "}" + ]) + else: + is_const = self._notif_unregister_param_is_const(iface) + if not is_const: + self._maybe_warn_nonconst_unregister(iface) + const_kw = FileData._CONST_PREFIX if is_const else "" + lines.extend([ + f"{method.m_return_type} {self.m_plugin_name}::Unregister({const_kw}{namespace}::{iface}::INotification* notification) {{", + self.notification_unregisters(iface).strip(), + *return_stmt, + "}" + ]) + else: + lines.extend([ + f"{method.m_return_type} {self.m_plugin_name}::{method.m_name}({paramsNoNames}){add_const} {{", + *self._defaultReturnStatement(method.m_return_type), + "}" + ]) if "event" in cls_data.m_tags: lines.extend([ @@ -631,7 +1077,44 @@ def _generateSourcePluginMethods(self) -> str: f"}}" ]) - return "\n".join(lines) + out = "\n".join(lines) + self._emit_warning_report() + return out + + def _generateSourceNotify(self) -> str: + """Generate notification method implementations for in-process plugins""" + if self.m_out_of_process or not self.m_notification_interfaces: + return '' + + event_entries = list(self.m_event_notification_entries if self.m_jsonrpc else []) + if not event_entries: + return '' + + all_methods = [] + + for fq_name, cls_data in event_entries: + iface = fq_name.split("::")[-2] + no_i = iface[1:] if iface.startswith("I") else iface + container = f"_{no_i.lower()}Notification" + + for m in cls_data.m_methods: + qualified_params = self._qualifyParamTypes(m.m_params, fq_name, cls_data, prefer_unqualified_owner=True) + _, param_names = self.commentParamnames(qualified_params) + param_str = ", ".join(param_names) + + method_lines = [ + f"void {self.m_plugin_name}::Notify{m.m_name}({qualified_params}) const {{", + " _adminLock.Lock();", + f" for (auto* notification : {container}) {{", + f" notification->{m.m_name}({param_str if param_str else ''});", + " }", + " _adminLock.Unlock();", + "}" + ] + + all_methods.append('\n'.join(method_lines)) + + return '\n\n'.join(all_methods) def _generateVariableUnusedDeinitial(self): return "" if self.m_out_of_process else "VARIABLE_IS_NOT_USED " @@ -643,16 +1126,13 @@ def _generateVariableUnusedInitial(self): return "VARIABLE_IS_NOT_USED " def _generateFirstCOMRPCInterface(self): + """Return the fully-qualified type name for the first selected COMRPC root interface.""" if not self.m_comrpc_interfaces: return "Exchange::IPlugin" short_name = self.m_comrpc_interfaces[0] - full_name = next((k for k in self.m_parsed if k.endswith(f"::{short_name}")), None) - if full_name: - namespace = self._extractStrippedNamespace(full_name) - return f"{namespace}::{short_name}" if namespace else short_name - else: - return short_name + ns = self._iface_namespace(short_name) + return f"{ns}::{short_name}" if ns else short_name def _generateFirstCOMRPCNoPrefix(self): name = self.m_comrpc_interfaces[0] if self.m_comrpc_interfaces else "" @@ -666,10 +1146,9 @@ def _generateJSONRegisterIP(self) -> str: for short_name in self.m_comrpc_interfaces: json_iface = convertToJSONRPC(short_name) if json_iface in self.m_jsonrpc_interfaces: - full_name = self.resolveFullName(short_name) - if full_name: - namespace = self._extractStrippedNamespace(full_name) - lines.append(f"{namespace}::{json_iface}::Register(*this, this);") + ns = self._iface_namespace(short_name) + if ns: + lines.append(f"{ns}::{json_iface}::Register(*this, this);") return generateSimpleText(lines) def _generateJSONUnregisterIP(self) -> str: @@ -677,17 +1156,16 @@ def _generateJSONUnregisterIP(self) -> str: for short_name in self.m_comrpc_interfaces: json_iface = convertToJSONRPC(short_name) if json_iface in self.m_jsonrpc_interfaces: - full_name = self.resolveFullName(short_name) - if full_name: - namespace = self._extractStrippedNamespace(full_name) - lines.append(f"{namespace}::{json_iface}::Unregister(*this);") + ns = self._iface_namespace(short_name) + if ns: + lines.append(f"{ns}::{json_iface}::Unregister(*this);") return generateSimpleText(lines) def _generateConfigurationIP(self): if not self.m_plugin_config: return '' return ( - "Config config;\n" + "PluginConfig config;\n" "config.FromString(service->ConfigLine());\n" "TRACE(Trace::Information, (_T(\"This is just an example: [%s]\"), config.Example.Value().c_str()));") @@ -701,73 +1179,99 @@ def _generateDeinitializeImplementation(self): template = FileUtils.readFile(path) return (FileUtils.replaceKeywords(template, self.m_keywords)if template else '') - def _generateNestedQuery(self) -> str: + def _iface_has_notification_register(self, iface: str) -> bool: + full_name = self.resolveFullName(iface) + if not full_name: + return False + cls_data, _ = self.m_parsed.get(full_name, (None, None)) + if not cls_data: + return False + + method_names = {m.m_name for m in getattr(cls_data, "m_methods", [])} + if not ({"Register", "Unregister"} <= method_names): + return False + + return any(fq.split("::")[-2] == iface for fq, _ in getattr(self.m_blueprint, "notification_entries", [])) + + def _generateNestedQuery(self) -> str: if not self.m_comrpc_interfaces: return "" interfaces = self.m_comrpc_interfaces - N = len(interfaces) - - # qi = queryinterface - has_qi = N >= 2 - init_lines = [] - - for short_name in interfaces: - json_iface = convertToJSONRPC(short_name) - if json_iface in self.m_jsonrpc_interfaces: - name = convertToBaseName(short_name) - full_name = next((k for k in self.m_parsed if k.endswith(f"::{short_name}")), None) - namespace = self._extractStrippedNamespace(full_name) - qualified_json = f"{namespace}::{json_iface}" - - if short_name in self.m_notification_interfaces: - init_lines.append(f"_impl{name}->Register(&_notification);") - init_lines.append(f"{qualified_json}::Register(*this, _impl{name});") + has_qi = len(interfaces) >= 2 + init_lines = self._nestedquery_init_lines(interfaces) if self.m_plugin_config: - rootName = convertToBaseName(interfaces[0]) - if init_lines and init_lines[-1] != "": - init_lines.append("") - init_lines.extend([ - f"Exchange::IConfiguration* configuration = _impl{rootName}->QueryInterface();", - "ASSERT(configuration != nullptr);", - "if (configuration != nullptr) {", - " if (configuration->Configure(service) != Core::ERROR_NONE) {", - f' message = _T("{self.m_plugin_name} could not be configured.");', - " }", - " configuration->Release();", - "}" - ]) + init_lines = self._nestedquery_add_configure(init_lines, interfaces[0]) - has_init = bool(init_lines) - has_work = has_qi or has_init - - # for the if statment: close or "else" - if not has_work: + if not (has_qi or init_lines): return generateSimpleText(["}"]) - lines = ["} else {"] + + lines: List[str] = ["} else {"] if has_qi: - for idx, short_name in enumerate(interfaces[1:], start=1): - baseName = convertToBaseName(short_name) - full_name = next((k for k in self.m_parsed if k.endswith(f"::{short_name}")), None) - namespace = self._extractStrippedNamespace(full_name) - lines.extend([ - f"_impl{baseName} = _impl{convertToBaseName(interfaces[0])}->QueryInterface<{namespace}::{short_name}>();", - f"if (_impl{baseName} == nullptr) {{", - f'message = _T("Failed to acquire {short_name} interface.");', - "} else {" - ]) + lines.extend(self._nestedquery_qi_lines(interfaces)) - if has_init: + if init_lines: lines.extend(init_lines) - close_count = 1 + (N - 1 if has_qi else 0) - lines.extend("}" for _ in range(close_count)) - + lines.extend(self._nestedquery_closing_braces(len(interfaces), has_qi)) return generateSimpleText(lines) + def _nestedquery_init_lines(self, interfaces: List[str]) -> List[str]: + init: List[str] = [] + for iface in interfaces: + base = convertToBaseName(iface) + + if self._iface_has_notification_register(iface): + init.append(f"_impl{base}->Register(&_notification);") + + json_iface = convertToJSONRPC(iface) + if json_iface in self.m_jsonrpc_interfaces: + ns = self._iface_namespace(iface) + init.append(f"{ns}::{json_iface}::Register(*this, _impl{base});") + + return init + + def _nestedquery_add_configure(self, init_lines: List[str], root_iface: str) -> List[str]: + root_base = convertToBaseName(root_iface) + out = list(init_lines) + if out and out[-1] != "": + out.append("") + out.extend([ + f"Exchange::IConfiguration* configuration = _impl{root_base}->QueryInterface();", + "ASSERT(configuration != nullptr);", + "if (configuration != nullptr) {", + " if (configuration->Configure(service) != Core::ERROR_NONE) {", + f' message = _T("{self.m_plugin_name} could not be configured.");', + " }", + " configuration->Release();", + "}", + ]) + return out + + def _nestedquery_qi_lines(self, interfaces: List[str]) -> List[str]: + root_base = convertToBaseName(interfaces[0]) + lines: List[str] = [] + + for iface in interfaces[1:]: + base = convertToBaseName(iface) + ns = self._iface_namespace(iface) + lines.extend([ + f"_impl{base} = _impl{root_base}->QueryInterface<{ns}::{iface}>();", + f"if (_impl{base} == nullptr) {{", + f'message = _T("Failed to acquire {iface} interface.");', + "} else {", + ]) + + return lines + + @staticmethod + def _nestedquery_closing_braces(interface_count: int, has_qi: bool) -> List[str]: + close_count = 1 + (interface_count - 1 if has_qi else 0) + return ["}" for _ in range(close_count)] + def _generateDeinitializeOOP(self): if not self.m_comrpc_interfaces: return "" @@ -777,59 +1281,67 @@ def _generateDeinitializeOOP(self): firstBase = convertToBaseName(first) firstJson = convertToJSONRPC(first) - full_name_first = next((k for k in self.m_parsed if k.endswith(f"::{first}")), None) - namespace_first = self._extractStrippedNamespace(full_name_first) + namespace_first = self._iface_namespace(first) lines.append(f"if (_impl{firstBase} != nullptr) {{") - if firstJson in self.m_jsonrpc_interfaces: - lines.append(f"{namespace_first}::{firstJson}::Unregister(*this);") - - if first in self.m_notification_interfaces: - lines.append(f"_impl{firstBase}->Unregister(&_notification);") - + # Unregister JSONRPC bindings + COMRPC notifications for secondary interfaces first for comrpc in self.m_comrpc_interfaces[1:]: baseName = convertToBaseName(comrpc) jsonName = convertToJSONRPC(comrpc) - full_name = next((k for k in self.m_parsed if k.endswith(f"::{comrpc}")), None) - namespace = self._extractStrippedNamespace(full_name) + namespace = self._iface_namespace(comrpc) lines.append("\n") lines.append(f"if (_impl{baseName} != nullptr) {{") if jsonName in self.m_jsonrpc_interfaces: lines.append(f"{namespace}::{jsonName}::Unregister(*this);") - - if comrpc in self.m_notification_interfaces: - lines.append(f"_impl{baseName}->Unregister(&_notification);") + + if self._iface_has_notification_register(comrpc): + lines.append(f"_impl{baseName}->Unregister(&_notification);") lines.append(f"_impl{baseName}->Release();") lines.append(f"_impl{baseName} = nullptr;") lines.append("}") + # Now unregister for the root interface last + if firstJson in self.m_jsonrpc_interfaces: + lines.append(f"{namespace_first}::{firstJson}::Unregister(*this);") + + if self._iface_has_notification_register(first): + lines.append(f"_impl{firstBase}->Unregister(&_notification);") + lines.extend([ "\n", - f"\n", + "\n", f"RPC::IRemoteConnection* connection(service->RemoteConnection(_connectionId));", f"VARIABLE_IS_NOT_USED uint32_t result = _impl{firstBase}->Release();", f"_impl{firstBase} = nullptr;", - ]) + ]) lines.append("\n") lines.append(f"ASSERT((result == Core::ERROR_DESTRUCTION_SUCCEEDED) || (result == Core::ERROR_CONNECTION_CLOSED));") lines.append("\n") lines.extend([ - f"// The process can disappear in the meantime...", - f"if (connection != nullptr) {{", - f"// But if it did not disappear in the meantime, forcefully terminate it. Shoot to kill", - f"connection->Terminate();", - f"connection->Release();", - f"}}", - f"}}" + "// The process can disappear in the meantime...", + "if (connection != nullptr) {", + "// But if it did not disappear in the meantime, forcefully terminate it. Shoot to kill", + "connection->Terminate();", + "connection->Release();", + "}", + "}", ]) return generateSimpleText(lines, remove_empty=False) + def _emit_warning_report(self) -> None: + if not getattr(self, "m_warnings", None): + return + unique = FileData._dedupe_preserve_order(self.m_warnings) + if not unique: + return + print("\n".join(unique)) + class CMakeData(FileData): def static_keys(self) -> dict: return { diff --git a/PluginSkeletonGenerator/generators/PluginRepositoryGenerator.py b/PluginSkeletonGenerator/generators/PluginRepositoryGenerator.py index b98cdb93..08ec5d6a 100644 --- a/PluginSkeletonGenerator/generators/PluginRepositoryGenerator.py +++ b/PluginSkeletonGenerator/generators/PluginRepositoryGenerator.py @@ -1,15 +1,33 @@ -import os +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' +import os from data.FileData import FileData from utils.FileUtils import FileUtils from utils.Indenter import Indenter import core.GlobalVariables as GlobalVariables - class PluginRepositoryGenerator: def __init__(self, blueprint) -> None: self.m_plugin_name = blueprint.name - self.m_directory = blueprint.name + root = getattr(blueprint, "output_dir", None) or os.getcwd() + self.m_directory = os.path.join(root, blueprint.name) os.makedirs(self.m_directory, exist_ok=False) self.m_indenter = Indenter() diff --git a/PluginSkeletonGenerator/menu/Menu.py b/PluginSkeletonGenerator/menu/Menu.py index 371cb9c5..0d2637ae 100644 --- a/PluginSkeletonGenerator/menu/Menu.py +++ b/PluginSkeletonGenerator/menu/Menu.py @@ -1,13 +1,33 @@ +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' + import os import sys import yaml import argparse +import re from typing import Dict, Tuple, List from parser.Parser import Printer, ClassData from parser.MultiParser import MultiParser from core.GeneratorCoordinator import GeneratorCoordinator - - + + def loadYAML(file_path: str) -> Dict: if not os.path.isfile(file_path): print(f"[ERROR]: Config file '{file_path}' does not exist") @@ -18,31 +38,68 @@ def loadYAML(file_path: str) -> Dict: except yaml.YAMLError as e: print(f"[ERROR]: Failed to parse: {e}") + def validatePaths(paths: List[str]) -> None: files = [p for p in paths if not os.path.isfile(p)] if files: print(f"[ERROR]: Following header paths do not exist: ") for missing in files: - print(f"-{missing}") + print(f"- {missing}") sys.exit(1) + def collectList(label: str) -> List[str]: - print(f"\nEnter {label} (press Enter on empty line to finish):") - entries = [] - while True: - entry = input(f"{label[:-1]}: ").strip() - if not entry: - break - entries.append(entry) - return entries - -def prompts() -> Tuple[str, bool, bool, List[str], Dict[str, str], List[str], List[str], List[str]]: + print(f"\nEnter {label} (press Enter on empty line to finish):") + entries = [] + while True: + entry = input(f"{label[:-1]}: ").strip() + if not entry: + break + entries.append(entry) + return entries + + +def _prompt_yes_no(question: str, default: bool = False) -> bool: + suffix = "[Y/N, Enter defaults to N]" if default is False else "[Y/N, Enter defaults to Y]" + while True: + raw = input(f"{question} (Y/N) {suffix}: ").strip().lower() + if raw == "": + return default + if raw in ("y", "yes"): + return True + if raw in ("n", "no"): + return False + print("[WARN] Invalid input. Please enter Y or N.") + + +def _validate_plugin_name(name: str) -> str: + name = name.strip() + if not name: + print("[ERROR]: Plugin name cannot be empty") + sys.exit(1) + # C++ identifier + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name): + print("[ERROR]: Invalid plugin name. Use only letters, digits and '_' and do not start with a digit.") + sys.exit(1) + return name + + +def prompts() -> Tuple[str, bool, bool, List[str], Dict[str, str], List[str], List[str], List[str], str]: print("=== Plugin Generator ===") - - plugin_name = input("What will your plugin be called: ").strip() - out_of_process = input("Is your plugin out of process? (Y/N): ").strip().lower() == "y" - plugin_config = input("Custom plugin specific configuration needed? (Y/N): ").strip().lower() == "y" - + + plugin_name = _validate_plugin_name(input("What will your plugin be called: ")) + + output_dir = input("Where should the plugin be generated? [default: current directory]: ").strip() + if not output_dir: + output_dir = os.getcwd() + output_dir = os.path.abspath(output_dir) + if not os.path.isdir(output_dir): + print(f"[ERROR]: Output directory does not exist: {output_dir}") + sys.exit(1) + + out_of_process = _prompt_yes_no("Is your plugin out of process?", default=False) + plugin_config = _prompt_yes_no("Custom plugin specific configuration needed?", default=False) + header_files = [] print("\nAdd FULL path to your interface (C++ IDL header file)...") print("Example: /home/Thunder/ThunderInterfaces/interfaces/ITestInterface.h") @@ -51,30 +108,32 @@ def prompts() -> Tuple[str, bool, bool, List[str], Dict[str, str], List[str], Li if not path: break header_files.append(path) - + if not header_files and out_of_process: print("Error: At least one header path must be provided") sys.exit(1) validatePaths(header_files) - - subsystems_enabled = input("\nDoes your plugin rely on Thunder subsystems [Preconditions, Terminations, Controls]? (Y/N): ").strip().lower() == "y" - - + + subsystems_enabled = _prompt_yes_no( + "Does your plugin rely on Thunder subsystems [Preconditions, Terminations, Controls]?", + default=False, + ) + preconditions = collectList("Preconditions") if subsystems_enabled else [] terminations = collectList("Terminations") if subsystems_enabled else [] controls = collectList("Controls") if subsystems_enabled else [] - - return plugin_name, out_of_process, plugin_config, header_files, {}, preconditions, terminations, controls - - + + return plugin_name, out_of_process, plugin_config, header_files, {}, preconditions, terminations, controls, output_dir + + def parserInterfaces(header_files: List[str]) -> Tuple[Dict[str, Tuple[ClassData, str]], Dict[str, str]]: parser = MultiParser(header_files) parsed_data = parser.parseAll() header_lookup = parser.m_header_lookup return parsed_data, header_lookup - - + + def displayParsedData(parsed_data: Dict[str, Tuple[ClassData, str]]) -> None: print("=" * 30) print(f"[INFO] Parsed {len(parsed_data)} interfaces:") @@ -82,59 +141,60 @@ def displayParsedData(parsed_data: Dict[str, Tuple[ClassData, str]]) -> None: print(f"[INFO]: {name} (from {header})") Printer.printTree(class_data) print("=" * 30) - - + + def _root(full_name: str) -> str: return full_name.split("::")[-1] - - + + def menu() -> None: parser = argparse.ArgumentParser(description="Plugin Skeleton Generator") parser.add_argument("--config", help="Path to .yaml file") args = parser.parse_args() - + if args.config: config = loadYAML(args.config) - + plugin_name = config.get("PluginName") out_of_process = config.get("OutOfProcess", False) plugin_config = config.get("PluginConfig", False) header_files = config.get("Paths", []) raw_locations = config.get("Locations", {}) - + preconditions = config.get("Preconditions", []) terminations = config.get("Terminations", []) controls = config.get("Controls", []) - + select_map = config.get("SelectInterfaces", {}) - + if not plugin_name: print("[ERROR]: PluginName must be specified in the config file") sys.exit(1) if not header_files: print("[ERROR]: Paths must be specified in the config file") sys.exit(1) + output_dir = os.getcwd() else: - plugin_name, out_of_process, plugin_config, header_files, raw_locations, preconditions, terminations, controls = prompts() + plugin_name, out_of_process, plugin_config, header_files, raw_locations, preconditions, terminations, controls, output_dir = prompts() select_map = {} - - if os.path.exists(plugin_name): - print(f"[ERROR]: Directory '{plugin_name}' already exists") + + plugin_root = os.path.join(output_dir, plugin_name) + if os.path.exists(plugin_root): + print(f"[ERROR]: Directory '{plugin_root}' already exists") sys.exit(1) - + parsed_data, header_lookup = parserInterfaces(header_files) - - # Grouping root level by header for root interface selection + by_header: Dict[str, List[str]] = {} for full_name, (_, header_path) in parsed_data.items(): by_header.setdefault(header_path, []).append(full_name) - + selected_full_names: set = set() - + if args.config: for header_path, full_names in by_header.items(): header_file = os.path.basename(header_path) - wanted = select_map.get(header_file) # list or none + wanted = select_map.get(header_file) if not wanted: selected_full_names.update(full_names) else: @@ -143,11 +203,11 @@ def menu() -> None: else: for header_path, full_names in by_header.items(): roots = [_root(fn) for fn in full_names] - + if len(full_names) == 1: selected_full_names.add(full_names[0]) continue - + print(f"\n[SELECT] Please pick which interfaces you want to use from {header_path}") for i, r in enumerate(roots, 1): print(f" {i}) {r}") @@ -165,18 +225,18 @@ def menu() -> None: print("[WARN] Empty/invalid selection, keeping ALL for this header.") chosen = full_names selected_full_names.update(chosen) - + parsed_data = {k: v for k, v in parsed_data.items() if k in selected_full_names} chosen_roots = {_root(k) for k in parsed_data.keys()} header_lookup = {k: v for k, v in header_lookup.items() if k in chosen_roots} - + locations: Dict[str, str] = {} for full_name, (_, header_path) in parsed_data.items(): iface_name = _root(full_name) header_file = os.path.basename(header_path) if header_file in raw_locations: locations[iface_name] = raw_locations[header_file] - + displayParsedData(parsed_data) header_lookup_test = [] if not args.config: @@ -194,7 +254,7 @@ def menu() -> None: if loc: for r in roots_in_header: locations[r] = loc - + coordinator = GeneratorCoordinator( plugin_name, out_of_process, @@ -205,5 +265,6 @@ def menu() -> None: preconditions, terminations, controls, + output_dir=output_dir, ) - coordinator.generateAll() + coordinator.generateAll() \ No newline at end of file diff --git a/PluginSkeletonGenerator/parser/MultiParser.py b/PluginSkeletonGenerator/parser/MultiParser.py index 1fb11c15..308702c9 100644 --- a/PluginSkeletonGenerator/parser/MultiParser.py +++ b/PluginSkeletonGenerator/parser/MultiParser.py @@ -1,3 +1,22 @@ +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' + import os from typing import List, Dict, Tuple from parser.Parser import Parser, ClassData diff --git a/PluginSkeletonGenerator/parser/Parser.py b/PluginSkeletonGenerator/parser/Parser.py index d8c3635b..cffd53bd 100755 --- a/PluginSkeletonGenerator/parser/Parser.py +++ b/PluginSkeletonGenerator/parser/Parser.py @@ -1,9 +1,27 @@ +''' + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Metrological + * + * 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. +''' + import re from enum import Enum import os from typing import Dict - class ScopeType(Enum): CLASS = 1 NAMESPACE = 2 @@ -24,6 +42,8 @@ def __init__(self, name, namespace=""): self.m_tags = [] self.m_methods = [] self.m_children = {} + self.m_usings = {} + self.m_types = set() def addMethod(self, method): self.m_methods.append(method) @@ -31,7 +51,6 @@ def addMethod(self, method): def addChild(self, child): self.m_children[child.m_name] = child - class InterfaceTree: ''' Each parsed file is represented as a tree of ClassData objects. @@ -129,6 +148,8 @@ def parseFile(self) -> Dict[str, ClassData]: self.processTag(line) self.processNamespace(line) self.processClass(line) + self.processUsing(line) + self.processTypeDecl(line) self.processMethod(line) self.processScope(line) return self.m_interface_tree.parsedClasses() @@ -146,11 +167,42 @@ def processNamespace(self, line): self.m_interface_tree.pushNamespace(namespace) def processClass(self, line): - match = re.match(r'struct\s+EXTERNAL\s+(\w+)\s*:\s*virtual\s+public\s+Core::IUnknown', line) + # struct EXTERNAL IFoo : virtual public Core::IUnknown + # struct IFoo : virtual public Core::IUnknown + match = re.match(r'struct\s+(?:EXTERNAL\s+)?(\w+)\s*:\s*virtual\s+public\s+Core::IUnknown', line) if match: interface = match.group(1) self.m_pending_class = interface + def processUsing(self, line): + """Parse a `using Name = ...;` alias inside the current class scope.""" + m = re.match(r'^using\s+(\w+)\s*=\s*(.+);$', line) + if m and self.m_interface_tree.m_class_stack: + name = m.group(1).strip() + value = m.group(2).strip() + current = self.m_interface_tree.m_class_stack[-1] + if name not in current.m_usings: + current.m_usings[name] = value + + def processTypeDecl(self, line: str): + """Capture nested typedef/enum/struct names inside the current class scope.""" + if not self.m_interface_tree.m_class_stack: + return + + current = self.m_interface_tree.m_class_stack[-1] + + m = re.match(r'^typedef\s+.+\s+(\w+)\s*;\s*$', line) + if m: + current.m_types.add(m.group(1)) + else: + m = re.match(r'^enum\s+(?:class\s+)?(\w+)\b', line) + if m: + current.m_types.add(m.group(1)) + elif 'Core::IUnknown' not in line: + m = re.match(r'^struct\s+(\w+)\b', line) + if m: + current.m_types.add(m.group(1)) + def processMethod(self, line): if "virtual" in line and ";" in line: self.m_interface_tree.addMethod(line) diff --git a/PluginSkeletonGenerator/templates/.cmake.txt b/PluginSkeletonGenerator/templates/.cmake.txt index 9ffe8712..be051820 100644 --- a/PluginSkeletonGenerator/templates/.cmake.txt +++ b/PluginSkeletonGenerator/templates/.cmake.txt @@ -51,7 +51,7 @@ add_library(${MODULE_NAME} SHARED set_target_properties(${MODULE_NAME} PROPERTIES ~INDENT_INCREASE~ - CXX_STANDARD 11 + CXX_STANDARD ${CXX_STD} CXX_STANDARD_REQUIRED YES) ~INDENT_DECREASE~ diff --git a/PluginSkeletonGenerator/templates/.plugin_header.txt b/PluginSkeletonGenerator/templates/.plugin_header.txt index 3dc86d8e..181be5e8 100644 --- a/PluginSkeletonGenerator/templates/.plugin_header.txt +++ b/PluginSkeletonGenerator/templates/.plugin_header.txt @@ -55,7 +55,7 @@ namespace Plugin { {{HEADER_INTERFACE_AGGREGATE}} END_INTERFACE_MAP - private: +{{ADD_PRIVATE_MEMBERS_FIELD}} {{HEADER_USING_CONTAINER}} {{HEADER_STATIC_TIMEOUT}} diff --git a/PluginSkeletonGenerator/templates/.plugin_implementation.txt b/PluginSkeletonGenerator/templates/.plugin_implementation.txt index 19c9f22a..33980087 100644 --- a/PluginSkeletonGenerator/templates/.plugin_implementation.txt +++ b/PluginSkeletonGenerator/templates/.plugin_implementation.txt @@ -41,8 +41,12 @@ namespace Plugin { BEGIN_INTERFACE_MAP({{PLUGIN_NAME}}Implementation) {{HEADER_INTERFACE_ENTRY}} END_INTERFACE_MAP - + + {{HEADER_USING_EXTERNAL}} + {{HEADER_INHERITED_METHOD}} + + {{CONFIGURE_OOP}} private: {{HEADER_USING_CONTAINER}} diff --git a/PluginSkeletonGenerator/templates/.plugin_source.txt b/PluginSkeletonGenerator/templates/.plugin_source.txt index c997e39c..d05aa6ad 100644 --- a/PluginSkeletonGenerator/templates/.plugin_source.txt +++ b/PluginSkeletonGenerator/templates/.plugin_source.txt @@ -24,7 +24,7 @@ namespace Plugin { namespace { - static Metadata<{{PLUGIN_NAME}}>metadata( + static Metadata<{{PLUGIN_NAME}}> metadata( // Version 1, 0, 0, // Preconditions @@ -49,5 +49,7 @@ string {{PLUGIN_NAME}}::Information() const { } {{SOURCE_METHOD_IMPL}} + +{{SOURCE_NOTIFY_IMPL}} } // Plugin } // Thunder \ No newline at end of file diff --git a/PluginSkeletonGenerator/templates/nested_class/.config_class.txt b/PluginSkeletonGenerator/templates/nested_class/.config_class.txt index a5aeac02..9a980ab1 100644 --- a/PluginSkeletonGenerator/templates/nested_class/.config_class.txt +++ b/PluginSkeletonGenerator/templates/nested_class/.config_class.txt @@ -1,17 +1,17 @@ -class Config : public Core::JSON::Container { +class PluginConfig : public Core::JSON::Container { public: - Config(const Config&) = delete; - Config& operator=(const Config&) = delete; - Config(Config&&) = delete; - Config& operator=(Config&&) = delete; + PluginConfig(const PluginConfig&) = delete; + PluginConfig& operator=(const PluginConfig&) = delete; + PluginConfig(PluginConfig&&) = delete; + PluginConfig& operator=(PluginConfig&&) = delete; - Config() + PluginConfig() : Core::JSON::Container() , Example() { Add(_T("example"), &Example); } - ~Config() override = default; + ~PluginConfig() override = default; public: Core::JSON::String Example; }; \ No newline at end of file diff --git a/PluginSkeletonGenerator/templates/nested_methods/.configure.txt b/PluginSkeletonGenerator/templates/nested_methods/.configure.txt index 9dd1f1cc..07e65d7a 100644 --- a/PluginSkeletonGenerator/templates/nested_methods/.configure.txt +++ b/PluginSkeletonGenerator/templates/nested_methods/.configure.txt @@ -1,7 +1,7 @@ uint32_t Configure(PluginHost::IShell* service) override { ASSERT(service != nullptr); - Config config; + PluginConfig config; config.FromString(service->ConfigLine()); - TRACE(Trace::Information, (_T("This is just an example: [%s]"), config.Example.Value.c_str())); + TRACE(Trace::Information, (_T("This is just an example: [%s]"), config.Example.Value().c_str())); return Core::ERROR_NONE; } \ No newline at end of file diff --git a/PluginSkeletonGenerator/utils/FileUtils.py b/PluginSkeletonGenerator/utils/FileUtils.py index 4a1b1ed1..e246db39 100644 --- a/PluginSkeletonGenerator/utils/FileUtils.py +++ b/PluginSkeletonGenerator/utils/FileUtils.py @@ -1,6 +1,11 @@ import re class FileUtils: + @staticmethod + def _postProcess(code: str) -> str: + code = re.sub(r"\b(Metadata\s*<[^>]+>)\s*(?=[A-Za-z_])", r"\1 ", code) + return code + @staticmethod def replaceKeywords(template, keywords): lines = template.split('\n') @@ -21,7 +26,7 @@ def replaceKeywords(template, keywords): replaced_line = pattern.sub(lambda m: keywords[m.group(0)], line) result_lines.append(replaced_line) - return '\n'.join(result_lines) + return FileUtils._postProcess('\n'.join(result_lines)) @staticmethod def readFile(template_name):