diff --git a/distribution/entrypoints/aidl_to_ifex.py b/distribution/entrypoints/aidl_to_ifex.py new file mode 100644 index 0000000..f9221c9 --- /dev/null +++ b/distribution/entrypoints/aidl_to_ifex.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# User-invocation script for Android IDL (AIDL) to IFEX conversion + +from ifex.models.aidl.aidl_lark import get_ast_from_aidl_file +from ifex.input_filters.aidl.aidl_to_ifex import aidl_to_ifex +from ifex.models.common.ast_utils import ast_as_yaml +import argparse + + +def aidl_to_ifex_run(): + + parser = argparse.ArgumentParser(description='Runs Android IDL (AIDL) to IFEX translator.') + parser.add_argument('input', metavar='file.aidl', type=str, help='path to input .aidl file') + + try: + args = parser.parse_args() + aidl_ast = get_ast_from_aidl_file(args.input) + ifex_ast = aidl_to_ifex(aidl_ast) + print(ast_as_yaml(ifex_ast)) + + except FileNotFoundError: + print(f"ERROR: File not found: {args.input}") + except Exception as e: + print(f"ERROR: Conversion error for {args.input}: {e}") + raise + + +if __name__ == "__main__": + aidl_to_ifex_run() diff --git a/distribution/entrypoints/ifex_to_aidl.py b/distribution/entrypoints/ifex_to_aidl.py new file mode 100644 index 0000000..e8dfba3 --- /dev/null +++ b/distribution/entrypoints/ifex_to_aidl.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# User-invocation script for IFEX to Android IDL (AIDL) generation + +from ifex.models.ifex.ifex_parser import get_ast_from_yaml_file +from ifex.output_filters.aidl.ifex_to_aidl import ifex_to_aidl +from ifex.output_filters.aidl.aidl_generator import aidl_to_text +import argparse + +def ifex_to_aidl_run(): + + parser = argparse.ArgumentParser(description='Runs IFEX to Android IDL (AIDL) translator.') + parser.add_argument('input', metavar='ifex-input-file', type=str, help='path to input IFEX (YAML) file') + + try: + args = parser.parse_args() + ifex_ast = get_ast_from_yaml_file(args.input) + aidl_files = ifex_to_aidl(ifex_ast) + print(aidl_to_text(aidl_files)) + + except FileNotFoundError: + print(f"ERROR: File not found: {args.input}") + except Exception as e: + print(f"ERROR: Conversion error for {args.input}: {e}") + raise + +if __name__ == "__main__": + ifex_to_aidl_run() diff --git a/ifex/input_filters/aidl/__init__.py b/ifex/input_filters/aidl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ifex/input_filters/aidl/aidl_to_ifex.py b/ifex/input_filters/aidl/aidl_to_ifex.py new file mode 100644 index 0000000..3a45f50 --- /dev/null +++ b/ifex/input_filters/aidl/aidl_to_ifex.py @@ -0,0 +1,210 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# This file is part of the IFEX project +# vim: tw=120 ts=4 et + +""" +Convert an AIDL AST (AIDLFile) to an IFEX AST (ifex.AST). + +Public API: + aidl_to_ifex(aidl_file: AIDLFile) -> ifex.AST + +Follows the same approach as protobuf_to_ifex.py: + - Manual tree-walking (no rule_translator) + - Capitalised function names to make AST type names stand out + - One conversion function per AIDL / IFEX type pair + +Mapping summary +--------------- +AIDL IFEX +---- ---- +AIDLFile.package -> Namespace.name (full dotted name) +Interface -> ifex.Interface (leading "I" stripped) +Method(oneway=False) -> ifex.Method +Method(oneway=True) -> ifex.Event +Parameter(dir="in") -> Argument in Method.input +Parameter(dir="out") -> Argument in Method.output +Method.return_type!="void" -> Argument in Method.returns +Parcelable -> ifex.Struct +ParcelableField -> ifex.Member +Enum -> ifex.Enumeration (datatype="int32") +EnumElement -> ifex.Option +""" + +import ifex.models.ifex.ifex_ast as ifex +from ifex.models.aidl.aidl_ast import ( + AIDLFile, Interface, Method, Parameter, Const, + Parcelable, ParcelableField, Enum, EnumElement, Import, +) + + +# ============================================================ +# Type translation (reverse of ifex_to_aidl._type_map) +# ============================================================ + +_reverse_type_map = { + "boolean": "boolean", + "byte": "uint8", + "short": "int16", + "int": "int32", + "long": "int64", + "float": "float", + "double": "double", + "String": "string", + "IBinder": "opaque", + "void": "void", # kept for internal use; not an IFEX type +} + + +def translate_type(t: str) -> str: + """Map an AIDL type string to an IFEX type string. + + Array types (e.g. "int[]") are handled by stripping and reattaching + the suffix. Unknown types pass through unchanged. + """ + if t.endswith("[]"): + base = t[:-2] + mapped = _reverse_type_map.get(base, base) + return mapped + "[]" + return _reverse_type_map.get(t, t) + + +# ============================================================ +# Conversion helpers (Capitalised names = AST types visible) +# ============================================================ + +def Params_to_Input(parameters) -> list: + """Return 'in' (and 'inout') parameters as IFEX input Arguments.""" + if not parameters: + return [] + return [ifex.Argument(name=p.name, datatype=translate_type(p.datatype)) + for p in parameters if p.direction in ('in', 'inout')] + + +def Params_to_Output(parameters) -> list: + """Return 'out' (and 'inout') parameters as IFEX output Arguments.""" + if not parameters: + return [] + return [ifex.Argument(name=p.name, datatype=translate_type(p.datatype)) + for p in parameters if p.direction in ('out', 'inout')] + + +def Method_to_Returns(method: Method): + """If a method has a non-void return type, create a single returns Argument.""" + if method.return_type and method.return_type != 'void': + return [ifex.Argument(name='_return', datatype=translate_type(method.return_type))] + return [] + + +def Methods_to_Methods(methods) -> list: + """Convert non-oneway AIDL Methods to IFEX Methods.""" + result = [] + for m in (methods or []): + if m.oneway: + continue # handled separately as Events + input_args = Params_to_Input(m.parameters) + output_args = Params_to_Output(m.parameters) + returns = Method_to_Returns(m) + result.append(ifex.Method( + name = m.name, + input = input_args if input_args else None, + output = output_args if output_args else None, + returns = returns if returns else None, + )) + return result + + +def Methods_to_Events(methods) -> list: + """Convert oneway AIDL Methods to IFEX Events.""" + result = [] + for m in (methods or []): + if not m.oneway: + continue + input_args = Params_to_Input(m.parameters) + result.append(ifex.Event( + name = m.name, + input = input_args if input_args else None, + )) + return result + + +def Fields_to_Members(fields) -> list: + """Convert Parcelable fields to IFEX Struct Members.""" + return [ifex.Member(name=f.name, datatype=translate_type(f.datatype)) + for f in (fields or [])] + + +def Elements_to_Options(elements) -> list: + """Convert Enum elements to IFEX Options.""" + result = [] + for e in (elements or []): + value = int(e.value) if e.value is not None else 0 + result.append(ifex.Option(name=e.name, value=value)) + return result + + +def Interface_to_Interface(aidl_iface: Interface) -> ifex.Interface: + """Convert an AIDL Interface to an IFEX Interface. + + Strips the leading 'I' convention (IFoo -> Foo) to recover the + original service name if present. + """ + name = aidl_iface.name + if name.startswith('I') and len(name) > 1 and name[1].isupper(): + name = name[1:] + + methods = Methods_to_Methods(aidl_iface.methods) + events = Methods_to_Events(aidl_iface.methods) + + return ifex.Interface( + name = name, + methods = methods if methods else None, + events = events if events else None, + ) + + +def Parcelable_to_Struct(p: Parcelable) -> ifex.Struct: + return ifex.Struct( + name = p.name, + members = Fields_to_Members(p.fields) or None, + ) + + +def Enum_to_Enumeration(e: Enum) -> ifex.Enumeration: + return ifex.Enumeration( + name = e.name, + datatype = 'int32', + options = Elements_to_Options(e.elements), + ) + + +# ============================================================ +# Main conversion entry point +# ============================================================ + +def aidl_to_ifex(aidl_file: AIDLFile) -> ifex.AST: + """Convert an AIDLFile AST to an IFEX AST. + + A single AIDLFile becomes an AST with one Namespace whose name is + the package from the AIDL file. The single declaration becomes + the appropriate IFEX construct within that Namespace. + + :param aidl_file: parsed AIDLFile AST + :return: ifex.AST with one Namespace + """ + declaration = aidl_file.declaration + + # Build the Namespace scaffold + ns = ifex.Namespace(name=aidl_file.package) + + if isinstance(declaration, Interface): + ns.interface = Interface_to_Interface(declaration) + elif isinstance(declaration, Parcelable): + ns.structs = [Parcelable_to_Struct(declaration)] + elif isinstance(declaration, Enum): + ns.enumerations = [Enum_to_Enumeration(declaration)] + else: + raise TypeError(f"Unknown AIDL declaration type: {type(declaration)}") + + return ifex.AST(namespaces=[ns]) diff --git a/ifex/models/aidl/__init__.py b/ifex/models/aidl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ifex/models/aidl/aidl.grammar b/ifex/models/aidl/aidl.grammar new file mode 100644 index 0000000..1b22996 --- /dev/null +++ b/ifex/models/aidl/aidl.grammar @@ -0,0 +1,170 @@ +# ============================================================ +# AIDL (Android Interface Definition Language) LARK GRAMMAR +# ============================================================ +# +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the IFEX project +# +# Grammar for Lark parser, covering the subset of AIDL represented +# in aidl_ast.py. Follows the same conventions as protobuf.grammar. +# +# Grammar conventions (same as protobuf.grammar): +# ----------------------------------------------- +# - Composite rules use lowercase names +# - Terminal/token names use UPPERCASE +# - Names prefixed X_ or x_ are additions beyond the AIDL spec that +# preserve semantic keywords Lark would otherwise discard silently +# - ? prefix on rules = Lark "inline/transparent" rule +# - Comments (// and /* */) are stripped by filter_comments() before +# the grammar ever sees them +# +# AIDL file structure: +# - Each .aidl file contains exactly ONE top-level declaration +# (interface, parcelable, or enum) plus a package statement and +# optional imports. +# +# ============================================================ + + +# ============================================================ +# Start rule +# ============================================================ + +?start: aidl_file + + +# ============================================================ +# Top-level file structure +# ============================================================ + +aidl_file: package_decl import_decl* declaration + + +# ============================================================ +# Package +# ============================================================ + +# e.g. package com.example.vehicle; +package_decl: "package" QUALNAME ";" + + +# ============================================================ +# Import +# ============================================================ + +# e.g. import com.example.vehicle.IFoo; +import_decl: "import" QUALNAME ";" + + +# ============================================================ +# Top-level declaration (exactly one per file) +# ============================================================ + +?declaration: interface_decl + | parcelable_decl + | enum_decl + + +# ============================================================ +# Interface +# ============================================================ + +# e.g. +# interface IFoo { ... } +# oneway interface IFoo { ... } +interface_decl: X_ONEWAY? "interface" IDENT "{" interface_member* "}" + +?interface_member: method_decl + | const_decl + + +# ============================================================ +# Method +# ============================================================ + +# e.g. +# void foo(in int x, out boolean y); +# oneway void bar(in String s); +# float getTemp(in int zone); +method_decl: X_ONEWAY? aidl_type IDENT "(" param_list? ")" ";" + +param_list: param ("," param)* + +param: X_DIRECTION aidl_type IDENT + + +# ============================================================ +# Const +# ============================================================ + +# e.g. const int MAX = 42; +const_decl: "const" aidl_type IDENT "=" CONST_VALUE ";" + + +# ============================================================ +# Parcelable +# ============================================================ + +# e.g. +# parcelable ClimateZone { int zoneId; float temp; } +parcelable_decl: "parcelable" IDENT "{" field_decl* "}" + +field_decl: aidl_type IDENT ";" + + +# ============================================================ +# Enum +# ============================================================ + +# e.g. +# enum AirflowMode { AUTO = 0, MANUAL = 1, MAX_COOL = 2, } +enum_decl: "enum" IDENT "{" enum_element* "}" + +# Trailing comma is optional; value assignment is optional +enum_element: IDENT ("=" INT)? ","? + + +# ============================================================ +# Types +# ============================================================ + +# A type is an identifier (primitive or user-defined) with an +# optional array suffix: e.g. int, String, MyType, int[], String[] +aidl_type: IDENT ARRAY_SUFFIX? + + +# ============================================================ +# Terminals (tokens) +# ============================================================ + +# Direction keywords — must be a named token so the value is preserved +X_DIRECTION: "in" | "out" | "inout" + +# oneway keyword — named token so the value is preserved +X_ONEWAY: "oneway" + +# Array suffix +ARRAY_SUFFIX: "[]" + +# Fully qualified name: com.example.vehicle.Foo +QUALNAME: /[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*/ + +# Simple identifier (lower priority than QUALNAME for disambiguation) +IDENT: /[A-Za-z_][A-Za-z0-9_]*/ + +# Integer literal (signed) +INT: /-?[0-9]+/ + +# Const value: everything up to the semicolon (loose match) +# Handles integer literals, hex (0x...), string literals, etc. +CONST_VALUE: /[^\s;][^;]*/ + + +# ============================================================ +# Whitespace +# ============================================================ + +%import common (WS) +%ignore WS diff --git a/ifex/models/aidl/aidl_ast.py b/ifex/models/aidl/aidl_ast.py new file mode 100644 index 0000000..e51fa98 --- /dev/null +++ b/ifex/models/aidl/aidl_ast.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# This file is part of the IFEX project +# vim: tw=120 ts=4 et + +# Node types representing an Android Interface Definition Language (AIDL) AST. +# Similar in structure like ifex_ast.py and others + +from dataclasses import dataclass, field +from typing import List, Optional, Union + +# Examples of input are shown in the docstrings. + +@dataclass +class Import: + """Example: import com.example.Foo;""" + path: str + + +@dataclass +class EnumElement: + """A single value inside an AIDL enum.""" + name: str + value: Optional[str] = None + + +@dataclass +class Enum: + """Example: enum Status { OK = 0, ERR = 1 }""" + name: str + elements: Optional[List[EnumElement]] = None + + +@dataclass +class Parameter: + """A method parameter with an explicit direction annotation.""" + name: str + datatype: str + direction: str = "in" # "in" | "out" | "inout" + + +@dataclass +class Method: + """A method declared inside an AIDL interface.""" + name: str + return_type: str # "void" or a type name + parameters: Optional[List[Parameter]] = None + oneway: bool = False + + +@dataclass +class Const: + """Example: const int MAX_VALUE = 100;""" + name: str + datatype: str + value: str + + +@dataclass +class Interface: + """Example: interface IFoo { ... }""" + name: str + methods: Optional[List[Method]] = None + consts: Optional[List[Const]] = None + oneway: bool = False # True → all methods are oneway + + +@dataclass +class ParcelableField: + """A field inside a parcelable data class.""" + name: str + datatype: str + + +@dataclass +class Parcelable: + """Example: parcelable Foo { ... }""" + name: str + fields: Optional[List[ParcelableField]] = None + + +@dataclass +class AIDLFile: + """AIDL source file + Each file is expected to have a package declaration, zero or more imports, + and one top-level declaration (interface, parcelable, or enum) + """ + package: str + declaration: Union[Interface, Parcelable, Enum] + imports: Optional[List[Import]] = None + + @property + def filename(self) -> str: + return self.declaration.name + ".aidl" diff --git a/ifex/models/aidl/aidl_lark.py b/ifex/models/aidl/aidl_lark.py new file mode 100644 index 0000000..d7580e5 --- /dev/null +++ b/ifex/models/aidl/aidl_lark.py @@ -0,0 +1,565 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# This file is part of the IFEX project +# vim: tw=120 ts=4 et + +""" +Parse an AIDL source file and build an AIDLFile AST. + +Public API: + get_ast_from_aidl_file(aidl_file: str) -> AIDLFile + Read a .aidl file and return its AIDLFile AST. + + get_ast_from_aidl_text(text: str) -> AIDLFile + Parse AIDL source text directly and return AIDLFile. + +Design notes +------------ + 1. Comments are stripped from the source before Lark ever sees it. + 2. Lark is run in LALR mode with the grammar in aidl.grammar. + 3. The resulting lark.Tree / lark.Token tree is walked manually + (destructively, using .children.pop(0)) to build typed AST + objects defined in aidl_ast.py. + +The Tree / Token data model (same as protobuf): + + Tree(data, children): + .data = Token('RULE', rule_name) -- the grammar rule name + .children = list of Tree or Token objects + + Token(type, value): + .type = uppercase terminal name from grammar (e.g. 'IDENT') + .value = matched string + +The same pattern-matching mini-framework is copied from protobuf_lark.py +so that the helper functions (assert_rule_match, assert_token, etc.) can +be reused verbatim. +""" + +import lark +import os +import re +import sys + +from lark import Lark, Tree, Token + +from ifex.models.aidl import aidl_ast as aidl_model +from ifex.models.aidl.aidl_ast import ( + AIDLFile, Import, Interface, Method, Parameter, Const, + Parcelable, ParcelableField, Enum, EnumElement, +) +from ifex.models.common.ast_utils import ast_as_yaml + + +# ============================================================ +# Low-level helpers +# ============================================================ + +# Remove lines matching regexp +def filter_out(s, re_pattern): + """Remove lines matching a regexp.""" + return '\n'.join([line for line in s.split('\n') if not re_pattern.match(line)]) + +# Remove partial lines matching regexp +def filter_out_partial(s, pattern): + """Remove partial matches from each line.""" + return '\n'.join([re.sub(pattern, "", line) for line in s.split('\n')]) + +# Useful helpers +def is_tree(node): + return type(node) is lark.Tree + +def is_token(node): + return type(node) is lark.Token + +def truncate_string(s, maxlen=77): + if len(s) > maxlen: + return s[0:maxlen] + "..." + return s + +# ============================================================ +# PATTERN MATCHING +# +# Here we build a set of functions that will take a pattern token-tree +# and compare it to the real token tree, to be able to recognize and extract +# features more easily. For both the Tree and Token type, it is possible to +# specify the .children or .value to match against, or to pass a wildcard. +# (we use ['*'] for lists (children) and '*' for strings (value) to match +# any value therein. + +# ============================================================ + +# String matcher which allows "*" = wildcard on the string we are comparing *to*! +def match_str(s1, s2): + return s1 == s2 or s2 == "*" + +# Checks that all objects in node-list match the corresponding pattern-list +def match_children(node_list, pattern_list): + return (pattern_list == ['*'] or + (len(node_list) == len(pattern_list) and + all(matcher(x, y) for (x, y) in zip(node_list, pattern_list)))) + +# Match any node against a pattern - can use wildcard in the treepattern +# For Tree nodes, it will recurse and require the entire sub-tree to match. +def matcher(node, pattern): + if type(node) is list: + return (pattern is list and + len(node) == len(pattern) and + all(matcher(x, y) for (x, y) in zip(node, pattern))) + elif is_tree(node): + return (is_tree(pattern) and + node.data == pattern.data and + match_children(node.children, pattern.children)) + elif is_token(node): + return (is_token(pattern) and + match_str(node.type, pattern.type) and + match_str(node.value, pattern.value)) + else: + raise TypeError(f"Unknown type passed to matcher(): {type(node)}") + + +# Helper to extract a subtree of a certain type (as identified by the grammar rule name) +def get_items_of_type(node, grammar_rule_name): + """Return all direct children of node whose rule name matches.""" + return [x for x in node.children + if matcher(x, Tree(Token('RULE', grammar_rule_name), ['*']))] + + +# ============================================================ +# ASSERTS - Functions to check that we have the expected format of the +# token-tree (Lark parser output). +# +# If we get invalid input then the parsing should _usually_ fail earlier +# according to the grammar rules. In the rest of the program we should have +# the right understanding of which sequence of tokens is received and asserts +# are used to check this understanding. If there is a mistake, these assert +# calls can help to catch it instead of passing invalid data to the next step. +# It is thus used primarily as a development tool. +# +# During development it is in other words possible that these will throw +# exception once in a while, to notify that something needs to be adjusted. + +# ============================================================ + +# Error message helper +def create_error_message(node, pattern): + node_string = truncate_string(f"{node!r}") + pattern_string = truncate_string(f"{pattern!r}") + return (f"\nPROBLEM: Failed expected match:\n" + f" - wanted pattern: {pattern_string}\n" + f" - item is: {node_string}") + + +# Raise exception if a node does not matches a pattern +def assert_match(node, pattern, error_message=None): + if not matcher(node, pattern): + if error_message is None: + error_message = create_error_message(node, pattern) + raise Exception(error_message) + + +# Check if the node is a tree representing a RULE of type "grammar_rule_name" +def rule_match(tree, grammar_rule_name): + return matcher(tree, Tree(Token('RULE', grammar_rule_name), ['*'])) + + +# Assert that the node is a tree representing a RULE of type "grammar_rule_name" +def assert_rule_match(tree, grammar_rule_name): + assert_match(tree, Tree(Token('RULE', grammar_rule_name), ['*'])) + + +# Assert that tree matches *at least one* of the named rules +def assert_rule_match_any(tree, grammar_rule_names): + if not any(matcher(tree, Tree(Token('RULE', y), ['*'])) for y in grammar_rule_names): + node_string = truncate_string(f"{tree!r}") + raise Exception( + f"PROBLEM: Failed expected match:\n" + f" - wanted one of {grammar_rule_names}\n" + f" - item is: {node_string}") + + +# Assert that node is a Token of the given type, optionally checking +# for specific data (or wildcard) +def assert_token(node, token_type, data_match='*'): + assert_match(node, Token(token_type, data_match)) + + +def assert_token_any(node, token_types, data_match='*'): + if not any(matcher(node, Token(y, "*")) for y in token_types): + node_string = truncate_string(f"{node!r}") + raise Exception( + f"PROBLEM: Failed expected token type(s):\n" + f" - wanted one of {token_types}\n" + f" - item is: {node_string}") + + +# ============================================================ +# Comment stripping (same approach as protobuf_lark.py) +# ============================================================ + +def filter_comments(text): + # Remove full comment lines (// ...) + text = filter_out(text, re.compile(r'^ *//')) + # Remove inline trailing comments (// ...) + text = filter_out_partial(text, r'//.*$') + # Remove block comments (/* ... */) + text = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL) + return text + + +# ============================================================ +# AIDL-specific processing functions +# ============================================================ + +def process_type(t): + """Extract the string representation of an aidl_type node. + + Grammar rule: aidl_type: IDENT ARRAY_SUFFIX? + + Returns a string like "int", "String", "MyType", "int[]". + """ + assert_rule_match(t, 'aidl_type') + name_token = t.children.pop(0) + assert_token(name_token, 'IDENT') + type_str = name_token.value + # Optional array suffix + if t.children: + suffix_token = t.children.pop(0) + assert_token(suffix_token, 'ARRAY_SUFFIX') + type_str += suffix_token.value + return type_str + + +def process_param(p): + """Process a param rule → Parameter. + + Grammar rule: param: X_DIRECTION aidl_type IDENT + """ + assert_rule_match(p, 'param') + + # 1. Direction + dir_token = p.children.pop(0) + assert_token(dir_token, 'X_DIRECTION') + direction = dir_token.value + + # 2. Type + type_node = p.children.pop(0) + datatype = process_type(type_node) + + # 3. Name + name_token = p.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + return Parameter(name=name, datatype=datatype, direction=direction) + + +def process_method(m): + """Process a method_decl rule → Method. + + Grammar rule: method_decl: X_ONEWAY? aidl_type IDENT "(" param_list? ")" ";" + """ + assert_rule_match(m, 'method_decl') + + # 1. Optional oneway keyword + oneway = False + if m.children and is_token(m.children[0]) and m.children[0].type == 'X_ONEWAY': + m.children.pop(0) + oneway = True + + # 2. Return type + type_node = m.children.pop(0) + return_type = process_type(type_node) + + # 3. Method name + name_token = m.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + # 4. Optional param_list + params = [] + param_list_nodes = get_items_of_type(m, 'param_list') + if param_list_nodes: + pl = param_list_nodes[0] + assert_rule_match(pl, 'param_list') + for param_node in get_items_of_type(pl, 'param'): + params.append(process_param(param_node)) + + return Method( + name=name, + return_type=return_type, + parameters=params if params else None, + oneway=oneway, + ) + + +def process_const(c): + """Process a const_decl rule → Const. + + Grammar rule: const_decl: "const" aidl_type IDENT "=" CONST_VALUE ";" + """ + assert_rule_match(c, 'const_decl') + + # 1. Type + type_node = c.children.pop(0) + datatype = process_type(type_node) + + # 2. Name + name_token = c.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + # 3. Value + value_token = c.children.pop(0) + assert_token(value_token, 'CONST_VALUE') + value = value_token.value.strip() + + return Const(name=name, datatype=datatype, value=value) + + +def process_interface(i): + """Process an interface_decl rule → Interface. + + Grammar rule: + interface_decl: X_ONEWAY? "interface" IDENT "{" interface_member* "}" + """ + assert_rule_match(i, 'interface_decl') + + # 1. Optional interface-level oneway + oneway = False + if i.children and is_token(i.children[0]) and i.children[0].type == 'X_ONEWAY': + i.children.pop(0) + oneway = True + + # 2. Interface name + name_token = i.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + # 3. Methods + methods = [] + for node in get_items_of_type(i, 'method_decl'): + methods.append(process_method(node)) + + # 4. Consts + consts = [] + for node in get_items_of_type(i, 'const_decl'): + consts.append(process_const(node)) + + return Interface( + name=name, + methods=methods if methods else None, + consts=consts if consts else None, + oneway=oneway, + ) + + +def process_field(f): + """Process a field_decl rule → ParcelableField. + + Grammar rule: field_decl: aidl_type IDENT ";" + """ + assert_rule_match(f, 'field_decl') + + # 1. Type + type_node = f.children.pop(0) + datatype = process_type(type_node) + + # 2. Name + name_token = f.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + return ParcelableField(name=name, datatype=datatype) + + +def process_parcelable(p): + """Process a parcelable_decl rule → Parcelable. + + Grammar rule: + parcelable_decl: "parcelable" IDENT "{" field_decl* "}" + """ + assert_rule_match(p, 'parcelable_decl') + + # 1. Name + name_token = p.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + # 2. Fields + fields = [] + for node in get_items_of_type(p, 'field_decl'): + fields.append(process_field(node)) + + return Parcelable( + name=name, + fields=fields if fields else None, + ) + + +def process_enum_element(e): + """Process an enum_element rule → EnumElement. + + Grammar rule: enum_element: IDENT ("=" INT)? ","? + """ + assert_rule_match(e, 'enum_element') + + # 1. Name + name_token = e.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + # 2. Optional value + value = None + if e.children: + value_token = e.children.pop(0) + assert_token(value_token, 'INT') + value = value_token.value + + return EnumElement(name=name, value=value) + + +def process_enum(e): + """Process an enum_decl rule → Enum. + + Grammar rule: enum_decl: "enum" IDENT "{" enum_element* "}" + """ + assert_rule_match(e, 'enum_decl') + + # 1. Name + name_token = e.children.pop(0) + assert_token(name_token, 'IDENT') + name = name_token.value + + # 2. Elements + elements = [] + for node in get_items_of_type(e, 'enum_element'): + elements.append(process_enum_element(node)) + + return Enum( + name=name, + elements=elements if elements else None, + ) + + +def process_import(i): + """Process an import_decl rule → Import. + + Grammar rule: import_decl: "import" QUALNAME ";" + """ + assert_rule_match(i, 'import_decl') + + path_token = i.children.pop(0) + assert_token(path_token, 'QUALNAME') + return Import(path=path_token.value) + + +def process_package(p): + """Process a package_decl rule → str. + + Grammar rule: package_decl: "package" QUALNAME ";" + """ + assert_rule_match(p, 'package_decl') + + path_token = p.children.pop(0) + assert_token(path_token, 'QUALNAME') + return path_token.value + + +# ============================================================ +# Top-level tree processor +# ============================================================ + +def process_lark_tree(root): + """Walk the root lark.Tree and build an AIDLFile AST. + + Grammar rule: aidl_file: package_decl import_decl* declaration + """ + assert_rule_match(root, 'aidl_file') + + # 1. Package (exactly one) + package_nodes = get_items_of_type(root, 'package_decl') + if len(package_nodes) != 1: + raise Exception(f"Expected exactly one package declaration, found {len(package_nodes)}") + package = process_package(package_nodes[0]) + + # 2. Imports (zero or more) + imports = [] + for node in get_items_of_type(root, 'import_decl'): + imports.append(process_import(node)) + + # 3. Declaration (exactly one: interface, parcelable, or enum) + interface_nodes = get_items_of_type(root, 'interface_decl') + parcelable_nodes = get_items_of_type(root, 'parcelable_decl') + enum_nodes = get_items_of_type(root, 'enum_decl') + + total = len(interface_nodes) + len(parcelable_nodes) + len(enum_nodes) + if total != 1: + raise Exception( + f"Expected exactly one top-level declaration (interface/parcelable/enum), found {total}") + + if interface_nodes: + declaration = process_interface(interface_nodes[0]) + elif parcelable_nodes: + declaration = process_parcelable(parcelable_nodes[0]) + else: + declaration = process_enum(enum_nodes[0]) + + return AIDLFile( + package=package, + declaration=declaration, + imports=imports if imports else None, + ) + + +# ============================================================ +# Grammar loading and parsing +# ============================================================ + +def _load_grammar() -> str: + """Load the AIDL Lark grammar from the models/aidl directory.""" + grammar_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'aidl.grammar') + with open(grammar_file, 'r') as f: + return f.read() + + +def parse_text(text: str) -> AIDLFile: + """Parse AIDL source text and return an AIDLFile AST.""" + grammar = _load_grammar() + parser = Lark(grammar, parser='lalr') + clean = filter_comments(text) + tree = parser.parse(clean) + return process_lark_tree(tree) + + +def read_aidl_file(aidl_file: str) -> str: + """Read an AIDL source file and strip comments.""" + with open(aidl_file, 'r') as f: + return filter_comments(f.read()) + + +# ============================================================ +# Public entry point +# ============================================================ + +def get_ast_from_aidl_file(aidl_file: str) -> AIDLFile: + """Read a .aidl file and return its AIDLFile AST. + + :param aidl_file: path to a .aidl source file + :return: AIDLFile abstract syntax tree + """ + text = read_aidl_file(aidl_file) + grammar = _load_grammar() + parser = Lark(grammar, parser='lalr') + tree = parser.parse(text) + return process_lark_tree(tree) + + +# ============================================================ +# Script entry point +# ============================================================ + +if __name__ == '__main__': + ast = get_ast_from_aidl_file(sys.argv[1]) + print(ast_as_yaml(ast)) diff --git a/ifex/output_filters/aidl/__init__.py b/ifex/output_filters/aidl/__init__.py new file mode 100644 index 0000000..a323988 --- /dev/null +++ b/ifex/output_filters/aidl/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 diff --git a/ifex/output_filters/aidl/aidl_generator.py b/ifex/output_filters/aidl/aidl_generator.py new file mode 100644 index 0000000..5b218ed --- /dev/null +++ b/ifex/output_filters/aidl/aidl_generator.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# This file is part of the IFEX project +# vim: tw=120 ts=4 et + +""" +Render an AIDL AST (list of AIDLFile objects) to text using Jinja2 templates. + +Public API: + aidl_file_to_text(aidl_file: AIDLFile) -> str + Render a single AIDLFile to its .aidl text representation. + + aidl_to_text(aidl_files: List[AIDLFile]) -> str + Render all files, separated by a filename header comment. + +Note: We build the Jinja2 environment directly rather than using JinjaSetup, +because JinjaSetup's template-discovery relies on ifex_ast_doc.walk_type_tree() +which cannot traverse Union type annotations. The AIDL AST uses a Union for +AIDLFile.declaration, so we map templates manually by class name. +""" + +import jinja2 +import os +from typing import List +from ifex.models.aidl.aidl_ast import AIDLFile + +_TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") + + +def _build_jinja_env(template_dir: str) -> jinja2.Environment: + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(template_dir), + autoescape=jinja2.select_autoescape([]), + undefined=jinja2.StrictUndefined, + ) + env.trim_blocks = True + env.lstrip_blocks = True + return env + + +def _make_gen(env: jinja2.Environment): + """Return a gen() closure that dispatches to the template named after the node's class.""" + def gen(node): + class_name = type(node).__name__ + template_file = class_name + ".j2" + try: + template = env.get_template(template_file) + except jinja2.TemplateNotFound: + raise ValueError(f"No AIDL template found for node type '{class_name}' " + f"(expected '{template_file}' in {env.loader.searchpath})") + return template.render(item=node) + return gen + + +def aidl_file_to_text(aidl_file: AIDLFile, template_dir: str = _TEMPLATE_DIR) -> str: + """Render one AIDLFile to its .aidl source text.""" + env = _build_jinja_env(template_dir) + gen = _make_gen(env) + env.globals["gen"] = gen + return gen(aidl_file).rstrip() + "\n" + + +def aidl_to_text(aidl_files: List[AIDLFile], template_dir: str = _TEMPLATE_DIR) -> str: + """Render a list of AIDLFile objects. + + Each file is preceded by a comment header showing its filename, making the + combined output easy to split into individual files later. + """ + parts = [] + for f in aidl_files: + parts.append(f"// ----- {f.filename} -----") + parts.append(aidl_file_to_text(f, template_dir)) + return "\n".join(parts) diff --git a/ifex/output_filters/aidl/ifex_to_aidl.py b/ifex/output_filters/aidl/ifex_to_aidl.py new file mode 100644 index 0000000..19a2057 --- /dev/null +++ b/ifex/output_filters/aidl/ifex_to_aidl.py @@ -0,0 +1,215 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# This file is part of the IFEX project +# vim: tw=120 ts=4 et + +""" +Functional transformation from IFEX AST to a list of AIDL AST nodes. + +Each top-level IFEX declaration (Interface, Struct, Enumeration) becomes a +separate AIDLFile, matching AIDL's file-per-type convention. + +IFEX → AIDL mapping: + Namespace → package name (string) + Interface → Interface (.aidl file) + Method (with output/returns) → Method (typed return, in/out params) + Event → Method(oneway=True, return_type="void") + Argument (input) → Parameter(direction="in") + Argument (output) → Parameter(direction="out") + Struct → Parcelable (.aidl file) + Member → ParcelableField + Enumeration → Enum (.aidl file) + Option → EnumElement + Property → getter/setter Method pair inside the Interface +""" + +import ifex.models.ifex.ifex_ast as ifex +from ifex.models.aidl.aidl_ast import ( + AIDLFile, Import, Interface, Method, Parameter, + Parcelable, ParcelableField, Enum, EnumElement, Const, +) +from typing import List, Optional + +# --------------------------------------------------------------------------- +# Type translation table: IFEX primitive types → AIDL types +# --------------------------------------------------------------------------- + +_type_map = { + "boolean": "boolean", + "uint8": "byte", + "int8": "byte", + "uint16": "int", # AIDL has no unsigned; widen to int + "int16": "short", + "uint32": "int", # lossy — no unsigned in AIDL + "int32": "int", + "uint64": "long", # lossy + "int64": "long", + "float": "float", + "double": "double", + "string": "String", + "uint8[]": "byte[]", + "opaque": "IBinder", +} + + +def translate_type(ifex_type: str) -> str: + """Map an IFEX type name to the closest AIDL equivalent.""" + if ifex_type is None: + return "void" + # Handle array syntax: e.g. "int32[]" → "int[]" + if ifex_type.endswith("[]"): + base = ifex_type[:-2] + return translate_type(base) + "[]" + return _type_map.get(ifex_type, ifex_type) + + +# --------------------------------------------------------------------------- +# Node converters +# --------------------------------------------------------------------------- + +def _convert_argument_in(arg: ifex.Argument) -> Parameter: + return Parameter( + name=arg.name, + datatype=translate_type(arg.datatype), + direction="in", + ) + + +def _convert_argument_out(arg: ifex.Argument) -> Parameter: + return Parameter( + name=arg.name, + datatype=translate_type(arg.datatype), + direction="out", + ) + + +def _convert_method(method: ifex.Method) -> Method: + """Convert an IFEX Method to an AIDL Method. + + Return type: first element of method.returns if present, else void. + Input arguments → in-parameters. + Output arguments → out-parameters. + """ + params: List[Parameter] = [] + + for arg in (method.input or []): + params.append(_convert_argument_in(arg)) + for arg in (method.output or []): + params.append(_convert_argument_out(arg)) + + # Determine return type + returns = method.returns or [] + if returns: + return_type = translate_type(returns[0].datatype) + else: + return_type = "void" + + return Method( + name=method.name, + return_type=return_type, + parameters=params or None, + oneway=False, + ) + + +def _convert_event(event: ifex.Event) -> Method: + """IFEX Events become oneway void methods.""" + params = [_convert_argument_in(arg) for arg in (event.input or [])] + return Method( + name=event.name, + return_type="void", + parameters=params or None, + oneway=True, + ) + + +def _convert_property(prop: ifex.Property) -> List[Method]: + """IFEX Properties become a getter and (if not read-only) a setter.""" + aidl_type = translate_type(prop.datatype) + getter = Method( + name="get" + prop.name[0].upper() + prop.name[1:], + return_type=aidl_type, + parameters=None, + oneway=False, + ) + setter = Method( + name="set" + prop.name[0].upper() + prop.name[1:], + return_type="void", + parameters=[Parameter(name="value", datatype=aidl_type, direction="in")], + oneway=False, + ) + return [getter, setter] + + +def _convert_interface(iface: ifex.Interface, package: str) -> AIDLFile: + methods: List[Method] = [] + + for m in (iface.methods or []): + methods.append(_convert_method(m)) + for e in (iface.events or []): + methods.append(_convert_event(e)) + for p in (iface.properties or []): + methods.extend(_convert_property(p)) + + aidl_iface = Interface( + name="I" + iface.name, + methods=methods or None, + ) + return AIDLFile(package=package, declaration=aidl_iface) + + +def _convert_struct(struct: ifex.Struct, package: str) -> AIDLFile: + fields = [ + ParcelableField(name=m.name, datatype=translate_type(m.datatype)) + for m in (struct.members or []) + ] + parcelable = Parcelable(name=struct.name, fields=fields or None) + return AIDLFile(package=package, declaration=parcelable) + + +def _convert_enumeration(enum: ifex.Enumeration, package: str) -> AIDLFile: + elements = [ + EnumElement(name=opt.name, value=str(opt.value) if opt.value is not None else None) + for opt in (enum.options or []) + ] + aidl_enum = Enum(name=enum.name, elements=elements or None) + return AIDLFile(package=package, declaration=aidl_enum) + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def ifex_to_aidl(ast: ifex.AST) -> List[AIDLFile]: + """Convert an IFEX AST into a list of AIDLFile objects (one per declaration). + + The IFEX Namespace name is used as the AIDL package name. + Nested namespaces are flattened with dot-separated names. + """ + files: List[AIDLFile] = [] + + for ns in (ast.namespaces or []): + _convert_namespace(ns, parent_package="", files=files) + + return files + + +def _convert_namespace(ns: ifex.Namespace, parent_package: str, files: List[AIDLFile]): + package = (parent_package + "." + ns.name) if parent_package else ns.name + + # Interface + if ns.interface is not None: + files.append(_convert_interface(ns.interface, package)) + + # Structs → Parcelables + for struct in (ns.structs or []): + files.append(_convert_struct(struct, package)) + + # Enumerations → Enums + for enum in (ns.enumerations or []): + files.append(_convert_enumeration(enum, package)) + + # Recurse into nested namespaces + for child_ns in (ns.namespaces or []): + _convert_namespace(child_ns, package, files) diff --git a/ifex/output_filters/aidl/templates/AIDLFile.j2 b/ifex/output_filters/aidl/templates/AIDLFile.j2 new file mode 100644 index 0000000..7cd5f02 --- /dev/null +++ b/ifex/output_filters/aidl/templates/AIDLFile.j2 @@ -0,0 +1,9 @@ +package {{ item.package }}; +{% if item.imports %} + +{% for imp in item.imports %} +{{ gen(imp) }} +{% endfor %} +{% endif %} + +{{ gen(item.declaration) }} diff --git a/ifex/output_filters/aidl/templates/Const.j2 b/ifex/output_filters/aidl/templates/Const.j2 new file mode 100644 index 0000000..b0a0c75 --- /dev/null +++ b/ifex/output_filters/aidl/templates/Const.j2 @@ -0,0 +1 @@ +const {{ item.datatype }} {{ item.name }} = {{ item.value }}; diff --git a/ifex/output_filters/aidl/templates/Enum.j2 b/ifex/output_filters/aidl/templates/Enum.j2 new file mode 100644 index 0000000..99aa259 --- /dev/null +++ b/ifex/output_filters/aidl/templates/Enum.j2 @@ -0,0 +1,7 @@ +enum {{ item.name }} { +{% if item.elements %} +{% for element in item.elements %} + {{ gen(element) }}{{ "" if loop.last else "" }} +{% endfor %} +{% endif %} +} diff --git a/ifex/output_filters/aidl/templates/EnumElement.j2 b/ifex/output_filters/aidl/templates/EnumElement.j2 new file mode 100644 index 0000000..f21ec82 --- /dev/null +++ b/ifex/output_filters/aidl/templates/EnumElement.j2 @@ -0,0 +1 @@ +{{ item.name }}{% if item.value is not none %} = {{ item.value }}{% endif %}, \ No newline at end of file diff --git a/ifex/output_filters/aidl/templates/Import.j2 b/ifex/output_filters/aidl/templates/Import.j2 new file mode 100644 index 0000000..8c19f8b --- /dev/null +++ b/ifex/output_filters/aidl/templates/Import.j2 @@ -0,0 +1 @@ +import {{ item.path }}; diff --git a/ifex/output_filters/aidl/templates/Interface.j2 b/ifex/output_filters/aidl/templates/Interface.j2 new file mode 100644 index 0000000..1cecb1d --- /dev/null +++ b/ifex/output_filters/aidl/templates/Interface.j2 @@ -0,0 +1,13 @@ +{% if item.oneway %}oneway {% endif %}interface {{ item.name }} { +{% if item.consts %} +{% for const in item.consts %} + {{ gen(const) }} +{% endfor %} + +{% endif %} +{% if item.methods %} +{% for method in item.methods %} + {{ gen(method) }} +{% endfor %} +{% endif %} +} diff --git a/ifex/output_filters/aidl/templates/Method.j2 b/ifex/output_filters/aidl/templates/Method.j2 new file mode 100644 index 0000000..ec8502c --- /dev/null +++ b/ifex/output_filters/aidl/templates/Method.j2 @@ -0,0 +1 @@ +{% if item.oneway %}oneway {% endif %}{{ item.return_type }} {{ item.name }}({% if item.parameters %}{% for param in item.parameters %}{{ gen(param) }}{{ "" if loop.last else ", " }}{% endfor %}{% endif %}); diff --git a/ifex/output_filters/aidl/templates/Parameter.j2 b/ifex/output_filters/aidl/templates/Parameter.j2 new file mode 100644 index 0000000..77c8a8c --- /dev/null +++ b/ifex/output_filters/aidl/templates/Parameter.j2 @@ -0,0 +1 @@ +{{ item.direction }} {{ item.datatype }} {{ item.name }} \ No newline at end of file diff --git a/ifex/output_filters/aidl/templates/Parcelable.j2 b/ifex/output_filters/aidl/templates/Parcelable.j2 new file mode 100644 index 0000000..c41f236 --- /dev/null +++ b/ifex/output_filters/aidl/templates/Parcelable.j2 @@ -0,0 +1,7 @@ +parcelable {{ item.name }} { +{% if item.fields %} +{% for field in item.fields %} + {{ gen(field) }} +{% endfor %} +{% endif %} +} diff --git a/ifex/output_filters/aidl/templates/ParcelableField.j2 b/ifex/output_filters/aidl/templates/ParcelableField.j2 new file mode 100644 index 0000000..d97bd96 --- /dev/null +++ b/ifex/output_filters/aidl/templates/ParcelableField.j2 @@ -0,0 +1 @@ +{{ item.datatype }} {{ item.name }}; diff --git a/setup.py b/setup.py index ccb0450..9e579c7 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ "ifexgen=distribution.entrypoints.generator:ifex_generator_run", "ifexgen_dbus=distribution.entrypoints.generator_dbus:ifex_dbus_generator_run", "ifexconv_protobuf=distribution.entrypoints.protobuf_ifex:protobuf_to_ifex_run", + "aidl_to_ifex=distribution.entrypoints.aidl_to_ifex:aidl_to_ifex_run", ], }, include_package_data=True, diff --git a/tests/test.aidl.sample/AirflowMode.aidl b/tests/test.aidl.sample/AirflowMode.aidl new file mode 100644 index 0000000..b6e8d83 --- /dev/null +++ b/tests/test.aidl.sample/AirflowMode.aidl @@ -0,0 +1,9 @@ +// Test fixture: AIDL enum for AIDL parser tests. + +package com.example.vehicle; + +enum AirflowMode { + AUTO = 0, + MANUAL = 1, + MAX_COOL = 2, +} diff --git a/tests/test.aidl.sample/ClimateZone.aidl b/tests/test.aidl.sample/ClimateZone.aidl new file mode 100644 index 0000000..6ab715a --- /dev/null +++ b/tests/test.aidl.sample/ClimateZone.aidl @@ -0,0 +1,9 @@ +// Test fixture: AIDL parcelable for AIDL parser tests. + +package com.example.vehicle; + +parcelable ClimateZone { + int zoneId; + float targetTemp; + boolean active; +} diff --git a/tests/test.aidl.sample/IClimateControl.aidl b/tests/test.aidl.sample/IClimateControl.aidl new file mode 100644 index 0000000..b98ef85 --- /dev/null +++ b/tests/test.aidl.sample/IClimateControl.aidl @@ -0,0 +1,10 @@ +// Test fixture: AIDL interface for AIDL parser tests. +// Mirrors the ClimateControl example in input.yaml. + +package com.example.vehicle; + +interface IClimateControl { + void setTemperature(in int zone, in float temperature, out boolean success); + float getTemperature(in int zone); + oneway void onTemperatureChanged(in int zone, in float newTemperature); +} diff --git a/tests/test.aidl.sample/input.yaml b/tests/test.aidl.sample/input.yaml new file mode 100644 index 0000000..907f366 --- /dev/null +++ b/tests/test.aidl.sample/input.yaml @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# Minimal IFEX file used as fixture for AIDL output filter tests. + +name: aidl_sample +major_version: 1 +minor_version: 0 + +namespaces: + - name: com.example.vehicle + + interface: + name: ClimateControl + description: Controls vehicle climate settings + + methods: + - name: setTemperature + description: Set the cabin temperature + input: + - name: zone + datatype: int32 + - name: temperature + datatype: float + output: + - name: success + datatype: boolean + + - name: getTemperature + description: Get the current temperature + input: + - name: zone + datatype: int32 + returns: + - name: temperature + datatype: float + + events: + - name: onTemperatureChanged + description: Fired when temperature changes + input: + - name: zone + datatype: int32 + - name: newTemperature + datatype: float + + structs: + - name: ClimateZone + description: Represents a climate zone configuration + members: + - name: zoneId + datatype: int32 + - name: targetTemp + datatype: float + - name: active + datatype: boolean + + enumerations: + - name: AirflowMode + datatype: int32 + options: + - name: AUTO + value: 0 + - name: MANUAL + value: 1 + - name: MAX_COOL + value: 2 diff --git a/tests/test_aidl.py b/tests/test_aidl.py new file mode 100644 index 0000000..73e45fc --- /dev/null +++ b/tests/test_aidl.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +""" +Tests for the AIDL output filter. + +Covers: + 1. AIDL AST model construction + 2. IFEX → AIDL transformation (unit test on AST structure) + 3. AIDL → text rendering (checks generated .aidl syntax) + 4. Entrypoint smoke test +""" + +import os +import sys +import pytest + +from ifex.models.ifex.ifex_parser import get_ast_from_yaml_file +from ifex.output_filters.aidl.ifex_to_aidl import ifex_to_aidl, translate_type +from ifex.output_filters.aidl.aidl_generator import aidl_file_to_text, aidl_to_text +from ifex.models.aidl.aidl_ast import ( + AIDLFile, Interface, Parcelable, Enum, Method, Parameter, ParcelableField, EnumElement +) + +TEST_DIR = os.path.dirname(os.path.realpath(__file__)) +SAMPLE_IFEX = os.path.join(TEST_DIR, "test.aidl.sample", "input.yaml") + + +# --------------------------------------------------------------------------- +# 1. Type translation +# --------------------------------------------------------------------------- + +def test_translate_type_primitives(): + assert translate_type("int32") == "int" + assert translate_type("int64") == "long" + assert translate_type("float") == "float" + assert translate_type("double") == "double" + assert translate_type("boolean") == "boolean" + assert translate_type("string") == "String" + assert translate_type("uint8") == "byte" + assert translate_type("int16") == "short" + +def test_translate_type_array(): + assert translate_type("int32[]") == "int[]" + assert translate_type("string[]") == "String[]" + +def test_translate_type_unknown_passthrough(): + assert translate_type("MyCustomType") == "MyCustomType" + + +# --------------------------------------------------------------------------- +# 2. IFEX → AIDL AST transformation +# --------------------------------------------------------------------------- + +def test_ifex_to_aidl_produces_three_files(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + # expect: IClimateControl.aidl, ClimateZone.aidl, AirflowMode.aidl + assert len(files) == 3 + +def test_ifex_to_aidl_interface_file(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface_file = next(f for f in files if isinstance(f.declaration, Interface)) + iface = iface_file.declaration + assert iface.name == "IClimateControl" + assert iface_file.package == "com.example.vehicle" + +def test_ifex_to_aidl_interface_methods(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface = next(f.declaration for f in files if isinstance(f.declaration, Interface)) + method_names = [m.name for m in iface.methods] + assert "setTemperature" in method_names + assert "getTemperature" in method_names + assert "onTemperatureChanged" in method_names + +def test_ifex_to_aidl_method_return_type(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface = next(f.declaration for f in files if isinstance(f.declaration, Interface)) + get_temp = next(m for m in iface.methods if m.name == "getTemperature") + assert get_temp.return_type == "float" + +def test_ifex_to_aidl_method_void_return(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface = next(f.declaration for f in files if isinstance(f.declaration, Interface)) + set_temp = next(m for m in iface.methods if m.name == "setTemperature") + assert set_temp.return_type == "void" + +def test_ifex_to_aidl_event_is_oneway(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface = next(f.declaration for f in files if isinstance(f.declaration, Interface)) + event_method = next(m for m in iface.methods if m.name == "onTemperatureChanged") + assert event_method.oneway is True + assert event_method.return_type == "void" + +def test_ifex_to_aidl_parameter_directions(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface = next(f.declaration for f in files if isinstance(f.declaration, Interface)) + set_temp = next(m for m in iface.methods if m.name == "setTemperature") + param_names = {p.name: p.direction for p in set_temp.parameters} + assert param_names["zone"] == "in" + assert param_names["temperature"] == "in" + assert param_names["success"] == "out" + +def test_ifex_to_aidl_parcelable(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + parcelable_file = next(f for f in files if isinstance(f.declaration, Parcelable)) + p = parcelable_file.declaration + assert p.name == "ClimateZone" + field_names = [f.name for f in p.fields] + assert "zoneId" in field_names + assert "targetTemp" in field_names + assert "active" in field_names + +def test_ifex_to_aidl_enum(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + enum_file = next(f for f in files if isinstance(f.declaration, Enum)) + e = enum_file.declaration + assert e.name == "AirflowMode" + element_names = [el.name for el in e.elements] + assert "AUTO" in element_names + assert "MANUAL" in element_names + assert "MAX_COOL" in element_names + + +# --------------------------------------------------------------------------- +# 3. AIDL → text rendering +# --------------------------------------------------------------------------- + +def test_aidl_interface_text(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface_file = next(f for f in files if isinstance(f.declaration, Interface)) + text = aidl_file_to_text(iface_file) + assert "package com.example.vehicle;" in text + assert "interface IClimateControl {" in text + assert "getTemperature" in text + assert "oneway" in text # for the event method + +def test_aidl_parcelable_text(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + parcelable_file = next(f for f in files if isinstance(f.declaration, Parcelable)) + text = aidl_file_to_text(parcelable_file) + assert "parcelable ClimateZone {" in text + assert "int zoneId;" in text + assert "float targetTemp;" in text + +def test_aidl_enum_text(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + enum_file = next(f for f in files if isinstance(f.declaration, Enum)) + text = aidl_file_to_text(enum_file) + assert "enum AirflowMode {" in text + assert "AUTO" in text + assert "MANUAL" in text + +def test_aidl_method_in_out_params_text(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + iface_file = next(f for f in files if isinstance(f.declaration, Interface)) + text = aidl_file_to_text(iface_file) + assert "in int zone" in text + assert "out boolean success" in text + +def test_aidl_combined_output(): + ast = get_ast_from_yaml_file(SAMPLE_IFEX) + files = ifex_to_aidl(ast) + text = aidl_to_text(files) + assert "IClimateControl.aidl" in text + assert "ClimateZone.aidl" in text + assert "AirflowMode.aidl" in text + + +# --------------------------------------------------------------------------- +# 4. Entrypoint smoke test +# --------------------------------------------------------------------------- + +def test_ifex_to_aidl_entrypoint(monkeypatch, capsys): + from distribution.entrypoints.ifex_to_aidl import ifex_to_aidl_run + monkeypatch.setattr(sys, "argv", ["ifex_to_aidl", SAMPLE_IFEX]) + ifex_to_aidl_run() + output = capsys.readouterr().out + assert "ERROR" not in output + assert "interface IClimateControl" in output + assert "parcelable ClimateZone" in output + assert "enum AirflowMode" in output diff --git a/tests/test_aidl_parser.py b/tests/test_aidl_parser.py new file mode 100644 index 0000000..8a0452f --- /dev/null +++ b/tests/test_aidl_parser.py @@ -0,0 +1,470 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +# Comprehensive tests of the AIDL parser. These are mostly AI-generated and +# have only moderate value for proving the parser, but since they cover a lot +# of different features they are likely to at least pick up regressions. + +# FIXME These are also quite inefficient because they will re-parse the same +# input file for each small test (but the files are small also) + +import os +import sys +import pytest + +from ifex.models.aidl.aidl_lark import get_ast_from_aidl_file, parse_text +from ifex.models.aidl.aidl_ast import ( + AIDLFile, Interface, Parcelable, Enum, + Method, Parameter, ParcelableField, EnumElement, Const, +) +from ifex.input_filters.aidl.aidl_to_ifex import aidl_to_ifex +import ifex.models.ifex.ifex_ast as ifex_ast + +TEST_DIR = os.path.dirname(os.path.realpath(__file__)) +SAMPLE_DIR = os.path.join(TEST_DIR, "test.aidl.sample") + +IFACE_FILE = os.path.join(SAMPLE_DIR, "IClimateControl.aidl") +PARCEL_FILE = os.path.join(SAMPLE_DIR, "ClimateZone.aidl") +ENUM_FILE = os.path.join(SAMPLE_DIR, "AirflowMode.aidl") + + +# --------------------------------------------------------------------------- +# 1. Grammar / parser — interface +# --------------------------------------------------------------------------- + +def test_parse_interface_returns_aidl_file(): + result = get_ast_from_aidl_file(IFACE_FILE) + assert isinstance(result, AIDLFile) + +def test_parse_interface_package(): + result = get_ast_from_aidl_file(IFACE_FILE) + assert result.package == "com.example.vehicle" + +def test_parse_interface_declaration_type(): + result = get_ast_from_aidl_file(IFACE_FILE) + assert isinstance(result.declaration, Interface) + +def test_parse_interface_name(): + result = get_ast_from_aidl_file(IFACE_FILE) + assert result.declaration.name == "IClimateControl" + +def test_parse_interface_method_count(): + result = get_ast_from_aidl_file(IFACE_FILE) + assert len(result.declaration.methods) == 3 + +def test_parse_interface_method_names(): + result = get_ast_from_aidl_file(IFACE_FILE) + names = [m.name for m in result.declaration.methods] + assert "setTemperature" in names + assert "getTemperature" in names + assert "onTemperatureChanged" in names + +def test_parse_interface_oneway_method(): + result = get_ast_from_aidl_file(IFACE_FILE) + event = next(m for m in result.declaration.methods if m.name == "onTemperatureChanged") + assert event.oneway is True + assert event.return_type == "void" + +def test_parse_interface_non_oneway_method(): + result = get_ast_from_aidl_file(IFACE_FILE) + method = next(m for m in result.declaration.methods if m.name == "setTemperature") + assert method.oneway is False + +def test_parse_interface_return_type(): + result = get_ast_from_aidl_file(IFACE_FILE) + get_temp = next(m for m in result.declaration.methods if m.name == "getTemperature") + assert get_temp.return_type == "float" + +def test_parse_interface_void_return(): + result = get_ast_from_aidl_file(IFACE_FILE) + set_temp = next(m for m in result.declaration.methods if m.name == "setTemperature") + assert set_temp.return_type == "void" + +def test_parse_interface_parameter_count(): + result = get_ast_from_aidl_file(IFACE_FILE) + set_temp = next(m for m in result.declaration.methods if m.name == "setTemperature") + assert len(set_temp.parameters) == 3 + +def test_parse_interface_parameter_directions(): + result = get_ast_from_aidl_file(IFACE_FILE) + set_temp = next(m for m in result.declaration.methods if m.name == "setTemperature") + directions = {p.name: p.direction for p in set_temp.parameters} + assert directions["zone"] == "in" + assert directions["temperature"] == "in" + assert directions["success"] == "out" + +def test_parse_interface_parameter_types(): + result = get_ast_from_aidl_file(IFACE_FILE) + set_temp = next(m for m in result.declaration.methods if m.name == "setTemperature") + types = {p.name: p.datatype for p in set_temp.parameters} + assert types["zone"] == "int" + assert types["temperature"] == "float" + assert types["success"] == "boolean" + + +# --------------------------------------------------------------------------- +# 2. Grammar / parser — parcelable +# --------------------------------------------------------------------------- + +def test_parse_parcelable_returns_aidl_file(): + result = get_ast_from_aidl_file(PARCEL_FILE) + assert isinstance(result, AIDLFile) + +def test_parse_parcelable_package(): + result = get_ast_from_aidl_file(PARCEL_FILE) + assert result.package == "com.example.vehicle" + +def test_parse_parcelable_declaration_type(): + result = get_ast_from_aidl_file(PARCEL_FILE) + assert isinstance(result.declaration, Parcelable) + +def test_parse_parcelable_name(): + result = get_ast_from_aidl_file(PARCEL_FILE) + assert result.declaration.name == "ClimateZone" + +def test_parse_parcelable_field_count(): + result = get_ast_from_aidl_file(PARCEL_FILE) + assert len(result.declaration.fields) == 3 + +def test_parse_parcelable_field_names(): + result = get_ast_from_aidl_file(PARCEL_FILE) + names = [f.name for f in result.declaration.fields] + assert "zoneId" in names + assert "targetTemp" in names + assert "active" in names + +def test_parse_parcelable_field_types(): + result = get_ast_from_aidl_file(PARCEL_FILE) + types = {f.name: f.datatype for f in result.declaration.fields} + assert types["zoneId"] == "int" + assert types["targetTemp"] == "float" + assert types["active"] == "boolean" + + +# --------------------------------------------------------------------------- +# 3. Grammar / parser — enum +# --------------------------------------------------------------------------- + +def test_parse_enum_returns_aidl_file(): + result = get_ast_from_aidl_file(ENUM_FILE) + assert isinstance(result, AIDLFile) + +def test_parse_enum_package(): + result = get_ast_from_aidl_file(ENUM_FILE) + assert result.package == "com.example.vehicle" + +def test_parse_enum_declaration_type(): + result = get_ast_from_aidl_file(ENUM_FILE) + assert isinstance(result.declaration, Enum) + +def test_parse_enum_name(): + result = get_ast_from_aidl_file(ENUM_FILE) + assert result.declaration.name == "AirflowMode" + +def test_parse_enum_element_count(): + result = get_ast_from_aidl_file(ENUM_FILE) + assert len(result.declaration.elements) == 3 + +def test_parse_enum_element_names(): + result = get_ast_from_aidl_file(ENUM_FILE) + names = [e.name for e in result.declaration.elements] + assert "AUTO" in names + assert "MANUAL" in names + assert "MAX_COOL" in names + +def test_parse_enum_element_values(): + result = get_ast_from_aidl_file(ENUM_FILE) + vals = {e.name: e.value for e in result.declaration.elements} + assert vals["AUTO"] == "0" + assert vals["MANUAL"] == "1" + assert vals["MAX_COOL"] == "2" + + +# --------------------------------------------------------------------------- +# 4. parse_text — inline text parsing +# --------------------------------------------------------------------------- + +def test_parse_text_minimal_interface(): + src = """ +package com.test; +interface IFoo { + void doSomething(in int x); +} +""" + result = parse_text(src) + assert isinstance(result.declaration, Interface) + assert result.declaration.name == "IFoo" + assert result.package == "com.test" + assert result.declaration.oneway == False + +def test_parse_text_minimal_parcelable(): + src = """ +package com.test; +parcelable Bar { + String name; +} +""" + result = parse_text(src) + assert isinstance(result.declaration, Parcelable) + assert result.declaration.name == "Bar" + assert result.declaration.fields[0].name == "name" + assert result.declaration.fields[0].datatype == "String" + +def test_parse_text_minimal_enum(): + src = """ +package com.test; +enum Status { + OK = 0, + ERR = 1, +} +""" + result = parse_text(src) + assert isinstance(result.declaration, Enum) + assert result.declaration.name == "Status" + assert len(result.declaration.elements) == 2 + +def test_parse_text_with_import(): + src = """ +package com.test; +import com.other.Baz; +interface IFoo { + void run(); +} +""" + result = parse_text(src) + assert result.imports is not None + assert len(result.imports) == 1 + assert result.imports[0].path == "com.other.Baz" + +def test_parse_text_const_in_interface(): + src = """ +package com.test; +interface IFoo { + const int MAX_RETRIES = 3; + void retry(); +} +""" + result = parse_text(src) + iface = result.declaration + assert iface.consts is not None + assert iface.consts[0].name == "MAX_RETRIES" + assert iface.consts[0].datatype == "int" + assert iface.consts[0].value == "3" + +def test_parse_text_comment_stripping(): + src = """ +/* File header comment */ +package com.test; // inline comment +interface IBar { // another comment + // method comment + void run(); +} +""" + result = parse_text(src) + assert result.declaration.name == "IBar" + +def test_parse_text_array_type(): + src = """ +package com.test; +parcelable DataBlob { + byte[] data; +} +""" + result = parse_text(src) + assert result.declaration.fields[0].datatype == "byte[]" + +def test_parse_text_inout_param(): + src = """ +package com.test; +interface IFoo { + void process(inout int value); +} +""" + result = parse_text(src) + param = result.declaration.methods[0].parameters[0] + assert param.direction == "inout" + assert param.name == "value" + +def test_parse_text_enum_no_values(): + src = """ +package com.test; +enum Color { + RED, + GREEN, + BLUE, +} +""" + result = parse_text(src) + assert isinstance(result.declaration, Enum) + names = [e.name for e in result.declaration.elements] + assert names == ["RED", "GREEN", "BLUE"] + # Values should all be None since no = assignment + for e in result.declaration.elements: + assert e.value is None + +def test_parse_text_oneway_interface(): + src = """ +package com.test; +oneway interface INotifier { + void notify(in int code); +} +""" + result = parse_text(src) + iface = result.declaration + assert iface.oneway is True + + +# --------------------------------------------------------------------------- +# 5. AIDL → IFEX conversion +# --------------------------------------------------------------------------- + +def test_aidl_to_ifex_interface_namespace(): + aidl = get_ast_from_aidl_file(IFACE_FILE) + result = aidl_to_ifex(aidl) + assert len(result.namespaces) == 1 + assert result.namespaces[0].name == "com.example.vehicle" + +def test_aidl_to_ifex_interface_name(): + aidl = get_ast_from_aidl_file(IFACE_FILE) + result = aidl_to_ifex(aidl) + ns = result.namespaces[0] + assert ns.interface is not None + assert ns.interface.name == "ClimateControl" # "I" prefix stripped + +def test_aidl_to_ifex_interface_methods(): + aidl = get_ast_from_aidl_file(IFACE_FILE) + result = aidl_to_ifex(aidl) + iface = result.namespaces[0].interface + method_names = [m.name for m in (iface.methods or [])] + assert "setTemperature" in method_names + assert "getTemperature" in method_names + +def test_aidl_to_ifex_event(): + aidl = get_ast_from_aidl_file(IFACE_FILE) + result = aidl_to_ifex(aidl) + iface = result.namespaces[0].interface + event_names = [e.name for e in (iface.events or [])] + assert "onTemperatureChanged" in event_names + +def test_aidl_to_ifex_method_input_output(): + aidl = get_ast_from_aidl_file(IFACE_FILE) + result = aidl_to_ifex(aidl) + iface = result.namespaces[0].interface + set_temp = next(m for m in iface.methods if m.name == "setTemperature") + input_names = [a.name for a in (set_temp.input or [])] + output_names = [a.name for a in (set_temp.output or [])] + assert "zone" in input_names + assert "temperature" in input_names + assert "success" in output_names + +def test_aidl_to_ifex_method_returns(): + aidl = get_ast_from_aidl_file(IFACE_FILE) + result = aidl_to_ifex(aidl) + iface = result.namespaces[0].interface + get_temp = next(m for m in iface.methods if m.name == "getTemperature") + assert get_temp.returns is not None + assert get_temp.returns[0].datatype == "float" + +def test_aidl_to_ifex_parcelable_struct(): + aidl = get_ast_from_aidl_file(PARCEL_FILE) + result = aidl_to_ifex(aidl) + ns = result.namespaces[0] + assert ns.structs is not None + assert ns.structs[0].name == "ClimateZone" + +def test_aidl_to_ifex_parcelable_members(): + aidl = get_ast_from_aidl_file(PARCEL_FILE) + result = aidl_to_ifex(aidl) + struct = result.namespaces[0].structs[0] + member_names = [m.name for m in (struct.members or [])] + assert "zoneId" in member_names + assert "targetTemp" in member_names + assert "active" in member_names + +def test_aidl_to_ifex_parcelable_member_types(): + aidl = get_ast_from_aidl_file(PARCEL_FILE) + result = aidl_to_ifex(aidl) + struct = result.namespaces[0].structs[0] + types = {m.name: m.datatype for m in (struct.members or [])} + assert types["zoneId"] == "int32" + assert types["targetTemp"] == "float" + assert types["active"] == "boolean" + +def test_aidl_to_ifex_enum(): + aidl = get_ast_from_aidl_file(ENUM_FILE) + result = aidl_to_ifex(aidl) + ns = result.namespaces[0] + assert ns.enumerations is not None + assert ns.enumerations[0].name == "AirflowMode" + +def test_aidl_to_ifex_enum_datatype(): + aidl = get_ast_from_aidl_file(ENUM_FILE) + result = aidl_to_ifex(aidl) + enum = result.namespaces[0].enumerations[0] + assert enum.datatype == "int32" + +def test_aidl_to_ifex_enum_options(): + aidl = get_ast_from_aidl_file(ENUM_FILE) + result = aidl_to_ifex(aidl) + enum = result.namespaces[0].enumerations[0] + option_names = [o.name for o in (enum.options or [])] + assert "AUTO" in option_names + assert "MANUAL" in option_names + assert "MAX_COOL" in option_names + +def test_aidl_to_ifex_type_translation(): + src = """ +package com.test; +parcelable Types { + int a; + long b; + float c; + double d; + boolean e; + String f; + byte g; + short h; +} +""" + aidl = parse_text(src) + result = aidl_to_ifex(aidl) + struct = result.namespaces[0].structs[0] + types = {m.name: m.datatype for m in struct.members} + assert types["a"] == "int32" + assert types["b"] == "int64" + assert types["c"] == "float" + assert types["d"] == "double" + assert types["e"] == "boolean" + assert types["f"] == "string" + assert types["g"] == "uint8" + assert types["h"] == "int16" + + +# --------------------------------------------------------------------------- +# 6. Entrypoint smoke test +# --------------------------------------------------------------------------- + +def test_aidl_to_ifex_entrypoint(monkeypatch, capsys): + from distribution.entrypoints.aidl_to_ifex import aidl_to_ifex_run + monkeypatch.setattr(sys, "argv", ["aidl_to_ifex", IFACE_FILE]) + aidl_to_ifex_run() + output = capsys.readouterr().out + assert "ERROR" not in output + assert "ClimateControl" in output # interface name (I prefix stripped) + assert "com.example.vehicle" in output + +def test_aidl_to_ifex_entrypoint_parcelable(monkeypatch, capsys): + from distribution.entrypoints.aidl_to_ifex import aidl_to_ifex_run + monkeypatch.setattr(sys, "argv", ["aidl_to_ifex", PARCEL_FILE]) + aidl_to_ifex_run() + output = capsys.readouterr().out + assert "ERROR" not in output + assert "ClimateZone" in output + +def test_aidl_to_ifex_entrypoint_enum(monkeypatch, capsys): + from distribution.entrypoints.aidl_to_ifex import aidl_to_ifex_run + monkeypatch.setattr(sys, "argv", ["aidl_to_ifex", ENUM_FILE]) + aidl_to_ifex_run() + output = capsys.readouterr().out + assert "ERROR" not in output + assert "AirflowMode" in output