diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 018dce9..8711ab3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -53,7 +53,7 @@ body: description: Provide a minimal C code example that demonstrates the issue render: c placeholder: | - #include "J2735_macros.h" + #include "J2735_api.h" int main(void) { uint8_t buf[] = { 0x15, 0xBD, ... }; diff --git a/.github/instructions/j2735_design.instructions.md b/.github/instructions/j2735_design.instructions.md index 54e1c0b..716f1d8 100644 --- a/.github/instructions/j2735_design.instructions.md +++ b/.github/instructions/j2735_design.instructions.md @@ -18,7 +18,7 @@ The library splits the concept of a "Message" into two layers to map high-level - Use "Container Structs" (byte arrays) only for pointer sizing, not for member access. - **Anti-Pattern**: Do not define standard C structs with members for data access. -### 2. The Access Layer (`J2735_macros.h`) +### 2. The Access Layer (`J2735_internal_common.h`) - **Concept**: Acts as the optical lens to read the data. - **Usage**: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9f5b5b..64b50bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: write pull-requests: write diff --git a/.github/workflows/pr-compliance.yml b/.github/workflows/pr-compliance.yml index b0e10e1..b56c539 100644 --- a/.github/workflows/pr-compliance.yml +++ b/.github/workflows/pr-compliance.yml @@ -32,6 +32,10 @@ on: - reopened - synchronize +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + # Security: Minimal permissions - read PR, write status checks permissions: pull-requests: read diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9877565..ae18c9d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -55,6 +55,10 @@ on: - 'tools/**' - '.github/workflows/python.yml' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: write pull-requests: write diff --git a/tools/j2735_asn1_constants.py b/tools/j2735_asn1_constants.py new file mode 100644 index 0000000..25bf155 --- /dev/null +++ b/tools/j2735_asn1_constants.py @@ -0,0 +1,38 @@ +# Copyright 2026 Yogev Neumann +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 Yogev Neumann +""" +ASN.1 language constants shared across J2735 parsing modules. + +Central repository for ASN.1 syntax tokens and patterns +used throughout the J2735 toolchain. +""" + +from re import DOTALL, MULTILINE, Pattern +from re import compile as re_compile +from typing import Final + +# ASN.1 syntax tokens +ASN1_COMMENT_PREFIX: Final[str] = "--" +ASN1_EXTENSION_MARKER: Final[str] = "..." +ASN1_FIELD_SEPARATOR: Final[str] = "," +ASN1_OPTIONAL_KEYWORD: Final[str] = "OPTIONAL" +ASN1_SEQUENCE_KEYWORD: Final[str] = "SEQUENCE" + +# ASN.1 type definition patterns +# Note: [\w-]+ allows hyphens in type names (e.g., Offset-B10, OffsetLL-B12) +ASN1_TYPE_DEF_PATTERN: Final[Pattern[str]] = re_compile(r"^([\w-]+)\s*::=\s*(.+)$", MULTILINE) +ASN1_TYPE_DEF_DOTALL_PATTERN: Final[Pattern[str]] = re_compile(r"([\w-]+)\s*::=\s*(.+)", DOTALL) diff --git a/tools/j2735_c_generator_bitwidth_constants.py b/tools/j2735_c_generator_bitwidth_constants.py index a479829..dfc3173 100644 --- a/tools/j2735_c_generator_bitwidth_constants.py +++ b/tools/j2735_c_generator_bitwidth_constants.py @@ -30,7 +30,7 @@ """ from .j2735_c_generator_jinja import create_jinja_env, get_template -from .j2735_spec_parser import ASN1TypeDefinition, J2735Specification +from .j2735_spec_parser import J2735Specification _BITWIDTH_TEMPLATE_NAME = "bitwidth_constants.j2" @@ -58,15 +58,7 @@ def generate_bitwidth_constants(spec: J2735Specification) -> str: >>> "7U" in code True """ - # Collect types with fixed bit-widths - fixed_types: list[ASN1TypeDefinition] = [] - variable_count = 0 - - for _, typedef in sorted(spec.type_registry.items()): - if typedef.uper_bit_width is not None: - fixed_types.append(typedef) - else: - variable_count += 1 + fixed_types, variable_count = spec.collect_fixed_width_types() env = create_jinja_env() template = get_template(env, _BITWIDTH_TEMPLATE_NAME) diff --git a/tools/j2735_c_generator_size_constants.py b/tools/j2735_c_generator_size_constants.py index 4774014..6f4051d 100644 --- a/tools/j2735_c_generator_size_constants.py +++ b/tools/j2735_c_generator_size_constants.py @@ -31,7 +31,7 @@ """ from .j2735_c_generator_jinja import create_jinja_env, get_template -from .j2735_spec_parser import ASN1TypeDefinition, J2735Specification +from .j2735_spec_parser import J2735Specification _TEMPLATE_NAME = "size_constants.j2" @@ -57,12 +57,7 @@ def generate_size_constants(spec: J2735Specification) -> str: >>> "37U" in code # 290 bits -> 37 bytes True """ - # Collect types with fixed bit-widths (same as bitwidth generator) - fixed_types: list[ASN1TypeDefinition] = [] - - for _, typedef in sorted(spec.type_registry.items()): - if typedef.uper_bit_width is not None: - fixed_types.append(typedef) + fixed_types, _ = spec.collect_fixed_width_types() env = create_jinja_env() template = get_template(env, _TEMPLATE_NAME) diff --git a/tools/j2735_spec_constraints.py b/tools/j2735_spec_constraints.py index ff78146..15dbeba 100644 --- a/tools/j2735_spec_constraints.py +++ b/tools/j2735_spec_constraints.py @@ -38,17 +38,19 @@ from re import compile as re_compile from typing import Annotated, ClassVar, Final, Self +from .j2735_asn1_constants import ( + ASN1_COMMENT_PREFIX, + ASN1_EXTENSION_MARKER, + ASN1_FIELD_SEPARATOR, + ASN1_OPTIONAL_KEYWORD, + ASN1_SEQUENCE_KEYWORD, +) + # These type aliases document constraints that are validated at runtime # via __post_init__. They help communicate intent to readers and tools. _PositiveInt = Annotated[int, "Value must be >= 1"] _NonNegativeInt = Annotated[int, "Value must be >= 0"] -# ASN.1 parsing constants -_ASN1_COMMENT_PREFIX: Final[str] = "--" -_ASN1_EXTENSION_MARKER: Final[str] = "..." -_ASN1_FIELD_SEPARATOR: Final[str] = "," -_ASN1_OPTIONAL_KEYWORD: Final[str] = "OPTIONAL" -_ASN1_SEQUENCE_KEYWORD: Final[str] = "SEQUENCE" _BITS_PER_BYTE: Final[int] = 8 # 8 bits per byte/octet in OCTET STRING # J2735-specific constants @@ -560,7 +562,7 @@ def from_asn1(cls, raw_def: str) -> Self | None: return None body = match.group(1) - is_extensible = _ASN1_EXTENSION_MARKER in body + is_extensible = ASN1_EXTENSION_MARKER in body # Parse individual alternatives alternatives: dict[str, str] = {} @@ -569,9 +571,7 @@ def from_asn1(cls, raw_def: str) -> Self | None: type_ref = alt_match.group(2).strip() # Skip J2735 regional extension fields (SEQUENCE OF RegionalExtension) # These are variable-length and would break fixed bit-width calculation - if name != _J2735_REGIONAL_FIELD_NAME or not type_ref.startswith( - _ASN1_SEQUENCE_KEYWORD - ): + if name != _J2735_REGIONAL_FIELD_NAME or not type_ref.startswith(ASN1_SEQUENCE_KEYWORD): alternatives[name] = type_ref if not alternatives: @@ -836,7 +836,7 @@ def from_asn1(cls, raw_def: str) -> Self | None: # Assign values in document order values = cls._assign_values(all_items) - return cls(values=values, is_extensible=_ASN1_EXTENSION_MARKER in body) + return cls(values=values, is_extensible=ASN1_EXTENSION_MARKER in body) @dataclass(frozen=True, kw_only=True, slots=True) @@ -1293,28 +1293,28 @@ def from_asn1(cls, raw_def: str) -> tuple[Self, ...]: continue # Check if this is a standalone comment line (starts with --) - if line.startswith(_ASN1_COMMENT_PREFIX): + if line.startswith(ASN1_COMMENT_PREFIX): pending_section_comment = line[ - len(_ASN1_COMMENT_PREFIX) : # noqa: E203 # Black VS flake8 + len(ASN1_COMMENT_PREFIX) : # noqa: E203 # Black VS flake8 ].strip() continue # Extract inline comment from end of line line_inline_comment: str = "" - comment_pos = line.find(_ASN1_COMMENT_PREFIX) + comment_pos = line.find(ASN1_COMMENT_PREFIX) if comment_pos != -1: line_inline_comment = line[ - comment_pos + len(_ASN1_COMMENT_PREFIX) : # noqa: E203 # Black VS flake8 + comment_pos + len(ASN1_COMMENT_PREFIX) : # noqa: E203 # Black VS flake8 ].strip() line = line[:comment_pos].strip() # Split line by field separator to get individual fields - parts = [p.strip() for p in line.split(_ASN1_FIELD_SEPARATOR) if p.strip()] + parts = [p.strip() for p in line.split(ASN1_FIELD_SEPARATOR) if p.strip()] # Process each field part for index, part in enumerate(parts): # Skip extension markers - if part == _ASN1_EXTENSION_MARKER: + if part == ASN1_EXTENSION_MARKER: continue # Parse "fieldName TypeName OPTIONAL" or "fieldName TypeName" @@ -1322,7 +1322,7 @@ def from_asn1(cls, raw_def: str) -> tuple[Self, ...]: if len(tokens) < 2: continue - is_optional = _ASN1_OPTIONAL_KEYWORD in tokens[2:] if len(tokens) > 2 else False + is_optional = ASN1_OPTIONAL_KEYWORD in tokens[2:] if len(tokens) > 2 else False # Inline comment only applies to the LAST field on the line inline_comment = line_inline_comment if index == len(parts) - 1 else "" @@ -1685,12 +1685,12 @@ def from_asn1(cls, raw_def: str) -> Self | None: True """ # Must start with SEQUENCE keyword (not CHOICE, etc.) - if not raw_def.strip().startswith(_ASN1_SEQUENCE_KEYWORD): + if not raw_def.strip().startswith(ASN1_SEQUENCE_KEYWORD): return None fields = SequenceField.from_asn1(raw_def) if not fields: return None - is_extensible = _ASN1_EXTENSION_MARKER in raw_def + is_extensible = ASN1_EXTENSION_MARKER in raw_def return cls(fields=fields, is_extensible=is_extensible) diff --git a/tools/j2735_spec_parser.py b/tools/j2735_spec_parser.py index 7ba2917..e4f91a7 100644 --- a/tools/j2735_spec_parser.py +++ b/tools/j2735_spec_parser.py @@ -33,6 +33,11 @@ from re import compile as re_compile from typing import Final, Self +from .j2735_asn1_constants import ( + ASN1_COMMENT_PREFIX, + ASN1_TYPE_DEF_DOTALL_PATTERN, + ASN1_TYPE_DEF_PATTERN, +) from .j2735_spec_constraints import ( BitStringConstraint, BooleanType, @@ -51,9 +56,6 @@ # Constants - ASN.1 Language Elements # ============================================================================= -# ASN.1 syntax tokens -_ASN1_COMMENT_PREFIX: Final[str] = "--" - # Parser constants _COMMENT_BIT_KEYWORD: Final[str] = "bit" _COMMENT_SIZE_KEYWORD: Final[str] = "size" @@ -66,11 +68,6 @@ # Section marker pattern (e.g., "") _SECTION_MARKER_PATTERN: Final[Pattern[str]] = re_compile(r"^$") -# ASN.1 type definition patterns -# Note: [\w-]+ allows hyphens in type names (e.g., Offset-B10, OffsetLL-B12) -_ASN1_TYPE_DEF_PATTERN: Final[Pattern[str]] = re_compile(r"^([\w-]+)\s*::=\s*(.+)$", MULTILINE) -_ASN1_TYPE_DEF_DOTALL_PATTERN: Final[Pattern[str]] = re_compile(r"([\w-]+)\s*::=\s*(.+)", DOTALL) - # SEQUENCE OF pattern _SEQUENCE_OF_PATTERN: Final[Pattern[str]] = re_compile( r"SEQUENCE\s*\(\s*SIZE\s*\(\s*(\d+)\s*\.\.\s*(\d+)\s*\)\s*\)\s*OF\s+(\w+)" @@ -344,6 +341,128 @@ class SpecEntry: remarks: str # TODO: Inspect if still unused line_number: int | None # TODO: Inspect if still unused + # ----------------------------------------------------------------- + # Block Parsing Helpers + # ----------------------------------------------------------------- + + @staticmethod + def _extract_block_sections(block: str) -> tuple[str, str, str]: + """Extract Use, ASN.1 text, and Remarks from a spec block. + + Each specification block (Message, Data Frame, Data Element) contains + the same three optional sections. This helper applies the shared + regex extraction once, returning empty strings for missing sections. + + Args: + block: The raw text block for one specification entry. + + Returns: + A tuple of ``(use_description, asn1_text, remarks)``. + + >>> SpecEntry._extract_block_sections( + ... "Use: A simple counter.\\n" + ... "ASN.1 Representation:\\n" + ... "MsgCount ::= INTEGER (0..127)\\n" + ... "Remarks: Wraps at 127." + ... ) + ('A simple counter.', 'MsgCount ::= INTEGER (0..127)', 'Wraps at 127.') + >>> SpecEntry._extract_block_sections("No sections here.") + ('', '', '') + """ + use_match = _USE_BLOCK_PATTERN.search(block) + use_description = use_match.group(1).strip() if use_match else "" + + asn1_match = _ASN1_REPR_BLOCK_PATTERN.search(block) + asn1_text = asn1_match.group(1).strip() if asn1_match else "" + + remarks_match = _REMARKS_BLOCK_PATTERN.search(block) + remarks = remarks_match.group(1).strip() if remarks_match else "" + + return use_description, asn1_text, remarks + + @staticmethod + def _parse_asn1_multiline( + asn1_text: str, section_number: str, use_description: str + ) -> ASN1TypeDefinition | None: + """Parse ASN.1 text using multiline strategy (for Data Elements). + + Data Element definitions are single-line with optional continuation + lines. This strategy matches the first ``name ::= definition`` line, + then walks subsequent lines, appending non-comment content and + comments that contain encoding info (bit/size keywords). + + Args: + asn1_text: The raw ASN.1 representation text. + section_number: The spec section (e.g., "7.99"). + use_description: The Use: description for this entry. + + Returns: + The parsed type definition, or None if parsing fails. + """ + if not asn1_text: + return None + + type_def_match = ASN1_TYPE_DEF_PATTERN.search(asn1_text) + if not type_def_match: + return None + + type_body = type_def_match.group(2) + continuation_text = asn1_text[type_def_match.end() :] # noqa: E203 # Black VS flake8 + + for line in continuation_text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith(ASN1_COMMENT_PREFIX): + type_body += " " + stripped + elif stripped.startswith(ASN1_COMMENT_PREFIX): + if ( + _COMMENT_BIT_KEYWORD in stripped.lower() + or _COMMENT_SIZE_KEYWORD in stripped.lower() + ): + type_body += " " + stripped + + return ASN1TypeDefinition.from_asn1( + type_def_match.group(1), + type_body, + spec_section=section_number, + description=use_description, + ) + + @staticmethod + def _parse_asn1_dotall( + asn1_text: str, section_number: str, use_description: str + ) -> ASN1TypeDefinition | None: + """Parse ASN.1 text using DOTALL strategy (for Data Frames/Messages). + + Frame and Message definitions span multiple lines with nested braces. + This strategy captures the full definition including newlines in a + single regex match. + + Args: + asn1_text: The raw ASN.1 representation text. + section_number: The spec section (e.g., "6.10"). + use_description: The Use: description for this entry. + + Returns: + The parsed type definition, or None if parsing fails. + """ + if not asn1_text: + return None + + type_def_match = ASN1_TYPE_DEF_DOTALL_PATTERN.search(asn1_text) + if not type_def_match: + return None + + return ASN1TypeDefinition.from_asn1( + type_def_match.group(1), + type_def_match.group(2).strip(), + spec_section=section_number, + description=use_description, + ) + + # ----------------------------------------------------------------- + # Classmethod Constructors + # ----------------------------------------------------------------- + @classmethod def from_data_element_block(cls, block: str, line_offset: int) -> Self | None: """Parse a single Data Element block from section 7. @@ -360,47 +479,7 @@ def from_data_element_block(cls, block: str, line_offset: int) -> Self | None: return None section_number = header_match.group(1) - - # Extract Use: description - use_match = _USE_BLOCK_PATTERN.search(block) - use_description = use_match.group(1).strip() if use_match else "" - - # Extract ASN.1 Representation - asn1_match = _ASN1_REPR_BLOCK_PATTERN.search(block) - - # Extract Remarks - remarks_match = _REMARKS_BLOCK_PATTERN.search(block) - - # Parse the ASN.1 definition - asn1_def = None - if asn1_text := asn1_match.group(1).strip() if asn1_match else "": - # Find the main type definition line - type_def_match = _ASN1_TYPE_DEF_PATTERN.search(asn1_text) - if type_def_match: - type_body = type_def_match.group(2) - - # For multi-line definitions, capture everything until comment-only lines - # Add continuation lines (those that aren't just comments) - for line in asn1_text[ - type_def_match.end() : # noqa: E203 # Black VS flake8 - ].splitlines(): - stripped = line.strip() - if stripped and not stripped.startswith(_ASN1_COMMENT_PREFIX): - type_body += " " + stripped - elif stripped.startswith(_ASN1_COMMENT_PREFIX): - # Keep comments that contain encoding info - if ( - _COMMENT_BIT_KEYWORD in stripped.lower() - or _COMMENT_SIZE_KEYWORD in stripped.lower() - ): - type_body += " " + stripped - - asn1_def = ASN1TypeDefinition.from_asn1( - type_def_match.group(1), - type_body, - spec_section=section_number, - description=use_description, - ) + use_description, asn1_text, remarks = cls._extract_block_sections(block) return cls( section_number=section_number, @@ -408,8 +487,8 @@ def from_data_element_block(cls, block: str, line_offset: int) -> Self | None: name=header_match.group(2), abbreviation="", use_description=use_description, - asn1_definition=asn1_def, - remarks=remarks_match.group(1).strip() if remarks_match else "", + asn1_definition=cls._parse_asn1_multiline(asn1_text, section_number, use_description), + remarks=remarks, line_number=line_offset, ) @@ -429,39 +508,15 @@ def from_data_frame_block(cls, block: str, line_offset: int) -> Self | None: return None section_number = header_match.group(1) - name = header_match.group(2) - - # Extract Use: description - use_match = _USE_BLOCK_PATTERN.search(block) - use_description = use_match.group(1).strip() if use_match else "" - - # Extract ASN.1 Representation - asn1_match = _ASN1_REPR_BLOCK_PATTERN.search(block) - asn1_text = asn1_match.group(1).strip() if asn1_match else "" - - # Extract Remarks - remarks_match = _REMARKS_BLOCK_PATTERN.search(block) - remarks = remarks_match.group(1).strip() if remarks_match else "" - - # Parse the ASN.1 definition - asn1_def = None - if asn1_text: - type_def_match = _ASN1_TYPE_DEF_DOTALL_PATTERN.search(asn1_text) - if type_def_match: - asn1_def = ASN1TypeDefinition.from_asn1( - type_def_match.group(1), - type_def_match.group(2).strip(), - spec_section=section_number, - description=use_description, - ) + use_description, asn1_text, remarks = cls._extract_block_sections(block) return cls( section_number=section_number, entry_type=J2735EntryKind.DATA_FRAMES, - name=name, + name=header_match.group(2), abbreviation="", use_description=use_description, - asn1_definition=asn1_def, + asn1_definition=cls._parse_asn1_dotall(asn1_text, section_number, use_description), remarks=remarks, line_number=line_offset, ) @@ -482,40 +537,15 @@ def from_message_block(cls, block: str, line_offset: int) -> Self | None: return None section_number = header_match.group(1) - name = header_match.group(2) - abbreviation = header_match.group(3) if header_match.group(3) else "" - - # Extract Use: description - use_match = _USE_BLOCK_PATTERN.search(block) - use_description = use_match.group(1).strip() if use_match else "" - - # Extract ASN.1 Representation - asn1_match = _ASN1_REPR_BLOCK_PATTERN.search(block) - asn1_text = asn1_match.group(1).strip() if asn1_match else "" - - # Extract Remarks - remarks_match = _REMARKS_BLOCK_PATTERN.search(block) - remarks = remarks_match.group(1).strip() if remarks_match else "" - - # Parse the ASN.1 definition - asn1_def = None - if asn1_text: - type_def_match = _ASN1_TYPE_DEF_DOTALL_PATTERN.search(asn1_text) - if type_def_match: - asn1_def = ASN1TypeDefinition.from_asn1( - type_def_match.group(1), - type_def_match.group(2).strip(), - spec_section=section_number, - description=use_description, - ) + use_description, asn1_text, remarks = cls._extract_block_sections(block) return cls( section_number=section_number, entry_type=J2735EntryKind.MESSAGES, - name=name, - abbreviation=abbreviation, + name=header_match.group(2), + abbreviation=header_match.group(3) or "", use_description=use_description, - asn1_definition=asn1_def, + asn1_definition=cls._parse_asn1_dotall(asn1_text, section_number, use_description), remarks=remarks, line_number=line_offset, ) @@ -645,6 +675,42 @@ class J2735Specification: data_elements: tuple[SpecEntry, ...] type_registry: Mapping[str, ASN1TypeDefinition] + def collect_fixed_width_types( + self, + ) -> tuple[list[ASN1TypeDefinition], int]: + """Collect all types with deterministic UPER bit-widths. + + Iterates ``type_registry`` in alphabetical order and partitions + types into those with a known fixed bit-width and those without. + + Returns: + A tuple of ``(fixed_types, variable_count)`` where *fixed_types* + is a sorted list of type definitions whose ``uper_bit_width`` is + not ``None``, and *variable_count* is the number of skipped + variable-width types. Returns ``([], 0)`` when the registry + is empty. + + Examples: + >>> from tools.tests.conftest import SPEC_FILE_PATH + >>> from tools.j2735_spec_parser import parse_spec_file + >>> spec = parse_spec_file(SPEC_FILE_PATH) + >>> fixed, variable = spec.collect_fixed_width_types() + >>> all(t.uper_bit_width is not None for t in fixed) + True + >>> variable >= 0 + True + """ + fixed_types: list[ASN1TypeDefinition] = [] + variable_count = 0 + + for _, typedef in sorted(self.type_registry.items()): + if typedef.uper_bit_width is not None: + fixed_types.append(typedef) + else: + variable_count += 1 + + return fixed_types, variable_count + def lookup_type(self, name: str) -> ASN1TypeDefinition | None: """Look up a type definition by name. diff --git a/tools/templates/sequence/sequence_size.j2 b/tools/templates/sequence/sequence_size.j2 index adf4690..cfa883a 100644 --- a/tools/templates/sequence/sequence_size.j2 +++ b/tools/templates/sequence/sequence_size.j2 @@ -35,7 +35,7 @@ Dependencies: - J2735_INTERNAL_ROOT_SIZE_BITS_ (from root_size generator) - J2735__HAS_EXTENSION (from has_extension generator) - - j2735_internal_inline_skip_extensions() (from J2735_macros.h) + - j2735_internal_inline_skip_extensions() (from J2735_internal_inline.h) -#} {%- set typedef_name = typedef.name -%} {%- set root_uper_bit_width = typedef.constraint.root_uper_bit_width -%} diff --git a/tools/tests/spec/test_spec_entry.py b/tools/tests/spec/test_spec_entry.py new file mode 100644 index 0000000..7809bc4 --- /dev/null +++ b/tools/tests/spec/test_spec_entry.py @@ -0,0 +1,468 @@ +# Copyright 2026 Yogev Neumann +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 Yogev Neumann +"""Tests for SpecEntry.from_*_block class methods. + +Tests cover the three block-parsing classmethods that convert raw +specification text blocks into SpecEntry instances: + - from_data_element_block (section 7, multiline ASN.1 strategy) + - from_data_frame_block (section 6, DOTALL ASN.1 strategy) + - from_message_block (section 5, DOTALL ASN.1 strategy + abbreviation) +""" + +from unittest import TestCase + +from tools.j2735_spec_parser import ( + ASN1TypeClass, + J2735EntryKind, + SpecEntry, +) + +# ============================================================================= +# Synthetic Blocks — Data Elements (Section 7) +# ============================================================================= + +_DE_FULL_BLOCK = """\ +7.99 Data Element: DE_MsgCount + +Use: A counter that increments each time a new message is generated. + +ASN.1 Representation: +MsgCount ::= INTEGER (0..127) + +Remarks: This is a simple counter. +""" + +_DE_MULTILINE_BLOCK = """\ +7.50 Data Element: DE_AllowedManeuvers + +Use: A BIT STRING defining allowed maneuvers at an intersection. + +ASN.1 Representation: +AllowedManeuvers ::= BIT STRING { + maneuverStraightAllowed (0), + maneuverLeftAllowed (1), + maneuverRightAllowed (2), + maneuverUTurnAllowed (3), + maneuverLeftTurnOnRedAllowed (4), + maneuverRightTurnOnRedAllowed (5), + maneuverLaneChangeAllowed (6), + maneuverNoStoppingAllowed (7), + yieldAllwaysRequired (8), + goWithHalt (9), + caution (10), + reserved1 (11) +} (SIZE(12)) + +Remarks: Bits map to maneuver permissions. +""" + +_DE_WITH_ENCODING_COMMENT_BLOCK = """\ +7.10 Data Element: DE_Elevation + +Use: The geographic elevation. + +ASN.1 Representation: +Elevation ::= INTEGER (-4096..61439) +-- In units of 10 cm steps above WGS-84 reference +-- size: 16 bits + +Remarks: Uses WGS-84 reference. +""" + +_DE_NO_USE_NO_REMARKS_BLOCK = """\ +7.77 Data Element: DE_SpeedAdvice + +ASN.1 Representation: +SpeedAdvice ::= INTEGER (0..500) +""" + +_DE_NO_ASN1_BLOCK = """\ +7.88 Data Element: DE_Mystery + +Use: This element has no ASN.1 definition yet. + +Remarks: Pending standardization. +""" + +_DE_INVALID_HEADER = "This is not a valid block at all." + + +# ============================================================================= +# Synthetic Blocks — Data Frames (Section 6) +# ============================================================================= + +_DF_FULL_BLOCK = """\ +6.10 Data Frame: DF_PathPrediction + +Use: Conveys a predicted path with a confidence value. + +ASN.1 Representation: +PathPrediction ::= SEQUENCE { + radiusOfCurve RadiusOfCurvature, + confidence Confidence +} + +Remarks: Used for path prediction in intersection safety. +""" + +_DF_NO_USE_NO_REMARKS_BLOCK = """\ +6.20 Data Frame: DF_SimpleFrame + +ASN.1 Representation: +SimpleFrame ::= SEQUENCE { + value INTEGER (0..255) +} +""" + +_DF_NO_ASN1_BLOCK = """\ +6.30 Data Frame: DF_EmptyFrame + +Use: A frame without ASN.1 definition. + +Remarks: Not yet defined. +""" + +_DF_INVALID_HEADER = "No data frame header here." + + +# ============================================================================= +# Synthetic Blocks — Messages (Section 5) +# ============================================================================= + +_MSG_WITH_ABBREVIATION_BLOCK = """\ +5.2 Message: MSG_BasicSafetyMessage (BSM) + +Use: A basic safety message broadcast by vehicles. + +ASN.1 Representation: +BasicSafetyMessage ::= SEQUENCE { + coreData BSMcoreData +} + +Remarks: Transmitted at 10 Hz. +""" + +_MSG_NO_ABBREVIATION_BLOCK = """\ +5.8 Message: MSG_ProbeDataManagement + +Use: Controls how probe data is collected. + +ASN.1 Representation: +ProbeDataManagement ::= SEQUENCE { + sample INTEGER (0..255) +} + +Remarks: Used by TMC. +""" + +_MSG_NO_USE_NO_REMARKS_BLOCK = """\ +5.15 Message: MSG_TestOnly + +ASN.1 Representation: +TestOnly ::= SEQUENCE { + value INTEGER (0..7) +} +""" + +_MSG_INVALID_HEADER = "Not a message block." + + +# ============================================================================= +# Test Cases — from_data_element_block +# ============================================================================= + + +class TestFromDataElementBlock(TestCase): + """Tests for SpecEntry.from_data_element_block.""" + + def test_returns_none_on_invalid_header(self) -> None: + """Returns None when block has no matching header.""" + result = SpecEntry.from_data_element_block(_DE_INVALID_HEADER, 1) + self.assertIsNone(result) + + def test_full_block_section_number(self) -> None: + """Extracts correct section number.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 100) + self.assertIsNotNone(entry) + assert entry is not None + self.assertEqual(entry.section_number, "7.99") + + def test_full_block_entry_type(self) -> None: + """Sets entry_type to DATA_ELEMENTS.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.entry_type, J2735EntryKind.DATA_ELEMENTS) + + def test_full_block_name(self) -> None: + """Extracts name without DE_ prefix.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.name, "MsgCount") + + def test_full_block_abbreviation_is_empty(self) -> None: + """Data Elements always have empty abbreviation.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.abbreviation, "") + + def test_full_block_use_description(self) -> None: + """Extracts Use: description text.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None + self.assertIn("counter", entry.use_description) + + def test_full_block_remarks(self) -> None: + """Extracts Remarks: text.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None + self.assertIn("simple counter", entry.remarks) + + def test_full_block_line_number(self) -> None: + """Stores the line offset.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 42) + assert entry is not None + self.assertEqual(entry.line_number, 42) + + def test_full_block_asn1_parsed(self) -> None: + """Parses simple INTEGER ASN.1 definition.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None + self.assertIsNotNone(entry.asn1_definition) + assert entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.name, "MsgCount") + self.assertEqual(entry.asn1_definition.type_class, ASN1TypeClass.INTEGER) + + def test_full_block_asn1_bit_width(self) -> None: + """MsgCount INTEGER (0..127) is 7 bits.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None and entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.uper_bit_width, 7) + + def test_multiline_bit_string_parsed(self) -> None: + """Multiline BIT STRING with named bits is correctly assembled.""" + entry = SpecEntry.from_data_element_block(_DE_MULTILINE_BLOCK, 1) + assert entry is not None + self.assertIsNotNone(entry.asn1_definition) + assert entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.name, "AllowedManeuvers") + self.assertEqual(entry.asn1_definition.type_class, ASN1TypeClass.BIT_STRING) + + def test_encoding_comment_preserved(self) -> None: + """Comments containing 'size' or 'bit' keywords are kept in type body.""" + entry = SpecEntry.from_data_element_block(_DE_WITH_ENCODING_COMMENT_BLOCK, 1) + assert entry is not None + self.assertIsNotNone(entry.asn1_definition) + assert entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.name, "Elevation") + # The "-- size: 16 bits" comment should be preserved in the raw definition + self.assertIn("size", entry.asn1_definition.raw_definition.lower()) + + def test_missing_use_and_remarks(self) -> None: + """Block without Use: and Remarks: sets empty strings.""" + entry = SpecEntry.from_data_element_block(_DE_NO_USE_NO_REMARKS_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.use_description, "") + self.assertEqual(entry.remarks, "") + + def test_missing_asn1(self) -> None: + """Block without ASN.1 Representation sets asn1_definition to None.""" + entry = SpecEntry.from_data_element_block(_DE_NO_ASN1_BLOCK, 1) + assert entry is not None + self.assertIsNone(entry.asn1_definition) + # Other fields should still be populated + self.assertEqual(entry.name, "Mystery") + + def test_asn1_spec_section_propagated(self) -> None: + """The section number is passed through to ASN1TypeDefinition.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None and entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.spec_section, "7.99") + + def test_asn1_description_propagated(self) -> None: + """The use description is passed through to ASN1TypeDefinition.""" + entry = SpecEntry.from_data_element_block(_DE_FULL_BLOCK, 1) + assert entry is not None and entry.asn1_definition is not None + self.assertIn("counter", entry.asn1_definition.description) + + +# ============================================================================= +# Test Cases — from_data_frame_block +# ============================================================================= + + +class TestFromDataFrameBlock(TestCase): + """Tests for SpecEntry.from_data_frame_block.""" + + def test_returns_none_on_invalid_header(self) -> None: + """Returns None when block has no matching header.""" + result = SpecEntry.from_data_frame_block(_DF_INVALID_HEADER, 1) + self.assertIsNone(result) + + def test_full_block_section_number(self) -> None: + """Extracts correct section number.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.section_number, "6.10") + + def test_full_block_entry_type(self) -> None: + """Sets entry_type to DATA_FRAMES.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.entry_type, J2735EntryKind.DATA_FRAMES) + + def test_full_block_name(self) -> None: + """Extracts name without DF_ prefix.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.name, "PathPrediction") + + def test_full_block_abbreviation_is_empty(self) -> None: + """Data Frames always have empty abbreviation.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.abbreviation, "") + + def test_full_block_use_description(self) -> None: + """Extracts Use: description text.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None + self.assertIn("predicted path", entry.use_description) + + def test_full_block_remarks(self) -> None: + """Extracts Remarks: text.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None + self.assertIn("path prediction", entry.remarks.lower()) + + def test_full_block_asn1_parsed(self) -> None: + """Parses SEQUENCE ASN.1 definition.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None + self.assertIsNotNone(entry.asn1_definition) + assert entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.name, "PathPrediction") + self.assertEqual(entry.asn1_definition.type_class, ASN1TypeClass.SEQUENCE) + + def test_missing_use_and_remarks(self) -> None: + """Block without Use: and Remarks: sets empty strings.""" + entry = SpecEntry.from_data_frame_block(_DF_NO_USE_NO_REMARKS_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.use_description, "") + self.assertEqual(entry.remarks, "") + + def test_missing_asn1(self) -> None: + """Block without ASN.1 Representation sets asn1_definition to None.""" + entry = SpecEntry.from_data_frame_block(_DF_NO_ASN1_BLOCK, 1) + assert entry is not None + self.assertIsNone(entry.asn1_definition) + self.assertEqual(entry.name, "EmptyFrame") + + def test_asn1_spec_section_propagated(self) -> None: + """The section number is passed through to ASN1TypeDefinition.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 1) + assert entry is not None and entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.spec_section, "6.10") + + def test_line_number_stored(self) -> None: + """Stores the line offset.""" + entry = SpecEntry.from_data_frame_block(_DF_FULL_BLOCK, 99) + assert entry is not None + self.assertEqual(entry.line_number, 99) + + +# ============================================================================= +# Test Cases — from_message_block +# ============================================================================= + + +class TestFromMessageBlock(TestCase): + """Tests for SpecEntry.from_message_block.""" + + def test_returns_none_on_invalid_header(self) -> None: + """Returns None when block has no matching header.""" + result = SpecEntry.from_message_block(_MSG_INVALID_HEADER, 1) + self.assertIsNone(result) + + def test_full_block_section_number(self) -> None: + """Extracts correct section number.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.section_number, "5.2") + + def test_full_block_entry_type(self) -> None: + """Sets entry_type to MESSAGES.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.entry_type, J2735EntryKind.MESSAGES) + + def test_full_block_name(self) -> None: + """Extracts name without MSG_ prefix.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.name, "BasicSafetyMessage") + + def test_abbreviation_extracted(self) -> None: + """Extracts abbreviation from parentheses in header.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.abbreviation, "BSM") + + def test_no_abbreviation(self) -> None: + """Sets empty abbreviation when none present in header.""" + entry = SpecEntry.from_message_block(_MSG_NO_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.abbreviation, "") + + def test_full_block_use_description(self) -> None: + """Extracts Use: description text.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertIn("basic safety message", entry.use_description.lower()) + + def test_full_block_remarks(self) -> None: + """Extracts Remarks: text.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertIn("10 Hz", entry.remarks) + + def test_full_block_asn1_parsed(self) -> None: + """Parses SEQUENCE ASN.1 definition.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None + self.assertIsNotNone(entry.asn1_definition) + assert entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.name, "BasicSafetyMessage") + self.assertEqual(entry.asn1_definition.type_class, ASN1TypeClass.SEQUENCE) + + def test_missing_use_and_remarks(self) -> None: + """Block without Use: and Remarks: sets empty strings.""" + entry = SpecEntry.from_message_block(_MSG_NO_USE_NO_REMARKS_BLOCK, 1) + assert entry is not None + self.assertEqual(entry.use_description, "") + self.assertEqual(entry.remarks, "") + + def test_asn1_spec_section_propagated(self) -> None: + """The section number is passed through to ASN1TypeDefinition.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 1) + assert entry is not None and entry.asn1_definition is not None + self.assertEqual(entry.asn1_definition.spec_section, "5.2") + + def test_line_number_stored(self) -> None: + """Stores the line offset.""" + entry = SpecEntry.from_message_block(_MSG_WITH_ABBREVIATION_BLOCK, 7) + assert entry is not None + self.assertEqual(entry.line_number, 7)