.phan/plugins/PrintfCheckerPlugin.php
<?php
declare(strict_types=1);
namespace Phan\Plugin\PrintfCheckerPlugin;
// Don't pollute the global namespace
use ast;
use ast\Node;
use Phan\AST\ASTReverter;
use Phan\AST\ContextNode;
use Phan\AST\UnionTypeVisitor;
use Phan\CodeBase;
use Phan\Exception\CodeBaseException;
use Phan\Issue;
use Phan\Language\Context;
use Phan\Language\Element\Func;
use Phan\Language\Element\FunctionInterface;
use Phan\Language\Type;
use Phan\Language\Type\FalseType;
use Phan\Language\Type\LiteralStringType;
use Phan\Language\Type\StringType;
use Phan\Language\UnionType;
use Phan\Library\ConversionSpec;
use Phan\PluginV3;
use Phan\PluginV3\AnalyzeFunctionCallCapability;
use Phan\PluginV3\ReturnTypeOverrideCapability;
use Throwable;
use function count;
use function implode;
use function is_object;
use function is_string;
use function strcasecmp;
use function var_export;
/**
* This plugin checks for invalid format strings and invalid uses of format strings in printf and sprintf, etc.
* e.g. for printf("literal format %s", $arg)
*
* This uses ConversionSpec as a heuristic to determine the positions used by PHP format strings.
* Some edge cases may have been overlooked.
*
* This validates strings of the form
* - constant strings, such as '%d of %s'
* - TODO: _(str) and gettext(str)
* - TODO: Better resolution of global constants and class constants
*
* This analyzes printf, sprintf, and fprintf.
*
* TODO: Add optional verbose warnings about unanalyzable strings
* TODO: Check if arg can cast to string.
*/
class PrintfCheckerPlugin extends PluginV3 implements AnalyzeFunctionCallCapability, ReturnTypeOverrideCapability
{
// Pylint error codes for emitted issues.
private const ERR_UNTRANSLATED_USE_ECHO = 1300;
private const ERR_UNTRANSLATED_NONE_USED = 1301;
private const ERR_UNTRANSLATED_NONEXISTENT = 1302;
private const ERR_UNTRANSLATED_UNUSED = 1303;
private const ERR_UNTRANSLATED_NOT_PERCENT = 1304;
private const ERR_UNTRANSLATED_INCOMPATIBLE_SPECIFIER = 1305;
private const ERR_UNTRANSLATED_INCOMPATIBLE_ARGUMENT = 1306; // E.g. passing a string where an int is expected
private const ERR_UNTRANSLATED_INCOMPATIBLE_ARGUMENT_WEAK = 1307; // E.g. passing an int where a string is expected
private const ERR_UNTRANSLATED_WIDTH_INSTEAD_OF_POSITION = 1308; // e.g. _('%1s'). Change to _('%1$1s' if you really mean that the width is 1, add positions for others ('%2$s', etc.)
private const ERR_UNTRANSLATED_UNKNOWN_FORMAT_STRING = 1310;
private const ERR_TRANSLATED_INCOMPATIBLE = 1309;
private const ERR_TRANSLATED_HAS_MORE_ARGS = 1311;
/**
* People who have translations may subclass this plugin and return a mapping from other locales to those locales translations of $fmt_str.
* @param string $fmt_str @phan-unused-param
* @return string[] mapping locale to the translation (e.g. ['fr_FR' => 'Bonjour'] for $fmt_str == 'Hello')
*/
protected static function gettextForAllLocales(string $fmt_str): array
{
return [];
}
/**
* Convert an expression(a list of tokens) to a primitive.
* People who have custom such as methods or functions to fetch translations
* may subclass this plugin and override this method to add checks for AST_CALL (foo()), AST_METHOD_CALL(MyClass::getTranslation($id), etc.)
*
* @param CodeBase $code_base
* @param Context $context
* @param bool|int|string|float|Node|array|null $ast_node
*/
protected function astNodeToPrimitive(CodeBase $code_base, Context $context, $ast_node): ?PrimitiveValue
{
// Base case: convert primitive tokens such as numbers and strings.
if (!($ast_node instanceof Node)) {
return new PrimitiveValue($ast_node);
}
switch ($ast_node->kind) {
// TODO: Resolve class constant access when those are format strings. Same for PregRegexCheckerPlugin.
case \ast\AST_CALL:
$name_node = $ast_node->children['expr'];
if ($name_node instanceof Node && $name_node->kind === \ast\AST_NAME) {
// TODO: Use Phan's function resolution?
// TODO: ngettext?
$name = $name_node->children['name'];
if (!\is_string($name)) {
break;
}
if ($name === '_' || strcasecmp($name, 'gettext') === 0) {
$child_arg = $ast_node->children['args']->children[0] ?? null;
if ($child_arg === null) {
break;
}
$prim = self::astNodeToPrimitive($code_base, $context, $child_arg);
if ($prim === null) {
break;
}
return new PrimitiveValue($prim->value, true);
}
}
break;
case \ast\AST_BINARY_OP:
if ($ast_node->flags !== ast\flags\BINARY_CONCAT) {
break;
}
$left = $this->astNodeToPrimitive($code_base, $context, $ast_node->children['left']);
if ($left === null) {
break;
}
$right = $this->astNodeToPrimitive($code_base, $context, $ast_node->children['right']);
if ($right === null) {
break;
}
$result = self::concatenateToPrimitive($left, $right);
if ($result) {
return $result;
}
break;
}
$union_type = UnionTypeVisitor::unionTypeFromNode($code_base, $context, $ast_node);
$result = $union_type->asSingleScalarValueOrNullOrSelf();
if (!is_object($result)) {
return new PrimitiveValue($result);
}
$scalar_union_types = $union_type->asScalarValues();
if (!$scalar_union_types) {
// We don't know how to convert this to a primitive, give up.
// (Subclasses may add their own logic first, then call self::astNodeToPrimitive)
return null;
}
$known_specs = null;
$first_str = null;
foreach ($union_type->getTypeSet() as $type) {
if (!$type instanceof LiteralStringType || $type->isNullable()) {
return null;
}
$str = $type->getValue();
$new_specs = ConversionSpec::extractAll($str);
if (\is_array($known_specs)) {
if ($known_specs != $new_specs) {
// We have different specs, e.g. %s and %d, %1$s and %2$s, etc.
// TODO: Could allow differences in padding or alignment
return null;
}
} else {
$known_specs = $new_specs;
$first_str = $str;
}
}
return new PrimitiveValue($first_str);
}
/**
* Convert a primitive and a sequence of tokens to a primitive formed by
* concatenating strings.
*
* @param PrimitiveValue $left the value on the left.
* @param PrimitiveValue $right the value on the right.
*/
protected static function concatenateToPrimitive(PrimitiveValue $left, PrimitiveValue $right): ?PrimitiveValue
{
// Combining untranslated strings with anything will cause problems.
if ($left->is_translated) {
return null;
}
if ($right->is_translated) {
return null;
}
$str = $left->value . $right->value;
return new PrimitiveValue($str);
}
/**
* @unused-param $code_base
*/
public function getReturnTypeOverrides(CodeBase $code_base): array
{
$string_union_type = StringType::instance(false)->asPHPDocUnionType();
/**
* @param list<Node|string|int|float> $args the nodes for the arguments to the invocation
*/
$sprintf_handler = static function (
CodeBase $code_base,
Context $context,
Func $function,
array $args
) use ($string_union_type): UnionType {
if (count($args) < 1) {
return FalseType::instance(false)->asRealUnionType();
}
$union_type = UnionTypeVisitor::unionTypeFromNode($code_base, $context, $args[0]);
$format_strings = [];
foreach ($union_type->getTypeSet() as $type) {
if (!$type instanceof LiteralStringType) {
return $string_union_type;
}
$format_strings[] = $type->getValue();
}
if (count($format_strings) === 0) {
return $string_union_type;
}
$result_union_type = UnionType::empty();
foreach ($format_strings as $format_string) {
$min_width = 0;
foreach (ConversionSpec::extractAll($format_string) as $spec_group) {
foreach ($spec_group as $spec) {
$min_width += ($spec->width ?: 0);
}
}
if (!LiteralStringType::canRepresentStringOfLength($min_width)) {
return $string_union_type;
}
$sprintf_args = [];
for ($i = 1; $i < count($args); $i++) {
$arg = UnionTypeVisitor::unionTypeFromNode($code_base, $context, $args[$i])->asSingleScalarValueOrNullOrSelf();
if (is_object($arg)) {
return $string_union_type;
}
$sprintf_args[] = $arg;
}
try {
$result = \with_disabled_phan_error_handler(
/** @return string|false */
static function () use ($format_string, $sprintf_args) {
// @phan-suppress-next-line PhanPluginPrintfVariableFormatString
return @\vsprintf($format_string, $sprintf_args);
}
);
} catch (Throwable $e) {
// PHP 8 throws ValueError for too few arguments to vsprintf
Issue::maybeEmit(
$code_base,
$context,
Issue::TypeErrorInInternalCall,
$args[0]->lineno ?? $context->getLineNumberStart(),
$function->getName(),
$e->getMessage()
);
// TODO: When PHP 8.0 stable is out, replace this with string?
$result = false;
}
$result_union_type = $result_union_type->withType(Type::fromObject($result));
}
return $result_union_type;
};
return [
'sprintf' => $sprintf_handler,
];
}
/**
* @param CodeBase $code_base @phan-unused-param
* @return \Closure[]
*/
public function getAnalyzeFunctionCallClosures(CodeBase $code_base): array
{
/**
* Analyzes a printf-like function with a format directive in the first position.
* @param list<Node|string|int|float> $args the nodes for the arguments to the invocation
*/
$printf_callback = function (
CodeBase $code_base,
Context $context,
Func $function,
array $args
): void {
// TODO: Resolve global constants and class constants?
// TODO: Check for AST_UNPACK
$pattern = $args[0] ?? null;
if ($pattern === null) {
return;
}
if ($pattern instanceof Node) {
$pattern = (new ContextNode($code_base, $context, $pattern))->getEquivalentPHPScalarValue();
}
$remaining_args = \array_slice($args, 1);
$this->analyzePrintfPattern($code_base, $context, $function, $pattern, $remaining_args);
};
/**
* Analyzes a printf-like function with a format directive in the first position.
* @param list<Node|string|int|float> $args the nodes for the arguments to the invocation
*/
$fprintf_callback = function (
CodeBase $code_base,
Context $context,
Func $function,
array $args
): void {
if (\count($args) < 2) {
return;
}
// TODO: Resolve global constants and class constants?
// TODO: Check for AST_UNPACK
$pattern = $args[1];
if ($pattern instanceof Node) {
$pattern = (new ContextNode($code_base, $context, $pattern))->getEquivalentPHPScalarValue();
}
$remaining_args = \array_slice($args, 2);
$this->analyzePrintfPattern($code_base, $context, $function, $pattern, $remaining_args);
};
/**
* Analyzes a printf-like function with a format directive in the first position.
* @param list<Node|int|string|float> $args
*/
$vprintf_callback = function (
CodeBase $code_base,
Context $context,
Func $function,
array $args
): void {
if (\count($args) < 2) {
return;
}
// TODO: Resolve global constants and class constants?
// TODO: Check for AST_UNPACK
$pattern = $args[0];
if ($pattern instanceof Node) {
$pattern = (new ContextNode($code_base, $context, $pattern))->getEquivalentPHPScalarValue();
}
$format_args_node = $args[1];
$format_args = (new ContextNode($code_base, $context, $format_args_node))->getEquivalentPHPValue();
$this->analyzePrintfPattern($code_base, $context, $function, $pattern, \is_array($format_args) ? $format_args : null);
};
/**
* Analyzes a printf-like function with a format directive in the first position.
* @param list<Node|string|int|float> $args the nodes for the arguments to the invocation
*/
$vfprintf_callback = function (
CodeBase $code_base,
Context $context,
Func $function,
array $args
): void {
if (\count($args) < 3) {
return;
}
// TODO: Resolve global constants and class constants?
// TODO: Check for AST_UNPACK
$pattern = $args[1];
if ($pattern instanceof Node) {
$pattern = (new ContextNode($code_base, $context, $pattern))->getEquivalentPHPScalarValue();
}
$format_args_node = $args[2];
$format_args = (new ContextNode($code_base, $context, $format_args_node))->getEquivalentPHPValue();
$this->analyzePrintfPattern($code_base, $context, $function, $pattern, \is_array($format_args) ? $format_args : null);
};
return [
// call
'printf' => $printf_callback,
'sprintf' => $printf_callback,
'fprintf' => $fprintf_callback,
'vprintf' => $vprintf_callback,
'vsprintf' => $vprintf_callback,
'vfprintf' => $vfprintf_callback,
];
}
protected static function encodeString(string $str): string
{
$result = \json_encode($str, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
if ($result !== false) {
return $result;
}
return var_export($str, true);
}
/**
* Analyzes a printf pattern, emitting issues if necessary
* @param CodeBase $code_base
* @param Context $context
* @param FunctionInterface $function
* @param Node|array|string|float|int|bool|resource|null $pattern_node
* @param ?(Node|string|int|float)[] $arg_nodes arguments following the format string. Null if the arguments could not be determined.
* @suppress PhanPartialTypeMismatchArgument TODO: refactor into smaller functions
*/
protected function analyzePrintfPattern(CodeBase $code_base, Context $context, FunctionInterface $function, $pattern_node, $arg_nodes): void
{
// Given a node, extract the printf directive and whether or not it could be translated
$primitive_for_fmtstr = $this->astNodeToPrimitive($code_base, $context, $pattern_node);
/**
* @param string $issue_type
* A name for the type of issue such as 'PhanPluginMyIssue'
*
* @param string $issue_message_format
* The complete issue message format string to emit such as
* 'class with fqsen {CLASS} is broken in some fashion' (preferred)
* or 'class with fqsen %s is broken in some fashion'
* The list of placeholders for between braces can be found
* in \Phan\Issue::uncolored_format_string_for_template.
*
* @param list<string|float|int> $issue_message_args
* The arguments for this issue format.
* If this array is empty, $issue_message_args is kept in place
*
* @param int $severity
* A value from the set {Issue::SEVERITY_LOW,
* Issue::SEVERITY_NORMAL, Issue::SEVERITY_HIGH}.
*
* @param int $issue_type_id An issue id for pylint
*/
$emit_issue = static function (string $issue_type, string $issue_message_format, array $issue_message_args, int $severity, int $issue_type_id) use ($code_base, $context): void {
self::emitIssue(
$code_base,
$context,
$issue_type,
$issue_message_format,
$issue_message_args,
$severity,
Issue::REMEDIATION_B,
$issue_type_id
);
};
if ($primitive_for_fmtstr === null) {
$emit_issue(
'PhanPluginPrintfVariableFormatString',
'Code {CODE} has a dynamic format string that could not be inferred by Phan',
[ASTReverter::toShortString($pattern_node)],
Issue::SEVERITY_LOW,
self::ERR_UNTRANSLATED_UNKNOWN_FORMAT_STRING
);
if (\is_array($arg_nodes) && count($arg_nodes) === 0) {
$replacement_function_name = \in_array($function->getName(), ['vprintf', 'fprintf', 'vfprintf'], true) ? 'fwrite' : 'echo';
$emit_issue(
"PhanPluginPrintfNoArguments",
"No format string arguments are given for {STRING_LITERAL}, consider using {FUNCTION} instead",
['(unknown)', $replacement_function_name],
Issue::SEVERITY_LOW,
self::ERR_UNTRANSLATED_USE_ECHO
);
return;
}
// TODO: Add a verbose option
return;
}
// Make sure that the untranslated format string is being used correctly.
// If the format string will be translated, also check the translations.
$fmt_str = $primitive_for_fmtstr->value;
$is_translated = $primitive_for_fmtstr->is_translated;
$specs = is_string($fmt_str) ? ConversionSpec::extractAll($fmt_str) : [];
$fmt_str = (string)$fmt_str;
// Check for extra or missing arguments
if (\is_array($arg_nodes) && \count($arg_nodes) === 0) {
if (count($specs) > 0) {
$largest_positional = \max(\array_keys($specs));
$examples = [];
foreach ($specs[$largest_positional] as $example_spec) {
$examples[] = self::encodeString($example_spec->directive);
}
// emit issues with 1-based offsets
$emit_issue(
'PhanPluginPrintfNonexistentArgument',
'Format string {STRING_LITERAL} refers to nonexistent argument #{INDEX} in {STRING_LITERAL}. This will be an ArgumentCountError in PHP 8.',
[self::encodeString($fmt_str), $largest_positional, \implode(',', $examples)],
Issue::SEVERITY_CRITICAL,
self::ERR_UNTRANSLATED_NONEXISTENT
);
}
$replacement_function_name = \in_array($function->getName(), ['vprintf', 'fprintf', 'vfprintf'], true) ? 'fwrite' : 'echo';
$emit_issue(
"PhanPluginPrintfNoArguments",
"No format string arguments are given for {STRING_LITERAL}, consider using {FUNCTION} instead",
[self::encodeString($fmt_str), $replacement_function_name],
Issue::SEVERITY_LOW,
self::ERR_UNTRANSLATED_USE_ECHO
);
return;
}
if (count($specs) === 0) {
$emit_issue(
'PhanPluginPrintfNoSpecifiers',
'None of the formatting arguments passed alongside format string {STRING_LITERAL} are used',
[self::encodeString($fmt_str)],
Issue::SEVERITY_LOW,
self::ERR_UNTRANSLATED_NONE_USED
);
return;
}
if (\is_array($arg_nodes)) {
$largest_positional = \max(\array_keys($specs));
if ($largest_positional > \count($arg_nodes)) {
$examples = [];
foreach ($specs[$largest_positional] as $example_spec) {
$examples[] = self::encodeString($example_spec->directive);
}
// emit issues with 1-based offsets
$emit_issue(
'PhanPluginPrintfNonexistentArgument',
'Format string {STRING_LITERAL} refers to nonexistent argument #{INDEX} in {STRING_LITERAL}. This will be an ArgumentCountError in PHP 8.',
[self::encodeString($fmt_str), $largest_positional, \implode(',', $examples)],
Issue::SEVERITY_CRITICAL,
self::ERR_UNTRANSLATED_NONEXISTENT
);
} elseif ($largest_positional < count($arg_nodes)) {
$emit_issue(
'PhanPluginPrintfUnusedArgument',
'Format string {STRING_LITERAL} does not use provided argument #{INDEX}',
[self::encodeString($fmt_str), $largest_positional + 1],
Issue::SEVERITY_NORMAL,
self::ERR_UNTRANSLATED_UNUSED
);
}
}
/** @var string[][] maps argument position to a list of possible canonical strings (e.g. '%1$d') for that argument */
$types_of_arg = [];
// Check format string alone for common signs of problems.
// E.g. "% s", "%1$d %1$s"
foreach ($specs as $i => $spec_group) {
$types = [];
foreach ($spec_group as $spec) {
$canonical = $spec->toCanonicalString();
$types[$canonical] = true;
if ((\strlen($spec->padding_char) > 0 || \strlen($spec->alignment)) && ($spec->width === '' || !$spec->position)) {
// Warn about "100% dollars" but not about "100%1$ 2dollars" (If both position and width were parsed, assume the padding was intentional)
$emit_issue(
'PhanPluginPrintfNotPercent',
// phpcs:ignore Generic.Files.LineLength.MaxExceeded
"Format string {STRING_LITERAL} contains something that is not a percent sign, it will be treated as a format string '{STRING_LITERAL}' with padding of \"{STRING_LITERAL}\" and alignment of '{STRING_LITERAL}' but no width. Use {DETAILS} for a literal percent sign, or '{STRING_LITERAL}' to be less ambiguous",
[self::encodeString($fmt_str), $spec->directive, $spec->padding_char, $spec->alignment, '%%', $canonical],
Issue::SEVERITY_NORMAL,
self::ERR_UNTRANSLATED_NOT_PERCENT
);
}
if ($is_translated && $spec->width &&
($spec->padding_char === '' || $spec->padding_char === ' ')
) {
$intended_string = $spec->toCanonicalStringWithWidthAsPosition();
$emit_issue(
'PhanPluginPrintfWidthNotPosition',
"Format string {STRING_LITERAL} is specifying a width({STRING_LITERAL}) instead of a position({STRING_LITERAL})",
[self::encodeString($fmt_str), self::encodeString($canonical), self::encodeString($intended_string)],
Issue::SEVERITY_NORMAL,
self::ERR_UNTRANSLATED_WIDTH_INSTEAD_OF_POSITION
);
}
}
$types_of_arg[$i] = $types;
if (count($types) > 1) {
// May be an off by one error in the format string.
$emit_issue(
'PhanPluginPrintfIncompatibleSpecifier',
'Format string {STRING_LITERAL} refers to argument #{INDEX} in different ways: {DETAILS}',
[self::encodeString($fmt_str), $i, implode(',', \array_keys($types))],
Issue::SEVERITY_LOW,
self::ERR_UNTRANSLATED_INCOMPATIBLE_SPECIFIER
);
}
}
if (\is_array($arg_nodes)) {
foreach ($specs as $i => $spec_group) {
// $arg_nodes is a 0-based array, $spec_group is 1-based.
$arg_node = $arg_nodes[$i - 1] ?? null;
if (!isset($arg_node)) {
continue;
}
$actual_union_type = UnionTypeVisitor::unionTypeFromNode($code_base, $context, $arg_node);
if ($actual_union_type->isEmpty()) {
// Nothing to check.
continue;
}
$expected_set = [];
foreach ($spec_group as $spec) {
$type_name = $spec->getExpectedUnionTypeName();
$expected_set[$type_name] = true;
}
$expected_union_type = UnionType::empty();
foreach ($expected_set as $type_name => $_) {
// @phan-suppress-next-line PhanThrowTypeAbsentForCall getExpectedUnionTypeName should only return valid union types
$expected_union_type = $expected_union_type->withType(Type::fromFullyQualifiedString($type_name));
}
if ($actual_union_type->canCastToUnionType($expected_union_type)) {
continue;
}
if (isset($expected_set['string'])) {
$can_cast_to_string = false;
// Allow passing objects with __toString() to printf whether or not strict types are used in the caller.
// TODO: Move into a common helper method?
try {
foreach ($actual_union_type->asExpandedTypes($code_base)->asClassList($code_base, $context) as $clazz) {
if ($clazz->hasMethodWithName($code_base, '__toString', true)) {
$can_cast_to_string = true;
break;
}
}
} catch (CodeBaseException $_) {
// Swallow "Cannot find class", go on to emit issue.
}
if ($can_cast_to_string) {
continue;
}
}
$expected_union_type_string = (string)$expected_union_type;
if (self::canWeakCast($actual_union_type, $expected_set)) {
// This can be resolved by casting the arg to (string) manually in printf.
$emit_issue(
'PhanPluginPrintfIncompatibleArgumentTypeWeak',
// phpcs:ignore Generic.Files.LineLength.MaxExceeded
'Format string {STRING_LITERAL} refers to argument #{INDEX} as {DETAILS}, so type {TYPE} is expected. However, {FUNCTION} was passed the type {TYPE} (which is weaker than {TYPE})',
[
self::encodeString($fmt_str),
$i,
self::getSpecStringsRepresentation($spec_group),
$expected_union_type_string,
$function->getName(),
(string)$actual_union_type,
$expected_union_type_string,
],
Issue::SEVERITY_LOW,
self::ERR_UNTRANSLATED_INCOMPATIBLE_ARGUMENT_WEAK
);
} else {
// This can be resolved by casting the arg to (int) manually in printf.
$emit_issue(
'PhanPluginPrintfIncompatibleArgumentType',
'Format string {STRING_LITERAL} refers to argument #{INDEX} as {DETAILS}, so type {TYPE} is expected, but {FUNCTION} was passed incompatible type {TYPE}',
[
self::encodeString($fmt_str),
$i,
self::getSpecStringsRepresentation($spec_group),
$expected_union_type_string,
$function->getName(),
(string)$actual_union_type,
],
Issue::SEVERITY_LOW,
self::ERR_UNTRANSLATED_INCOMPATIBLE_ARGUMENT
);
}
}
}
// Make sure the translations are compatible with this format string.
// In order to take advantage of the ability to analyze translations, override gettextForAllLocales
if ($is_translated) {
$this->validateTranslations($code_base, $context, $fmt_str, $types_of_arg);
}
}
/**
* @param ConversionSpec[] $specs
*/
private static function getSpecStringsRepresentation(array $specs): string
{
return \implode(',', \array_unique(\array_map(static function (ConversionSpec $spec): string {
return $spec->directive;
}, $specs)));
}
/**
* @param array<string,true> $expected_set the types being checked for the ability to weakly cast to
*/
private static function canWeakCast(UnionType $actual_union_type, array $expected_set): bool
{
if (isset($expected_set['string'])) {
static $string_weak_types;
if ($string_weak_types === null) {
$string_weak_types = UnionType::fromFullyQualifiedPHPDocString('int|string|float');
}
return $actual_union_type->canCastToUnionType($string_weak_types);
}
// We already allow int->float conversion
return false;
}
/**
* TODO: Finish testing this.
*
* By default, this is a no-op, unless gettextForAllLocales is overridden in a subclass
*
* Check that the translations of the format string $fmt_str
* are compatible with the untranslated format string.
*
* In virtually all cases, the conversions specifiers should be
* identical to the conversion specifier (apart from whether or not
* position is explicitly stated)
*
* Emits issues.
*
* @param CodeBase $code_base
* @param Context $context
* @param string $fmt_str
* @param ConversionSpec[][] $types_of_arg contains array of ConversionSpec for
* each position in the untranslated format string.
*/
protected static function validateTranslations(CodeBase $code_base, Context $context, string $fmt_str, array $types_of_arg): void
{
$translations = static::gettextForAllLocales($fmt_str);
foreach ($translations as $locale => $translated_fmt_str) {
// Skip untranslated or equal strings.
if ($translated_fmt_str === $fmt_str) {
continue;
}
// Compare the translated specs for a given position to the existing spec.
$translated_specs = ConversionSpec::extractAll($translated_fmt_str);
foreach ($translated_specs as $i => $spec_group) {
$expected = $types_of_arg[$i] ?? [];
foreach ($spec_group as $spec) {
$canonical = $spec->toCanonicalString();
if (!isset($expected[$canonical])) {
$expected_types = $expected ? implode(',', \array_keys($expected))
: 'unused';
if ($expected_types !== 'unused') {
$severity = Issue::SEVERITY_NORMAL;
$issue_type_id = self::ERR_TRANSLATED_INCOMPATIBLE;
$issue_type = 'PhanPluginPrintfTranslatedIncompatible';
} else {
$severity = Issue::SEVERITY_NORMAL;
$issue_type_id = self::ERR_TRANSLATED_HAS_MORE_ARGS;
$issue_type = 'PhanPluginPrintfTranslatedHasMoreArgs';
}
self::emitIssue(
$code_base,
$context,
$issue_type,
// phpcs:ignore Generic.Files.LineLength.MaxExceeded
'Translated string {STRING_LITERAL} has local {DETAILS} which refers to argument #{INDEX} as {STRING_LITERAL}, but the original format string treats it as {DETAILS} (ORIGINAL: {STRING_LITERAL}, TRANSLATION: {STRING_LITERAL})',
[
self::encodeString($fmt_str),
$locale,
$i,
$canonical,
$expected_types,
self::encodeString($fmt_str),
self::encodeString($translated_fmt_str),
],
$severity,
Issue::REMEDIATION_B,
$issue_type_id
);
}
}
}
}
}
}
/**
* Represents the information we have about the result of evaluating an expression.
* Currently, used only for printf arguments.
*/
class PrimitiveValue
{
/** @var array|int|string|float|bool|null The primitive value of the expression if it could be determined. */
public $value;
/** @var bool Whether or not the expression value was translated. */
public $is_translated;
/**
* @param array|int|string|float|bool|null $value
*/
public function __construct($value, bool $is_translated = false)
{
$this->value = $value;
$this->is_translated = $is_translated;
}
}
return new PrintfCheckerPlugin();