From a77a5a5031321a81ca85386e37c59cbad653f73d Mon Sep 17 00:00:00 2001 From: Stanislav Pankevich Date: Fri, 3 Oct 2025 21:36:09 +0200 Subject: [PATCH] feat(backend/sdoc_source_code): parse SDoc nodes from source code with more flexible syntax --- strictdoc/backend/sdoc/grammar/grammar.py | 2 +- .../comment_parser/marker_lexer.py | 6 +- .../sdoc_source_code/models/source_node.py | 2 + .../backend/sdoc_source_code/reader_c.py | 9 + strictdoc/core/file_traceability_index.py | 287 ++++++++++-------- .../export/html/_static/source_file_screen.js | 11 +- .../parent.sdoc | 12 + .../requirements.sdoc | 41 +++ .../src/example/example.c | 18 ++ .../strictdoc.toml | 10 + .../test.itest | 17 ++ .../sdoc_source_code/test_marker_lexer.py | 32 +- 12 files changed, 313 insertions(+), 134 deletions(-) create mode 100644 tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/parent.sdoc create mode 100644 tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/requirements.sdoc create mode 100644 tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/src/example/example.c create mode 100644 tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/strictdoc.toml create mode 100644 tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/test.itest diff --git a/strictdoc/backend/sdoc/grammar/grammar.py b/strictdoc/backend/sdoc/grammar/grammar.py index 950a3f9bb..d967e4c8b 100644 --- a/strictdoc/backend/sdoc/grammar/grammar.py +++ b/strictdoc/backend/sdoc/grammar/grammar.py @@ -48,7 +48,7 @@ ; FieldName[noskipws]: - /{NEGATIVE_UID}{NEGATIVE_RELATIONS}[A-Z]+[A-Z_0-9]*/ + /{NEGATIVE_UID}{NEGATIVE_RELATIONS}[A-Z]+[A-Za-z0-9_\-]*/ ; """ diff --git a/strictdoc/backend/sdoc_source_code/comment_parser/marker_lexer.py b/strictdoc/backend/sdoc_source_code/comment_parser/marker_lexer.py index 3b62bc624..557ae1f39 100644 --- a/strictdoc/backend/sdoc_source_code/comment_parser/marker_lexer.py +++ b/strictdoc/backend/sdoc_source_code/comment_parser/marker_lexer.py @@ -17,6 +17,7 @@ class GrammarTemplate(Template): RELATION_MARKER_START = r"@relation[\(\{]" +REGEX_NODE_NAME = r"[A-Za-z0-9_\-]+" GRAMMAR = GrammarTemplate(""" start: ##START @@ -29,13 +30,13 @@ class GrammarTemplate(Template): relation_role: ALPHANUMERIC_WORD node_field: node_name ":" node_multiline_value -node_name: /(?!(##RESERVED_KEYWORDS))[A-Z_]+/ +node_name: /(?!(##RESERVED_KEYWORDS))##REGEX_NODE_NAME/ node_multiline_value: (_WS_INLINE | _NL) (NODE_FIRST_STRING_VALUE _NL) (NODE_STRING_VALUE _NL)* NODE_FIRST_STRING_VALUE.2: /\\s*[^\n\r]+/x NODE_STRING_VALUE.2: /(?![ ]*##RELATION_MARKER_START)(?!\\s*[A-Z_]+: )[^\n\r]+/x -_NORMAL_STRING_NO_MARKER_NO_NODE: /(?!\\s*##RELATION_MARKER_START)((?!\\s*[A-Z_]+: )|(##RESERVED_KEYWORDS)).+/ +_NORMAL_STRING_NO_MARKER_NO_NODE: /(?!\\s*##RELATION_MARKER_START)((?!\\s*##REGEX_NODE_NAME: )|(##RESERVED_KEYWORDS)).+/ _NORMAL_STRING_NO_MARKER: /(?!\\s*##RELATION_MARKER_START).+/ @@ -68,6 +69,7 @@ def parse(source_input: str, parse_nodes: bool = False) -> ParseTree: start = "(relation_marker | _NORMAL_STRING_NO_MARKER | _WS)*" grammar = GRAMMAR.substitute( + REGEX_NODE_NAME=REGEX_NODE_NAME, RELATION_MARKER_START=RELATION_MARKER_START, RESERVED_KEYWORDS=RESERVED_KEYWORDS, REGEX_REQ=REGEX_REQ, diff --git a/strictdoc/backend/sdoc_source_code/models/source_node.py b/strictdoc/backend/sdoc_source_code/models/source_node.py index 4e5d8de79..e121af077 100644 --- a/strictdoc/backend/sdoc_source_code/models/source_node.py +++ b/strictdoc/backend/sdoc_source_code/models/source_node.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from typing import Dict, List, Optional, Union +from strictdoc.backend.sdoc_source_code.models.function import Function from strictdoc.backend.sdoc_source_code.models.function_range_marker import ( FunctionRangeMarker, ) @@ -21,3 +22,4 @@ class SourceNode: default_factory=list ) fields: Dict[str, str] = field(default_factory=dict) + function: Optional[Function] = None diff --git a/strictdoc/backend/sdoc_source_code/reader_c.py b/strictdoc/backend/sdoc_source_code/reader_c.py index a46c32a1a..04aca4f8c 100644 --- a/strictdoc/backend/sdoc_source_code/reader_c.py +++ b/strictdoc/backend/sdoc_source_code/reader_c.py @@ -21,6 +21,7 @@ from strictdoc.backend.sdoc_source_code.models.source_file_info import ( SourceFileTraceabilityInfo, ) +from strictdoc.backend.sdoc_source_code.models.source_node import SourceNode from strictdoc.backend.sdoc_source_code.parse_context import ParseContext from strictdoc.backend.sdoc_source_code.processors.general_language_marker_processors import ( function_range_marker_processor, @@ -69,6 +70,8 @@ def read( tree = parser.parse(input_buffer) nodes = traverse_tree(tree) + + source_node: Optional[SourceNode] for node_ in nodes: function_name: str function_markers: List[FunctionRangeMarker] @@ -168,6 +171,7 @@ def read( if specifier_node_.text == b"static": function_attributes.add(FunctionAttribute.STATIC) + source_node = None function_markers = [] function_comment_node = None if ( @@ -215,6 +219,8 @@ def read( markers=function_markers, attributes=function_attributes, ) + if source_node is not None: + source_node.function = new_function traceability_info.functions.append(new_function) elif node_.type == "function_definition": @@ -266,6 +272,7 @@ def read( if function_name.startswith("TEST"): function_display_name = function_name + source_node = None function_markers = [] function_comment_node = None function_comment_text = None @@ -326,6 +333,8 @@ def read( traceability_info.ng_map_names_to_definition_functions[ function_name ] = new_function + if source_node is not None: + source_node.function = new_function elif node_.type == "comment": # # FIXME: Here parsing of function comments can happen as well diff --git a/strictdoc/core/file_traceability_index.py b/strictdoc/core/file_traceability_index.py index d0fe4ba51..6dd0d6da8 100644 --- a/strictdoc/core/file_traceability_index.py +++ b/strictdoc/core/file_traceability_index.py @@ -38,6 +38,7 @@ SourceFileTraceabilityInfo, ) from strictdoc.core.constants import GraphLinkType +from strictdoc.core.document_iterator import DocumentCachingIterator from strictdoc.core.project_config import ProjectConfig from strictdoc.core.source_tree import SourceFile from strictdoc.helpers.cast import assert_cast @@ -560,119 +561,6 @@ def validate_and_resolve( function_marker ) - # - # STEP: For each trace info object: - # - Sort the markers according to their source location. - # - Calculate coverage information. - # - for ( - path, - traceability_info_, - ) in self.map_paths_to_source_file_traceability_info.items(): - - def marker_comparator_start( - marker: RelationMarkerType, - ) -> int: - assert marker.ng_range_line_begin is not None - return marker.ng_range_line_begin - - sorted_markers = sorted( - traceability_info_.markers, key=marker_comparator_start - ) - - traceability_info_.markers = sorted_markers - # Finding how many lines are covered by the requirements in the file. - # Quick and dirty: https://stackoverflow.com/a/15273749/598057 - merged_ranges: List[List[int]] = [] - for marker_ in traceability_info_.markers: - assert isinstance( - marker_, - ( - FunctionRangeMarker, - ForwardRangeMarker, - RangeMarker, - LineMarker, - ), - ), marker_ - if marker_.ng_is_nodoc: - continue - if not marker_.is_begin(): - continue - begin, end = ( - assert_cast(marker_.ng_range_line_begin, int), - assert_cast(marker_.ng_range_line_end, int), - ) - if merged_ranges and merged_ranges[-1][1] >= (begin - 1): - merged_ranges[-1][1] = max(merged_ranges[-1][1], end) - else: - merged_ranges.append([begin, end]) - coverage = 0 - for merged_range in merged_ranges: - for line_ in range(merged_range[0], merged_range[1] + 1): - if traceability_info_.file_stats.lines_info[line_]: - coverage += 1 - - for function_ in traceability_info_.functions: - for merged_range in merged_ranges: - if ( - function_.line_begin >= merged_range[0] - and function_.line_end <= merged_range[1] - ): - traceability_info_.covered_functions += 1 - break - - traceability_info_.set_coverage_stats(merged_ranges, coverage) - - for ( - req_uid_, - markers_, - ) in traceability_info_.ng_map_reqs_to_markers.items(): - - def marker_comparator_range( - marker: RelationMarkerType, - ) -> Tuple[int, int]: - assert marker.ng_range_line_begin is not None - assert marker.ng_range_line_end is not None - return marker.ng_range_line_begin, marker.ng_range_line_end - - markers_.sort(key=marker_comparator_range) - - # Validate here, SDocNode.relations doesn't track marker roles. - node = traceability_index.get_node_by_uid(req_uid_) - document = node.get_document() - assert document is not None - assert document.grammar is not None - grammar_element = document.grammar.elements_by_type[ - node.node_type - ] - for marker in markers_: - # Backwards markers do not require referenced node grammar - # to have the relation/role registered in the grammar. - if isinstance(marker, (FunctionRangeMarker, RangeMarker)): - continue - - if not grammar_element.has_relation_type_role( - relation_type="File", - relation_role=marker.role, - ): - raise StrictDocSemanticError.invalid_marker_role( - node=node, - marker=marker, - path_to_src_file=path, - ) - - # Sort by paths alphabetically. - for paths_with_role in self.map_reqs_uids_to_paths.values(): - paths_with_role.sort() - - # Sort by node UID alphabetically. - for path_requirements_ in self.map_paths_to_reqs.values(): - - def compare_sdocnode_by_uid(node_: SDocNode) -> str: - return assert_cast(node_.reserved_uid, str) - - path_requirements_.sort(key=compare_sdocnode_by_uid) - # # STEP: Create auto-generated documents created from source file comments. # Register these documents with the main traceability index. @@ -702,6 +590,8 @@ def create_folder_section( ) return section_node + documents_with_generated_content = set() + section_cache = {} source_nodes_config: List[Dict[str, str]] = project_config.source_nodes for ( @@ -723,6 +613,7 @@ def create_folder_section( document_uid = relevant_source_node_entry["uid"] document = traceability_index.get_node_by_uid(document_uid) + documents_with_generated_content.add(document) current_top_node = document path_components = path_to_source_file_.split("/") @@ -758,7 +649,6 @@ def create_folder_section( source_sdoc_node.ng_including_document_reference = ( DocumentReference() ) - source_sdoc_node.set_field_value( field_name="UID", form_field_index=0, @@ -784,6 +674,34 @@ def create_folder_section( rhs_node=source_sdoc_node, ) + source_node_function = source_node_.function + assert source_node_function is not None + + function_marker = self.forward_function_marker_from_function( + function=source_node_function, + marker_type=RangeMarkerType.FUNCTION, + reqs=[Req(None, source_sdoc_node_uid)], + role=None, + description=f"function {source_node_function.display_name}()", + ) + + traceability_info_.ng_map_reqs_to_markers.setdefault( + source_sdoc_node_uid, [] + ).append(function_marker) + + self.map_reqs_uids_to_paths.setdefault( + source_sdoc_node_uid, OrderedSet() + ).add(path_to_source_file_) + + self.map_paths_to_reqs.setdefault( + path_to_source_file_, OrderedSet() + ).add(source_sdoc_node) + + function_marker_copy = function_marker.create_end_marker() + + traceability_info_.markers.append(function_marker) + traceability_info_.markers.append(function_marker_copy) + # # This connects: # - Source nodes and auto-generated requirements. @@ -792,15 +710,6 @@ def create_folder_section( for marker_ in source_node_.markers: if not isinstance(marker_, FunctionRangeMarker): continue - traceability_info_.ng_map_reqs_to_markers.setdefault( - source_sdoc_node_uid, [] - ).append(marker_) - self.map_reqs_uids_to_paths.setdefault( - source_sdoc_node_uid, OrderedSet() - ).add(path_to_source_file_) - self.map_paths_to_reqs.setdefault( - path_to_source_file_, OrderedSet() - ).add(source_sdoc_node) for req_ in marker_.reqs: node = traceability_index.get_node_by_uid_weak2(req_) @@ -815,6 +724,19 @@ def create_folder_section( rhs_node=source_sdoc_node, ) + # Iterate over all generated documents to calculate all node levels. + for document_ in documents_with_generated_content: + document_iterator = DocumentCachingIterator(document_) + for _, _ in document_iterator.all_content( + print_fragments=False, + ): + pass + + # + # STEP: Calculate requirements coverage by code. Sort nodes. + # + self.calculate_code_coverage_and_sort_nodes(traceability_index) + def create_requirement_with_forward_source_links( self, requirement: SDocNode ) -> None: @@ -938,3 +860,122 @@ def forward_file_marker_from_file_info( marker.ng_source_line_begin = 1 marker.ng_range_line_end = file_info.file_stats.lines_total return marker + + def calculate_code_coverage_and_sort_nodes( + self, traceability_index: "TraceabilityIndex" + ) -> None: + """ + Finalize code coverage and sort all nodes. + + For each trace info object: + - Sort the markers according to their source location. + - Calculate coverage information. + """ + + for ( + path, + traceability_info_, + ) in self.map_paths_to_source_file_traceability_info.items(): + + def marker_comparator_start( + marker: RelationMarkerType, + ) -> int: + assert marker.ng_range_line_begin is not None + return marker.ng_range_line_begin + + sorted_markers = sorted( + traceability_info_.markers, key=marker_comparator_start + ) + + traceability_info_.markers = sorted_markers + # Finding how many lines are covered by the requirements in the file. + # Quick and dirty: https://stackoverflow.com/a/15273749/598057 + merged_ranges: List[List[int]] = [] + for marker_ in traceability_info_.markers: + assert isinstance( + marker_, + ( + FunctionRangeMarker, + ForwardRangeMarker, + RangeMarker, + LineMarker, + ), + ), marker_ + if marker_.ng_is_nodoc: + continue + if not marker_.is_begin(): + continue + begin, end = ( + assert_cast(marker_.ng_range_line_begin, int), + assert_cast(marker_.ng_range_line_end, int), + ) + if merged_ranges and merged_ranges[-1][1] >= (begin - 1): + merged_ranges[-1][1] = max(merged_ranges[-1][1], end) + else: + merged_ranges.append([begin, end]) + coverage = 0 + for merged_range in merged_ranges: + for line_ in range(merged_range[0], merged_range[1] + 1): + if traceability_info_.file_stats.lines_info[line_]: + coverage += 1 + + for function_ in traceability_info_.functions: + for merged_range in merged_ranges: + if ( + function_.line_begin >= merged_range[0] + and function_.line_end <= merged_range[1] + ): + traceability_info_.covered_functions += 1 + break + + traceability_info_.set_coverage_stats(merged_ranges, coverage) + + for ( + req_uid_, + markers_, + ) in traceability_info_.ng_map_reqs_to_markers.items(): + + def marker_comparator_range( + marker: RelationMarkerType, + ) -> Tuple[int, int]: + assert marker.ng_range_line_begin is not None + assert marker.ng_range_line_end is not None + return marker.ng_range_line_begin, marker.ng_range_line_end + + markers_.sort(key=marker_comparator_range) + + # Validate here, SDocNode.relations doesn't track marker roles. + node = traceability_index.get_node_by_uid(req_uid_) + document = node.get_document() + assert document is not None + assert document.grammar is not None + grammar_element = document.grammar.elements_by_type[ + node.node_type + ] + for marker in markers_: + # Backwards markers do not require referenced node grammar + # to have the relation/role registered in the grammar. + if isinstance(marker, (FunctionRangeMarker, RangeMarker)): + continue + + if not grammar_element.has_relation_type_role( + relation_type="File", + relation_role=marker.role, + ): + raise StrictDocSemanticError.invalid_marker_role( + node=node, + marker=marker, + path_to_src_file=path, + ) + + # Sort by paths alphabetically. + for paths_with_role in self.map_reqs_uids_to_paths.values(): + paths_with_role.sort() + + # Sort by node UID alphabetically. + for path_requirements_ in self.map_paths_to_reqs.values(): + + def compare_sdocnode_by_uid(node_: SDocNode) -> str: + return assert_cast(node_.reserved_uid, str) + + path_requirements_.sort(key=compare_sdocnode_by_uid) diff --git a/strictdoc/export/html/_static/source_file_screen.js b/strictdoc/export/html/_static/source_file_screen.js index 4235a358a..8e47e20b5 100644 --- a/strictdoc/export/html/_static/source_file_screen.js +++ b/strictdoc/export/html/_static/source_file_screen.js @@ -285,7 +285,16 @@ class Dom { this.active.labels?.forEach(label => label?.classList.remove(this.activeClass)); if (this.active.rangeAlias) { this.ranges[this.active.rangeAlias].banner.classList.remove(this.activeClass); - this.closers[this.active.rangeEnd].classList.remove(this.activeClass); + + const closer = this.closers?.[this.active.rangeEnd]; + console.assert( + closer, + "Closer must not be null. One known way of getting this error is " + + "when a closing function/range marker is missing and not registered " + + "with the file traceability info." + ); + + closer.classList.remove(this.activeClass); } // make changes to state diff --git a/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/parent.sdoc b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/parent.sdoc new file mode 100644 index 000000000..278b98a77 --- /dev/null +++ b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/parent.sdoc @@ -0,0 +1,12 @@ +[DOCUMENT] +TITLE: Hello world doc + +[REQUIREMENT] +UID: REQ-1 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-2 +TITLE: Requirement Title #2 +STATEMENT: Requirement Statement #2 diff --git a/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/requirements.sdoc b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/requirements.sdoc new file mode 100644 index 000000000..c7644d043 --- /dev/null +++ b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/requirements.sdoc @@ -0,0 +1,41 @@ +[DOCUMENT] +MID: c2d4542d5f1741c88dfcb4f68ad7dcbd +TITLE: SPDX requirements +UID: SPDX_DOC + +[GRAMMAR] +ELEMENTS: +- TAG: SECTION + PROPERTIES: + IS_COMPOSITE: True + FIELDS: + - TITLE: UID + TYPE: String + REQUIRED: False + - TITLE: TITLE + TYPE: String + REQUIRED: True +- TAG: REQUIREMENT + PROPERTIES: + VIEW_STYLE: Narrative + FIELDS: + - TITLE: UID + TYPE: String + REQUIRED: False + - TITLE: TITLE + TYPE: String + REQUIRED: False + - TITLE: SPDX-Req-ID + TYPE: String + REQUIRED: False + - TITLE: SPDX-Text + TYPE: String + REQUIRED: False + RELATIONS: + - TYPE: Parent + - TYPE: File + +[[SECTION]] +TITLE: Introduction + +[[/SECTION]] diff --git a/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/src/example/example.c b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/src/example/example.c new file mode 100644 index 000000000..a73ba34f2 --- /dev/null +++ b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/src/example/example.c @@ -0,0 +1,18 @@ +#include + +/** + * Some text. + * + * @relation(REQ-1, scope=function) + * + * SPDX-Req-ID: SRC-1 + * + * SPDX-Text: This + * is + * a statement + * \n\n + * And this is the same statement's another paragraph. + */ +void example_1(void) { + print("hello world\n"); +} diff --git a/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/strictdoc.toml b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/strictdoc.toml new file mode 100644 index 000000000..d145827c0 --- /dev/null +++ b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/strictdoc.toml @@ -0,0 +1,10 @@ +[project] + +features = [ + "REQUIREMENT_TO_SOURCE_TRACEABILITY", + "SOURCE_FILE_LANGUAGE_PARSERS", +] + +source_nodes = [ + { "src/" = { uid = "SPDX_DOC", node_type = "REQUIREMENT" } } +] diff --git a/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/test.itest b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/test.itest new file mode 100644 index 000000000..b24ddee3b --- /dev/null +++ b/tests/integration/features/source_code_traceability/_source_nodes/20_linux_spdx_in_source_identifiers/test.itest @@ -0,0 +1,17 @@ +RUN: %strictdoc --debug export %S --output-dir %T | filecheck %s + +CHECK: Published: Hello world doc + +RUN: %check_exists --file "%T/html/_source_files/src/example/example.c.html" + +RUN: %cat %T/html/%THIS_TEST_FOLDER/requirements.html | filecheck %s --check-prefix CHECK-HTML +CHECK-HTML: SPDX requirements +CHECK-HTML: SPDX_DOC/src/example/example.c/example_1 +CHECK-HTML: src/example/example.c, lines: 3-18, function example_1() + +RUN: %cat %T/html/_source_files/src/example/example.c.html | filecheck %s --check-prefix CHECK-SOURCE-FILE + +CHECK-SOURCE-FILE: SPDX_DOC/src/example/example.c/example_1 + +RUN: %cat %T/html/source_coverage.html | filecheck %s --check-prefix CHECK-SOURCE-COVERAGE +CHECK-SOURCE-COVERAGE: 94.1 diff --git a/tests/unit/strictdoc/backend/sdoc_source_code/test_marker_lexer.py b/tests/unit/strictdoc/backend/sdoc_source_code/test_marker_lexer.py index 4dab16fbd..c3909cae0 100644 --- a/tests/unit/strictdoc/backend/sdoc_source_code/test_marker_lexer.py +++ b/tests/unit/strictdoc/backend/sdoc_source_code/test_marker_lexer.py @@ -409,13 +409,6 @@ def test_33_multiline_and_multiparagraph_fields(): def test_60_exclude_reserved_keywords(): - """ - Ensure that a single field can be parsed. - - It turns out that this particular case is pretty sensitive with respect to - how the grammar is constructed. - """ - input_string = """ FIXME: This can likely replace _weak below with no problem. @@ -426,3 +419,28 @@ def test_60_exclude_reserved_keywords(): assert tree.data == "start" assert len(tree.children) == 0 + + +def test_80_linux_spdx_like_identifiers(): + input_string = """\ +SPDX-ID: REQ-1 + +SPDX-Text: This + is + a statement + \n\n + And this is the same statement's another paragraph. +""" + + tree = MarkerLexer.parse(input_string, parse_nodes=True) + assert tree.data == "start" + + assert len(tree.children) == 2 + assert tree.children[0].data == "node_field" + assert tree.children[0].children[0].data == "node_name" + assert tree.children[0].children[0].children[0].value == "SPDX-ID" + assert tree.children[0].children[1].data == "node_multiline_value" + assert tree.children[1].data == "node_field" + assert tree.children[1].children[0].data == "node_name" + assert tree.children[1].children[0].children[0].value == "SPDX-Text" + assert tree.children[1].children[1].data == "node_multiline_value"