Skip to content

Exception naming sniffs #63

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 51 commits into from
Closed

Exception naming sniffs #63

wants to merge 51 commits into from

Conversation

SenseException
Copy link
Member

@SenseException SenseException commented Jun 8, 2018

I've started to create sniffs for exception naming as described in the readme: Abstract exception class names and exception interface names should be suffixed with Exception.

This PR isn't a finished one yet and contains the first draft for naming exception interfaces. As this is about static code analysis, I had to make some assumptions about the parsed code.

Interfaces

  • If an interface has the name suffix *Exception, I assume, that the interface is an exception interface and needs to be handled by the sniff.
  • If an interface extends an exception interface, I assume, that the interface has to be an exception and needs to be handled by the sniff.
  • If an interface class extends \Throwable, it is definitely an exception and needs the *Exception suffix.
  • If one of the extended interfaces is an exception, I assume that the interface extending them is an exception interface too.

This one is covered for most cases with the creation of the PR. There are still forms of interfaces I need to cover and the sniff isn't finished either, but there's still room for feedback on the existing code.

The sniff is currently not covering use cases, where class alias, comma separation and group use declaration is used, but the dependency slevomat/coding-standard has helper classes like UseStatementHelper, that can help parsing those. Should I use the classes of this dependency for doctrine's custom coding standard sniffs or should I not introduce code from dependencies there?

Classes

This still needs to be handled with this PR.

  • If an abstract class has the name suffix *Exception, I assume, that the class is an exception class and needs to be handled by the sniff.
  • If a class extends an exception class or implements an exception interface, I assume, that the class has to be an exception and needs to be handled by the sniff.
  • If one of the extended classes or implemented interfaces has the suffix *Exception in its name, I assume the class is an exception class and needs its name checked.

Question

Should an abstract class only extend other abstract exception classes and basic PHP exception classes? Otherwise how do I know that an extended class without *Exception in its name is a valid regular exception class as the coding standard allows? Should I check if the class is in an exception namespace? Does the standard expect all exceptions in an exception namespace?

Addendum: leave exception classes have to be final. Exception namespaces are not in the scope of this PR.

phpunit.xml.dist Outdated
3) To run the tests against the database type the following from within the
tests/ folder: phpunit -c <filename> ...
Example: phpunit -c mysqlconf.xml AllTests
-->
Copy link
Member

Choose a reason for hiding this comment

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

Probably needs to be removed since this repo does not have to do with dbms'es

Copy link
Member Author

Choose a reason for hiding this comment

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

True. I guess we don't need the hint to copy the dist-file into a xml either, so I removed it.

@alcaeus
Copy link
Member

alcaeus commented Jun 11, 2018

The sniff is currently not covering use cases, where class alias, comma separation and group use declaration is used, but the dependency slevomat/coding-standard has helper classes like UseStatementHelper, that can help parsing those. Should I use the classes of this dependency for doctrine's custom coding standard sniffs or should I not introduce code from dependencies there?

That depends on whether those classes are part of the public API: if they are, go for it. @kukulich could you clarify whether those helpers are public API covered under Semver rules?

Should an abstract class only extend other abstract exception classes and basic PHP exception classes?

The assumption would make the sniff considerably easier, but strictly speaking, this isn't the case. I believe that we should take the shortcut and then expand the sniff once it's necessary to do so.

@kukulich
Copy link
Contributor

could you clarify whether those helpers are public API covered under Semver rules?

Yes, they are :)

@alcaeus
Copy link
Member

alcaeus commented Jun 11, 2018

Wonderful, thanks!

@SenseException
Copy link
Member Author

Great. I'll refactor that part using the UseStatementHelper. :-)

Copy link
Contributor

@Majkl578 Majkl578 left a comment

Choose a reason for hiding this comment

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

This sniff will be really helpful (not only) for ORM 3.0, DBAL 3.0, Migrations 2.0 and other libraries.

I'd also love to see a sniff that validates actual throw that we don't throw i.e. SPL exceptions. But that'd be separate task/sniff. :)

@SenseException
Copy link
Member Author

@Majkl578 I've updated the PR for the interface handling.

use PHP_CodeSniffer\Files\File;
use SlevomatCodingStandard\Helpers\UseStatementHelper as UseHelper;

class UseStatementHelper
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not a fan of Helper classes or a helper-namespace, but I kept naming and usage of static methods for consistency. If we should use a different approach for outsourcing logic, please let me know.

Copy link

Choose a reason for hiding this comment

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

