diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b8566850..d05269440 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,13 @@ file. Note that this requires `phpize` to be run for PHP 8.2 to make use of all features. After changing a stub file, run `./build/gen_stub.php` to regenerate the corresponding arginfo files and commit the results. +## Generating function maps for static analysis tools + +PHPStan and Psalm use function maps to provide users with correct type analysis +when using this extension. To generate the function map, run the +`generate-function-map` make target. The generated map will be stored in +`scripts/functionMap.php`. + ## Testing The extension's test use the PHPT format from PHP internals. This format is diff --git a/Makefile.frag b/Makefile.frag index d46e7e03c..6c69049b1 100644 --- a/Makefile.frag +++ b/Makefile.frag @@ -1,4 +1,4 @@ -.PHONY: coverage test-clean package package.xml format format-changed format-check +.PHONY: mv-coverage lcov-coveralls lcov-local coverage coveralls format format-changed format-check test-clean package package.xml libmongoc-version-current libmongocrypt-version-current generate-function-map ifneq (,$(realpath $(EXTENSION_DIR)/json.so)) PHP_TEST_SHARED_EXTENSIONS := "-d" "extension=$(EXTENSION_DIR)/json.so" $(PHP_TEST_SHARED_EXTENSIONS) @@ -66,3 +66,25 @@ libmongoc-version-current: libmongocrypt-version-current: cd src/libmongocrypt/ && python etc/calc_release_version.py > ../LIBMONGOCRYPT_VERSION_CURRENT + +generate-function-map: all + @if test ! -z "$(PHP_EXECUTABLE)" && test -x "$(PHP_EXECUTABLE)"; then \ + INI_FILE=`$(PHP_EXECUTABLE) -d 'display_errors=stderr' -r 'echo php_ini_loaded_file();' 2> /dev/null`; \ + if test "$$INI_FILE"; then \ + $(EGREP) -h -v $(PHP_DEPRECATED_DIRECTIVES_REGEX) "$$INI_FILE" > $(top_builddir)/tmp-php.ini; \ + else \ + echo > $(top_builddir)/tmp-php.ini; \ + fi; \ + INI_SCANNED_PATH=`$(PHP_EXECUTABLE) -d 'display_errors=stderr' -r '$$a = explode(",\n", trim(php_ini_scanned_files())); echo $$a[0];' 2> /dev/null`; \ + if test "$$INI_SCANNED_PATH"; then \ + INI_SCANNED_PATH=`$(top_srcdir)/build/shtool path -d $$INI_SCANNED_PATH`; \ + $(EGREP) -h -v $(PHP_DEPRECATED_DIRECTIVES_REGEX) "$$INI_SCANNED_PATH"/*.ini >> $(top_builddir)/tmp-php.ini; \ + fi; \ + CC="$(CC)" \ + $(PHP_EXECUTABLE) -n -c $(top_builddir)/tmp-php.ini -n -c $(top_builddir)/tmp-php.ini -d extension_dir=$(top_builddir)/modules/ $(PHP_TEST_SHARED_EXTENSIONS) $(top_srcdir)/scripts/generate-functionmap.php; \ + RESULT_EXIT_CODE=$$?; \ + rm $(top_builddir)/tmp-php.ini; \ + exit $$RESULT_EXIT_CODE; \ + else \ + echo "ERROR: Cannot generate function maps without CLI sapi."; \ + fi diff --git a/scripts/generate-functionmap.php b/scripts/generate-functionmap.php new file mode 100644 index 000000000..54571ab76 --- /dev/null +++ b/scripts/generate-functionmap.php @@ -0,0 +1,138 @@ +createFunctionMap($filename); +printf("Created call map in %s\n", $filename); + +class FunctionMapGenerator +{ + public function createFunctionMap(string $filename): void + { + $this->writeFunctionMap($filename, $this->getFunctionMap()); + } + + private function getFunctionMap(): array + { + $classes = array_filter(get_declared_classes(), $this->filterItems(...)); + $interfaces = array_filter(get_declared_interfaces(), $this->filterItems(...)); + $functions = array_filter(get_defined_functions()['internal'], $this->filterItems(...)); + + $functionMap = []; + + // Generate call map for functions + foreach ($functions as $functionName) { + $reflectionFunction = new ReflectionFunction($functionName); + $functionMap[$reflectionFunction->getName()] = $this->getFunctionMapEntry($reflectionFunction); + } + + // Generate call map for classes and interfaces + $members = array_merge($classes, $interfaces); + sort($members); + + $skippedMethods = ['__set_state', '__wakeup', '__serialize', '__unserialize']; + + foreach ($members as $member) { + $reflectionClass = new ReflectionClass($member); + + foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass() != $reflectionClass && $method->getName() != '__toString') { + continue; + } + + if (in_array($method->getName(), $skippedMethods, true)) { + continue; + } + + $methodKey = $reflectionClass->getName() . '::' . $method->getName(); + $functionMap[$methodKey] = $this->getFunctionMapEntry($method); + } + } + + return $functionMap; + } + + private function writeFunctionMap(string $filename, array $functionMap): void + { + $lines = []; + foreach ($functionMap as $methodName => $typeInfo) { + $generatedTypeInfo = implode( + ', ', + array_map( + function (string|int $key, string $value): string { + if (is_int($key)) { + return $this->removeDoubleBackslash(var_export($value, true)); + } + + return sprintf('%s => %s', var_export($key, true), $this->removeDoubleBackslash(var_export($value, true))); + }, + array_keys($typeInfo), + array_values($typeInfo) + ) + ); + + $lines[] = sprintf( + ' %s => [%s],', + $this->removeDoubleBackslash(var_export($methodName, true)), + $generatedTypeInfo + ); + } + + $fileTemplate = <<<'PHP' +hasReturnType() => (string) $function->getReturnType(), + $function->hasTentativeReturnType() => (string) $function->getTentativeReturnType(), + default => 'void', + }; + + $functionMapEntry = [$returnType]; + + foreach ($function->getParameters() as $parameter) { + $parameterKey = $parameter->getName(); + if ($parameter->isOptional()) { + $parameterKey .= '='; + } + + $functionMapEntry[$parameterKey] = (string) $parameter->getType(); + } + + return $functionMapEntry; + } + + private function removeDoubleBackslash(string $string): string + { + return str_replace('\\\\', '\\', $string); + } +}