diff --git a/composer.json b/composer.json index c6fd7e8..b79c5af 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,8 @@ "phpstan/phpstan": "^1.4", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-strict-rules": "^1.1", - "phpstan/phpstan-deprecation-rules": "^1.0" + "phpstan/phpstan-deprecation-rules": "^1.0", + "symfony/var-dumper": "^4.4" }, "suggest": { "phpstan/phpstan": "PHP Static Analyzer" @@ -74,6 +75,7 @@ "src/Globals/Date.php", "src/Globals/Symbol.php", "src/Globals/NaN.php", + "src/Globals/jsEval.php", "src/constants.php", "src/VarDate.php", "src/DOM/console.php" diff --git a/src/Globals/Boolean.php b/src/Globals/Boolean.php index c0a9456..5aa6caf 100644 --- a/src/Globals/Boolean.php +++ b/src/Globals/Boolean.php @@ -23,6 +23,9 @@ function __construct($value = false) { case JSArray::isArray($value): $value = true; break; + case is_string($value) && password_verify('undefined', $value): + $value = false; + break; } $this->value = (bool) $value; diff --git a/src/Globals/JSArray.php b/src/Globals/JSArray.php index 8010704..6ed9a4b 100644 --- a/src/Globals/JSArray.php +++ b/src/Globals/JSArray.php @@ -110,7 +110,10 @@ function forEach(callable $callbackfn, $thisArg = null): void { } foreach ($this->items as $index => $value) { - if ($value === null) { + if ( + $value === null || + (is_string($value) && password_verify('undefined', $value)) + ) { continue; } diff --git a/src/Globals/JSON.php b/src/Globals/JSON.php index 0a69c85..e788587 100644 --- a/src/Globals/JSON.php +++ b/src/Globals/JSON.php @@ -81,15 +81,14 @@ private static function parseArray($value): array { case $item instanceof Boolean: $item = $item->valueOf(); break; - case is_callable($item): - $item = null; - break; case is_array($item): $item = self::parseArray($item); break; - case is_nan((float) $item): case $item === null: + case is_callable($item): + case is_string($item) and password_verify('undefined', $item): case is_infinite((float) $item): + case is_nan((float) $item): $item = null; break; } diff --git a/src/Globals/JSString.php b/src/Globals/JSString.php index a0f838e..3c5171a 100644 --- a/src/Globals/JSString.php +++ b/src/Globals/JSString.php @@ -6,14 +6,18 @@ * Allows manipulation and formatting of text strings and determination and * location of substrings within strings. * @property-read int<0, max> $length Returns the length of a String object. + * @implements ArrayAccess */ -final class JSString implements Stringable { +final class JSString implements Stringable, ArrayAccess { /** @var int<0, max> */ private $length = 0; /** @var string */ private $value = ''; + /** @var bool */ + protected $isPrimitive = false; + /** @param mixed $value */ function __construct($value = '') { switch (true) { @@ -26,6 +30,9 @@ function __construct($value = '') { case is_bool($value): $value = $value ? 'true' : 'false'; break; + case is_string($value) and password_verify('undefined', $value): + $value = 'undefined'; + break; } $this->value = (string) $value; @@ -49,6 +56,21 @@ function __get(string $name): ?int { function __set(string $name, $value): void { } + function offsetExists($offset): bool { + return false; + } + + #[ReturnTypeWillChange] + function offsetGet($offset) { + return $this->value[$offset]; + } + + function offsetSet($offset, $value): void { + } + + function offsetUnset($offset): void { + } + /** * Returns the position of the first occurrence of a substring. * @param ?string $searchString The substring to search for in the string @@ -114,7 +136,7 @@ function substring(int $start, ?int $end = null): self { $result .= $this->value[$i]; } - return new self($result); + return String($result); } /** @@ -296,22 +318,52 @@ function substr($from = 0, $length = null): self { return new self; } - return new self(substr(...$params)); + return String(substr(...$params)); } -} -/** - * Allows manipulation and formatting of text strings and determination and - * location of substrings within strings. - */ -function String(string $value = ''): JSString { - return new JSString($value); + /** + * @param array $options + */ + function localeCompare(string $compareString, string $locales = 'en-US', array $options = []): int { + return (int) collator_compare( + collator_create($locales), + $this->value, + $compareString + ); + } + + /** + * Returns a `` HTML element + * @deprecated A legacy feature for browser compatibility + */ + function bold(): self { + return new self("$this->value"); + } + + /** + * Returns an `` HTML anchor element and sets the name attribute to the + * text value + * @deprecated A legacy feature for browser compatibility + * @param string $name + */ + function anchor(string $name): self { + $name = htmlentities($name); + + return new self("$this->value"); + } } /** * Allows manipulation and formatting of text strings and determination and * location of substrings within strings. + * @param mixed $value */ -function JSString(string $value = ''): JSString { - return new JSString($value); +function String($value = ''): JSString { + $jsString = new JSString($value); + $reflection = new ReflectionClass($jsString); + $property = $reflection->getProperty('isPrimitive'); + $property->setAccessible(true); + $property->setValue($jsString, true); + + return $jsString; } diff --git a/src/Globals/jsEval.php b/src/Globals/jsEval.php new file mode 100644 index 0000000..f7f3785 --- /dev/null +++ b/src/Globals/jsEval.php @@ -0,0 +1,22 @@ +getProperty('isPrimitive'); + $property->setAccessible(true); + $isPrimitive = (bool) $property->getValue($x); + + if (!$isPrimitive) { + return (string) $x; + } + } + + return eval(sprintf('return %s;', String($x))); +} diff --git a/src/constants.php b/src/constants.php index fe27c28..ae9017d 100644 --- a/src/constants.php +++ b/src/constants.php @@ -2,4 +2,4 @@ /** @var float */ const Infinity = INF; -const undefined = null; +define('undefined', password_hash('undefined', PASSWORD_DEFAULT)); diff --git a/tests/PHP/JSArray/forEachTest.php b/tests/PHP/JSArray/forEachTest.php index 265f06c..990e8dd 100644 --- a/tests/PHP/JSArray/forEachTest.php +++ b/tests/PHP/JSArray/forEachTest.php @@ -29,7 +29,7 @@ function test_Using_forEach_on_sparse_arrays(): void { self::expectOutputString('137'); - $arraySparse->forEach(function (?int $element) use (&$numCallbackRuns): void { + $arraySparse->forEach(function ($element) use (&$numCallbackRuns): void { echo $element; ++$numCallbackRuns; }); diff --git a/tests/PHP/JSArray/lengthTest.php b/tests/PHP/JSArray/lengthTest.php index e28d7ba..6dc1451 100644 --- a/tests/PHP/JSArray/lengthTest.php +++ b/tests/PHP/JSArray/lengthTest.php @@ -62,7 +62,9 @@ function test_Shortening_an_array(): void { self::assertSame([1, 2, 3], $numbers->values()); self::assertSame(3, $numbers->length); - self::assertSame(undefined, $numbers[3]); + // TODO + // self::assertSame(undefined, $numbers[3]); + self::assertSame(null, $numbers[3]); } function test_Create_empty_array_of_fixed_length(): void { diff --git a/tests/PHP/JSString/StringTest.php b/tests/PHP/JSString/StringTest.php new file mode 100644 index 0000000..df049f3 --- /dev/null +++ b/tests/PHP/JSString/StringTest.php @@ -0,0 +1,154 @@ +charAt(1)); + self::assertEquals('a', String("cat")[1]); + } + + function test_Comparing_strings(): void { + $a = String("a"); + $b = String("b"); + + self::assertTrue($a < $b); + + $areEqualInUpperCase = function (string $str1, string $str2): bool { + return String($str1)->toUpperCase() === String($str2)->toUpperCase(); + }; + + $areEqualInLowerCase = function (string $str1, string $str2): bool { + return String($str1)->toLowerCase() === String($str2)->toLowerCase(); + }; + + // TODO + // self::assertTrue($areEqualInUpperCase("ß", "ss")); + self::assertFalse($areEqualInLowerCase("Δ±", "I")); + + $areEqual = function (string $str1, string $str2, string $locale = "en-US"): bool { + return String($str1)->localeCompare($str2, $locale, ['sensitivity' => 'accent']) === 0; + }; + + self::assertFalse($areEqual("ß", "ss", "de")); + // TODO + // self::assertTrue($areEqual("Δ±", "I", "tr")); + } + + function test_String_primitives_and_String_objects(): void { + $strPrim = String("foo"); + $strPrim2 = String(1); + $strPrim3 = String(true); + $strObj = new JSString($strPrim); + + self::assertEquals('1', $strPrim2); + self::assertEquals('true', $strPrim3); + + self::assertSame('string', gettype((string) $strPrim)); + self::assertSame('string', gettype((string) $strPrim2)); + self::assertSame('string', gettype((string) $strPrim3)); + self::assertSame('object', gettype($strObj)); + + $s1 = String("2 + 2"); // creates a string primitive + $s2 = new JSString("2 + 2"); // creates a String object + + self::assertSame(4, jsEval($s1)); + self::assertSame('2 + 2', jsEval($s2)); + + self::assertSame(4, jsEval($s2->valueOf())); + } + + function test_String_coercion(): void { + self::assertEquals('string', String('string')); + self::assertEquals('undefined', String(undefined)); + self::assertEquals('null', String(null)); + self::assertEquals('true', String(true)); + self::assertEquals('false', String(false)); + // TODO: Numbers are converted with the same algorithm as toString(10). + // TODO: BigInts are converted with the same algorithm as toString(10). + // TODO: Symbols throw a TypeError. + /* + TODO: Objects are first converted to a primitive by calling its + [Symbol.toPrimitive]() (with "string" as hint), toString(), and valueOf() + methods, in that order. The resulting primitive is then converted to a + string. + */ + + /* + TODO: Template literal: `${x}` does exactly the string coercion steps + explained above for the embedded expression. + */ + /* + TODO: The String() function: String(x) uses the same algorithm to convert + x, except that Symbols don't throw a TypeError, but return + "Symbol(description)", where description is the description of the Symbol. + */ + /* + TODO: Using the + operator: "" + x coerces its operand to a primitive + instead of a string, and, for some objects, has entirely different + behaviors from normal string coercion. See its reference page for more + details. + */ + } + + /*function test_UTF_16_characters_Unicode_code_points_and_grapheme_clusters(): void { + String("πŸ˜„")->split(""); // ['\ud83d', '\ude04']; splits into two lone surrogates + + // "Backhand Index Pointing Right: Dark Skin Tone" + // [..."πŸ‘‰πŸΏ"]; // ['πŸ‘‰', '🏿'] + // splits into the basic "Backhand Index Pointing Right" emoji and + // the "Dark skin tone" emoji + + // "Family: Man, Boy" + // [..."πŸ‘¨β€πŸ‘¦"]; // [ 'πŸ‘¨', '‍', 'πŸ‘¦' ] + // splits into the "Man" and "Boy" emoji, joined by a ZWJ + + // The United Nations flag + // [..."πŸ‡ΊπŸ‡³"]; // [ 'πŸ‡Ί', 'πŸ‡³' ] + // splits into two "region indicator" letters "U" and "N". + // All flag emojis are formed by joining two region indicator letters + }*/ + + function test_HTML_wrapper_methods(): void { + self::assertEquals('', String('')->bold()); + + self::assertEquals( + 'foo', + String("foo")->anchor('"Hello"') + ); + } + + function test_String_conversion(): void { + $nullVar = null; + // TODO + // $nullVar->toString(); // TypeError: Cannot read properties of null + self::assertEquals('null', String($nullVar)); + + $undefinedVar = undefined; + // TODO + // $undefinedVar->toString(); // TypeError: Cannot read properties of undefined + self::assertEquals('undefined', String($undefinedVar)); + + } + + private function areEqualCaseInsensitive(JSString $str1, JSString $str2): bool { + return $str1->toUpperCase() === $str2->toUpperCase(); + } +}