It actually fits it better to use non-static class accepting File $phpcsFile in its constructor.

@Majkl578
Copy link
Contributor

I'll try to provide a more detailed review in the afternoon, here are just a few issues I spotted:

  • namespace should be Doctrine\CodingStandard, or if not possible, only DoctrineCodingStandard
  • please make sure tests/ is covered by our CS too (except the tested files themselves) - this will point out some CS issues already there (missing declare()s, redundant @param. missing return types, missing spaces around concatenation)
  • try to upgrade to PHPStan 0.10 with phpstan-strict-rules, putting configuration into phpstan.neon.dist

If you want, I can provide some base for writing/testing sniffs, similar to how slevomat/coding-standard and cdn77/coding-standard are currently tested.
We should also finally rework the way we test our CS (not in this PR), because the current way of maintaining tests/input + tests/expected_report.txt is really messy and not maintainable. :)

Feel free to ping me on Slack if you want to discuss any of above or anything else related to the CS. 👍

@Majkl578 Majkl578 self-requested a review July 11, 2018 02:10
];
}

/**
* @return string[][]|int[][]
Copy link
Member Author

Choose a reason for hiding this comment

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

How does the correct syntax for multidimensional arrays look like? Seems like the sniff is satisfied with multiple possible formats.

Copy link
Member

Choose a reason for hiding this comment

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

This is correct. (string|int)[][] would be more correct, but it is not supported by most tooling yet :-\

@SenseException
Copy link
Member Author

Addition to Exception classes: They have to be final.

@Ocramius
Copy link
Member

Addition to Exception classes: They have to be final.

Disagree on exception classes being final - unless we really segregate all exception types to interfaces (which would be OK), exceptions do in fact match the typical inheritance-first approach.

This means that each exception is required to implement at least 2 interfaces.

@Majkl578
Copy link
Contributor

Majkl578 commented Sep 1, 2018

Disagree on exception classes being final - unless we really segregate all exception types to interfaces (which would be OK), exceptions do in fact match the typical inheritance-first approach.

I'd say the ideal solution would be something like this:

  • leaf exceptions must be final
  • leaf exceptions must either:
    • extend an abstract ancestor exception, or
    • extend a root (SPL) exception and implement an exception marker interface
  • exception marker interfaces must either:
    • extend Throwable, or
    • extend another marker interface from the same or any upper namespace
  • intermediate (abstract) exceptions should be avoided, if used, they must either:
    • extend a root (SPL) exception and implement an exception marker interface, or
    • extend another intermediate (abstract) exception

Interface-based hiearchy should be generally always preferred and usage of intermediate (abstract) exceptions should be rather exceptional.
Example:

Throwable
┗ [IM] Doctrine\DBAL\Exception\DbalException
       ┗ [IM] Doctrine\DBAL\Driver\Exception\DriverException

RuntimeException + Doctrine\DBAL\Driver\Exception\DriverException
┗ [A] Doctrine\DBAL\Driver\Exception\ContextualException (w/ i.e. `getSQLState()`)
        ┗ [L] Doctrine\DBAL\Driver\Exception\FailedQuery

[I] = exception interface
[M] = marker interface
[A] = intermediate (abstract) exception
[L] = leaf (final) exception

Copy link
Contributor

@Majkl578 Majkl578 left a comment

Choose a reason for hiding this comment

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

I did a more thorough review - let me know if something is not clear, reviewing sniffs isn't easy since different people choose different approaches for the same outcome. :)

private function parseExtendedInterfaces(File $phpcsFile, int $stackPtr) : array
{
$limit = $phpcsFile->findNext([T_OPEN_CURLY_BRACKET], $stackPtr) - 1;
$start = $phpcsFile->findNext([T_EXTENDS], $stackPtr, $limit);
Copy link
Contributor

Choose a reason for hiding this comment

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

Use SlevomatCodingStandard\Helpers\TokenHelper::findNext($phpcsFile, [T_EXTENDS], $stackPtr + 1, $limit).

*/
private function parseExtendedInterfaces(File $phpcsFile, int $stackPtr) : array
{
$limit = $phpcsFile->findNext([T_OPEN_CURLY_BRACKET], $stackPtr) - 1;
Copy link
Contributor

Choose a reason for hiding this comment

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

Use SlevomatCodingStandard\Helpers\TokenHelper::findNext($phpcsFile, [T_OPEN_CURLY_BRACKET], $stackPtr + 1).

$limit = $phpcsFile->findNext([T_OPEN_CURLY_BRACKET], $stackPtr) - 1;
$start = $phpcsFile->findNext([T_EXTENDS], $stackPtr, $limit);

if ($start === false) {
Copy link
Contributor

Choose a reason for hiding this comment

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

With the changes above: === null.

return [];
}

$extendedInterfaces = explode(',', $phpcsFile->getTokensAsString($start + 1, $limit - $start));
Copy link
Contributor

Choose a reason for hiding this comment

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

It'd be better to do this properly, using combination of SlevomatCodingStandard\Helpers\TokenHelper::findNextLocal($phpcsFile, [T_COMMA], $start, $limit) + SlevomatCodingStandard\Helpers\TokenHelper::getContent($phpcsFile, $start, $localEnd) between $start/comma and comma/$limit (can be done with for or do...whie.

/**
* @return string[]
*/
private function parseExtendedInterfaces(File $phpcsFile, int $stackPtr) : array
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we move this to Helpers?

}

