Skip to content

Fix counting *scanf() format string placeholders#5594

Open
hakre wants to merge 2 commits intophpstan:2.1.xfrom
hakre:patch-1
Open

Fix counting *scanf() format string placeholders#5594
hakre wants to merge 2 commits intophpstan:2.1.xfrom
hakre:patch-1

Conversation

@hakre
Copy link
Copy Markdown

@hakre hakre commented May 3, 2026

Update PrintfHelper.php:

Make public function getScanfPlaceholdersCount(string $format): ?int returning the sscanf() vetted number of placeholders that give/return/assign conversions.

refs:

@hakre hakre force-pushed the patch-1 branch 4 times, most recently from 57da830 to 5742f2b Compare May 3, 2026 19:24
@hakre hakre marked this pull request as draft May 5, 2026 19:34
@hakre hakre changed the title Counting sscanf/fscanf format string placeholders Fix counting sscanf/fscanf format string placeholders May 5, 2026
@hakre hakre marked this pull request as ready for review May 6, 2026 02:59
@phpstan-bot
Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

Comment thread src/Rules/Functions/PrintfHelper.php Outdated
Comment thread tests/PHPStan/Rules/Functions/data/printf.php Outdated
@hakre hakre changed the title Fix counting sscanf/fscanf format string placeholders Fix counting *scanf() format string placeholders May 6, 2026
@hakre
Copy link
Copy Markdown
Author

hakre commented May 6, 2026

Do you know by chance why https://github.com/phpstan/phpstan-src/actions/runs/25431166468/job/74597885107 keeps failing on my changes? I'd like to better understand @staabm

@hakre hakre requested a review from staabm May 6, 2026 12:20
@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 6, 2026

Do you know by chance why phpstan/phpstan-src/actions/runs/25431166468/job/74597885107 keeps failing on my changes?

we see the same errors on others PRs targeting 2.1.x -> this means these errors are not related to your PR

@hakre hakre force-pushed the patch-1 branch 5 times, most recently from 04f1759 to bd2dc56 Compare May 6, 2026 22:22
Comment thread tests/PHPStan/Rules/Functions/PrintfHelperTest.php Outdated
Comment thread tests/PHPStan/Rules/Functions/PrintfHelperTest.php Outdated
@hakre hakre force-pushed the patch-1 branch 2 times, most recently from 8a37d48 to ddd449b Compare May 7, 2026 07:09
hakre added 2 commits May 7, 2026 20:09
Additional by-catch fix of a variable misnomer in 023fa08 ("Fix printf
parameters rule", 2017-04-02) spotted during review.
Update PrintfHelper.php to make

    public function getScanfPlaceholdersCount(string $format): ?int`

return the sscanf() vetted number of placeholders that return/assign
conversions.

Addresses long-standing regressions reported originally in
[phpstan-10260].

References:

- [phpstan-10260]
- phpstan/phpstan#10260 (comment)
- [phpstan-src-5591]
- [phpstan-14567]
- https://3v4l.org/WR85Q

[phpstan-10260]: phpstan/phpstan#10260
[phpstan-src-5591]: phpstan#5591
[phpstan-14567]: phpstan/phpstan#14567
Comment on lines +24 to +34
#[RequiresPhp('< 8.0.0')]
public function testReturnsNullForInvalidPatternOnLegacyPhpVersion(): void
{
$this->assertNull($this->printf->getScanfPlaceholdersCount('%a'));
}

#[RequiresPhp('>= 8.0.0')]
public function testReturnsNullForInvalidPatternOnPhp8(): void
{
$this->assertNull($this->printf->getScanfPlaceholdersCount('%a'));
}
Copy link
Copy Markdown
Contributor

@staabm staabm May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these 2 tests look identical. can't we just merge them and remove the #[RequiresPhp(...)]?

Copy link
Copy Markdown
Author

@hakre hakre May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two tests cover different internal paths of getScanfPlaceholdersCount:

  • PHP < 8.0 uses @sscanf and converts the suppressed warning to null.
  • PHP ≥ 8.0 catches a ValueError and also returns null.

The #[RequiresPhp] attributes guarantee each branch is exercised in the correct CI environment. Merging them would lose the version‑gated execution and the guarantee that both paths are actually tested where they matter (the original request specifically asked for a test that runs on CI PHP 7.x with a warning‑emitting pattern).

So the tests are intentionally symmetric – a “Gegenprobe” to make sure the legacy and the modern path behave identically.

I’m happy to add a short comment above each test to make that explicit, or to merge them if you still prefer – just let me know.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should merge the tests if they are identical

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point – the assertion body is identical. The reason I kept them separate is the test report itself. With #[RequiresPhp] and distinct names, the test runner says:

  • testReturnsNullForInvalidPatternOnLegacyPhpVersionskipped on PHP 8 (→ we know the @ path wasn't testable here)
  • testReturnsNullForInvalidPatternOnPhp8passed on PHP 8 (→ confirms the exception path)

A single merged test would just show "passed" on both, without making it visible that the other branch couldn't be tested in that environment. It turns the "skipped" signal into silence.

If you value that clarity, I can reduce duplication by extracting the assertion into a private method but keep the two named, version‑gated test cases. If you'd rather have a single test for simplicity, that works too – just say the word. 😊

Comment on lines +42 to +54
if ($this->phpVersion->throwsValueErrorForInternalFunctions()) {
try {
$result = sscanf('', '%*n' . $format);
} catch (ValueError) {
return null;
}
} else {
$result = @sscanf('', '%*n' . $format);
}
if ($result === null) {
return null;
}
return count($result);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be simplified

Suggested change
if ($this->phpVersion->throwsValueErrorForInternalFunctions()) {
try {
$result = sscanf('', '%*n' . $format);
} catch (ValueError) {
return null;
}
} else {
$result = @sscanf('', '%*n' . $format);
}
if ($result === null) {
return null;
}
return count($result);
try {
$result = sscanf('', '%*n' . $format);
} catch (ValueError) {
return null;
}
if ($result === null) {
return null;
}
return count($result);

which would also simplify the tests IMO

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the appeal of simplifying, but the @ operator is essential on PHP < 8.0 to suppress the E_WARNING that sscanf emits for invalid formats. On PHP 7.x, catch (ValueError) cannot catch warnings – they'd propagate and cause test failures or unwanted noise. The version check (throwsValueErrorForInternalFunctions()) guards exactly this: on newer PHP we can rely on exceptions, on older PHP we need @.

The tests mirror that – they ensure both branches (suppressed warning on 7.x, caught exception on 8.x) return null as expected. If we drop the @ and always use try‑catch, the PHP 7.x test would see an uncaught warning and likely fail.

I’m happy to add a code comment clarifying the reasoning, but I’d recommend keeping the current implementation – it's the minimal correct cross‑version approach.

Copy link
Copy Markdown
Contributor

@staabm staabm May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { condition does not only depend on the php runtime php-version, but can also be defined via phpstan.neon phpVersion.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification about the config. Since I'm not familiar with how the DI wiring and CI setup interact, I'd rather not guess.

Could you please give me concrete instructions for both?

  1. Production code – keep the current version‑check + @, or simplify to a plain try/catch?
  2. Tests – we currently have three cases: the two that look identical plus the one that expects a ValueError with a fake legacy version. With your proposed production change, I could reduce these to just one test, or leave them as is. Just tell me exactly which test(s) you want, and I'll make it so.

Happy to push whatever you prefer – just point me in the right direction. 😊 (Late here on Friday, so I'll pick this up over the weekend. If you don't hear back right away, feel free to give me a nudge—notifications have a mind of their own sometimes.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants