diff --git a/phpdotnet/phd/Package/Generic/XHTML.php b/phpdotnet/phd/Package/Generic/XHTML.php index 1491fb1a..f9a16b16 100644 --- a/phpdotnet/phd/Package/Generic/XHTML.php +++ b/phpdotnet/phd/Package/Generic/XHTML.php @@ -1493,23 +1493,219 @@ public function format_modifier_text($value, $tag) { } private function format_attribute_modifier_text(string $value): string { - // Anything that is not a leading "#[\Attribute(" / "#[\Attribute]" chunk - // e.g. "|" separator between arguments passes through. - if (!preg_match('/^(#\[)(.+?)([](])$/', $value, $match)) { - if (trim($value) === '|') { - return ' | '; + $trimmed = trim($value); + $namePattern = '\\\\?[A-Za-z_][A-Za-z0-9_\\\\]*'; + + // Full attribute with literal arguments: #[\Name(args)] + if (preg_match('/^(#\[)(' . $namePattern . ')\((.+)\)]$/s', $trimmed, $match)) { + [, $prefix, $name, $args] = $match; + return $prefix . $this->link_attribute_name($name) . '(' . $this->render_attribute_args($args) . ')]'; + } + + // Simple attribute: #[\Name] + if (preg_match('/^#\[(' . $namePattern . ')]$/', $trimmed, $match)) { + $name = $match[1]; + $attribute = strtolower(ltrim($name, "\\")); + $href = $this->getFilename('class.' . $attribute); + $token = '#[' . $name . ']'; + if (!$href) { + return $token; } - return $value; + + return '' . $token . ' '; + } + + // Opening of an attribute followed by child elements: #[\Name( + if (preg_match('/^(#\[)(' . $namePattern . ')\($/', $trimmed, $match)) { + [, $prefix, $name] = $match; + return $prefix . $this->link_attribute_name($name) . '('; + } + + // Separator between attribute arguments + if ($trimmed === '|') { + return ' | '; } - [, $prefix, $name, $suffix] = $match; + return $trimmed === '' ? '' : $value; + } + + private function link_attribute_name(string $name): string { $attribute = strtolower(ltrim($name, "\\")); $href = $this->getFilename('class.' . $attribute); if (!$href) { - return $value; + return $name; + } + + return '' . $name . ''; + } + + private function render_attribute_args(string $args): string { + $args = trim(preg_replace('/\s+/', ' ', $args)); + $parts = $this->split_attribute_args($args); + + if (count($parts) <= 1) { + return $this->render_attribute_arg_value($parts[0]['value'] ?? ''); + } + + $lines = []; + $prefix = ''; + foreach ($parts as $part) { + $rendered = $this->render_attribute_arg_value($part['value']); + $line = '    ' . $prefix . $rendered; + if ($part['separator'] === ',') { + $line .= ','; + $prefix = ''; + } elseif ($part['separator'] === '|') { + $prefix = '| '; + } else { + $prefix = ''; + } + $lines[] = $line; + } + + return '
' . implode('
', $lines) . '
'; + } + + /** + * @return list + */ + private function split_attribute_args(string $args): array { + $parts = []; + $current = ''; + $depth = 0; + $inString = false; + $stringChar = ''; + $length = strlen($args); + + for ($i = 0; $i < $length; $i++) { + $ch = $args[$i]; + + if ($inString) { + $current .= $ch; + if ($ch === '\\' && $i + 1 < $length) { + $current .= $args[++$i]; + continue; + } + if ($ch === $stringChar) { + $inString = false; + } + continue; + } + + if ($ch === '"' || $ch === "'") { + $inString = true; + $stringChar = $ch; + $current .= $ch; + continue; + } + + if ($ch === '(' || $ch === '[') { + $depth++; + } elseif ($ch === ')' || $ch === ']') { + $depth--; + } + + if ($depth === 0 && ($ch === '|' || $ch === ',')) { + $parts[] = ['value' => trim($current), 'separator' => $ch]; + $current = ''; + continue; + } + + $current .= $ch; } - return $prefix . '' . $name . '' . $suffix; + if (trim($current) !== '') { + $parts[] = ['value' => trim($current), 'separator' => null]; + } + + return $parts; + } + + private function render_attribute_arg_value(string $value): string { + $prefix = ''; + if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:(?!:)\s*/', $value, $m)) { + $prefix = '' . $m[1] . ': '; + $value = substr($value, strlen($m[0])); + } + + $out = ''; + $buffer = ''; + $length = strlen($value); + for ($i = 0; $i < $length; $i++) { + $ch = $value[$i]; + if ($ch !== '"' && $ch !== "'") { + $buffer .= $ch; + continue; + } + + if ($buffer !== '') { + $out .= $this->format_attribute_non_string_segment($buffer); + $buffer = ''; + } + + $stringChar = $ch; + $literal = $ch; + $i++; + for (; $i < $length; $i++) { + $sc = $value[$i]; + $literal .= $sc; + if ($sc === '\\' && $i + 1 < $length) { + $literal .= $value[++$i]; + continue; + } + if ($sc === $stringChar) { + break; + } + } + $out .= '' . htmlspecialchars($literal, ENT_NOQUOTES, 'UTF-8') . ''; + } + if ($buffer !== '') { + $out .= $this->format_attribute_non_string_segment($buffer); + } + + return $prefix . $out; + } + + private function format_attribute_non_string_segment(string $segment): string { + $linked = $this->link_constants_in_text($segment); + + // Split out ... regions so we don't touch their contents. + $parts = preg_split('/(]*>[^<]*<\/a>)/', $linked, -1, PREG_SPLIT_DELIM_CAPTURE); + foreach ($parts as $i => $part) { + if ($i % 2 === 1) { + continue; + } + $part = preg_replace_callback( + '/\b(true|false|null)\b/i', + fn(array $m) => '' . $m[1] . '', + $part, + ); + $part = preg_replace_callback( + '/(? '' . $m[0] . '', + $part, + ); + $parts[$i] = $part; + } + return implode('', $parts); + } + + private function link_constants_in_text(string $text): string { + $escaped = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); + + // Link Class::CONST references that appear as literal text + return preg_replace_callback( + '/\\\\?[A-Za-z_][\w\\\\]*::[A-Za-z_][\w]*/', + function (array $match): string { + $constant = $match[0]; + $link = $this->createLink($this->convertConstantNameToId($constant)); + if ($link === null) { + return $constant; + } + return '' . $constant . ''; + }, + $escaped, + ); } public function format_methodsynopsis($open, $name, $attrs, $props) { diff --git a/tests/package/generic/attribute_formatting_001.phpt b/tests/package/generic/attribute_formatting_001.phpt index 5c4c6d2b..76bbf787 100644 --- a/tests/package/generic/attribute_formatting_001.phpt +++ b/tests/package/generic/attribute_formatting_001.phpt @@ -42,7 +42,7 @@ Content:

2. Class methodparameter with known attribute

-
public mysqli::__construct(#[\KnownAttribute]stringnull $password = null)
+
public mysqli::__construct(#[\KnownAttribute] stringnull $password = null)
@@ -54,7 +54,7 @@ Content:

4. Function parameter with known attribute

-
bool password_verify(#[\KnownAttribute]string $password, string $hash)
+
bool password_verify(#[\KnownAttribute] string $password, string $hash)
diff --git a/tests/package/generic/attribute_formatting_002.phpt b/tests/package/generic/attribute_formatting_002.phpt index b5e0d17f..2fe743b0 100644 --- a/tests/package/generic/attribute_formatting_002.phpt +++ b/tests/package/generic/attribute_formatting_002.phpt @@ -62,8 +62,8 @@ Content:

2. Class with known attributes

}
@@ -80,8 +80,8 @@ Content:

4. Method with known attributes

- @@ -96,8 +96,8 @@ Content:

6. Constructor with known attributes

- @@ -130,21 +130,21 @@ Content:

8. Class, constructor and methods with known attributes

-
#[\KnownAttribute]
- #[\AnotherKnownAttribute]
+
#[\KnownAttribute]
+ #[\AnotherKnownAttribute]
public ClassName::methodName1()
- @@ -161,10 +161,10 @@ Content:

10. Function with known attributes

- -
+
\ No newline at end of file diff --git a/tests/package/generic/attribute_formatting_003.phpt b/tests/package/generic/attribute_formatting_003.phpt index 0506abaa..4bf942e2 100644 --- a/tests/package/generic/attribute_formatting_003.phpt +++ b/tests/package/generic/attribute_formatting_003.phpt @@ -75,8 +75,8 @@ Content: {
/* Properties/Constants */
- #[\KnownAttribute]
- #[\AnotherKnownAttribute]
+ #[\KnownAttribute]
+ #[\AnotherKnownAttribute]
public readonly string @@ -102,4 +102,4 @@ Content: }
-
+ \ No newline at end of file diff --git a/tests/package/generic/attribute_formatting_005.phpt b/tests/package/generic/attribute_formatting_005.phpt new file mode 100644 index 00000000..b5d4eca2 --- /dev/null +++ b/tests/package/generic/attribute_formatting_005.phpt @@ -0,0 +1,130 @@ +--TEST-- +Attribute formatting 005 - Attribute with literal parameter arguments +--FILE-- +xmlFile = $xmlFile; + +$format = new TestGenericChunkedXHTML($config, $outputHandler); + +$format->SQLiteIndex( + null, null, + "class.deprecated", + "class.deprecated", + "", "", "", "", "", "", 0, +); +$format->SQLiteIndex( + null, null, + "class.attribute", + "class.attribute", + "", "", "", "", "", "", 0, +); +$format->SQLiteIndex( + null, null, + "attribute.constants.target-function", + "class.attribute", + "", "", "", "", "", "", 0, +); +$format->SQLiteIndex( + null, null, + "attribute.constants.target-method", + "class.attribute", + "", "", "", "", "", "", 0, +); +$format->SQLiteIndex( + null, null, + "attribute.constants.target-class", + "class.attribute", + "", "", "", "", "", "", 0, +); + +$render = new TestRender(new Reader($outputHandler), $config, $format); + +$render->run(); +?> +--EXPECT-- +Filename: attribute-formatting-005.html +Content: +
+
+

1. Attribute with literal named arguments

+
+ + #[\Deprecated(
    since: '8.5',
    message: 'Deprecated since PHP 8.4'
)]

+ final + class Deprecated + {
+ }
+
+ +
+

2. Attribute with single literal positional argument

+
+ + #[\Deprecated('Deprecated since PHP 8.4')]
+ final + class Deprecated + {
+ }
+
+ +
+

3. Unknown attribute with literal argument

+
+ + #[\UnknownAttribute(foo: 'bar')]
+ final + class Deprecated + {
+ }
+
+ +
+

4. Namespaced attribute with literal argument

+
+ + #[\Some\Namespaced\Attribute(value: 42)]
+ final + class Deprecated + {
+ }
+
+ +
+

5. Multi-line attribute with literal class constant arguments

+
+ + #[\Attribute(
    Attribute::TARGET_FUNCTION
    | Attribute::TARGET_METHOD
    | Attribute::TARGET_CLASS
)]