$isImplementingExceptions = count(preg_grep('/Exception$/', $implementedInterfaces)) > 0;

Copy link
Contributor

Choose a reason for hiding this comment

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

You should early-exit here if $isImplementingExceptions is true.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is the sniff for classes. Implementing an Exception interface is not enough for a class, because it needs to extend another Exception class too. I don't know about a requirement that an Exception class has to implement an interface.

Copy link
Contributor

Choose a reason for hiding this comment

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

Implementing an Exception interface is not enough for a class

That's true, but this sniff is about naming only, it should not report inheritance issues IMO.
Not extending some Exception will cause PHP fatal error, but that is out of scope for this sniff.

Copy link
Member Author

Choose a reason for hiding this comment

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

I even start to see no point here of checking for an exception interface when a class, regular or abstract, has to extend an exception. This is a relict from before making exception classes final. I think the complexity of checks for exception interfaces can be removed here. How do you think about that?


$importedClassNames = UseStatementHelper::getUseStatements($phpcsFile);

// TODO Should throwable be checked separately, because it can't be implemented on non-abstract exception class?
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's not allow this at all - you can't implement Throwable without extending Exception/Error.

$hasValidClassName = ($isAbstract && $hasExceptionName) ||
(! $isAbstract && ! $hasExceptionName && $isFinal);
$isValidException = $hasValidClassName && ($isExtendingException || $isImplementingException);
$isNoException = ! $hasExceptionName && ! $isExtendingException && ! $isImplementingException;
Copy link
Contributor

Choose a reason for hiding this comment

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

Please try to turn these combinations into multiple early-exit ifs, this is too complex.


