diff --git a/docs/building.md b/docs/building.md index db8220d0..cc2f30bd 100644 --- a/docs/building.md +++ b/docs/building.md @@ -444,6 +444,9 @@ Before a commit: - `python tools/check_content.py --staged`: nothing game-derived and no compiled shader is staged. CI runs the same check; `git config core.hooksPath .githooks` runs it before each local commit. - `build\\nx2navi.exe --selftest`: layout, dispatch table and host-function registry, without game files. +- `python tools\check_platform.py` after a runtime change outside `runtime/platform/`: no shared file uses Win32 outside + a branch that only a Windows build compiles (`#ifdef _WIN32`, the `#else` of `#ifndef _WIN32`). + `python tests\check_platform_test.py` checks the scanner itself on made-up files. A change to the in-game menu's look (`runtime/ui/menu_skin.cpp`, `option_rows.cpp`, `perf_panel.cpp`) can be looked at without game files: `python tools/menu_preview.py` builds the menu's own drawing code with the host's clang++ and diff --git a/tests/check_platform_test.py b/tests/check_platform_test.py new file mode 100644 index 00000000..c217b6d9 --- /dev/null +++ b/tests/check_platform_test.py @@ -0,0 +1,70 @@ +"""tools/check_platform.py on made-up runtime/ trees: a Win32 use behind a Windows-only preprocessor branch passes, an +unguarded one fails, and the repository's own runtime/ passes. No game files; stdlib only. + +Run: python tests/check_platform_test.py +""" +import contextlib +import io +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / 'tools')) + +import check_platform # noqa: E402 + + +class CheckPlatformTest(unittest.TestCase): + def check(self, files, runtime=None): + """(exit code, output) of check_platform.py on a runtime/ folder holding files ({name: text}), or on + runtime.""" + with tempfile.TemporaryDirectory() as d: + if runtime is None: + runtime = Path(d) / 'runtime' + runtime.mkdir() + for name, text in files.items(): + (runtime / name).write_text(text, encoding='utf-8') + out = io.StringIO() + with mock.patch.object(sys, 'argv', ['check_platform.py', '--runtime', str(runtime)]), \ + contextlib.redirect_stdout(out): + code = check_platform.main() + return code, out.getvalue() + + def test_windows_only_branches_pass(self): + code, out = self.check({ + 'a.c': '#ifdef _WIN32\n#include \n#endif\nint a;\n', + 'b.c': '#ifndef _WIN32\n#include \nvoid f(int h) { (void)h; }\n#else\n' + 'void f(HANDLE h) { CloseHandle(h); }\n#endif\n', + 'c.cpp': '#if defined(_WIN32) // the DLL\n# if X\nstatic DWORD d;\n# endif\n' + 'static void g() { FreeLibrary(0); }\n#elif Y\nint y;\n#else\nint z;\n#endif\n', + 'd.h': '#if !defined(_WIN32)\nint p;\n#elif Z\nint q;\n#else\n#include \n#endif\n', + 'e.c': '/* CloseHandle in a comment */\nconst char *s = "GetLastError";\n', + }) + self.assertEqual(code, 0, out) + self.assertNotIn('FAIL', out) + + def test_unguarded_uses_fail(self): + code, out = self.check({ + 'a.c': '#include \nint a;\n', + 'b.c': '#ifndef _WIN32\nint b;\n#endif\nvoid f(void *h) { CloseHandle(h); }\n', + 'c.c': '#ifdef _WIN32\nint c;\n#else\nstatic DWORD d;\n#endif\n', + 'd.c': '#ifdef _WIN32\nint d;\n#elif 1\nvoid g(void) { Sleep(1); }\n#endif\n', + 'e.c': '#if defined(_WIN32) && X\nvoid h(void) { Sleep(1); }\n#endif\n', # scanned: never let through + 'f.c': '#ifdef _WIN32\n#endif\n\n#include \n', + 'ok.c': '#ifdef _WIN32\nvoid f(void *h) { CloseHandle(h); }\n#endif\n', + }) + self.assertEqual(code, 1) + for name in 'abcdef': + self.assertIn(f'FAIL runtime/{name}.c', out) + self.assertNotIn('runtime/ok.c', out) + + def test_the_runtime_passes(self): + code, out = self.check({}, ROOT / 'runtime') + self.assertEqual(code, 0, out) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tools/check_platform.py b/tools/check_platform.py index b74a2b76..cbd40667 100644 --- a/tools/check_platform.py +++ b/tools/check_platform.py @@ -3,12 +3,15 @@ Runtime code goes through runtime/platform/platform.h. The Win32 back ends of the window system are Win32 by design and are not scanned: files named *_win.c / *_win.cpp (runtime/ and runtime/ui/), whose SDL counterparts (*_sdl.*) serve -other hosts. Any other file that still uses Win32 goes into ALLOWED with the reason; a file in ALLOWED that no longer +other hosts. Nor are the branches of a shared file that only a Windows build compiles (windows_only_lines): a file +with a portable path of its own may reach a Windows-only library there (the upscalers' DLLs, RivaTuner's shared +memory). Any other file that still uses Win32 goes into ALLOWED with the reason; a file in ALLOWED that no longer matches is noted, so the entry gets removed. Usage: python tools/check_platform.py [--root DIR] (DIR holds runtime/; default: the repository root) """ import argparse +import bisect import re import sys from pathlib import Path @@ -54,11 +57,49 @@ def strip(text): return re.sub(r'"(?:\\.|[^"\\\n])*"', blank, text) +DIRECTIVE = re.compile(r"#\s*(if|ifdef|ifndef|elif|else|endif)\b") +WIN_IF = re.compile(r"#\s*(ifdef\s+_WIN32|if\s+defined\s*\(\s*_WIN32\s*\)|if\s+defined\s+_WIN32|if\s+_WIN32)$") +WIN_IFNOT = re.compile(r"#\s*(ifndef\s+_WIN32|if\s+!\s*defined\s*\(\s*_WIN32\s*\)|if\s+!\s*defined\s+_WIN32)$") + + +def windows_only_lines(code): + """The numbers (from 0) of the lines of code (comments blanked: strip) that only a Windows build compiles: the + branch of #ifdef _WIN32, #if defined(_WIN32) or #if _WIN32 up to its #else, #elif or #endif, the #else branch of + #ifndef _WIN32 or #if !defined(_WIN32), and whatever a conditional inside such a branch holds. The directives + themselves are not among them. Any other branch counts as compiled everywhere, #elif defined(_WIN32) and + #if defined(_WIN32) && X too: such a branch is scanned, which can fail the check but never lets a use through.""" + lines, stack = set(), [] # stack: [kind ('win', 'notwin', 'other'), whether its open branch is Windows-only] + for n, line in enumerate(code.split("\n")): + s = line.strip() + m = DIRECTIVE.match(s) + if not m: + if any(w for _, w in stack): + lines.add(n) + continue + d = m.group(1) + if d in ("if", "ifdef", "ifndef"): + kind = "win" if WIN_IF.match(s) else "notwin" if WIN_IFNOT.match(s) else "other" + stack.append([kind, kind == "win"]) + elif d == "else" and stack: + stack[-1][1] = stack[-1][0] == "notwin" + elif d == "elif" and stack: + stack[-1][1] = False + elif d == "endif" and stack: + stack.pop() + return lines + + def scan(path): raw = path.read_text(encoding="utf-8", errors="replace") - hits = [("include", m.group(0).strip()) for m in INCLUDE.finditer(raw)] - code = strip(raw) - hits += [("call", m.group(1)) for m in CALL_RE.finditer(code)] + code = strip(raw) # the same lines as raw + skip = windows_only_lines(code) + starts = [0] + [i + 1 for i, c in enumerate(raw) if c == "\n"] + + def guarded(pos): + return bisect.bisect_right(starts, pos) - 1 in skip + # an include's match can begin on the line before it (^\s* takes that line's newline): its end is on its own line + hits = [("include", m.group(0).strip()) for m in INCLUDE.finditer(raw) if not guarded(m.end() - 1)] + hits += [("call", m.group(1)) for m in CALL_RE.finditer(code) if not guarded(m.start())] return hits