+ final + class Deprecated + {
+ }
+
+ +
+

6. Attribute with mix of known and unknown class constants

+
+ + #[\Attribute(
    Attribute::TARGET_CLASS
    | Unknown::CONST
)]

+ final + class Deprecated + {
+ }
+
+ +
+

7. Attribute with bool, null, int and float literal arguments

+
+ + #[\UnknownAttribute(
    enabled: true,
    fallback: false,
    default: null,
    count: 42,
    ratio: 3.14
)]

+ final + class Deprecated + {
+ }
+
+
diff --git a/tests/package/generic/data/attribute_formatting_005.xml b/tests/package/generic/data/attribute_formatting_005.xml new file mode 100644 index 00000000..1837b4b4 --- /dev/null +++ b/tests/package/generic/data/attribute_formatting_005.xml @@ -0,0 +1,80 @@ + +
+ 1. Attribute with literal named arguments + + + #[\Deprecated(since: '8.5', message: 'Deprecated since PHP 8.4')] + final + Deprecated + + +
+ +
+ 2. Attribute with single literal positional argument + + + #[\Deprecated('Deprecated since PHP 8.4')] + final + Deprecated + + +
+ +
+ 3. Unknown attribute with literal argument + + + #[\UnknownAttribute(foo: 'bar')] + final + Deprecated + + +
+ +
+ 4. Namespaced attribute with literal argument + + + #[\Some\Namespaced\Attribute(value: 42)] + final + Deprecated + + +
+ +
+ 5. Multi-line attribute with literal class constant arguments + + + + #[\Attribute(Attribute::TARGET_FUNCTION|Attribute::TARGET_METHOD|Attribute::TARGET_CLASS)] + + final + Deprecated + + +
+ +
+ 6. Attribute with mix of known and unknown class constants + + + #[\Attribute(Attribute::TARGET_CLASS | Unknown::CONST)] + final + Deprecated + + +
+ +
+ 7. Attribute with bool, null, int and float literal arguments + + + #[\UnknownAttribute(enabled: true, fallback: false, default: null, count: 42, ratio: 3.14)] + final + Deprecated + + +
+