if (! $hasValidClassName) {
$phpcsFile->addError(
'Use "Exception" suffix for abstract exception classes and make non-abstract classes final',
Copy link
Contributor

Choose a reason for hiding this comment

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

Exception classes must end with an "Exception" suffix. (should not mention abstract/final here since we check for class name)

}

$phpcsFile->addError(
'Class is not a valid exception',
Copy link
Contributor

Choose a reason for hiding this comment

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

Should say why: Exception classes must be either abstract or final.

@SenseException SenseException requested a review from a team as a code owner September 6, 2018 19:53
@SenseException
Copy link
Member Author

Not ready yet, but I'm almost done.

@SenseException
Copy link
Member Author

Ready for another review.

phpcs.xml.dist Outdated
@@ -13,7 +13,8 @@
<!-- Show progress of the run -->
<arg value="p"/>

<rule ref="Doctrine"/>
<rule ref="DoctrineCodingStandard"/>
Copy link
Member

Choose a reason for hiding this comment

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

That is quite the BC break - are we sure we have to/want to rename the standard?

Copy link
Member Author

Choose a reason for hiding this comment

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

I run into issues and chose the second solution mentioned:
#63 (comment)

But I admit, I didn't use much time to test how to make it run with the previous namespace.


final class StillNoExtension implements FooException
{
}
Copy link
Member

Choose a reason for hiding this comment

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

Some of these files don't contain a trailing EOL. It's either missing from the sniff or we don't properly check tests. Either way, there should be a consistent rule on this.

Copy link
Member Author

Choose a reason for hiding this comment

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

These tests are only for the new exception sniffs to prevent failing tests on new additions in future PRs.

Copy link
Member

Choose a reason for hiding this comment

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

I mean the trailing newlines - this is part of PSR-2 and should be fixed here as well.

public static function isImplementingThrowable(array $importedClassNames, array $implementedInterfaces) : bool
{
return (in_array(Throwable::class, $importedClassNames, true) &&
in_array(Throwable::class, $implementedInterfaces, true)) ||
Copy link
Member

Choose a reason for hiding this comment

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

Just to make sure, I assume this could lead to issues if Throwable is aliased?

use My\Namespace\Throwable;
use Throwable as BaseThrowable;

class Exception implements Throwable

We might want to also fetch aliases from use statements and explicitly check against that to avoid wrong results.

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

I guess the whole thing depends on whether findImplementedInterfaceNames returns aliased names or the actual FQCN. If it's the latter, this works as expected.

FWIW, there's a typo in TrowableAlias in your example that I just spotted now.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch. It looks like it doesn't matter for the functionality, but names in code are important to me.

@Majkl578 Majkl578 modified the milestones: 5.0.0, 6.0.0 Sep 24, 2018
@SenseException
Copy link
Member Author

This PR should also get an addition to the documentation for the new rules.

@SenseException
Copy link
Member Author

SenseException commented Oct 9, 2018

phpcbf seems to change the docblock of tests/input2/test-case.php from

    /**
     * @test
     *
     * @covers MyClass::test
     *
     * @uses MyClass::__construct
     */

to

    /**
     * @uses MyClass::__construct
     *
     * @test
     * @covers MyClass::test
     */

The Apply fixes stage build fails.

(PR #91 handles this case)

@SenseException
Copy link
Member Author

I've rebased to the current branch to keep this PR up to date.

@ostrolucky
Copy link
Member

I agree with @Ocramius, not liking idea about any exception classes being final.

On different note, even though this PR seems like great work, it does too much rules at once, which is why people hesitate to review it. Having less rules at least initially would allow less disagreements and easier time to review and merge it.

@Ocramius
Copy link
Member

@SenseException do you believe this to be ready for review or still WIP?

@SenseException
Copy link
Member Author

I would prefer reviews for the changes @Majkl578 and I did. Feedback can help development on this PR. It does a lot of stuff and there might be still some uncovered cases. The question about having final a few months ago is still open, which started in Slack and wasn't discussed here much. This is a contribution that involves first custom code for this repository after all.

@SenseException
Copy link
Member Author

I would prefer reviews before I rebase against master.

{
public static function hasExceptionSuffix(string $className) : bool
{
return preg_match('~Exception$~', $className) === 1;
Copy link
Member

Choose a reason for hiding this comment

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

What about ExceptionInterface?

Copy link
Member Author

Choose a reason for hiding this comment

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

Interfaces have to end with Exception. I can add ExceptionInterface to a test for a fail case.

Copy link
Member

Choose a reason for hiding this comment

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

Interfaces have to end with Exception

That's true only for doctrine code. Userland (or doctrine codebase itself) can be extending 3rd party exceptions with different naming convention. Detecting Exception(Interface)? is small step but easy win in such cases

Copy link
Member Author

Choose a reason for hiding this comment

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

True. It started for Doctrine code, but if someone else wants to use it, this might become a nuisance. But the detection of what is an Exception and what not depends on a naming convention. Your example uses a classic "end with Interface" convention, but what about a 3rd party exception naming convention without any suffixes or prefixes? This would make detecting exceptions with this sniff very hard.

What about making the naming convention configurable?

Copy link
Member

Choose a reason for hiding this comment

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

If you want to make this configurable, feel free. For me, handling ExceptionInterface would be good enough step, as such convention is common.

public static function getRealName(File $phpcsFile, string $referencedName) : string
{
$uses = self::getUseStatements($phpcsFile);
return $uses[$referencedName] ?? $referencedName;
Copy link
Member

Choose a reason for hiding this comment

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

I thought we have a rule for enforcing return on newline

Copy link
Member Author

Choose a reason for hiding this comment

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

That rule had to be added after my last commit, because the last build is green. After review, I'll rebase and fix the rest of the coding styles.

@Ocramius Ocramius modified the milestones: 6.0.0, 7.0.0 Mar 16, 2019
@Ocramius Ocramius modified the milestones: 7.0.0, 8.0.0 Dec 9, 2019
@Ocramius
Copy link
Member

Ocramius commented Dec 9, 2019

Deferred to 8.0.0

@carusogabriel carusogabriel removed this from the 8.0.0 milestone Dec 18, 2019
@carusogabriel
Copy link
Contributor

What is the status of this PR?

@SenseException
Copy link
Member Author

I'm currently not working on this WIP and don't know when I'll come back to it.

@greg0ire greg0ire changed the base branch from master to 8.2.x October 25, 2020 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants