diff --git a/docs/runtime-semantics.md b/docs/runtime-semantics.md new file mode 100644 index 00000000000..4e3d8aac71d --- /dev/null +++ b/docs/runtime-semantics.md @@ -0,0 +1,29 @@ +# Runtime semantics (VM / JIT / AOT) + +Documented behavior for web and array access. See also [#176](https://github.com/PurHur/php-compiler/issues/176) (capability matrix). + +## Undefined array keys ([#273](https://github.com/PurHur/php-compiler/issues/273)) + +When `error_reporting` includes `E_WARNING` (default in VM: full `E_ALL`): + +| Context | Missing key read | Warning | +|---------|------------------|---------| +| VM (`bin/vm.php`, `phpc run`) | Value is `null` | `Warning: Undefined array key "key"` (string keys quoted; integer keys unquoted) | +| JIT / AOT (`__hashtable__` string keys) | Value is `null` / empty | Same message via `__compiler_undefined_array_key_warning_cstr` on stderr | + +`isset($arr['missing'])` and `empty($arr['missing'])` do **not** emit warnings. + +Writes to missing keys (`$arr['new'] = 1`) create the key without a warning (PHP behavior). + +Recommended app pattern once [#99](https://github.com/PurHur/php-compiler/issues/99) lands: `$name = $_GET['name'] ?? 'Guest';` + +## Verification + +```bash +docker run --rm -v "$(pwd):/compiler" -w /compiler php-compiler:22.04-dev \ + ./phpc run examples/001-SimpleWeb/example.php +# Without ?name= — VM emits Warning on stderr; page still renders with null coerced in echo. + +./script/ci-local.sh --filter UndefinedArrayKey +./script/ci-local.sh --filter undefined_array_key_get +``` diff --git a/lib/AOT/runtime/superglobals_refresh.c b/lib/AOT/runtime/superglobals_refresh.c index 3590c8690ea..641405e45ef 100644 --- a/lib/AOT/runtime/superglobals_refresh.c +++ b/lib/AOT/runtime/superglobals_refresh.c @@ -1002,3 +1002,20 @@ __string__ *__compiler_strip_tags(__string__ *input, __string__ *allowed) return result; } } + +/* + * Zend parity for missing array string keys (issue #273). + * Called from JIT __hashtable__readStringKeyValue when lookup returns NULL. + */ +void __compiler_undefined_array_key_warning_cstr(const char *key, size_t len) +{ + if (!key) { + return; + } + fprintf(stderr, "Warning: Undefined array key \"%.*s\"\n", (int) len, key); +} + +void __compiler_undefined_array_key_warning_long(long long key) +{ + fprintf(stderr, "Warning: Undefined array key %lld\n", key); +} diff --git a/lib/JIT/Builtin/Type.php b/lib/JIT/Builtin/Type.php index f51a4bb3927..2d748e58950 100755 --- a/lib/JIT/Builtin/Type.php +++ b/lib/JIT/Builtin/Type.php @@ -92,6 +92,23 @@ public function register(): void { ); $fnFormatDt = $this->context->module->addFunction('__compiler_format_datetime', $fntypeFormatDt); $this->context->registerFunction('__compiler_format_datetime', $fnFormatDt); + $fntypeUndefKeyStr = $this->context->context->functionType( + $void, + false, + $i8p, + $sizeT + ); + $fnUndefKeyStr = $this->context->module->addFunction( + '__compiler_undefined_array_key_warning_cstr', + $fntypeUndefKeyStr + ); + $this->context->registerFunction('__compiler_undefined_array_key_warning_cstr', $fnUndefKeyStr); + $fntypeUndefKeyLong = $this->context->context->functionType($void, false, $i64); + $fnUndefKeyLong = $this->context->module->addFunction( + '__compiler_undefined_array_key_warning_long', + $fntypeUndefKeyLong + ); + $this->context->registerFunction('__compiler_undefined_array_key_warning_long', $fnUndefKeyLong); $i8p = $this->context->getTypeFromString('int8*'); $i64p = $this->context->getTypeFromString('int64*'); $libcFns = [ diff --git a/lib/JIT/Builtin/Type/HashTable.php b/lib/JIT/Builtin/Type/HashTable.php index 8de319fd646..4dd4a0dd887 100755 --- a/lib/JIT/Builtin/Type/HashTable.php +++ b/lib/JIT/Builtin/Type/HashTable.php @@ -754,6 +754,31 @@ private function implementReadStringKeyValue(): void $ht = $fn->getParam(0); $key = $fn->getParam(1); $valPtr = $this->lookupStringKeyValue($fn, $block, $ht, $key); + $afterLookup = $fn->appendBasicBlock('strkey_read_val_after_lookup'); + $this->context->builder->branch($afterLookup); + $this->context->builder->positionAtEnd($afterLookup); + $isNull = $this->context->builder->icmp(Builder::INT_EQ, $valPtr, $valPtr->typeOf()->constNull()); + $hasValue = $fn->appendBasicBlock('strkey_read_val_has_value'); + $warn = $fn->appendBasicBlock('strkey_read_val_warn'); + $merge = $fn->appendBasicBlock('strkey_read_val_merge'); + $this->context->builder->branchIf($isNull, $warn, $hasValue); + $this->context->builder->positionAtEnd($warn); + $strMap = $this->context->structFieldMap['__string__']; + $i8p = $this->context->getTypeFromString('int8*'); + $keyLen = $this->context->builder->load( + $this->context->builder->structGep($key, $strMap['length']) + ); + $keyBytes = $this->stringDataPtr($key); + $keyCStr = $this->context->builder->pointerCast($keyBytes, $i8p); + $this->context->builder->call( + $this->context->lookupFunction('__compiler_undefined_array_key_warning_cstr'), + $keyCStr, + $keyLen + ); + $this->context->builder->branch($merge); + $this->context->builder->positionAtEnd($hasValue); + $this->context->builder->branch($merge); + $this->context->builder->positionAtEnd($merge); $this->context->builder->returnValue($valPtr); } diff --git a/lib/VM.php b/lib/VM.php index 9ad307ccb78..f1d8f32ade4 100755 --- a/lib/VM.php +++ b/lib/VM.php @@ -70,7 +70,11 @@ public function run(Block $block): int { $offset->stringOffset($container, $arg3->toInt()); $arg1->indirect($offset); } elseif ($container->type === Variable::TYPE_ARRAY) { - $arg1->indirect($container->toArray()->findVariable($arg3, false)); + $table = $container->toArray(); + if (!$table->keyExists($arg3)) { + $this->context->errors->undefinedArrayKey($arg3); + } + $arg1->indirect($table->findVariable($arg3, false)); } else { throw new \LogicException('Illegal offset'); } diff --git a/lib/VM/Context.php b/lib/VM/Context.php index dcccbc75363..3049f2260d2 100755 --- a/lib/VM/Context.php +++ b/lib/VM/Context.php @@ -24,10 +24,12 @@ class Context { private array $superglobalVars = []; public Runtime $runtime; - + + public ErrorReporter $errors; public function __construct(Runtime $runtime) { $this->runtime = $runtime; + $this->errors = new ErrorReporter(); } public function constantFetch(string $name): ?Variable { diff --git a/lib/VM/ErrorReporter.php b/lib/VM/ErrorReporter.php new file mode 100644 index 00000000000..c9d4895d9dd --- /dev/null +++ b/lib/VM/ErrorReporter.php @@ -0,0 +1,62 @@ +errorReporting = $errorReporting; + $this->displayErrors = $displayErrors; + } + + public function setErrorReporting(int $level): void + { + $this->errorReporting = $level; + } + + public function setDisplayErrors(bool $display): void + { + $this->displayErrors = $display; + } + + public function undefinedArrayKey(Variable $index, ?string $file = null): void + { + if (0 === ($this->errorReporting & self::E_WARNING)) { + return; + } + $key = $this->formatArrayKey($index); + $message = "Warning: Undefined array key {$key}"; + if (null !== $file && '' !== $file) { + $message .= " in {$file}"; + } + $message .= "\n"; + if ($this->displayErrors) { + fwrite(STDERR, $message); + } + } + + private function formatArrayKey(Variable $index): string + { + if (Variable::TYPE_STRING === $index->type) { + return '"' . $index->toString() . '"'; + } + if (Variable::TYPE_INTEGER === $index->type) { + return (string) $index->toInt(); + } + + return '"' . $index->toString() . '"'; + } +} diff --git a/lib/VM/HashTable.php b/lib/VM/HashTable.php index 0abb56f6e54..2f833eacd62 100755 --- a/lib/VM/HashTable.php +++ b/lib/VM/HashTable.php @@ -87,6 +87,18 @@ public function iterateKeyed(bool $resolveIndirect = false): \Generator } } + public function keyExists(Variable $index): bool + { + switch ($index->type) { + case Variable::TYPE_INTEGER: + return null !== $this->findIndex($index->toInt()); + case Variable::TYPE_STRING: + return null !== $this->find($index->toString()); + default: + throw new \LogicException("Unknown index type {$index->type}"); + } + } + public function findVariable(Variable $index, bool $forWrite): ?Variable { switch ($index->type) { case Variable::TYPE_INTEGER: diff --git a/test/real/cases/undefined_array_key_get.phpt b/test/real/cases/undefined_array_key_get.phpt new file mode 100644 index 00000000000..d5544431a44 --- /dev/null +++ b/test/real/cases/undefined_array_key_get.phpt @@ -0,0 +1,10 @@ +--TEST-- +Web: missing $_GET key yields null (Zend warning when error_reporting includes E_WARNING) +--ENV-- +QUERY_STRING= +--FILE-- +string('present'); + $this->assertFalse($ht->keyExists($key)); + + $val = new Variable(); + $val->string('x'); + $ht->add('present', $val); + $this->assertTrue($ht->keyExists($key)); + + $missing = new Variable(); + $missing->string('absent'); + $this->assertFalse($ht->keyExists($missing)); + } + + public function testErrorReporterFormatsArrayKeysLikePhp8(): void + { + $reporter = new ErrorReporter(); + $ref = new \ReflectionClass(ErrorReporter::class); + $method = $ref->getMethod('formatArrayKey'); + $method->setAccessible(true); + + $stringKey = new Variable(); + $stringKey->string('name'); + $this->assertSame('"name"', $method->invoke($reporter, $stringKey)); + + $intKey = new Variable(); + $intKey->int(0); + $this->assertSame('0', $method->invoke($reporter, $intKey)); + } +}