diff --git a/file_utils.py b/file_utils.py index b67ed9c8..c86bfdd9 100644 --- a/file_utils.py +++ b/file_utils.py @@ -142,7 +142,21 @@ def get_existing_files_content(build_folder, existing_files): return existing_files_content +def ensure_final_newline(content: str) -> str: + """Ends non-empty text with a newline, as linters expect (e.g. RuboCop, ESLint eol-last, Ruff W292).""" + if content and not content.endswith("\n"): + return content + "\n" + return content + + def store_response_files(target_folder, response_files, existing_files): + """Writes the files returned by the API to target_folder. None content deletes the file. + + Every written file ends with a newline - whole-file rewrites come back from the API without one. The + added newline is also written back into response_files, so callers that diff response_files against + the folder afterwards see exactly what is on disk. Content is written byte-for-byte, without newline + translation, so the disk matches response_files on every platform. + """ for file_name in response_files: full_file_name = os.path.join(target_folder, file_name) @@ -156,9 +170,11 @@ def store_response_files(target_folder, response_files, existing_files): continue + response_files[file_name] = ensure_final_newline(response_files[file_name]) + os.makedirs(os.path.dirname(full_file_name), exist_ok=True) - with open(full_file_name, "w", encoding="utf-8") as f: + with open(full_file_name, "w", encoding="utf-8", newline="") as f: f.write(response_files[file_name]) if file_name not in existing_files: diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index 6b306792..28b77862 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -3,7 +3,9 @@ import pytest -from file_utils import load_linked_resources, store_response_files +import file_utils +from diff_utils import get_code_diff +from file_utils import get_existing_files_content, load_linked_resources, store_response_files from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType from plain2code_utils import MAX_BASE64_BLOB_LENGTH @@ -82,11 +84,91 @@ def test_load_linked_resources_real_base64_image_raises(template_dir): def test_store_response_files_writes_unicode_as_utf8(template_dir): - # Content with a non-cp1252 character (📍 U+1F4CD) must be written as UTF-8 - # regardless of the platform's default text encoding (e.g. cp1252 on Windows). - content = "Location 📍 marker" + """A non-cp1252 character (📍 U+1F4CD) is written as UTF-8, whatever the platform's default encoding.""" + content = "Location 📍 marker\n" store_response_files(template_dir, {"notes.md": content}, []) file_path = os.path.join(template_dir, "notes.md") with open(file_path, "rb") as f: assert f.read().decode("utf-8") == content + + +def _read_bytes(folder, file_name): + """Returns a file's content exactly as stored on disk, without newline translation.""" + with open(os.path.join(folder, file_name), "rb") as f: + return f.read().decode("utf-8") + + +@pytest.mark.parametrize( + "content, expected", + [ + ("class Foo\nend", "class Foo\nend\n"), + ("class Foo\nend\n", "class Foo\nend\n"), + ("class Foo\nend\n\n", "class Foo\nend\n\n"), + ("class Foo\r\nend\r\n", "class Foo\r\nend\r\n"), + ("", ""), + ], +) +def test_store_response_files_ends_every_text_file_with_a_newline(template_dir, content, expected): + """A missing final newline is added, as linters such as RuboCop, ESLint and Ruff require. + + An existing final newline is not doubled, and empty content stays empty. + """ + store_response_files(template_dir, {"app/foo.rb": content}, []) + + assert _read_bytes(template_dir, "app/foo.rb") == expected + + +def test_store_response_files_hands_back_the_content_it_wrote(template_dir): + """response_files holds the content as written, so diffs taken after storing match the disk. + + The fix journal and the conformance fixes handed to the unit tests fixer take such diffs. + """ + response_files = {"app/foo.rb": "class Foo\nend", "app/bar.rb": "class Bar\nend\n"} + + store_response_files(template_dir, response_files, []) + + assert response_files == {"app/foo.rb": "class Foo\nend\n", "app/bar.rb": "class Bar\nend\n"} + + +def test_rewriting_a_file_without_its_final_newline_does_not_change_it(template_dir): + """A rewrite that drops only the final newline leaves the file byte-identical and produces no diff.""" + existing_files = store_response_files(template_dir, {"app/foo.rb": "class Foo\nend\n"}, []) + existing_files_content = get_existing_files_content(template_dir, existing_files) + + rewrite = {"app/foo.rb": "class Foo\nend"} + store_response_files(template_dir, rewrite, existing_files) + + assert get_existing_files_content(template_dir, existing_files) == existing_files_content + assert get_code_diff(rewrite, existing_files_content) == {} + + +def test_store_response_files_still_deletes_a_file_whose_content_is_none(template_dir): + """None content still deletes the file and removes it from existing_files, and is not turned into a newline.""" + existing_files = store_response_files(template_dir, {"app/foo.rb": "class Foo\nend\n"}, []) + + response_files = {"app/foo.rb": None} + store_response_files(template_dir, response_files, existing_files) + + assert not os.path.exists(os.path.join(template_dir, "app/foo.rb")) + assert existing_files == [] + assert response_files == {"app/foo.rb": None} + + +def test_store_response_files_writes_the_same_bytes_where_text_mode_writes_crlf(template_dir, monkeypatch): + """Files hold exactly the normalized content even where text mode writes CRLF line endings (native Windows).""" + # Imported here: isort 8 and isort 9 disagree on which section a top-level `import _pyio` belongs to. + import _pyio + + # Python's reference io (_pyio) takes its line separator from os.linesep, so here it translates like Windows. + monkeypatch.setattr(os, "linesep", "\r\n") + monkeypatch.setattr(file_utils, "open", _pyio.open, raising=False) + with _pyio.open(os.path.join(template_dir, "probe.txt"), "w") as probe: + probe.write("\n") + assert _read_bytes(template_dir, "probe.txt") == "\r\n" + + response_files = {"app/foo.rb": "class Foo\nend", "app/bar.rb": "class Bar\r\nend\r\n"} + store_response_files(template_dir, response_files, []) + + assert _read_bytes(template_dir, "app/foo.rb") == response_files["app/foo.rb"] == "class Foo\nend\n" + assert _read_bytes(template_dir, "app/bar.rb") == response_files["app/bar.rb"] == "class Bar\r\nend\r\n"