diff --git a/blink/README.md b/blink/README.md index d2da3030ce..bb27d5e509 100644 --- a/blink/README.md +++ b/blink/README.md @@ -14,7 +14,7 @@ with `bin/rule-counts.sh`; CI keeps it current. | tool | rules | coverage | |---|---:|---:| | fixer universe (PHP-CS-Fixer + Symplify + in-set sniffs) | 338 | 100% | -| blink | 268 | 78% | +| blink | 288 | 84% | ## Build diff --git a/blink/internal/fixer/rules/phpunit_assert_new_names.go b/blink/internal/fixer/rules/phpunit_assert_new_names.go new file mode 100644 index 0000000000..337320eaf9 --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_assert_new_names.go @@ -0,0 +1,46 @@ +package rules + +import ( + "github.com/ecsphp/ecs-src/blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitAssertNewNamesFixer.php +// +// PhpUnitAssertNewNames renames deprecated PHPUnit assertions to their new +// names (assertFileNotExists -> assertFileDoesNotExist, ...) inside test +// classes. Risky: assumes the methods are PHPUnit's own. +type PhpUnitAssertNewNames struct{} + +var phpUnitAssertNewNamesMap = map[string]string{ + "assertnotisreadable": "assertIsNotReadable", + "assertnotiswritable": "assertIsNotWritable", + "assertdirectorynotexists": "assertDirectoryDoesNotExist", + "assertfilenotexists": "assertFileDoesNotExist", + "assertdirectorynotisreadable": "assertDirectoryIsNotReadable", + "assertdirectorynotiswritable": "assertDirectoryIsNotWriteable", + "assertfilenotisreadable": "assertFileIsNotReadable", + "assertfilenotiswritable": "assertFileIsNotWriteable", + "assertregexp": "assertMatchesRegularExpression", + "assertnotregexp": "assertDoesNotMatchRegularExpression", +} + +func (PhpUnitAssertNewNames) Name() string { + return `PhpCsFixer\Fixer\PhpUnit\PhpUnitAssertNewNamesFixer` +} + +func (PhpUnitAssertNewNames) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitAssertNewNamesFixer.php" +} + +func (PhpUnitAssertNewNames) Fix(s *tokens.Stream) bool { + changed := false + for _, cls := range phpUnitTestClasses(s) { + for _, call := range puPreviousAssertCalls(s, cls[0], cls[1]) { + if replacement, ok := phpUnitAssertNewNamesMap[call.lowered]; ok { + s.SetValue(call.index, replacement) + changed = true + } + } + } + return changed +} diff --git a/blink/internal/fixer/rules/phpunit_assert_new_names_upstream_test.go b/blink/internal/fixer/rules/phpunit_assert_new_names_upstream_test.go new file mode 100644 index 0000000000..6d79f9e3ef --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_assert_new_names_upstream_test.go @@ -0,0 +1,25 @@ +package rules + +import "testing" + +// Cases checked against PHP-CS-Fixer 3.95 (php_unit_assert_new_names). +func TestPhpUnitAssertNewNamesPhpCsFixerCases(t *testing.T) { + t.Parallel() + aliasRunUpstreamCases(t, PhpUnitAssertNewNames{}, []aliasUpstreamCase{ + { + "all renames", nil, + "assertFileNotExists(\"a\");\n $this->assertNotIsWritable(\"b\");\n $this->assertRegExp(\"/x/\", $s);\n $this->assertNotRegExp(\"/x/\", $s);\n $this->assertDirectoryNotExists(\"d\");\n $this->assertSame(1, 2);\n }\n}\n", + "assertFileDoesNotExist(\"a\");\n $this->assertIsNotWritable(\"b\");\n $this->assertMatchesRegularExpression(\"/x/\", $s);\n $this->assertDoesNotMatchRegularExpression(\"/x/\", $s);\n $this->assertDirectoryDoesNotExist(\"d\");\n $this->assertSame(1, 2);\n }\n}\n", + }, + { + "self and static receivers", nil, + "assertFileNotExists(\"c\");\n }\n}\n", + "assertFileNotExists(\"c\");\n }\n}\n", + }, + { + "not a PHPUnit class is untouched", nil, + "assertFileNotExists(\"a\");\n }\n}\n", + "assertFileNotExists(\"a\");\n }\n}\n", + }, + }) +} diff --git a/blink/internal/fixer/rules/phpunit_dedicate_assert_internal_type.go b/blink/internal/fixer/rules/phpunit_dedicate_assert_internal_type.go new file mode 100644 index 0000000000..976fe6525a --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_dedicate_assert_internal_type.go @@ -0,0 +1,122 @@ +package rules + +import ( + "strings" + + "github.com/ecsphp/ecs-src/blink/internal/fixer" + "github.com/ecsphp/ecs-src/blink/internal/token" + "github.com/ecsphp/ecs-src/blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixer.php +// +// PhpUnitDedicateAssertInternalType replaces assertInternalType("array", $x) +// with the dedicated assertIsArray($x) and assertNotInternalType with the +// negated form, inside test classes. The target option does not change the +// mapping. Risky: assumes PHPUnit's own methods. +type PhpUnitDedicateAssertInternalType struct{} + +var phpUnitInternalTypeMap = map[string]string{ + "array": "assertIsArray", + "boolean": "assertIsBool", + "bool": "assertIsBool", + "double": "assertIsFloat", + "float": "assertIsFloat", + "integer": "assertIsInt", + "int": "assertIsInt", + "null": "assertNull", + "numeric": "assertIsNumeric", + "object": "assertIsObject", + "real": "assertIsFloat", + "resource": "assertIsResource", + "string": "assertIsString", + "scalar": "assertIsScalar", + "callable": "assertIsCallable", + "iterable": "assertIsIterable", +} + +func (PhpUnitDedicateAssertInternalType) Name() string { + return `PhpCsFixer\Fixer\PhpUnit\PhpUnitDedicateAssertInternalTypeFixer` +} + +func (PhpUnitDedicateAssertInternalType) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixer.php" +} + +// WithConfig accepts the target option, which does not affect behavior. +func (f PhpUnitDedicateAssertInternalType) WithConfig(map[string]any) fixer.Fixer { + return f +} + +func (PhpUnitDedicateAssertInternalType) Fix(s *tokens.Stream) bool { + changed := false + for _, cls := range puClassesLastFirst(s) { + if dedicateInternalTypeClass(s, cls[0], cls[1]) { + changed = true + } + } + return changed +} + +func dedicateInternalTypeClass(s *tokens.Stream, startIndex, endIndex int) bool { + anon := map[int]int{} + for i := startIndex; i < endIndex; i++ { + if !kwIs(s.At(i), "class") || !taIsAnonymousClass(s, i) { + continue + } + open := nextPunctOfKind(s, i, "{") + if open < 0 { + continue + } + if end := s.MatchForward(open); end > open { + anon[end] = open + } + } + + changed := false + for index := endIndex - 1; index > startIndex; index-- { + if open, ok := anon[index]; ok { + index = open + continue + } + t := s.At(index) + if t.Kind != token.Ident { + continue + } + fn := strings.ToLower(t.Value) + if fn != "assertinternaltype" && fn != "assertnotinternaltype" { + continue + } + bracket := sigNext(s, index) + if !isPunctVal(s, bracket, "(") { + continue + } + typeIdx := sigNext(s, bracket) + if typeIdx < 0 || !s.At(typeIdx).IsLiteral() { + continue + } + expectedType := strings.Trim(s.At(typeIdx).Value, "'\"") + newAssertion, ok := phpUnitInternalTypeMap[expectedType] + if !ok { + continue + } + comma := sigNext(s, typeIdx) + if !isPunctVal(s, comma, ",") { + continue + } + if fn == "assertnotinternaltype" { + newAssertion = strings.ReplaceAll(newAssertion, "Is", "IsNot") + newAssertion = strings.ReplaceAll(newAssertion, "Null", "NotNull") + } + next := sigNext(s, comma) + if next < 0 { + continue + } + s.ReplaceRange(index, next-1, []token.Token{ + {Kind: token.Ident, Value: newAssertion}, + {Kind: token.Punct, Value: "("}, + }) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/phpunit_dedicate_assert_internal_type_upstream_test.go b/blink/internal/fixer/rules/phpunit_dedicate_assert_internal_type_upstream_test.go new file mode 100644 index 0000000000..29244518f9 --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_dedicate_assert_internal_type_upstream_test.go @@ -0,0 +1,25 @@ +package rules + +import "testing" + +// Cases checked against PHP-CS-Fixer 3.95 (php_unit_dedicate_assert_internal_type). +func TestPhpUnitDedicateAssertInternalTypePhpCsFixerCases(t *testing.T) { + t.Parallel() + aliasRunUpstreamCases(t, PhpUnitDedicateAssertInternalType{}, []aliasUpstreamCase{ + { + "mapped types, negation, unknown and malformed left", nil, + "assertInternalType(\"array\", $var);\n $this->assertNotInternalType(\"array\", $var);\n $this->assertInternalType('float', $var);\n $this->assertInternalType(\"unknownType\", $var);\n $obj->assertInternalType(\"array\", $var);\n $this->assertInternalType(\"array\");\n $this->assertInternalType($var, $x);\n }\n}\n", + "assertIsArray($var);\n $this->assertIsNotArray($var);\n $this->assertIsFloat($var);\n $this->assertInternalType(\"unknownType\", $var);\n $obj->assertIsArray($var);\n $this->assertInternalType(\"array\");\n $this->assertInternalType($var, $x);\n }\n}\n", + }, + { + "null maps to assertNull / assertNotNull", nil, + "assertInternalType(\"null\", $var);\n $this->assertNotInternalType(\"null\", $var);\n }\n}\n", + "assertNull($var);\n $this->assertNotNull($var);\n }\n}\n", + }, + { + "not a PHPUnit class is untouched", nil, + "assertInternalType(\"array\", $var);\n }\n}\n", + "assertInternalType(\"array\", $var);\n }\n}\n", + }, + }) +} diff --git a/blink/internal/fixer/rules/phpunit_mock.go b/blink/internal/fixer/rules/phpunit_mock.go new file mode 100644 index 0000000000..f5746f1e5e --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_mock.go @@ -0,0 +1,74 @@ +package rules + +import ( + "strings" + + "github.com/ecsphp/ecs-src/blink/internal/fixer" + "github.com/ecsphp/ecs-src/blink/internal/token" + "github.com/ecsphp/ecs-src/blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitMockFixer.php +// +// PhpUnitMock replaces ->getMock and ->getMockWithoutInvokingTheOriginalConstructor +// with ->createMock / ->createPartialMock inside test classes. The default +// target (newest) also rewrites a two-argument getMock to createPartialMock. +// Risky: assumes PHPUnit's own methods. +type PhpUnitMock struct { + target string +} + +func (PhpUnitMock) Name() string { + return `PhpCsFixer\Fixer\PhpUnit\PhpUnitMockFixer` +} + +func (PhpUnitMock) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitMockFixer.php" +} + +func (f PhpUnitMock) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["target"].(string); ok { + f.target = v + } + return f +} + +func (f PhpUnitMock) Fix(s *tokens.Stream) bool { + fixCreatePartial := phpUnitTargetFulfills(f.target, "5.5") + changed := false + for _, cls := range phpUnitTestClasses(s) { + start, end := cls[0], cls[1] + for index := start; index < end && index < s.Len(); index++ { + if !puIsObjectOperator(s, index) { + continue + } + index = sigNext(s, index) + if index < 0 { + break + } + if t := s.At(index); t.Kind != token.Ident { + continue + } + name := s.At(index).Value + switch { + case strings.EqualFold(name, "getMockWithoutInvokingTheOriginalConstructor"): + s.SetValue(index, "createMock") + changed = true + case strings.EqualFold(name, "getMock"): + open := sigNext(s, index) + if !isPunctVal(s, open, "(") { + continue + } + n := len(aliasArguments(s, open, s.MatchForward(open))) + if n == 1 { + s.SetValue(index, "createMock") + changed = true + } else if n == 2 && fixCreatePartial { + s.SetValue(index, "createPartialMock") + changed = true + } + } + } + } + return changed +} diff --git a/blink/internal/fixer/rules/phpunit_mock_upstream_test.go b/blink/internal/fixer/rules/phpunit_mock_upstream_test.go new file mode 100644 index 0000000000..09ca380cad --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_mock_upstream_test.go @@ -0,0 +1,25 @@ +package rules + +import "testing" + +// Cases checked against PHP-CS-Fixer 3.95 (php_unit_mock). +func TestPhpUnitMockPhpCsFixerCases(t *testing.T) { + t.Parallel() + aliasRunUpstreamCases(t, PhpUnitMock{}, []aliasUpstreamCase{ + { + "newest target", nil, + "getMockWithoutInvokingTheOriginalConstructor(\"Foo\");\n $b = $this->getMock(\"Foo\");\n $c = $this->getMock(\"Bar\", [\"x\"]);\n $d = $this->getMock(\"Baz\", [\"x\"], [\"y\"]);\n }\n}\n", + "createMock(\"Foo\");\n $b = $this->createMock(\"Foo\");\n $c = $this->createPartialMock(\"Bar\", [\"x\"]);\n $d = $this->getMock(\"Baz\", [\"x\"], [\"y\"]);\n }\n}\n", + }, + { + "target 5.4 keeps two-arg getMock", map[string]any{"target": "5.4"}, + "getMock(\"Foo\");\n $c = $this->getMock(\"Bar\", [\"x\"]);\n }\n}\n", + "createMock(\"Foo\");\n $c = $this->getMock(\"Bar\", [\"x\"]);\n }\n}\n", + }, + { + "not a PHPUnit class is untouched", nil, + "getMock(\"Foo\");\n }\n}\n", + "getMock(\"Foo\");\n }\n}\n", + }, + }) +} diff --git a/blink/internal/fixer/rules/phpunit_namespaced.go b/blink/internal/fixer/rules/phpunit_namespaced.go new file mode 100644 index 0000000000..6e66667136 --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_namespaced.go @@ -0,0 +1,230 @@ +package rules + +import ( + "strings" + + "github.com/ecsphp/ecs-src/blink/internal/fixer" + "github.com/ecsphp/ecs-src/blink/internal/token" + "github.com/ecsphp/ecs-src/blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitNamespacedFixer.php +// +// PhpUnitNamespaced rewrites the old underscore PHPUnit class names to their +// namespaced form (PHPUnit_Framework_TestCase -> PHPUnit\Framework\TestCase). +// The default target (newest) matches every PHPUnit_* class and uses a +// dedicated map for the classes whose namespaced name differs. Risky: assumes +// PHPUnit's own classes. +type PhpUnitNamespaced struct { + target string +} + +func (PhpUnitNamespaced) Name() string { + return `PhpCsFixer\Fixer\PhpUnit\PhpUnitNamespacedFixer` +} + +func (PhpUnitNamespaced) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitNamespacedFixer.php" +} + +func (f PhpUnitNamespaced) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["target"].(string); ok { + f.target = v + } + return f +} + +// phpUnitNamespacedClassMap is the target=6.0/newest dedicated map (lowercased +// keys), for classes whose namespaced name is not the underscore-to-backslash +// transform of the original. +var phpUnitNamespacedClassMap = map[string]string{ + "phpunit_extensions_phpttestcase": `PHPUnit\Runner\PhptTestCase`, + "phpunit_framework_constraint": `PHPUnit\Framework\Constraint\Constraint`, + "phpunit_framework_constraint_stringmatches": `PHPUnit\Framework\Constraint\StringMatchesFormatDescription`, + "phpunit_framework_constraint_jsonmatches_errormessageprovider": `PHPUnit\Framework\Constraint\JsonMatchesErrorMessageProvider`, + "phpunit_framework_constraint_pcrematch": `PHPUnit\Framework\Constraint\RegularExpression`, + "phpunit_framework_constraint_exceptionmessageregexp": `PHPUnit\Framework\Constraint\ExceptionMessageRegularExpression`, + "phpunit_framework_constraint_and": `PHPUnit\Framework\Constraint\LogicalAnd`, + "phpunit_framework_constraint_or": `PHPUnit\Framework\Constraint\LogicalOr`, + "phpunit_framework_constraint_not": `PHPUnit\Framework\Constraint\LogicalNot`, + "phpunit_framework_constraint_xor": `PHPUnit\Framework\Constraint\LogicalXor`, + "phpunit_framework_error": `PHPUnit\Framework\Error\Error`, + "phpunit_framework_testsuite_dataprovider": `PHPUnit\Framework\DataProviderTestSuite`, + "phpunit_framework_mockobject_invocation_static": `PHPUnit\Framework\MockObject\Invocation\StaticInvocation`, + "phpunit_framework_mockobject_invocation_object": `PHPUnit\Framework\MockObject\Invocation\ObjectInvocation`, + "phpunit_framework_mockobject_stub_return": `PHPUnit\Framework\MockObject\Stub\ReturnStub`, + "phpunit_runner_filter_group_exclude": `PHPUnit\Runner\Filter\ExcludeGroupFilterIterator`, + "phpunit_runner_filter_group_include": `PHPUnit\Runner\Filter\IncludeGroupFilterIterator`, + "phpunit_runner_filter_test": `PHPUnit\Runner\Filter\NameFilterIterator`, + "phpunit_util_php": `PHPUnit\Util\PHP\AbstractPhpProcess`, + "phpunit_util_php_default": `PHPUnit\Util\PHP\DefaultPhpProcess`, + "phpunit_util_php_windows": `PHPUnit\Util\PHP\WindowsPhpProcess`, + "phpunit_util_regex": `PHPUnit\Util\RegularExpression`, + "phpunit_util_testdox_resultprinter_xml": `PHPUnit\Util\TestDox\XmlResultPrinter`, + "phpunit_util_testdox_resultprinter_html": `PHPUnit\Util\TestDox\HtmlResultPrinter`, + "phpunit_util_testdox_resultprinter_text": `PHPUnit\Util\TestDox\TextResultPrinter`, + "phpunit_util_testsuiteiterator": `PHPUnit\Framework\TestSuiteIterator`, + "phpunit_util_xml": `PHPUnit\Util\Xml`, +} + +func (f PhpUnitNamespaced) Fix(s *tokens.Stream) bool { + match := f.matcher() + imported := map[string]bool{} + changed := false + currIndex := 0 + for { + currIndex = nsNextIdent(s, currIndex) + if currIndex < 0 { + break + } + prev := sigPrev(s, currIndex) + if prev >= 0 && (kwIs(s.At(prev), "const") || isPunctVal(s, prev, "::")) { + continue + } + original := s.At(currIndex).Value + allowed := nsIsClassyInvocation(s, currIndex) || nsIsImport(s, currIndex) + if !allowed || !match(original) { + currIndex++ + continue + } + repl := nsGenerateReplacement(original) + if imported[original] { + repl = repl[len(repl)-1:] + } + s.ReplaceRange(currIndex, currIndex, repl) + changed = true + + prev = sigPrev(s, currIndex) + if prev >= 0 && kwIs(s.At(prev), "use") { + imported[original] = true + } else if prev >= 0 && isPunctVal(s, prev, `\`) { + if pp := sigPrev(s, prev); pp >= 0 && kwIs(s.At(pp), "use") { + imported[original] = true + } + } + } + return changed +} + +// matcher returns the predicate for the configured target: which PHPUnit_* +// names are rewritten. The classMap is only non-empty for target 6.0/newest. +func (f PhpUnitNamespaced) matcher() func(string) bool { + switch { + case phpUnitTargetFulfills(f.target, "6.0"): + return func(name string) bool { + l := strings.ToLower(name) + return strings.HasPrefix(l, "phpunit_") && len(l) > len("phpunit_") + } + case phpUnitTargetFulfills(f.target, "5.7"): + return func(name string) bool { + switch strings.ToLower(name) { + case "phpunit_framework_testcase", "phpunit_framework_assert", + "phpunit_framework_basetestlistener", "phpunit_framework_testlistener": + return true + } + return false + } + default: + return func(name string) bool { + return strings.EqualFold(name, "phpunit_framework_testcase") + } + } +} + +// nsGenerateReplacement ports generateReplacement: build the namespaced token +// sequence for an original class name. +func nsGenerateReplacement(original string) []token.Token { + delimiter := "_" + str := original + if mapped, ok := phpUnitNamespacedClassMap[strings.ToLower(original)]; ok { + delimiter = `\` + str = mapped + } + parts := strings.Split(str, delimiter) + out := make([]token.Token, 0, len(parts)*2-1) + for i, p := range parts { + if i > 0 { + out = append(out, token.Token{Kind: token.Punct, Value: `\`}) + } + out = append(out, token.Token{Kind: token.Ident, Value: p}) + } + return out +} + +// nsNextIdent returns the first Ident with index strictly greater than from. +func nsNextIdent(s *tokens.Stream, from int) int { + for i := from + 1; i < s.Len(); i++ { + if s.At(i).Kind == token.Ident { + return i + } + } + return -1 +} + +// nsIsImport ports PhpUnitNamespacedFixer::isImport. +func nsIsImport(s *tokens.Stream, index int) bool { + prev := sigPrev(s, index) + if prev >= 0 && isPunctVal(s, prev, `\`) { + prev = sigPrev(s, prev) + } + return prev >= 0 && kwIs(s.At(prev), "use") +} + +// nsIsClassyInvocation ports the branches of ClassyAnalyzer::isClassyInvocation +// that can apply to a PHPUnit_* legacy class name (never a reserved type, no +// type-position unions for such names). It is only consulted for PHPUnit_* +// tokens, so cases that cannot occur for them are omitted. +func nsIsClassyInvocation(s *tokens.Stream, index int) bool { + next := sigNext(s, index) + if next >= 0 { + if isPunctVal(s, next, `\`) { + return false + } + if isPunctVal(s, next, "::") || isPunctVal(s, next, "...") || s.At(next).Kind == token.Variable { + return true + } + } + + prev := sigPrev(s, index) + for prev >= 0 { + t := s.At(prev) + if t.Kind == token.Ident || isPunctVal(s, prev, `\`) || aliasIsNamespaceOperator(s, prev) { + prev = sigPrev(s, prev) + continue + } + break + } + if prev < 0 { + return false + } + pt := s.At(prev) + if pt.Kind == token.Keyword { + switch strings.ToLower(pt.Value) { + case "extends", "instanceof", "insteadof", "implements", "new": + return true + } + } + if pt.Kind == token.AttributeStart { + return true + } + // catch (PHPUnit_Exception): next ")", prev "(", before "catch" + if next >= 0 && isPunctVal(s, next, ")") && isPunctVal(s, prev, "(") { + if pp := sigPrev(s, prev); pp >= 0 && kwIs(s.At(pp), "catch") { + return true + } + } + // implements A, PHPUnit_X list + if isPunctVal(s, prev, ",") { + p := prev + for p >= 0 { + t := s.At(p) + if isPunctVal(s, p, ",") || t.Kind == token.Ident || isPunctVal(s, p, `\`) || aliasIsNamespaceOperator(s, p) { + p = sigPrev(s, p) + continue + } + break + } + return p >= 0 && kwIs(s.At(p), "implements") + } + return false +} diff --git a/blink/internal/fixer/rules/phpunit_namespaced_upstream_test.go b/blink/internal/fixer/rules/phpunit_namespaced_upstream_test.go new file mode 100644 index 0000000000..68b2f8ed56 --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_namespaced_upstream_test.go @@ -0,0 +1,87 @@ +package rules + +import "testing" + +// Cases checked against PHP-CS-Fixer 3.95 (php_unit_namespaced). +func TestPhpUnitNamespacedPhpCsFixerCases(t *testing.T) { + t.Parallel() + aliasRunUpstreamCases(t, PhpUnitNamespaced{}, []aliasUpstreamCase{ + { + "extends, static call, new and mapped class", nil, + `PHPUnit_Framework_Assert(); + return PHPUnit_Framework_TestCase::class; + } +} +`, + `PHPUnit_Framework_Assert(); + return TestCase::class; + } +} +`, + }, + { + "target 5.7 only converts the forward-compat classes", map[string]any{"target": "5.7"}, + `/self::/static:: assert* call: the name-token +// index and the lowercased name. +type puAssertCall struct { + index int + lowered string +} + +// puPreviousAssertCalls mirrors AbstractPhpUnitFixer::getPreviousAssertCall: the +// assert* same-class calls in [startIndex, endIndex], yielded last first. +func puPreviousAssertCalls(s *tokens.Stream, startIndex, endIndex int) []puAssertCall { + var out []puAssertCall + for index := endIndex; index > startIndex; index-- { + prev := puPrevIdent(s, index) + if prev <= startIndex { + break + } + index = prev + lowered := strings.ToLower(s.At(index).Value) + if !strings.HasPrefix(lowered, "assert") { + continue + } + if !isPunctVal(s, sigNext(s, index), "(") { + continue + } + if !puIsTheSameClassCall(s, index) { + continue + } + out = append(out, puAssertCall{index: index, lowered: lowered}) + } + return out +} + +// phpUnitTargetFulfills mirrors PhpUnitTargetVersion::fulfills for the simple +// MAJOR.MINOR targets used by the PHPUnit fixers. An empty or "newest" +// candidate always fulfills. +func phpUnitTargetFulfills(candidate, target string) bool { + if candidate == "" || candidate == "newest" { + return true + } + cMajor, cMinor := phpUnitParseVersion(candidate) + tMajor, tMinor := phpUnitParseVersion(target) + if cMajor != tMajor { + return cMajor > tMajor + } + return cMinor >= tMinor +} + +func phpUnitParseVersion(v string) (int, int) { + major, minor := 0, 0 + if before, after, ok := strings.Cut(v, "."); ok { + major = atoiSafe(before) + minor = atoiSafe(after) + } else { + major = atoiSafe(v) + } + return major, minor +} + +func atoiSafe(s string) int { + n := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + n = n*10 + int(c-'0') + } + return n +} + +// puPrevIdent returns the index of the nearest Ident strictly before i, or -1, +// mirroring getPrevTokenOfKind with a [T_STRING] target. +func puPrevIdent(s *tokens.Stream, i int) int { + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind == token.Ident { + return j + } + } + return -1 +} + // puNextNonWhitespace mirrors Tokens::getNextNonWhitespace (cleared placeholder // tokens count as whitespace, as PHP's empty tokens do). func puNextNonWhitespace(s *tokens.Stream, i int) int { diff --git a/blink/internal/fixer/rules/phpunit_strict.go b/blink/internal/fixer/rules/phpunit_strict.go new file mode 100644 index 0000000000..72a44ce48e --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_strict.go @@ -0,0 +1,89 @@ +package rules + +import ( + "github.com/ecsphp/ecs-src/blink/internal/fixer" + "github.com/ecsphp/ecs-src/blink/internal/token" + "github.com/ecsphp/ecs-src/blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitStrictFixer.php +// +// PhpUnitStrict replaces loose PHPUnit assertions with their strict counterpart +// (assertEquals -> assertSame, ...) inside test classes, when the call has two +// or three arguments. The assertions option lists the methods to fix (all four +// by default). Risky: assumes the methods are PHPUnit's own. +type PhpUnitStrict struct { + assertions []string +} + +var phpUnitStrictMap = map[string]string{ + "assertAttributeEquals": "assertAttributeSame", + "assertAttributeNotEquals": "assertAttributeNotSame", + "assertEquals": "assertSame", + "assertNotEquals": "assertNotSame", +} + +var phpUnitStrictDefault = []string{"assertAttributeEquals", "assertAttributeNotEquals", "assertEquals", "assertNotEquals"} + +func (PhpUnitStrict) Name() string { + return `PhpCsFixer\Fixer\PhpUnit\PhpUnitStrictFixer` +} + +func (PhpUnitStrict) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitStrictFixer.php" +} + +func (f PhpUnitStrict) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["assertions"]; ok { + if list, ok := arrayNotationStringList(v); ok { + f.assertions = append([]string{}, list...) + } + } + return f +} + +func (f PhpUnitStrict) Fix(s *tokens.Stream) bool { + assertions := f.assertions + if assertions == nil { + assertions = phpUnitStrictDefault + } + changed := false + for _, cls := range phpUnitTestClasses(s) { + start, end := cls[0], cls[1] + for _, methodBefore := range assertions { + methodAfter, ok := phpUnitStrictMap[methodBefore] + if !ok { + continue + } + for index := start; index < end; index++ { + methodIndex := phpUnitNextStringExact(s, index, methodBefore) + if methodIndex < 0 { + break + } + if puIsTheSameClassCall(s, methodIndex) { + open := sigNext(s, methodIndex) + if isPunctVal(s, open, "(") { + if n := len(aliasArguments(s, open, s.MatchForward(open))); n == 2 || n == 3 { + s.SetValue(methodIndex, methodAfter) + changed = true + } + } + } + index = methodIndex + } + } + } + return changed +} + +// phpUnitNextStringExact returns the index of the next Ident after `from` whose +// value equals name exactly (case-sensitive), mirroring getNextTokenOfKind with +// a [T_STRING, name] target. It is not bounded by the class end, like upstream. +func phpUnitNextStringExact(s *tokens.Stream, from int, name string) int { + for i := from + 1; i < s.Len(); i++ { + if t := s.At(i); t.Kind == token.Ident && t.Value == name { + return i + } + } + return -1 +} diff --git a/blink/internal/fixer/rules/phpunit_strict_upstream_test.go b/blink/internal/fixer/rules/phpunit_strict_upstream_test.go new file mode 100644 index 0000000000..169ebae3a3 --- /dev/null +++ b/blink/internal/fixer/rules/phpunit_strict_upstream_test.go @@ -0,0 +1,36 @@ +package rules + +import "testing" + +// Cases checked against PHP-CS-Fixer 3.95 (php_unit_strict); every expected +// output is what the fixer actually produces for the input. +func TestPhpUnitStrictPhpCsFixerCases(t *testing.T) { + t.Parallel() + aliasRunUpstreamCases(t, PhpUnitStrict{}, []aliasUpstreamCase{ + { + "two args, all four assertions", nil, + "assertAttributeEquals(a(), b());\n $this->assertAttributeNotEquals(a(), b());\n $this->assertEquals(a(), b());\n $this->assertNotEquals(a(), b());\n }\n}\n", + "assertAttributeSame(a(), b());\n $this->assertAttributeNotSame(a(), b());\n $this->assertSame(a(), b());\n $this->assertNotSame(a(), b());\n }\n}\n", + }, + { + "two and three args fixed, one and four left", nil, + "assertEquals($a);\n $this->assertEquals($a, $b);\n $this->assertEquals($a, $b, $c);\n $this->assertEquals($a, $b, $c, $d);\n }\n}\n", + "assertEquals($a);\n $this->assertSame($a, $b);\n $this->assertSame($a, $b, $c);\n $this->assertEquals($a, $b, $c, $d);\n }\n}\n", + }, + { + "self and static fixed, other receivers left", nil, + "assertEquals(1, $a);\n assertEquals(1, $a);\n }\n}\n", + "assertEquals(1, $a);\n assertEquals(1, $a);\n }\n}\n", + }, + { + "config limits to assertEquals", map[string]any{"assertions": []any{"assertEquals"}}, + "assertEquals(1, $a);\n $this->assertNotEquals(1, $a);\n }\n}\n", + "assertSame(1, $a);\n $this->assertNotEquals(1, $a);\n }\n}\n", + }, + { + "not a PHPUnit class is untouched", nil, + "assertEquals(1, $a);\n }\n}\n", + "assertEquals(1, $a);\n }\n}\n", + }, + }) +} diff --git a/blink/internal/fixer/rules/priorities.go b/blink/internal/fixer/rules/priorities.go index c0e4ded874..461221bf01 100644 --- a/blink/internal/fixer/rules/priorities.go +++ b/blink/internal/fixer/rules/priorities.go @@ -39,6 +39,11 @@ var fixerPriority = map[string]int{ "PhpUnitFqcnAnnotationFixer": -9, "PhpUnitMockShortWillReturnFixer": 0, "PhpUnitTestAnnotationFixer": 10, + "PhpUnitStrictFixer": 0, + "PhpUnitAssertNewNamesFixer": -10, + "PhpUnitMockFixer": 0, + "PhpUnitDedicateAssertInternalTypeFixer": -16, + "PhpUnitNamespacedFixer": 2, "GlobalKeywordSniff": 0, // getPriority() is 0 upstream; raised to run before blank_line_after_opening_tag // so blink reaches the same output in a single pass (PHP-CS-Fixer loops to fixpoint) diff --git a/blink/internal/fixer/rules/registry.go b/blink/internal/fixer/rules/registry.go index 000aec8b6f..9d9abbedf8 100644 --- a/blink/internal/fixer/rules/registry.go +++ b/blink/internal/fixer/rules/registry.go @@ -246,6 +246,11 @@ func ConstructFixers() []fixer.Fixer { PhpUnitFqcnAnnotation{}, PhpUnitMockShortWillReturn{}, PhpUnitTestAnnotation{}, + PhpUnitStrict{}, + PhpUnitAssertNewNames{}, + PhpUnitMock{}, + PhpUnitDedicateAssertInternalType{}, + PhpUnitNamespaced{}, GlobalKeyword{}, OperatorLinebreak{}, PhpdocSeparation{},