Skip to content

Commit 2cb650b

Browse files
authored
Add script to generate function map for static analysis tools (#1436)
* Add script to generate function map for static analysis tools * Refactor function map generation script * Remove workaround for unserialize methods * Update list of phony make targets * Add section about generating function maps to contribution docs * Fix code style in function map generator
1 parent c4d1951 commit 2cb650b

File tree

3 files changed

+168
-1
lines changed

3 files changed

+168
-1
lines changed

CONTRIBUTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ file. Note that this requires `phpize` to be run for PHP 8.2 to make use
3535
of all features. After changing a stub file, run `./build/gen_stub.php`
3636
to regenerate the corresponding arginfo files and commit the results.
3737

38+
## Generating function maps for static analysis tools
39+
40+
PHPStan and Psalm use function maps to provide users with correct type analysis
41+
when using this extension. To generate the function map, run the
42+
`generate-function-map` make target. The generated map will be stored in
43+
`scripts/functionMap.php`.
44+
3845
## Testing
3946

4047
The extension's test use the PHPT format from PHP internals. This format is

Makefile.frag

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: coverage test-clean package package.xml format format-changed format-check
1+
.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
22

33
ifneq (,$(realpath $(EXTENSION_DIR)/json.so))
44
PHP_TEST_SHARED_EXTENSIONS := "-d" "extension=$(EXTENSION_DIR)/json.so" $(PHP_TEST_SHARED_EXTENSIONS)
@@ -66,3 +66,25 @@ libmongoc-version-current:
6666

6767
libmongocrypt-version-current:
6868
cd src/libmongocrypt/ && python etc/calc_release_version.py > ../LIBMONGOCRYPT_VERSION_CURRENT
69+
70+
generate-function-map: all
71+
@if test ! -z "$(PHP_EXECUTABLE)" && test -x "$(PHP_EXECUTABLE)"; then \
72+
INI_FILE=`$(PHP_EXECUTABLE) -d 'display_errors=stderr' -r 'echo php_ini_loaded_file();' 2> /dev/null`; \
73+
if test "$$INI_FILE"; then \
74+
$(EGREP) -h -v $(PHP_DEPRECATED_DIRECTIVES_REGEX) "$$INI_FILE" > $(top_builddir)/tmp-php.ini; \
75+
else \
76+
echo > $(top_builddir)/tmp-php.ini; \
77+
fi; \
78+
INI_SCANNED_PATH=`$(PHP_EXECUTABLE) -d 'display_errors=stderr' -r '$$a = explode(",\n", trim(php_ini_scanned_files())); echo $$a[0];' 2> /dev/null`; \
79+
if test "$$INI_SCANNED_PATH"; then \
80+
INI_SCANNED_PATH=`$(top_srcdir)/build/shtool path -d $$INI_SCANNED_PATH`; \
81+
$(EGREP) -h -v $(PHP_DEPRECATED_DIRECTIVES_REGEX) "$$INI_SCANNED_PATH"/*.ini >> $(top_builddir)/tmp-php.ini; \
82+
fi; \
83+
CC="$(CC)" \
84+
$(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; \
85+
RESULT_EXIT_CODE=$$?; \
86+
rm $(top_builddir)/tmp-php.ini; \
87+
exit $$RESULT_EXIT_CODE; \
88+
else \
89+
echo "ERROR: Cannot generate function maps without CLI sapi."; \
90+
fi

scripts/generate-functionmap.php

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
if (PHP_VERSION_ID < 80000) {
4+
echo 'This script requires PHP 8.0 or higher';
5+
exit(1);
6+
}
7+
8+
$filename = __DIR__ . '/functionmap.php';
9+
(new FunctionMapGenerator)->createFunctionMap($filename);
10+
printf("Created call map in %s\n", $filename);
11+
12+
class FunctionMapGenerator
13+
{
14+
public function createFunctionMap(string $filename): void
15+
{
16+
$this->writeFunctionMap($filename, $this->getFunctionMap());
17+
}
18+
19+
private function getFunctionMap(): array
20+
{
21+
$classes = array_filter(get_declared_classes(), $this->filterItems(...));
22+
$interfaces = array_filter(get_declared_interfaces(), $this->filterItems(...));
23+
$functions = array_filter(get_defined_functions()['internal'], $this->filterItems(...));
24+
25+
$functionMap = [];
26+
27+
// Generate call map for functions
28+
foreach ($functions as $functionName) {
29+
$reflectionFunction = new ReflectionFunction($functionName);
30+
$functionMap[$reflectionFunction->getName()] = $this->getFunctionMapEntry($reflectionFunction);
31+
}
32+
33+
// Generate call map for classes and interfaces
34+
$members = array_merge($classes, $interfaces);
35+
sort($members);
36+
37+
$skippedMethods = ['__set_state', '__wakeup', '__serialize', '__unserialize'];
38+
39+
foreach ($members as $member) {
40+
$reflectionClass = new ReflectionClass($member);
41+
42+
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
43+
if ($method->getDeclaringClass() != $reflectionClass && $method->getName() != '__toString') {
44+
continue;
45+
}
46+
47+
if (in_array($method->getName(), $skippedMethods, true)) {
48+
continue;
49+
}
50+
51+
$methodKey = $reflectionClass->getName() . '::' . $method->getName();
52+
$functionMap[$methodKey] = $this->getFunctionMapEntry($method);
53+
}
54+
}
55+
56+
return $functionMap;
57+
}
58+
59+
private function writeFunctionMap(string $filename, array $functionMap): void
60+
{
61+
$lines = [];
62+
foreach ($functionMap as $methodName => $typeInfo) {
63+
$generatedTypeInfo = implode(
64+
', ',
65+
array_map(
66+
function (string|int $key, string $value): string {
67+
if (is_int($key)) {
68+
return $this->removeDoubleBackslash(var_export($value, true));
69+
}
70+
71+
return sprintf('%s => %s', var_export($key, true), $this->removeDoubleBackslash(var_export($value, true)));
72+
},
73+
array_keys($typeInfo),
74+
array_values($typeInfo)
75+
)
76+
);
77+
78+
$lines[] = sprintf(
79+
' %s => [%s],',
80+
$this->removeDoubleBackslash(var_export($methodName, true)),
81+
$generatedTypeInfo
82+
);
83+
}
84+
85+
$fileTemplate = <<<'PHP'
86+
<?php
87+
88+
$mongoDBFunctionMap = [
89+
%s
90+
];
91+
92+
PHP;
93+
94+
file_put_contents($filename, sprintf($fileTemplate, implode("\n", $lines)));
95+
}
96+
97+
private function filterItems(string $name): bool {
98+
$namespaces = ['MongoDB\BSON\\', 'MongoDB\Driver\\'];
99+
100+
$name = strtolower($name);
101+
102+
foreach ($namespaces as $namespace) {
103+
// Always compare lowercase names, as get_defined_functions lowercases function names by default
104+
if (str_starts_with($name, strtolower($namespace))) {
105+
return true;
106+
}
107+
}
108+
109+
return false;
110+
}
111+
112+
private function getFunctionMapEntry(ReflectionFunctionAbstract $function): array
113+
{
114+
$returnType = match(true) {
115+
$function->hasReturnType() => (string) $function->getReturnType(),
116+
$function->hasTentativeReturnType() => (string) $function->getTentativeReturnType(),
117+
default => 'void',
118+
};
119+
120+
$functionMapEntry = [$returnType];
121+
122+
foreach ($function->getParameters() as $parameter) {
123+
$parameterKey = $parameter->getName();
124+
if ($parameter->isOptional()) {
125+
$parameterKey .= '=';
126+
}
127+
128+
$functionMapEntry[$parameterKey] = (string) $parameter->getType();
129+
}
130+
131+
return $functionMapEntry;
132+
}
133+
134+
private function removeDoubleBackslash(string $string): string
135+
{
136+
return str_replace('\\\\', '\\', $string);
137+
}
138+
}

0 commit comments

Comments
 (0)