diff --git a/plain_file.py b/plain_file.py index aea1732c..ca856e40 100644 --- a/plain_file.py +++ b/plain_file.py @@ -455,6 +455,19 @@ def process_imports( return required_concepts +def normalize_line_endings(plain_source_text: str) -> str: + return plain_source_text.replace("\r\n", "\n").replace("\r", "\n") + + +def restore_stripped_lines(plain_source_text: str, content: str) -> str: + stripped_source = plain_source_text.rstrip() + if not content or not stripped_source.endswith(content): + return content + + stripped_line_count = stripped_source[: len(stripped_source) - len(content)].count("\n") + return "\n" * stripped_line_count + content + + def read_plain_source_metadata(plain_source_text): try: plain_source_obj = frontmatter.loads(plain_source_text) @@ -490,6 +503,8 @@ def parse_plain_source( # noqa: C901 imported_modules: list[str], modules_trace: list[str], ) -> PlainFileParseResult: + plain_source_text = normalize_line_endings(plain_source_text) + plain_source_obj = read_plain_source_metadata(plain_source_text) plain_source = PLAIN_SOURCE_TEMPLATE.copy() @@ -508,7 +523,9 @@ def parse_plain_source( # noqa: C901 [_, loaded_templates] = file_utils.get_loaded_templates(template_dirs, plain_source_text) - plain_source_full_text = render_plain_source(plain_source_obj.content, loaded_templates, code_variables) + plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content) + + plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables) plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 3ff20956..403e85de 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -36,7 +36,7 @@ def test_unknown_section(): with pytest.raises( Exception, match=re.escape( - "Syntax error at line 3: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests" + "Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests" ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) @@ -50,7 +50,7 @@ def test_duplicate_section(): """ with pytest.raises( Exception, - match=re.escape("Syntax error at line 3: Duplicate specification heading (`definitions`)"), + match=re.escape("Syntax error at line 4: Duplicate specification heading (`definitions`)"), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) @@ -64,11 +64,48 @@ def test_invalid_top_level_element(): """ with pytest.raises( Exception, - match=re.escape("Syntax error at line 2: Invalid source structure (`code block`)"), + match=re.escape("Syntax error at line 3: Invalid source structure (`code block`)"), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) +def test_syntax_error_line_number_accounts_for_frontmatter(): + plain_source = """--- +description: 'Plain file with frontmatter' +--- + +***definitions*** + +***Unknown Section:*** +""" + with pytest.raises( + Exception, + match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_syntax_error_line_number_with_windows_line_endings(): + plain_source = ( + "---\r\n" + "description: 'Plain file with frontmatter'\r\n" + "---\r\n" + "\r\n" + "***definitions***\r\n" + "\r\n" + "***Unknown Section:***\r\n" + ) + with pytest.raises( + Exception, + match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_normalize_line_endings_does_not_duplicate_newlines(): + assert plain_file.normalize_line_endings("a\r\nb\rc\nd") == "a\nb\nc\nd" + + def test_plain_file_parser_with_comments(get_test_data_path): _, plain_sections, _ = plain_file.plain_file_parser( "plain_file_parser_with_comments.plain", @@ -423,7 +460,7 @@ def test_acceptance_tests_top_level_rejected(): with pytest.raises( PlainSyntaxError, match=re.escape( - "Syntax error at line 1: acceptance tests heading should be nested under specific functional spec." + "Syntax error at line 2: acceptance tests heading should be nested under specific functional spec." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], [])