diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0a948654..6db5446b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -76,6 +76,13 @@ jobs: cmake --install build/ --config Release echo "SDL3_net_ROOT=$(pwd)/prefix" >> $GITHUB_ENV ( cd prefix; find . ) | LC_ALL=C sort -u + + - name: Verify exports files + if: ${{ matrix.platform.test-exports }} + run: | + set -e + python src/genexports.py + git diff --exit-code -- src/SDL_net.sym src/SDL_net.exports - name: Verify CMake configuration files run: | cmake -S cmake/test -B cmake_config_build \ diff --git a/CMakeLists.txt b/CMakeLists.txt index f709f22d..e5430513 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,12 +158,15 @@ if(NOT ANDROID) VERSION "${SO_VERSION}" ) if(APPLE) - cmake_minimum_required(VERSION 3.17...3.28) set_target_properties(${sdl3_net_target_name} PROPERTIES MACHO_COMPATIBILITY_VERSION "${DYLIB_COMPAT_VERSION}" MACHO_CURRENT_VERSION "${DYLIB_CURRENT_VERSION}" ) sdl_no_deprecated_errors(${sdl3_net_target_name}) + set_property(TARGET ${sdl3_net_target_name} APPEND PROPERTY LINK_DEPENDS + "${PROJECT_SOURCE_DIR}/src/SDL_net.exports") + target_link_options(${sdl3_net_target_name} PRIVATE + "SHELL:-Wl,-exported_symbols_list,${PROJECT_SOURCE_DIR}/src/SDL_net.exports") endif() endif() if(SDLNET_BUILD_SHARED_LIBS) diff --git a/src/SDL_net.exports b/src/SDL_net.exports new file mode 100644 index 00000000..ef3bf338 --- /dev/null +++ b/src/SDL_net.exports @@ -0,0 +1,35 @@ +# SDL3_net.dylib exports +_NET_AcceptClient +_NET_CompareAddresses +_NET_CreateClient +_NET_CreateDatagramSocket +_NET_CreateServer +_NET_DestroyDatagram +_NET_DestroyDatagramSocket +_NET_DestroyServer +_NET_DestroyStreamSocket +_NET_FreeLocalAddresses +_NET_GetAddressStatus +_NET_GetAddressString +_NET_GetConnectionStatus +_NET_GetLocalAddresses +_NET_GetStreamSocketAddress +_NET_GetStreamSocketPendingWrites +_NET_Version +_NET_Init +_NET_Quit +_NET_ReadFromStreamSocket +_NET_ReceiveDatagram +_NET_RefAddress +_NET_ResolveHostname +_NET_SendDatagram +_NET_SimulateAddressResolutionLoss +_NET_SimulateDatagramPacketLoss +_NET_SimulateStreamPacketLoss +_NET_UnrefAddress +_NET_WaitUntilConnected +_NET_WaitUntilInputAvailable +_NET_WaitUntilResolved +_NET_WaitUntilStreamSocketDrained +_NET_WriteToStreamSocket +# extra symbols go here (don't modify this line) diff --git a/src/SDL_net.sym b/src/SDL_net.sym index d7b8a93a..53c9c9c1 100644 --- a/src/SDL_net.sym +++ b/src/SDL_net.sym @@ -33,5 +33,6 @@ SDL3_net_0.0.0 { NET_WaitUntilResolved; NET_WaitUntilStreamSocketDrained; NET_WriteToStreamSocket; + # extra symbols go here (don't modify this line) local: *; }; diff --git a/src/genexports.py b/src/genexports.py new file mode 100755 index 00000000..0f48f6d3 --- /dev/null +++ b/src/genexports.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 + +# Simple DirectMedia Layer +# Copyright (C) 1997-2025 Sam Lantinga +# +# This software is provided 'as-is', without any express or implied +# warranty. In no event will the authors be held liable for any damages +# arising from the use of this software. +# +# Permission is granted to anyone to use this software for any purpose, +# including commercial applications, and to alter it and redistribute it +# freely, subject to the following restrictions: +# +# 1. The origin of this software must not be misrepresented; you must not +# claim that you wrote the original software. If you use this software +# in a product, an acknowledgment in the product documentation would be +# appreciated but is not required. +# 2. Altered source versions must be plainly marked as such, and must not be +# misrepresented as being the original software. +# 3. This notice may not be removed or altered from any source distribution. + +# WHAT IS THIS? +# When you add a public API to a SDL library, please run this script, make sure the +# output looks sane (git diff, it adds to existing files), and commit it. +# It keeps the export lists in sync with the API. +# + +import argparse +import dataclasses +import json +import logging +import os +from pathlib import Path +import pprint +import re + +RE_EXTERN_C = re.compile(r'.*extern[ "]*C[ "].*') +RE_COMMENT_REMOVE_CONTENT = re.compile(r'\/\*.*\*/') +RE_PARSING_FUNCTION = re.compile(r'(.*SDLCALL[^\(\)]*) ([a-zA-Z0-9_]+) *\((.*)\) *;.*') + +#eg: +# void (SDLCALL *callback)(void*, int) +# \1(\2)\3 +RE_PARSING_CALLBACK = re.compile(r'([^\(\)]*)\(([^\(\)]+)\)(.*)') + + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class SdlProjectSymbolProperties: + include_dir: Path + version_export_path: Path + macos_exports_path: Path + re_symbol: str + + @classmethod + def from_root(cls, project_root: Path) -> "SdlProjectSymbolProperties": + wikiheaders_options = {} + with (project_root / ".wikiheaders-options").open("r", newline="\n") as f: + for line in f.readlines(): + key, value = line.split("=", 1) + key, value = key.strip(), value.strip() + wikiheaders_options[key] = value + return cls( + include_dir=project_root / wikiheaders_options["incsubdir"], + version_export_path=project_root / "src" / (wikiheaders_options["projectfullname"] + ".sym"), + macos_exports_path=project_root / "src" / (wikiheaders_options["projectfullname"] + ".exports"), + re_symbol= wikiheaders_options["apiprefixregex"], + ) + +@dataclasses.dataclass(frozen=True) +class SdlProcedure: + retval: str + name: str + parameter: list[str] + parameter_name: list[str] + header: str + comment: str + + @property + def variadic(self) -> bool: + return "..." in self.parameter + + +def parse_header(header_path: Path) -> list[SdlProcedure]: + logger.debug("Parse header: %s", header_path) + + header_procedures = [] + + parsing_function = False + current_func = "" + parsing_comment = False + current_comment = "" + ignore_wiki_documentation = False + + with header_path.open() as f: + for line in f: + + # Skip lines if we're in a wiki documentation block. + if ignore_wiki_documentation: + if line.startswith("#endif"): + ignore_wiki_documentation = False + continue + + # Discard wiki documentations blocks. + if line.startswith("#ifdef SDL_WIKI_DOCUMENTATION_SECTION"): + ignore_wiki_documentation = True + continue + + # Discard pre-processor directives ^#.* + if line.startswith("#"): + continue + + # Discard "extern C" line + match = RE_EXTERN_C.match(line) + if match: + continue + + # Remove one line comment // ... + # eg: extern SDL_DECLSPEC SDL_hid_device * SDLCALL SDL_hid_open_path(const char *path, int bExclusive /* = false */) + line = RE_COMMENT_REMOVE_CONTENT.sub('', line) + + # Get the comment block /* ... */ across several lines + match_start = "/*" in line + match_end = "*/" in line + if match_start and match_end: + continue + if match_start: + parsing_comment = True + current_comment = line + continue + if match_end: + parsing_comment = False + current_comment += line + continue + if parsing_comment: + current_comment += line + continue + + # Get the function prototype across several lines + if parsing_function: + # Append to the current function + current_func += " " + current_func += line.strip() + else: + # if is contains "extern", start grabbing + if "extern" not in line: + continue + # Start grabbing the new function + current_func = line.strip() + parsing_function = True + + # If it contains ';', then the function is complete + if ";" not in current_func: + continue + + # Got function/comment, reset vars + parsing_function = False + func = current_func + comment = current_comment + current_func = "" + current_comment = "" + + # Discard if it doesn't contain 'SDLCALL' + if "SDLCALL" not in func: + logger.debug(" Discard, doesn't have SDLCALL: %r", func) + continue + + # Discard if it contains 'SDLMAIN_DECLSPEC' (these are not SDL symbols). + if "SDLMAIN_DECLSPEC" in func: + logger.debug(" Discard, has SDLMAIN_DECLSPEC: %r", func) + continue + + logger.debug("Raw data: %r", func) + + # Replace unusual stuff... + func = func.replace(" SDL_PRINTF_VARARG_FUNC(1)", "") + func = func.replace(" SDL_PRINTF_VARARG_FUNC(2)", "") + func = func.replace(" SDL_PRINTF_VARARG_FUNC(3)", "") + func = func.replace(" SDL_PRINTF_VARARG_FUNC(4)", "") + func = func.replace(" SDL_PRINTF_VARARG_FUNCV(1)", "") + func = func.replace(" SDL_PRINTF_VARARG_FUNCV(2)", "") + func = func.replace(" SDL_PRINTF_VARARG_FUNCV(3)", "") + func = func.replace(" SDL_PRINTF_VARARG_FUNCV(4)", "") + func = func.replace(" SDL_WPRINTF_VARARG_FUNC(3)", "") + func = func.replace(" SDL_WPRINTF_VARARG_FUNCV(3)", "") + func = func.replace(" SDL_SCANF_VARARG_FUNC(2)", "") + func = func.replace(" SDL_SCANF_VARARG_FUNCV(2)", "") + func = func.replace(" SDL_ANALYZER_NORETURN", "") + func = func.replace(" SDL_MALLOC", "") + func = func.replace(" SDL_ALLOC_SIZE2(1, 2)", "") + func = func.replace(" SDL_ALLOC_SIZE(2)", "") + func = re.sub(r" SDL_ACQUIRE\(.*\)", "", func) + func = re.sub(r" SDL_ACQUIRE_SHARED\(.*\)", "", func) + func = re.sub(r" SDL_TRY_ACQUIRE\(.*\)", "", func) + func = re.sub(r" SDL_TRY_ACQUIRE_SHARED\(.*\)", "", func) + func = re.sub(r" SDL_RELEASE\(.*\)", "", func) + func = re.sub(r" SDL_RELEASE_SHARED\(.*\)", "", func) + func = re.sub(r" SDL_RELEASE_GENERIC\(.*\)", "", func) + func = re.sub(r"([ (),])(SDL_IN_BYTECAP\([^)]*\))", r"\1", func) + func = re.sub(r"([ (),])(SDL_OUT_BYTECAP\([^)]*\))", r"\1", func) + func = re.sub(r"([ (),])(SDL_INOUT_Z_CAP\([^)]*\))", r"\1", func) + func = re.sub(r"([ (),])(SDL_OUT_Z_CAP\([^)]*\))", r"\1", func) + + # Should be a valid function here + match = RE_PARSING_FUNCTION.match(func) + if not match: + logger.error("Cannot parse: %s", func) + raise ValueError(func) + + func_ret = match.group(1) + func_name = match.group(2) + func_params = match.group(3) + + # + # Parse return value + # + func_ret = func_ret.replace('extern', ' ') + func_ret = func_ret.replace('SDLCALL', ' ') + func_ret = func_ret.replace('SDL_DECLSPEC', ' ') + func_ret, _ = re.subn('([ ]{2,})', ' ', func_ret) + # Remove trailing spaces in front of '*' + func_ret = func_ret.replace(' *', '*') + func_ret = func_ret.strip() + + # + # Parse parameters + # + func_params = func_params.strip() + if func_params == "": + func_params = "void" + + # Identify each function parameters with type and name + # (eventually there are callbacks of several parameters) + tmp = func_params.split(',') + tmp2 = [] + param = "" + for t in tmp: + if param == "": + param = t + else: + param = param + "," + t + # Identify a callback or parameter when there is same count of '(' and ')' + if param.count('(') == param.count(')'): + tmp2.append(param.strip()) + param = "" + + # Process each parameters, separation name and type + func_param_type = [] + func_param_name = [] + for t in tmp2: + if t == "void": + func_param_type.append(t) + func_param_name.append("") + continue + + if t == "...": + func_param_type.append(t) + func_param_name.append("") + continue + + param_name = "" + + # parameter is a callback + if '(' in t: + match = RE_PARSING_CALLBACK.match(t) + if not match: + logger.error("cannot parse callback: %s", t) + raise ValueError(t) + a = match.group(1).strip() + b = match.group(2).strip() + c = match.group(3).strip() + + try: + (param_type, param_name) = b.rsplit('*', 1) + except: + param_type = t + param_name = "param_name_not_specified" + + # bug rsplit ?? + if param_name == "": + param_name = "param_name_not_specified" + + # reconstruct a callback name for future parsing + func_param_type.append(a + " (" + param_type.strip() + " *REWRITE_NAME)" + c) + func_param_name.append(param_name.strip()) + + continue + + # array like "char *buf[]" + has_array = False + if t.endswith("[]"): + t = t.replace("[]", "") + has_array = True + + # pointer + if '*' in t: + try: + (param_type, param_name) = t.rsplit('*', 1) + except: + param_type = t + param_name = "param_name_not_specified" + + # bug rsplit ?? + if param_name == "": + param_name = "param_name_not_specified" + + val = param_type.strip() + "*REWRITE_NAME" + + # Remove trailing spaces in front of '*' + tmp = "" + while val != tmp: + tmp = val + val = val.replace(' ', ' ') + val = val.replace(' *', '*') + # first occurrence + val = val.replace('*', ' *', 1) + val = val.strip() + + else: # non pointer + # cut-off last word on + try: + (param_type, param_name) = t.rsplit(' ', 1) + except: + param_type = t + param_name = "param_name_not_specified" + + val = param_type.strip() + " REWRITE_NAME" + + # set back array + if has_array: + val += "[]" + + func_param_type.append(val) + func_param_name.append(param_name.strip()) + + new_proc = SdlProcedure( + retval=func_ret, # Return value type + name=func_name, # Function name + comment=comment, # Function comment + header=header_path.name, # Header file + parameter=func_param_type, # List of parameters (type + anonymized param name 'REWRITE_NAME') + parameter_name=func_param_name, # Real parameter name, or 'param_name_not_specified' + ) + + header_procedures.append(new_proc) + + if logger.getEffectiveLevel() <= logging.DEBUG: + logger.debug("%s", pprint.pformat(new_proc)) + + return header_procedures + + +# Dump API into a json file +def full_API_json(path: Path, procedures: list[SdlProcedure]): + with path.open('w', newline='') as f: + json.dump([dataclasses.asdict(proc) for proc in procedures], f, indent=4, sort_keys=True) + logger.info("dump API to '%s'", path) + + +class CallOnce: + def __init__(self, cb): + self._cb = cb + self._called = False + def __call__(self, *args, **kwargs): + if self._called: + return + self._called = True + self._cb(*args, **kwargs) + + +# Check public function comments are correct +def print_check_comment_header(): + logger.warning("") + logger.warning("Please fix following warning(s):") + logger.warning("--------------------------------") + + +def check_documentations(procedures: list[SdlProcedure]) -> None: + + check_comment_header = CallOnce(print_check_comment_header) + + warning_header_printed = False + + # Check \param + for proc in procedures: + expected = len(proc.parameter) + if expected == 1: + if proc.parameter[0] == 'void': + expected = 0 + count = proc.comment.count("\\param") + if count != expected: + # skip SDL_stdinc.h + if proc.header != 'SDL_stdinc.h': + # Warning mismatch \param and function prototype + check_comment_header() + logger.warning(" In file %s: function %s() has %d '\\param' but expected %d", proc.header, proc.name, count, expected) + + # Warning check \param uses the correct parameter name + # skip SDL_stdinc.h + if proc.header != 'SDL_stdinc.h': + for n in proc.parameter_name: + if n != "" and "\\param " + n not in proc.comment and "\\param[out] " + n not in proc.comment: + check_comment_header() + logger.warning(" In file %s: function %s() missing '\\param %s'", proc.header, proc.name, n) + + # Check \returns + for proc in procedures: + expected = 1 + if proc.retval == 'void': + expected = 0 + + count = proc.comment.count("\\returns") + if count != expected: + # skip SDL_stdinc.h + if proc.header != 'SDL_stdinc.h': + # Warning mismatch \param and function prototype + check_comment_header() + logger.warning(" In file %s: function %s() has %d '\\returns' but expected %d" % (proc.header, proc.name, count, expected)) + + # Check \since + for proc in procedures: + expected = 1 + count = proc.comment.count("\\since") + if count != expected: + # skip SDL_stdinc.h + if proc.header != 'SDL_stdinc.h': + # Warning mismatch \param and function prototype + check_comment_header() + logger.warning(" In file %s: function %s() has %d '\\since' but expected %d" % (proc.header, proc.name, count, expected)) + + +# Parse 'sdl_dynapi_procs_h' file to find existing functions +def find_existing_proc_names(project_properties: SdlProjectSymbolProperties) -> set[str]: + versioned_symbols = set() + re_version_export_symbol = re.compile(r'\s*(' + project_properties.re_symbol + r"[a-zA-Z0-9_]+);\s*") + with project_properties.version_export_path.open() as f: + for line in f: + match = re_version_export_symbol.match(line) + if not match: + continue + existing_func = match.group(1) + versioned_symbols.add(existing_func) + logger.debug("symbols from version script: %r", versioned_symbols) + + macos_symbols = set() + re_macos_export_symbol = re.compile(r'\s*_(' + project_properties.re_symbol + r"[a-zA-Z0-9_]+)\s*") + with project_properties.macos_exports_path.open() as f: + for line in f: + match = re_macos_export_symbol.match(line) + if not match: + continue + existing_func = match.group(1) + macos_symbols.add(existing_func) + logger.debug("symbols from macos exports file: %r", macos_symbols) + + non_matching_symbols = (versioned_symbols - macos_symbols).union(macos_symbols - versioned_symbols) + if non_matching_symbols: + logger.error("Following symbols do not match: %r", non_matching_symbols) + raise RuntimeError("Non-matching symbols", non_matching_symbols) + return versioned_symbols + +# Get list of SDL headers +def get_header_list(project_properties: SdlProjectSymbolProperties) -> list[Path]: + ret = [] + + for f in project_properties.include_dir.iterdir(): + # Only *.h files + if f.is_file() and f.suffix == ".h": + ret.append(f) + else: + logger.debug("Skip %s", f) + + # Order headers for reproducible behavior + ret.sort() + + return ret + +# Write the new API in files: _procs.h _overrides.h and .sym +def add_dyn_api(proc: SdlProcedure, project_properties:SdlProjectSymbolProperties) -> None: + decl_args: list[str] = [] + call_args = [] + for i, argtype in enumerate(proc.parameter): + # Special case, void has no parameter name + if argtype == "void": + assert len(decl_args) == 0 + assert len(proc.parameter) == 1 + decl_args.append("void") + continue + + # Var name: a, b, c, ... + varname = chr(ord('a') + i) + + decl_args.append(argtype.replace("REWRITE_NAME", varname)) + if argtype != "...": + call_args.append(varname) + + macro_args = ( + proc.retval, + proc.name, + "({})".format(",".join(decl_args)), + "({})".format(",".join(call_args)), + "" if proc.retval == "void" else "return", + ) + + # File: SDL_{image,mixer,net,ttf}.sym + # + # Add before "extra symbols go here" line + with project_properties.version_export_path.open(newline="\n") as f: + new_input = [] + for line in f: + if "extra symbols go here" in line: + new_input.append(f" {proc.name};\n") + new_input.append(line) + + with project_properties.version_export_path.open('w', newline='') as f: + for line in new_input: + f.write(line) + + # File: SDL_{image,mixer,net,ttf}.exports + # + # Add before "extra symbols go here" line + with project_properties.macos_exports_path.open(newline="\n") as f: + new_input = [] + for line in f: + if "extra symbols go here" in line: + new_input.append(f"_{proc.name}\n") + new_input.append(line) + + with project_properties.macos_exports_path.open("w", newline="\n") as f: + for line in new_input: + f.write(line) + + + +def main(): + parser = argparse.ArgumentParser() + parser.set_defaults(loglevel=logging.INFO) + parser.add_argument('--dump', nargs='?', default=None, const="sdl.json", metavar="JSON", help='output all API into a .json file') + parser.add_argument('--debug', action='store_const', const=logging.DEBUG, dest="loglevel", help='add debug traces') + args = parser.parse_args() + + logging.basicConfig(level=args.loglevel, format='[%(levelname)s] %(message)s') + + root = Path(__file__).resolve().parents[1] + + project_properties = SdlProjectSymbolProperties.from_root(root) + logger.debug("project_properties=%r", project_properties) + + # Get list of SDL headers + sdl_list_includes = get_header_list(project_properties) + procedures = [] + for filename in sdl_list_includes: + header_procedures = parse_header(filename) + procedures.extend(header_procedures) + + # Parse 'SDL_{image,mixer,ttf}.sym' file to find existing functions + existing_proc_names = find_existing_proc_names(project_properties) + for procedure in procedures: + if procedure.name not in existing_proc_names: + logger.info("NEW %s", procedure.name) + add_dyn_api(procedure, project_properties) + + if args.dump: + # Dump API into a json file + full_API_json(path=Path(args.dump), procedures=procedures) + + # Check comment formatting + check_documentations(procedures) + + +if __name__ == "__main__": + raise SystemExit(main())