diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py index 98af05a031..9a9189e8ee 100644 --- a/esphome/build_helpers/ninja.py +++ b/esphome/build_helpers/ninja.py @@ -4,6 +4,7 @@ from __future__ import annotations import os from pathlib import Path +import re import shutil from esphome.core import EsphomeError @@ -31,3 +32,45 @@ def find_ninja() -> Path: "esphome Python environment" ) return wheel_binary + + +def escape(value) -> str: + """Escape a path or token for a ninja file.""" + return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + + +def quote_arg(tok: str) -> str: + """Wrap a token in double quotes with the Windows argv rule. + + Same escaping rule as ``subprocess.list2cmdline``: a backslash run + doubles only immediately before a quote (or the closing quote), and the + quote itself is escaped. POSIX sh parses the result identically for + backslashes and quotes. ``$`` must already be doubled for ninja. + """ + quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok) + quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted) + return f'"{quoted}"' + + +_NEEDS_QUOTE = re.compile(r'[\s"\']') + + +def shell_token(tok: str, force: bool = False) -> str: + """Quote a lexed token only when needed; ``force`` always quotes. + + Lexing strips the quoting a user wrote (``-DX="a b"`` becomes the single + token ``-DX=a b``); re-quote on the way out so the compiler receives the + same argv element SCons would pass under PlatformIO. After ninja + un-doubles ``$$``, sh still expands ``$VAR`` while CreateProcess passes + it literally -- the same divergence SCons-under-sh has, so this stays + PlatformIO parity. + """ + tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing + if force or _NEEDS_QUOTE.search(tok): + return quote_arg(tok) + return tok + + +def quote_path(value) -> str: + """Force-quote a path for the ninja command line (shell/CreateProcess).""" + return shell_token(str(value), force=True) diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py index cf51d1b8d8..9143988acd 100644 --- a/tests/unit_tests/build_helpers/test_ninja.py +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -48,3 +48,25 @@ def test_find_ninja_missing_everywhere(tmp_path: Path) -> None: pytest.raises(EsphomeError, match="ninja not found"), ): ninja_helper.find_ninja() + + +def test_escape_ninja_specials() -> None: + assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d" + + +def test_quote_arg_windows_argv_rule() -> None: + # Backslash runs double only before a quote (subprocess.list2cmdline rule) + assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"' + assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"' + + +def test_shell_token_quotes_only_when_needed() -> None: + assert ninja_helper.shell_token("-Os") == "-Os" + assert ninja_helper.shell_token("-DX=$HOME") == "-DX=$$HOME" + assert ninja_helper.shell_token("-DP=C:\\x y") == '"-DP=C:\\x y"' + assert ninja_helper.shell_token("plain", force=True) == '"plain"' + + +def test_quote_path_force_quotes() -> None: + assert ninja_helper.quote_path(Path("a b")) == '"a b"' + assert ninja_helper.quote_path("simple") == '"simple"'