From 7ac65f56c9ceecac07e298b01f031407a88332b3 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Tue, 29 Sep 2026 11:05:24 +0200 Subject: [PATCH 1/2] Add blink: Go port of ECS under blink/ with parity gate --- .github/workflows/blink_parity.yaml | 162 ++++++ .github/workflows/blink_rule_counts.yaml | 32 ++ .github/workflows/blink_tests.yaml | 144 +++++ .gitignore | 6 + blink/Makefile | 21 + blink/README.md | 123 ++++ blink/bin/rule-counts.sh | 87 +++ blink/blink.json.dist | 5 + blink/cmd/check-sources/main.go | 49 ++ blink/ecs-all-fixers.txt | 206 +++++++ blink/ecs-rules.txt | 43 ++ blink/go.mod | 3 + blink/internal/config/config.go | 122 ++++ blink/internal/config/config_test.go | 104 ++++ blink/internal/config/ecsconfig.go | 129 +++++ blink/internal/config/ecsconfig_test.go | 161 ++++++ blink/internal/diff/diff.go | 148 +++++ blink/internal/diff/diff_test.go | 28 + blink/internal/finder/finder.go | 63 ++ blink/internal/finder/finder_test.go | 50 ++ blink/internal/fixer/fixer.go | 70 +++ .../fixer/rules/add_missing_param_name.go | 133 +++++ blink/internal/fixer/rules/alias.go | 162 ++++++ .../fixer/rules/alias_lang_construct.go | 53 ++ .../internal/fixer/rules/array_indentation.go | 227 ++++++++ .../fixer/rules/array_indentation_test.go | 29 + .../fixer/rules/array_list_item_newline.go | 102 ++++ .../rules/array_list_item_newline_test.go | 29 + blink/internal/fixer/rules/array_notation.go | 167 ++++++ .../rules/array_opener_and_closer_newline.go | 61 ++ .../array_opener_and_closer_newline_test.go | 31 + blink/internal/fixer/rules/batch10_test.go | 103 ++++ blink/internal/fixer/rules/batchA_test.go | 70 +++ blink/internal/fixer/rules/batchD_test.go | 38 ++ .../fixer/rules/binary_operator_spaces.go | 45 ++ .../rules/blank_line_after_opening_tag.go | 41 ++ .../rules/blank_line_after_strict_types.go | 74 +++ .../rules/blank_line_before_statement.go | 130 +++++ blink/internal/fixer/rules/blank_lines.go | 44 ++ blink/internal/fixer/rules/blocks.go | 147 +++++ blink/internal/fixer/rules/braces_position.go | 112 ++++ .../fixer/rules/braces_position_test.go | 90 +++ blink/internal/fixer/rules/casing.go | 161 ++++++ blink/internal/fixer/rules/casing2.go | 127 ++++ blink/internal/fixer/rules/cast_casing.go | 73 +++ blink/internal/fixer/rules/cast_spaces.go | 104 ++++ .../internal/fixer/rules/class_definition.go | 82 +++ blink/internal/fixer/rules/class_elements.go | 161 ++++++ .../fixer/rules/class_members_test.go | 52 ++ .../fixer/rules/class_notation_test.go | 100 ++++ blink/internal/fixer/rules/comment.go | 46 ++ blink/internal/fixer/rules/comments2.go | 119 ++++ .../internal/fixer/rules/comments_analyzer.go | 183 ++++++ .../internal/fixer/rules/common_batch_test.go | 83 +++ blink/internal/fixer/rules/concat_space.go | 28 + blink/internal/fixer/rules/constructs.go | 178 ++++++ blink/internal/fixer/rules/control.go | 40 ++ .../fixer/rules/control_structure_braces.go | 248 ++++++++ .../rules/control_structure_braces_test.go | 50 ++ .../rules/control_structure_continuation.go | 71 +++ .../rules/control_structure_tier1_test.go | 74 +++ blink/internal/fixer/rules/declare.go | 40 ++ blink/internal/fixer/rules/docblock.go | 71 +++ blink/internal/fixer/rules/docblock_lines.go | 131 +++++ .../fixer/rules/doctrine_annotation.go | 258 +++++++++ .../doctrine_annotation_array_assignment.go | 43 ++ ...ctrine_annotation_array_assignment_test.go | 28 + .../rules/doctrine_annotation_indentation.go | 111 ++++ .../doctrine_annotation_indentation_test.go | 18 + .../fixer/rules/doctrine_annotation_parser.go | 374 ++++++++++++ .../fixer/rules/doctrine_annotation_test.go | 49 ++ .../fixer/rules/explicit_string_variable.go | 228 ++++++++ .../rules/explicit_string_variable_test.go | 39 ++ .../rules/fully_qualified_strict_types.go | 298 ++++++++++ .../fully_qualified_strict_types_phpdoc.go | 274 +++++++++ .../fully_qualified_strict_types_test.go | 36 ++ .../fully_qualified_strict_types_walk.go | 437 ++++++++++++++ .../fixer/rules/function_to_constant.go | 103 ++++ blink/internal/fixer/rules/gen2_casing.go | 196 +++++++ .../internal/fixer/rules/gen2_casing_test.go | 62 ++ blink/internal/fixer/rules/gen2_comment.go | 82 +++ .../internal/fixer/rules/gen2_comment_test.go | 60 ++ blink/internal/fixer/rules/gen2_ops.go | 85 +++ blink/internal/fixer/rules/gen2_ops_test.go | 72 +++ blink/internal/fixer/rules/gen2_phpdoc.go | 197 +++++++ .../internal/fixer/rules/gen2_phpdoc_test.go | 78 +++ blink/internal/fixer/rules/gen2_types.go | 154 +++++ blink/internal/fixer/rules/gen2_types_test.go | 66 +++ blink/internal/fixer/rules/gen3_class_sep.go | 219 +++++++ .../fixer/rules/gen3_class_sep_test.go | 179 ++++++ blink/internal/fixer/rules/gen3_func.go | 197 +++++++ blink/internal/fixer/rules/gen3_func_test.go | 65 +++ blink/internal/fixer/rules/gen3_types.go | 192 +++++++ blink/internal/fixer/rules/gen3_types_test.go | 105 ++++ blink/internal/fixer/rules/gen4_a.go | 238 ++++++++ blink/internal/fixer/rules/gen4_a_test.go | 134 +++++ blink/internal/fixer/rules/gen4_b.go | 66 +++ blink/internal/fixer/rules/gen4_b_test.go | 63 ++ blink/internal/fixer/rules/gen4_phpdoc.go | 80 +++ .../internal/fixer/rules/gen4_phpdoc_test.go | 61 ++ blink/internal/fixer/rules/gen5_lang.go | 214 +++++++ blink/internal/fixer/rules/gen5_lang_test.go | 122 ++++ .../internal/fixer/rules/gen5_phpdoc_misc.go | 76 +++ .../fixer/rules/gen5_phpdoc_misc_test.go | 74 +++ .../internal/fixer/rules/gen5_phpdoc_order.go | 140 +++++ .../fixer/rules/gen5_phpdoc_order_test.go | 98 ++++ blink/internal/fixer/rules/gen6_phpdoc.go | 240 ++++++++ .../internal/fixer/rules/gen6_phpdoc_test.go | 68 +++ blink/internal/fixer/rules/gen_arrays.go | 150 +++++ blink/internal/fixer/rules/gen_arrays_test.go | 126 ++++ blink/internal/fixer/rules/gen_control.go | 121 ++++ .../internal/fixer/rules/gen_control_test.go | 114 ++++ blink/internal/fixer/rules/gen_imports.go | 167 ++++++ .../internal/fixer/rules/gen_imports_test.go | 87 +++ blink/internal/fixer/rules/gen_ops_cast.go | 104 ++++ .../internal/fixer/rules/gen_ops_cast_test.go | 72 +++ blink/internal/fixer/rules/gen_ops_inc.go | 302 ++++++++++ .../internal/fixer/rules/gen_ops_inc_test.go | 134 +++++ blink/internal/fixer/rules/gen_phpdoc_tags.go | 150 +++++ .../fixer/rules/gen_phpdoc_tags_test.go | 96 ++++ blink/internal/fixer/rules/gen_phpdoc_var.go | 121 ++++ .../fixer/rules/gen_phpdoc_var_test.go | 76 +++ blink/internal/fixer/rules/gen_types.go | 209 +++++++ blink/internal/fixer/rules/gen_types_test.go | 73 +++ blink/internal/fixer/rules/gen_ws.go | 198 +++++++ blink/internal/fixer/rules/gen_ws_test.go | 69 +++ .../rules/general_phpdoc_annotation_remove.go | 68 +++ .../general_phpdoc_annotation_remove_test.go | 38 ++ .../fixer/rules/general_phpdoc_tag_rename.go | 22 + blink/internal/fixer/rules/imports.go | 301 ++++++++++ blink/internal/fixer/rules/imports_order.go | 238 ++++++++ blink/internal/fixer/rules/imports_unused.go | 295 ++++++++++ .../fixer/rules/indentation_braces_test.go | 33 ++ blink/internal/fixer/rules/is_null.go | 130 +++++ blink/internal/fixer/rules/is_null_test.go | 33 ++ .../fixer/rules/lambda_not_used_import.go | 445 ++++++++++++++ .../rules/lambda_not_used_import_test.go | 40 ++ .../rules/linebreak_after_opening_tag.go | 46 ++ blink/internal/fixer/rules/magic_casing.go | 104 ++++ .../fixer/rules/method_argument_space.go | 249 ++++++++ .../fixer/rules/method_argument_space_test.go | 48 ++ .../rules/method_chaining_indentation.go | 47 ++ .../fixer/rules/method_chaining_newline.go | 160 ++++++ .../rules/method_chaining_newline_test.go | 44 ++ blink/internal/fixer/rules/misc_rules.go | 102 ++++ .../internal/fixer/rules/modifier_keywords.go | 143 +++++ .../fixer/rules/modifier_keywords_test.go | 24 + blink/internal/fixer/rules/more_rules_test.go | 82 +++ .../fixer/rules/multiline_semicolons.go | 82 +++ .../fixer/rules/no_alternative_syntax.go | 167 ++++++ .../fixer/rules/no_alternative_syntax_test.go | 44 ++ .../rules/no_blank_line_between_imports.go | 60 ++ .../no_blank_line_between_imports_test.go | 23 + .../internal/fixer/rules/no_break_comment.go | 454 +++++++++++++++ .../fixer/rules/no_break_comment_test.go | 39 ++ .../rules/no_leading_namespace_whitespace.go | 46 ++ ...singleline_whitespace_before_semicolons.go | 38 ++ .../fixer/rules/no_superfluous_elseif.go | 264 +++++++++ .../fixer/rules/no_superfluous_elseif_test.go | 33 ++ .../fixer/rules/no_superfluous_phpdoc_tags.go | 352 ++++++++++++ .../rules/no_superfluous_phpdoc_tags_test.go | 93 +++ .../fixer/rules/no_trailing_whitespace.go | 92 +++ .../fixer/rules/no_unneeded_braces.go | 103 ++++ .../fixer/rules/no_unneeded_braces_test.go | 38 ++ .../rules/no_unneeded_control_parentheses.go | 81 +++ .../no_unreachable_default_argument_value.go | 234 ++++++++ ...unreachable_default_argument_value_test.go | 35 ++ .../rules/no_useless_nullsafe_operator.go | 38 ++ .../no_useless_nullsafe_operator_test.go | 22 + .../internal/fixer/rules/no_useless_return.go | 58 ++ .../rules/no_whitespace_in_blank_line.go | 50 ++ .../not_operator_with_successor_space.go | 42 ++ .../fixer/rules/nullable_type_declaration.go | 113 ++++ blink/internal/fixer/rules/offset.go | 55 ++ .../fixer/rules/operator_linebreak.go | 158 +++++ .../fixer/rules/operator_linebreak_ct.go | 369 ++++++++++++ .../fixer/rules/operator_linebreak_test.go | 52 ++ blink/internal/fixer/rules/operators3.go | 40 ++ .../fixer/rules/ordered_class_elements.go | 193 +++++++ .../fixer/rules/ordered_interfaces.go | 134 +++++ blink/internal/fixer/rules/ordered_traits.go | 273 +++++++++ blink/internal/fixer/rules/ordered_types.go | 160 ++++++ .../param_return_and_var_tag_malforms.go | 23 + .../internal/fixer/rules/parity_5bugs_test.go | 48 ++ blink/internal/fixer/rules/percs.go | 172 ++++++ blink/internal/fixer/rules/percs_test.go | 45 ++ blink/internal/fixer/rules/php_tag.go | 95 +++ blink/internal/fixer/rules/phpdoc.go | 156 +++++ blink/internal/fixer/rules/phpdoc_align.go | 180 ++++++ .../fixer/rules/phpdoc_align_match.go | 335 +++++++++++ .../internal/fixer/rules/phpdoc_align_test.go | 34 ++ blink/internal/fixer/rules/phpdoc_batch.go | 348 +++++++++++ .../internal/fixer/rules/phpdoc_batch_test.go | 185 ++++++ .../rules/phpdoc_no_useless_inheritdoc.go | 78 +++ .../phpdoc_no_useless_inheritdoc_test.go | 30 + .../internal/fixer/rules/phpdoc_separation.go | 170 ++++++ .../fixer/rules/phpdoc_separation_test.go | 33 ++ blink/internal/fixer/rules/phpdoc_test.go | 73 +++ .../internal/fixer/rules/phpdoc_to_comment.go | 43 ++ .../fixer/rules/phpdoc_to_comment_test.go | 41 ++ blink/internal/fixer/rules/phpunit_fixers.go | 403 +++++++++++++ blink/internal/fixer/rules/priorities.go | 194 +++++++ .../fixer/rules/protected_to_private.go | 113 ++++ .../fixer/rules/rector_regression_test.go | 34 ++ blink/internal/fixer/rules/registry.go | 294 ++++++++++ blink/internal/fixer/rules/regression_test.go | 32 ++ .../rules/remove_useless_default_comment.go | 93 +++ .../fixer/rules/return_ternary_test.go | 110 ++++ .../fixer/rules/return_type_declaration.go | 103 ++++ blink/internal/fixer/rules/rules_test.go | 372 ++++++++++++ blink/internal/fixer/rules/self_accessor.go | 146 +++++ .../fixer/rules/self_static_accessor.go | 118 ++++ .../fixer/rules/self_static_accessor_test.go | 43 ++ .../fixer/rules/simplified_null_return.go | 143 +++++ .../fixer/rules/single_blank_line_at_eof.go | 44 ++ .../single_blank_line_before_namespace.go | 42 ++ blink/internal/fixer/rules/source_test.go | 30 + .../rules/space_after_comma_here_now_doc.go | 42 ++ .../space_after_comma_here_now_doc_test.go | 22 + .../fixer/rules/space_after_semicolon.go | 45 ++ blink/internal/fixer/rules/space_around.go | 152 +++++ .../standalone_line_in_multiline_array.go | 97 ++++ ...standalone_line_in_multiline_array_test.go | 29 + ...standalone_line_plain_constructor_param.go | 78 +++ ...alone_line_plain_constructor_param_test.go | 29 + .../standalone_line_promoted_property.go | 77 +++ .../standalone_line_promoted_property_test.go | 23 + .../rules/standalone_line_required_param.go | 95 +++ .../standalone_line_required_param_test.go | 35 ++ ...standalone_line_symfony_attribute_param.go | 173 ++++++ ...alone_line_symfony_attribute_param_test.go | 29 + .../fixer/rules/statement_indentation.go | 76 +++ blink/internal/fixer/rules/statements.go | 80 +++ blink/internal/fixer/rules/string_notation.go | 42 ++ blink/internal/fixer/rules/structural.go | 212 +++++++ blink/internal/fixer/rules/switch_case.go | 101 ++++ .../internal/fixer/rules/symplify_docblock.go | 215 +++++++ .../fixer/rules/symplify_docblock2.go | 176 ++++++ .../fixer/rules/symplify_docblock3.go | 175 ++++++ .../fixer/rules/symplify_docblock_fixers.go | 543 ++++++++++++++++++ .../fixer/rules/symplify_docblock_test.go | 112 ++++ .../fixer/rules/ternary_operator_spaces.go | 146 +++++ .../fixer/rules/ternary_to_null_coalescing.go | 177 ++++++ .../rules/ternary_to_null_coalescing_test.go | 25 + blink/internal/fixer/rules/tier2_test.go | 72 +++ blink/internal/fixer/rules/token_batch.go | 391 +++++++++++++ .../internal/fixer/rules/token_batch_test.go | 137 +++++ blink/internal/fixer/rules/trailing_comma.go | 101 ++++ .../fixer/rules/visibility_required.go | 87 +++ blink/internal/fixer/rules/yoda_style.go | 256 +++++++++ blink/internal/fixer/rules/yoda_style_test.go | 47 ++ blink/internal/lexer/lexer.go | 411 +++++++++++++ blink/internal/lexer/lexer_test.go | 180 ++++++ blink/internal/reporter/progress.go | 86 +++ blink/internal/reporter/reporter.go | 196 +++++++ blink/internal/reporter/reporter_test.go | 57 ++ blink/internal/reporter/winsize_unix.go | 25 + blink/internal/reporter/winsize_windows.go | 29 + blink/internal/runner/runner.go | 129 +++++ blink/internal/runner/runner_test.go | 51 ++ blink/internal/set/set.go | 288 ++++++++++ blink/internal/set/set_test.go | 25 + blink/internal/token/token.go | 53 ++ blink/internal/tokens/stream.go | 138 +++++ blink/internal/tokens/stream_test.go | 65 +++ blink/main.go | 155 +++++ blink/psr12-left.txt | 60 ++ blink/testdata/dirty.php | 12 + 268 files changed, 31401 insertions(+) create mode 100644 .github/workflows/blink_parity.yaml create mode 100644 .github/workflows/blink_rule_counts.yaml create mode 100644 .github/workflows/blink_tests.yaml create mode 100644 blink/Makefile create mode 100644 blink/README.md create mode 100755 blink/bin/rule-counts.sh create mode 100644 blink/blink.json.dist create mode 100644 blink/cmd/check-sources/main.go create mode 100644 blink/ecs-all-fixers.txt create mode 100644 blink/ecs-rules.txt create mode 100644 blink/go.mod create mode 100644 blink/internal/config/config.go create mode 100644 blink/internal/config/config_test.go create mode 100644 blink/internal/config/ecsconfig.go create mode 100644 blink/internal/config/ecsconfig_test.go create mode 100644 blink/internal/diff/diff.go create mode 100644 blink/internal/diff/diff_test.go create mode 100644 blink/internal/finder/finder.go create mode 100644 blink/internal/finder/finder_test.go create mode 100644 blink/internal/fixer/fixer.go create mode 100644 blink/internal/fixer/rules/add_missing_param_name.go create mode 100644 blink/internal/fixer/rules/alias.go create mode 100644 blink/internal/fixer/rules/alias_lang_construct.go create mode 100644 blink/internal/fixer/rules/array_indentation.go create mode 100644 blink/internal/fixer/rules/array_indentation_test.go create mode 100644 blink/internal/fixer/rules/array_list_item_newline.go create mode 100644 blink/internal/fixer/rules/array_list_item_newline_test.go create mode 100644 blink/internal/fixer/rules/array_notation.go create mode 100644 blink/internal/fixer/rules/array_opener_and_closer_newline.go create mode 100644 blink/internal/fixer/rules/array_opener_and_closer_newline_test.go create mode 100644 blink/internal/fixer/rules/batch10_test.go create mode 100644 blink/internal/fixer/rules/batchA_test.go create mode 100644 blink/internal/fixer/rules/batchD_test.go create mode 100644 blink/internal/fixer/rules/binary_operator_spaces.go create mode 100644 blink/internal/fixer/rules/blank_line_after_opening_tag.go create mode 100644 blink/internal/fixer/rules/blank_line_after_strict_types.go create mode 100644 blink/internal/fixer/rules/blank_line_before_statement.go create mode 100644 blink/internal/fixer/rules/blank_lines.go create mode 100644 blink/internal/fixer/rules/blocks.go create mode 100644 blink/internal/fixer/rules/braces_position.go create mode 100644 blink/internal/fixer/rules/braces_position_test.go create mode 100644 blink/internal/fixer/rules/casing.go create mode 100644 blink/internal/fixer/rules/casing2.go create mode 100644 blink/internal/fixer/rules/cast_casing.go create mode 100644 blink/internal/fixer/rules/cast_spaces.go create mode 100644 blink/internal/fixer/rules/class_definition.go create mode 100644 blink/internal/fixer/rules/class_elements.go create mode 100644 blink/internal/fixer/rules/class_members_test.go create mode 100644 blink/internal/fixer/rules/class_notation_test.go create mode 100644 blink/internal/fixer/rules/comment.go create mode 100644 blink/internal/fixer/rules/comments2.go create mode 100644 blink/internal/fixer/rules/comments_analyzer.go create mode 100644 blink/internal/fixer/rules/common_batch_test.go create mode 100644 blink/internal/fixer/rules/concat_space.go create mode 100644 blink/internal/fixer/rules/constructs.go create mode 100644 blink/internal/fixer/rules/control.go create mode 100644 blink/internal/fixer/rules/control_structure_braces.go create mode 100644 blink/internal/fixer/rules/control_structure_braces_test.go create mode 100644 blink/internal/fixer/rules/control_structure_continuation.go create mode 100644 blink/internal/fixer/rules/control_structure_tier1_test.go create mode 100644 blink/internal/fixer/rules/declare.go create mode 100644 blink/internal/fixer/rules/docblock.go create mode 100644 blink/internal/fixer/rules/docblock_lines.go create mode 100644 blink/internal/fixer/rules/doctrine_annotation.go create mode 100644 blink/internal/fixer/rules/doctrine_annotation_array_assignment.go create mode 100644 blink/internal/fixer/rules/doctrine_annotation_array_assignment_test.go create mode 100644 blink/internal/fixer/rules/doctrine_annotation_indentation.go create mode 100644 blink/internal/fixer/rules/doctrine_annotation_indentation_test.go create mode 100644 blink/internal/fixer/rules/doctrine_annotation_parser.go create mode 100644 blink/internal/fixer/rules/doctrine_annotation_test.go create mode 100644 blink/internal/fixer/rules/explicit_string_variable.go create mode 100644 blink/internal/fixer/rules/explicit_string_variable_test.go create mode 100644 blink/internal/fixer/rules/fully_qualified_strict_types.go create mode 100644 blink/internal/fixer/rules/fully_qualified_strict_types_phpdoc.go create mode 100644 blink/internal/fixer/rules/fully_qualified_strict_types_test.go create mode 100644 blink/internal/fixer/rules/fully_qualified_strict_types_walk.go create mode 100644 blink/internal/fixer/rules/function_to_constant.go create mode 100644 blink/internal/fixer/rules/gen2_casing.go create mode 100644 blink/internal/fixer/rules/gen2_casing_test.go create mode 100644 blink/internal/fixer/rules/gen2_comment.go create mode 100644 blink/internal/fixer/rules/gen2_comment_test.go create mode 100644 blink/internal/fixer/rules/gen2_ops.go create mode 100644 blink/internal/fixer/rules/gen2_ops_test.go create mode 100644 blink/internal/fixer/rules/gen2_phpdoc.go create mode 100644 blink/internal/fixer/rules/gen2_phpdoc_test.go create mode 100644 blink/internal/fixer/rules/gen2_types.go create mode 100644 blink/internal/fixer/rules/gen2_types_test.go create mode 100644 blink/internal/fixer/rules/gen3_class_sep.go create mode 100644 blink/internal/fixer/rules/gen3_class_sep_test.go create mode 100644 blink/internal/fixer/rules/gen3_func.go create mode 100644 blink/internal/fixer/rules/gen3_func_test.go create mode 100644 blink/internal/fixer/rules/gen3_types.go create mode 100644 blink/internal/fixer/rules/gen3_types_test.go create mode 100644 blink/internal/fixer/rules/gen4_a.go create mode 100644 blink/internal/fixer/rules/gen4_a_test.go create mode 100644 blink/internal/fixer/rules/gen4_b.go create mode 100644 blink/internal/fixer/rules/gen4_b_test.go create mode 100644 blink/internal/fixer/rules/gen4_phpdoc.go create mode 100644 blink/internal/fixer/rules/gen4_phpdoc_test.go create mode 100644 blink/internal/fixer/rules/gen5_lang.go create mode 100644 blink/internal/fixer/rules/gen5_lang_test.go create mode 100644 blink/internal/fixer/rules/gen5_phpdoc_misc.go create mode 100644 blink/internal/fixer/rules/gen5_phpdoc_misc_test.go create mode 100644 blink/internal/fixer/rules/gen5_phpdoc_order.go create mode 100644 blink/internal/fixer/rules/gen5_phpdoc_order_test.go create mode 100644 blink/internal/fixer/rules/gen6_phpdoc.go create mode 100644 blink/internal/fixer/rules/gen6_phpdoc_test.go create mode 100644 blink/internal/fixer/rules/gen_arrays.go create mode 100644 blink/internal/fixer/rules/gen_arrays_test.go create mode 100644 blink/internal/fixer/rules/gen_control.go create mode 100644 blink/internal/fixer/rules/gen_control_test.go create mode 100644 blink/internal/fixer/rules/gen_imports.go create mode 100644 blink/internal/fixer/rules/gen_imports_test.go create mode 100644 blink/internal/fixer/rules/gen_ops_cast.go create mode 100644 blink/internal/fixer/rules/gen_ops_cast_test.go create mode 100644 blink/internal/fixer/rules/gen_ops_inc.go create mode 100644 blink/internal/fixer/rules/gen_ops_inc_test.go create mode 100644 blink/internal/fixer/rules/gen_phpdoc_tags.go create mode 100644 blink/internal/fixer/rules/gen_phpdoc_tags_test.go create mode 100644 blink/internal/fixer/rules/gen_phpdoc_var.go create mode 100644 blink/internal/fixer/rules/gen_phpdoc_var_test.go create mode 100644 blink/internal/fixer/rules/gen_types.go create mode 100644 blink/internal/fixer/rules/gen_types_test.go create mode 100644 blink/internal/fixer/rules/gen_ws.go create mode 100644 blink/internal/fixer/rules/gen_ws_test.go create mode 100644 blink/internal/fixer/rules/general_phpdoc_annotation_remove.go create mode 100644 blink/internal/fixer/rules/general_phpdoc_annotation_remove_test.go create mode 100644 blink/internal/fixer/rules/general_phpdoc_tag_rename.go create mode 100644 blink/internal/fixer/rules/imports.go create mode 100644 blink/internal/fixer/rules/imports_order.go create mode 100644 blink/internal/fixer/rules/imports_unused.go create mode 100644 blink/internal/fixer/rules/indentation_braces_test.go create mode 100644 blink/internal/fixer/rules/is_null.go create mode 100644 blink/internal/fixer/rules/is_null_test.go create mode 100644 blink/internal/fixer/rules/lambda_not_used_import.go create mode 100644 blink/internal/fixer/rules/lambda_not_used_import_test.go create mode 100644 blink/internal/fixer/rules/linebreak_after_opening_tag.go create mode 100644 blink/internal/fixer/rules/magic_casing.go create mode 100644 blink/internal/fixer/rules/method_argument_space.go create mode 100644 blink/internal/fixer/rules/method_argument_space_test.go create mode 100644 blink/internal/fixer/rules/method_chaining_indentation.go create mode 100644 blink/internal/fixer/rules/method_chaining_newline.go create mode 100644 blink/internal/fixer/rules/method_chaining_newline_test.go create mode 100644 blink/internal/fixer/rules/misc_rules.go create mode 100644 blink/internal/fixer/rules/modifier_keywords.go create mode 100644 blink/internal/fixer/rules/modifier_keywords_test.go create mode 100644 blink/internal/fixer/rules/more_rules_test.go create mode 100644 blink/internal/fixer/rules/multiline_semicolons.go create mode 100644 blink/internal/fixer/rules/no_alternative_syntax.go create mode 100644 blink/internal/fixer/rules/no_alternative_syntax_test.go create mode 100644 blink/internal/fixer/rules/no_blank_line_between_imports.go create mode 100644 blink/internal/fixer/rules/no_blank_line_between_imports_test.go create mode 100644 blink/internal/fixer/rules/no_break_comment.go create mode 100644 blink/internal/fixer/rules/no_break_comment_test.go create mode 100644 blink/internal/fixer/rules/no_leading_namespace_whitespace.go create mode 100644 blink/internal/fixer/rules/no_singleline_whitespace_before_semicolons.go create mode 100644 blink/internal/fixer/rules/no_superfluous_elseif.go create mode 100644 blink/internal/fixer/rules/no_superfluous_elseif_test.go create mode 100644 blink/internal/fixer/rules/no_superfluous_phpdoc_tags.go create mode 100644 blink/internal/fixer/rules/no_superfluous_phpdoc_tags_test.go create mode 100644 blink/internal/fixer/rules/no_trailing_whitespace.go create mode 100644 blink/internal/fixer/rules/no_unneeded_braces.go create mode 100644 blink/internal/fixer/rules/no_unneeded_braces_test.go create mode 100644 blink/internal/fixer/rules/no_unneeded_control_parentheses.go create mode 100644 blink/internal/fixer/rules/no_unreachable_default_argument_value.go create mode 100644 blink/internal/fixer/rules/no_unreachable_default_argument_value_test.go create mode 100644 blink/internal/fixer/rules/no_useless_nullsafe_operator.go create mode 100644 blink/internal/fixer/rules/no_useless_nullsafe_operator_test.go create mode 100644 blink/internal/fixer/rules/no_useless_return.go create mode 100644 blink/internal/fixer/rules/no_whitespace_in_blank_line.go create mode 100644 blink/internal/fixer/rules/not_operator_with_successor_space.go create mode 100644 blink/internal/fixer/rules/nullable_type_declaration.go create mode 100644 blink/internal/fixer/rules/offset.go create mode 100644 blink/internal/fixer/rules/operator_linebreak.go create mode 100644 blink/internal/fixer/rules/operator_linebreak_ct.go create mode 100644 blink/internal/fixer/rules/operator_linebreak_test.go create mode 100644 blink/internal/fixer/rules/operators3.go create mode 100644 blink/internal/fixer/rules/ordered_class_elements.go create mode 100644 blink/internal/fixer/rules/ordered_interfaces.go create mode 100644 blink/internal/fixer/rules/ordered_traits.go create mode 100644 blink/internal/fixer/rules/ordered_types.go create mode 100644 blink/internal/fixer/rules/param_return_and_var_tag_malforms.go create mode 100644 blink/internal/fixer/rules/parity_5bugs_test.go create mode 100644 blink/internal/fixer/rules/percs.go create mode 100644 blink/internal/fixer/rules/percs_test.go create mode 100644 blink/internal/fixer/rules/php_tag.go create mode 100644 blink/internal/fixer/rules/phpdoc.go create mode 100644 blink/internal/fixer/rules/phpdoc_align.go create mode 100644 blink/internal/fixer/rules/phpdoc_align_match.go create mode 100644 blink/internal/fixer/rules/phpdoc_align_test.go create mode 100644 blink/internal/fixer/rules/phpdoc_batch.go create mode 100644 blink/internal/fixer/rules/phpdoc_batch_test.go create mode 100644 blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc.go create mode 100644 blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc_test.go create mode 100644 blink/internal/fixer/rules/phpdoc_separation.go create mode 100644 blink/internal/fixer/rules/phpdoc_separation_test.go create mode 100644 blink/internal/fixer/rules/phpdoc_test.go create mode 100644 blink/internal/fixer/rules/phpdoc_to_comment.go create mode 100644 blink/internal/fixer/rules/phpdoc_to_comment_test.go create mode 100644 blink/internal/fixer/rules/phpunit_fixers.go create mode 100644 blink/internal/fixer/rules/priorities.go create mode 100644 blink/internal/fixer/rules/protected_to_private.go create mode 100644 blink/internal/fixer/rules/rector_regression_test.go create mode 100644 blink/internal/fixer/rules/registry.go create mode 100644 blink/internal/fixer/rules/regression_test.go create mode 100644 blink/internal/fixer/rules/remove_useless_default_comment.go create mode 100644 blink/internal/fixer/rules/return_ternary_test.go create mode 100644 blink/internal/fixer/rules/return_type_declaration.go create mode 100644 blink/internal/fixer/rules/rules_test.go create mode 100644 blink/internal/fixer/rules/self_accessor.go create mode 100644 blink/internal/fixer/rules/self_static_accessor.go create mode 100644 blink/internal/fixer/rules/self_static_accessor_test.go create mode 100644 blink/internal/fixer/rules/simplified_null_return.go create mode 100644 blink/internal/fixer/rules/single_blank_line_at_eof.go create mode 100644 blink/internal/fixer/rules/single_blank_line_before_namespace.go create mode 100644 blink/internal/fixer/rules/source_test.go create mode 100644 blink/internal/fixer/rules/space_after_comma_here_now_doc.go create mode 100644 blink/internal/fixer/rules/space_after_comma_here_now_doc_test.go create mode 100644 blink/internal/fixer/rules/space_after_semicolon.go create mode 100644 blink/internal/fixer/rules/space_around.go create mode 100644 blink/internal/fixer/rules/standalone_line_in_multiline_array.go create mode 100644 blink/internal/fixer/rules/standalone_line_in_multiline_array_test.go create mode 100644 blink/internal/fixer/rules/standalone_line_plain_constructor_param.go create mode 100644 blink/internal/fixer/rules/standalone_line_plain_constructor_param_test.go create mode 100644 blink/internal/fixer/rules/standalone_line_promoted_property.go create mode 100644 blink/internal/fixer/rules/standalone_line_promoted_property_test.go create mode 100644 blink/internal/fixer/rules/standalone_line_required_param.go create mode 100644 blink/internal/fixer/rules/standalone_line_required_param_test.go create mode 100644 blink/internal/fixer/rules/standalone_line_symfony_attribute_param.go create mode 100644 blink/internal/fixer/rules/standalone_line_symfony_attribute_param_test.go create mode 100644 blink/internal/fixer/rules/statement_indentation.go create mode 100644 blink/internal/fixer/rules/statements.go create mode 100644 blink/internal/fixer/rules/string_notation.go create mode 100644 blink/internal/fixer/rules/structural.go create mode 100644 blink/internal/fixer/rules/switch_case.go create mode 100644 blink/internal/fixer/rules/symplify_docblock.go create mode 100644 blink/internal/fixer/rules/symplify_docblock2.go create mode 100644 blink/internal/fixer/rules/symplify_docblock3.go create mode 100644 blink/internal/fixer/rules/symplify_docblock_fixers.go create mode 100644 blink/internal/fixer/rules/symplify_docblock_test.go create mode 100644 blink/internal/fixer/rules/ternary_operator_spaces.go create mode 100644 blink/internal/fixer/rules/ternary_to_null_coalescing.go create mode 100644 blink/internal/fixer/rules/ternary_to_null_coalescing_test.go create mode 100644 blink/internal/fixer/rules/tier2_test.go create mode 100644 blink/internal/fixer/rules/token_batch.go create mode 100644 blink/internal/fixer/rules/token_batch_test.go create mode 100644 blink/internal/fixer/rules/trailing_comma.go create mode 100644 blink/internal/fixer/rules/visibility_required.go create mode 100644 blink/internal/fixer/rules/yoda_style.go create mode 100644 blink/internal/fixer/rules/yoda_style_test.go create mode 100644 blink/internal/lexer/lexer.go create mode 100644 blink/internal/lexer/lexer_test.go create mode 100644 blink/internal/reporter/progress.go create mode 100644 blink/internal/reporter/reporter.go create mode 100644 blink/internal/reporter/reporter_test.go create mode 100644 blink/internal/reporter/winsize_unix.go create mode 100644 blink/internal/reporter/winsize_windows.go create mode 100644 blink/internal/runner/runner.go create mode 100644 blink/internal/runner/runner_test.go create mode 100644 blink/internal/set/set.go create mode 100644 blink/internal/set/set_test.go create mode 100644 blink/internal/token/token.go create mode 100644 blink/internal/tokens/stream.go create mode 100644 blink/internal/tokens/stream_test.go create mode 100644 blink/main.go create mode 100644 blink/psr12-left.txt create mode 100644 blink/testdata/dirty.php diff --git a/.github/workflows/blink_parity.yaml b/.github/workflows/blink_parity.yaml new file mode 100644 index 0000000000..4d6e10a3bd --- /dev/null +++ b/.github/workflows/blink_parity.yaml @@ -0,0 +1,162 @@ +name: Blink Parity + +# Runs blink and the ECS in this repository on the same real codebase (full +# rector-src) and fails if blink's output differs from ECS (psr12 + common) on +# any file. This is the 1:1 parity gate - it stays red until blink reproduces +# ECS exactly. + +on: + pull_request: + paths: + - 'blink/**' + - 'src/**' + - 'packages/**' + - 'config/**' + - '.github/workflows/blink_parity.yaml' + workflow_dispatch: null + +jobs: + blink-parity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + - name: Build blink + working-directory: blink + run: go build -o /tmp/blink . + + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + coverage: none + + # installs this repository's ECS so parity runs against the code being shipped + - uses: "ramsey/composer-install@v4" + + - name: Fetch a real codebase sample + run: | + git clone --depth 1 https://github.com/rectorphp/rector-src.git /tmp/rector + # full rector-src tree; .php.inc fixtures are skipped by the *.php find below + mkdir -p /tmp/sample + cp -r /tmp/rector/src /tmp/rector/rules /tmp/rector/tests /tmp/sample/ + cp -r /tmp/sample /tmp/ours + cp -r /tmp/sample /tmp/ecs + + - name: Run blink + run: /tmp/blink --fix /tmp/ours || true + + - name: Build the local ECS config + run: | + cat > /tmp/ecs.php <<'PHP' + withPreparedSets(psr12: true, common: true); + PHP + + - name: Run the local ECS + run: bin/ecs check /tmp/ecs --fix --no-progress-bar --config /tmp/ecs.php || true + + - name: Rank missing fixers (data-driven backlog) + run: | + # what ECS would apply on the pristine sample, as JSON + bin/ecs check /tmp/sample --output-format=json --config /tmp/ecs.php > /tmp/ecs.json 2>/dev/null || true + # fixers we already implement + /tmp/blink list-checkers | sed 's/^[[:space:]]*-[[:space:]]*//; /Registered/d' | sort -u > /tmp/impl.txt + # count applied checkers across files, most frequent first + jq -r '[.files[]?.diffs[]?.applied_checkers[]?] | group_by(.) | map("\(length) \(.[0])") | .[]' /tmp/ecs.json 2>/dev/null | sort -rn > /tmp/counts.txt || true + { + echo "## Backlog: ECS fixers not yet in blink, ranked by frequency on the sample" + echo "" + echo "| fixer | files |" + echo "|---|---|" + } >> "$GITHUB_STEP_SUMMARY" + n=0 + while read -r count chk; do + [ "$n" -ge 25 ] && break + if ! grep -qxF "$chk" /tmp/impl.txt; then + echo "| \`$chk\` | $count |" >> "$GITHUB_STEP_SUMMARY" + n=$((n + 1)) + fi + done < /tmp/counts.txt + if [ "$n" -eq 0 ]; then + echo "| (no data - check the ECS JSON shape) | |" >> "$GITHUB_STEP_SUMMARY" + fi + # stdout diagnostics (the summary table is not fetchable via CLI) + echo "=== implemented: $(wc -l < /tmp/impl.txt) | ecs.json bytes: $(wc -c < /tmp/ecs.json) ===" + echo "=== top applied checkers, raw (before excluding implemented) ===" + head -20 /tmp/counts.txt || true + + - name: Compare outputs + run: | + total=0 + differ=0 + for f in $(cd /tmp/ours && find . -name '*.php'); do + total=$((total + 1)) + if ! diff -q "/tmp/ours/$f" "/tmp/ecs/$f" >/dev/null 2>&1; then + differ=$((differ + 1)) + fi + done + same=$((total - differ)) + { + echo "## blink vs ECS (psr12 + common) on full rector-src" + echo "" + echo "| metric | value |" + echo "|---|---|" + echo "| files | $total |" + echo "| identical output | $same |" + echo "| differing output | $differ |" + echo "" + echo "This is the 1:1 parity gate: it fails while any file differs from ECS." + } >> "$GITHUB_STEP_SUMMARY" + echo "identical=$same differing=$differ of $total" + + # Per differing file: which rules ECS applied vs blink, and the + # snippet they differ on. ECS applied checkers come from the JSON + # captured on the pristine sample; blink's from a dry-run check. + echo "### Divergences ($differ files): applied rules + diff" >> "$GITHUB_STEP_SUMMARY" + echo "" + echo "=== Divergences ($differ files) ===" + export LC_ALL=C + for f in $(cd /tmp/ours && find . -name '*.php' | sort); do + diff -q "/tmp/ours/$f" "/tmp/ecs/$f" >/dev/null 2>&1 && continue + rel=${f#./} + abs="/tmp/sample/$rel" + ecs_rules=$(jq -r --arg k "$abs" '.files[$k].diffs[]?.applied_checkers[]?' /tmp/ecs.json 2>/dev/null | sed -E 's#.*\\##' | sort -u) + go_rules=$(/tmp/blink "$abs" 2>/dev/null | sed -n 's/^[[:space:]]*\*[[:space:]]//p' | sed -E 's#.*\\##' | sort -u) + ecs_only=$(comm -23 <(printf '%s\n' "$ecs_rules") <(printf '%s\n' "$go_rules") | grep -v '^$' | paste -sd, - || true) + go_only=$(comm -13 <(printf '%s\n' "$ecs_rules") <(printf '%s\n' "$go_rules") | grep -v '^$' | paste -sd, - || true) + # stdout (CI log): file + which rules differ + the diff + echo "" + echo "--- $rel" + echo " ECS applied, blink did not: ${ecs_only:-(none)}" + echo " blink applied, ECS did not: ${go_only:-(none)}" + diff "/tmp/ours/$f" "/tmp/ecs/$f" | sed 's/^/ /' | head -40 || true + # job summary (rich, collapsible) + { + echo "" + echo "
$rel" + echo "" + echo "- **ECS applied, blink did not:** ${ecs_only:-(none)}" + echo "- **blink applied, ECS did not:** ${go_only:-(none)}" + echo "" + echo '```diff' + diff "/tmp/ours/$f" "/tmp/ecs/$f" | head -40 || true + echo '```' + echo "
" + } >> "$GITHUB_STEP_SUMMARY" + done + + # fail when blink output does not match ECS + if [ "$differ" -gt 0 ]; then + echo "::error::blink output differs from ECS on $differ of $total files" + exit 1 + fi diff --git a/.github/workflows/blink_rule_counts.yaml b/.github/workflows/blink_rule_counts.yaml new file mode 100644 index 0000000000..08d6032299 --- /dev/null +++ b/.github/workflows/blink_rule_counts.yaml @@ -0,0 +1,32 @@ +name: Blink Rule counts + +on: + pull_request: + branches: + - main + paths: + - 'blink/**' + - '.github/workflows/blink_rule_counts.yaml' + push: + branches: + - main + paths: + - 'blink/**' + - '.github/workflows/blink_rule_counts.yaml' + +jobs: + rule-counts: + runs-on: ubuntu-latest + defaults: + run: + working-directory: blink + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + # counts ECS/blink rules, writes them to the job summary + # (shown on every PR), and fails if README is stale + - run: bin/rule-counts.sh --check diff --git a/.github/workflows/blink_tests.yaml b/.github/workflows/blink_tests.yaml new file mode 100644 index 0000000000..18e0524b37 --- /dev/null +++ b/.github/workflows/blink_tests.yaml @@ -0,0 +1,144 @@ +name: Blink Tests + +on: + pull_request: + paths: + - 'blink/**' + - '.github/workflows/blink_tests.yaml' + push: + branches: + - main + paths: + - 'blink/**' + - '.github/workflows/blink_tests.yaml' + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: blink + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + - run: go build ./... + + - run: go vet ./... + + - run: go test ./... + + windows: + runs-on: windows-latest + defaults: + run: + working-directory: blink + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + - run: go build -o blink.exe . + + - run: go test ./... + + - name: Fix a sample file + shell: bash + run: | + mkdir sample + printf ' sample/Sample.php + ./blink.exe --fix sample || true + grep -q '\$values = \[1, 2\];' sample/Sample.php + + format: + runs-on: ubuntu-latest + defaults: + run: + working-directory: blink + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + - name: Check gofmt -s + run: | + unformatted=$(gofmt -l -s $(find . -name '*.go')) + if [ -n "$unformatted" ]; then + echo "These files need 'gofmt -s -w':" + echo "$unformatted" + exit 1 + fi + + - name: Check go fix ./... + if: ${{ !cancelled() }} + run: | + changed=$(go fix -diff ./... 2>/dev/null) + if [ -n "$changed" ]; then + echo "These files need 'go fix ./...':" + echo "$changed" + exit 1 + fi + + analysis: + runs-on: ubuntu-latest + defaults: + run: + working-directory: blink + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + - name: gopls modernize + run: go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest ./... + + - uses: golangci/golangci-lint-action@v8 + if: ${{ !cancelled() }} + with: + version: v2.12.2 + working-directory: blink + + security: + runs-on: ubuntu-latest + defaults: + run: + working-directory: blink + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + - uses: actions/cache@v4 + with: + path: ~/go/bin/govulncheck + key: govulncheck-v1.6.0 + - name: install govulncheck + run: test -x ~/go/bin/govulncheck || go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + - name: govulncheck + run: govulncheck ./... + + fixer_sources: + runs-on: ubuntu-latest + defaults: + run: + working-directory: blink + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + + # verifies every fixer's SourceURL() points at a reachable PHP-CS-Fixer rule + - run: go run ./cmd/check-sources diff --git a/.gitignore b/.gitignore index 4866fbb652..be76b526d5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ php-scoper.phar # gpt .claude + +# blink Go build artifacts +/blink/blink +/blink/blink.exe +*.test +*.out diff --git a/blink/Makefile b/blink/Makefile new file mode 100644 index 0000000000..1d890282c0 --- /dev/null +++ b/blink/Makefile @@ -0,0 +1,21 @@ +.PHONY: build check fix test vet clean + +BINARY := blink + +build: + go build -o $(BINARY) . + +check: build + ./$(BINARY) . + +fix: build + ./$(BINARY) --fix . + +test: + go test ./... + +vet: + go vet ./... + +clean: + rm -f $(BINARY) diff --git a/blink/README.md b/blink/README.md new file mode 100644 index 0000000000..ea0756170d --- /dev/null +++ b/blink/README.md @@ -0,0 +1,123 @@ +# blink + +Fast, token-based PHP coding-standard checker and fixer - an [ECS](https://github.com/symplify/easy-coding-standard)-style +tool written in Go. Runs across all CPU cores by default. Lives under `blink/` +in the ECS repository and mirrors ECS's `psr12` + `common` output. + +## Rule coverage + +How many ECS rules blink implements. Regenerate with `bin/rule-counts.sh`; +CI keeps it current. + + +| tool | rules | of ECS | +|---|---:|---:| +| [ECS](https://github.com/symplify/easy-coding-standard) (baseline) | 208 | 100% | +| blink | 209 | 100% | + + +## Build + +Requires Go (see `go.mod` for the version). + +```bash +cd blink +make build # produces ./blink +``` + +## Usage + +Check your code (reports a diff of what would change, exit code 1 if issues): + +```bash +./blink src tests +``` + +Fix in place: + +```bash +./blink --fix src tests +``` + +List the active fixers: + +```bash +./blink list-checkers +``` + +## Configuration + +Drop a `blink.json` in your project root (auto-loaded, or point at one with +`--config`): + +```json +{ + "paths": ["src", "tests"], + "skip": ["*/Fixture/*"], + "sets": ["spaces"], + "level": {"spaces": 6} +} +``` + +- `sets` - enable a prepared set: `spaces`, `casing`, `psr12`, `per-cs`, `common`. + `per-cs` mirrors PHP-CS-Fixer's `@PER-CS` (as ECS's `SetList::PER_CS`) and adds + `single_line_empty_body` on top of the full set. +- `level` - gradual adoption: `{"spaces": N}` enables the first N rules of the + spaces set (safest first), so you can raise coverage one step at a time. +- `rules` - enable individual fixers by name. +- `paths` / `skip` - files to scan and glob patterns to ignore. + +With no config file, every fixer runs. CLI path arguments override `paths`. + +### Consuming an ECS config + +`blink --ecs-config ` reads the JSON that ECS's `ecs dump-config` produces +(its resolved paths, rules and skips) and runs blink over it. Because every +blink fixer is named by its PHP-CS-Fixer class, the ECS rules map straight onto +blink fixers by class name. Any rule blink has no fixer for is reported and +skipped, so the run is never silently narrower than the ECS config. Rule +configuration is not modelled yet - a configured rule runs with blink's built-in +behaviour, which is noted in the report. + +## Parity with ECS + +The `blink parity` CI workflow runs blink and the ECS in this repository over the +same real codebase (full rector-src, `psr12` + `common`) and fails if blink's +output differs from ECS on any file. It is the 1:1 parity gate: it stays red +until blink reproduces ECS exactly, and its job summary ranks the missing fixers +by how often ECS applies them on the sample. + +## PSR-12 + +The `psr12` set implements the token-safe part of PHP-CS-Fixer's `@PSR-12`: +casing (keywords, constants, static references, casts), operator spacing +(assignment, arrow, comparison and logical operators), parenthesis and +language-construct spacing, `else if` -> `elseif`, `declare` normalization, +leading import slash removal, one import per statement, blank lines before a +namespace, no blank lines after a class opening, and tab-to-space indentation. + +## What it looks like + +``` +1) src/Foo.php + + ---------- begin diff ---------- +@@ @@ + " +END="" + +ecs=$(grep -oE '[0-9]+ rules configured in ECS' "$RULES_TXT" | grep -oE '^[0-9]+') + +go build -C "$ROOT" -o /tmp/blink-count . >&2 +go_count=$(/tmp/blink-count list-checkers | grep -cE '^[[:space:]]*- ') + +# rules ECS configures (across its sets) that blink does not implement yet +missing="" +if [ -f "$ALL_FIXERS" ]; then + /tmp/blink-count list-checkers | sed 's/^[[:space:]]*-[[:space:]]*//' | LC_ALL=C sort -u > /tmp/blink-impl.txt + missing=$(LC_ALL=C comm -23 <(LC_ALL=C sort -u "$ALL_FIXERS") /tmp/blink-impl.txt | sed -E 's#.*\\##') +fi +missing_count=$(printf '%s' "$missing" | grep -c . || true) + +go_pct=$(( go_count * 100 / ecs )) + +block=$(cat </dev/null | grep -c . || echo 0) ECS fixers)" + if [ -n "$missing" ]; then + printf '%s\n' "$missing" | sed 's/^/- `/; s/$/`/' + else + echo "None - full coverage." + fi + } >> "$GITHUB_STEP_SUMMARY" +fi + +# Always print the missing list to stdout too (visible in CI logs). +echo "Missing in blink ($missing_count):" +if [ -n "$missing" ]; then + printf ' %s\n' $missing +fi + +if [ "${1:-}" = "--check" ]; then + if ! diff <(printf '%s\n' "$new") "$README" >/dev/null; then + echo "README rule counts are stale. Run: bin/rule-counts.sh" >&2 + diff "$README" <(printf '%s\n' "$new") || true + exit 1 + fi + echo "Rule counts up to date: ECS $ecs, blink $go_count" +else + printf '%s\n' "$new" > "$README" + echo "Updated README: ECS $ecs, blink $go_count" +fi diff --git a/blink/blink.json.dist b/blink/blink.json.dist new file mode 100644 index 0000000000..58094726e9 --- /dev/null +++ b/blink/blink.json.dist @@ -0,0 +1,5 @@ +{ + "paths": ["src", "tests"], + "skip": ["*/Fixture/*", "*/Source/*"], + "sets": ["psr12"] +} diff --git a/blink/cmd/check-sources/main.go b/blink/cmd/check-sources/main.go new file mode 100644 index 0000000000..f4cb6a2f2c --- /dev/null +++ b/blink/cmd/check-sources/main.go @@ -0,0 +1,49 @@ +// Command check-sources verifies that every registered fixer links to a +// reachable PHP-CS-Fixer source file on GitHub. It is run in CI so a renamed or +// removed upstream rule fails the build. +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "blink/internal/fixer/rules" +) + +func main() { + client := &http.Client{Timeout: 20 * time.Second} + failed := false + + for _, f := range rules.All() { + url := f.SourceURL() + + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + fmt.Printf("FAIL %s: %v\n", f.Name(), err) + failed = true + continue + } + + resp, err := client.Do(req) + if err != nil { + fmt.Printf("FAIL %s: %v\n", f.Name(), err) + failed = true + continue + } + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Printf("FAIL %s: %s -> %d\n", f.Name(), url, resp.StatusCode) + failed = true + continue + } + fmt.Printf("OK %s\n", url) + } + + if failed { + os.Exit(1) + } + fmt.Println("All fixer sources reachable.") +} diff --git a/blink/ecs-all-fixers.txt b/blink/ecs-all-fixers.txt new file mode 100644 index 0000000000..cf80f1b6ef --- /dev/null +++ b/blink/ecs-all-fixers.txt @@ -0,0 +1,206 @@ +PhpCsFixer\Fixer\Alias\NoAliasFunctionsFixer +PhpCsFixer\Fixer\Alias\NoAliasLanguageConstructCallFixer +PhpCsFixer\Fixer\Alias\NoMixedEchoPrintFixer +PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer +PhpCsFixer\Fixer\ArrayNotation\NoMultilineWhitespaceAroundDoubleArrowFixer +PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceBeforeCommaInArrayFixer +PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceInEmptyArrayFixer +PhpCsFixer\Fixer\ArrayNotation\NormalizeIndexBraceFixer +PhpCsFixer\Fixer\ArrayNotation\TrimArraySpacesFixer +PhpCsFixer\Fixer\ArrayNotation\WhitespaceAfterCommaInArrayFixer +PhpCsFixer\Fixer\AttributeNotation\AttributeBlockNoSpacesFixer +PhpCsFixer\Fixer\Basic\BracesPositionFixer +PhpCsFixer\Fixer\Basic\EncodingFixer +PhpCsFixer\Fixer\Basic\NoMultipleStatementsPerLineFixer +PhpCsFixer\Fixer\Basic\NoTrailingCommaInSinglelineFixer +PhpCsFixer\Fixer\Basic\PsrAutoloadingFixer +PhpCsFixer\Fixer\Basic\SingleLineEmptyBodyFixer +PhpCsFixer\Fixer\Casing\ClassReferenceNameCasingFixer +PhpCsFixer\Fixer\Casing\ConstantCaseFixer +PhpCsFixer\Fixer\Casing\IntegerLiteralCaseFixer +PhpCsFixer\Fixer\Casing\LowercaseKeywordsFixer +PhpCsFixer\Fixer\Casing\LowercaseStaticReferenceFixer +PhpCsFixer\Fixer\Casing\MagicConstantCasingFixer +PhpCsFixer\Fixer\Casing\MagicMethodCasingFixer +PhpCsFixer\Fixer\Casing\NativeFunctionCasingFixer +PhpCsFixer\Fixer\Casing\NativeTypeDeclarationCasingFixer +PhpCsFixer\Fixer\CastNotation\CastSpacesFixer +PhpCsFixer\Fixer\CastNotation\LowercaseCastFixer +PhpCsFixer\Fixer\CastNotation\NoShortBoolCastFixer +PhpCsFixer\Fixer\CastNotation\NoUnsetCastFixer +PhpCsFixer\Fixer\CastNotation\ShortScalarCastFixer +PhpCsFixer\Fixer\ClassNotation\ClassAttributesSeparationFixer +PhpCsFixer\Fixer\ClassNotation\ClassDefinitionFixer +PhpCsFixer\Fixer\ClassNotation\ModifierKeywordsFixer +PhpCsFixer\Fixer\ClassNotation\NoBlankLinesAfterClassOpeningFixer +PhpCsFixer\Fixer\ClassNotation\NoNullPropertyInitializationFixer +PhpCsFixer\Fixer\ClassNotation\OrderedClassElementsFixer +PhpCsFixer\Fixer\ClassNotation\OrderedInterfacesFixer +PhpCsFixer\Fixer\ClassNotation\OrderedTraitsFixer +PhpCsFixer\Fixer\ClassNotation\OrderedTypesFixer +PhpCsFixer\Fixer\ClassNotation\ProtectedToPrivateFixer +PhpCsFixer\Fixer\ClassNotation\SelfAccessorFixer +PhpCsFixer\Fixer\ClassNotation\SelfStaticAccessorFixer +PhpCsFixer\Fixer\ClassNotation\SingleClassElementPerStatementFixer +PhpCsFixer\Fixer\ClassNotation\SingleTraitInsertPerStatementFixer +PhpCsFixer\Fixer\ClassNotation\VisibilityRequiredFixer +PhpCsFixer\Fixer\Comment\MultilineCommentOpeningClosingFixer +PhpCsFixer\Fixer\Comment\NoEmptyCommentFixer +PhpCsFixer\Fixer\Comment\NoTrailingWhitespaceInCommentFixer +PhpCsFixer\Fixer\Comment\SingleLineCommentSpacingFixer +PhpCsFixer\Fixer\Comment\SingleLineCommentStyleFixer +PhpCsFixer\Fixer\ControlStructure\ControlStructureBracesFixer +PhpCsFixer\Fixer\ControlStructure\ControlStructureContinuationPositionFixer +PhpCsFixer\Fixer\ControlStructure\ElseifFixer +PhpCsFixer\Fixer\ControlStructure\IncludeFixer +PhpCsFixer\Fixer\ControlStructure\NoAlternativeSyntaxFixer +PhpCsFixer\Fixer\ControlStructure\NoBreakCommentFixer +PhpCsFixer\Fixer\ControlStructure\NoSuperfluousElseifFixer +PhpCsFixer\Fixer\ControlStructure\NoUnneededBracesFixer +PhpCsFixer\Fixer\ControlStructure\NoUnneededControlParenthesesFixer +PhpCsFixer\Fixer\ControlStructure\NoUnneededCurlyBracesFixer +PhpCsFixer\Fixer\ControlStructure\SwitchCaseSemicolonToColonFixer +PhpCsFixer\Fixer\ControlStructure\SwitchCaseSpaceFixer +PhpCsFixer\Fixer\ControlStructure\SwitchContinueToBreakFixer +PhpCsFixer\Fixer\ControlStructure\TrailingCommaInMultilineFixer +PhpCsFixer\Fixer\ControlStructure\YodaStyleFixer +PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationArrayAssignmentFixer +PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationIndentationFixer +PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationSpacesFixer +PhpCsFixer\Fixer\FunctionNotation\FunctionDeclarationFixer +PhpCsFixer\Fixer\FunctionNotation\LambdaNotUsedImportFixer +PhpCsFixer\Fixer\FunctionNotation\MethodArgumentSpaceFixer +PhpCsFixer\Fixer\FunctionNotation\NoSpacesAfterFunctionNameFixer +PhpCsFixer\Fixer\FunctionNotation\NoUnreachableDefaultArgumentValueFixer +PhpCsFixer\Fixer\FunctionNotation\NullableTypeDeclarationForDefaultNullValueFixer +PhpCsFixer\Fixer\FunctionNotation\ReturnTypeDeclarationFixer +PhpCsFixer\Fixer\Import\FullyQualifiedStrictTypesFixer +PhpCsFixer\Fixer\Import\NoLeadingImportSlashFixer +PhpCsFixer\Fixer\Import\NoUnneededImportAliasFixer +PhpCsFixer\Fixer\Import\NoUnusedImportsFixer +PhpCsFixer\Fixer\Import\OrderedImportsFixer +PhpCsFixer\Fixer\Import\SingleImportPerStatementFixer +PhpCsFixer\Fixer\Import\SingleLineAfterImportsFixer +PhpCsFixer\Fixer\LanguageConstruct\DeclareEqualNormalizeFixer +PhpCsFixer\Fixer\LanguageConstruct\DeclareParenthesesFixer +PhpCsFixer\Fixer\LanguageConstruct\ExplicitIndirectVariableFixer +PhpCsFixer\Fixer\LanguageConstruct\FunctionToConstantFixer +PhpCsFixer\Fixer\LanguageConstruct\IsNullFixer +PhpCsFixer\Fixer\LanguageConstruct\NullableTypeDeclarationFixer +PhpCsFixer\Fixer\LanguageConstruct\SingleSpaceAroundConstructFixer +PhpCsFixer\Fixer\ListNotation\ListSyntaxFixer +PhpCsFixer\Fixer\NamespaceNotation\BlankLineAfterNamespaceFixer +PhpCsFixer\Fixer\NamespaceNotation\BlankLinesBeforeNamespaceFixer +PhpCsFixer\Fixer\NamespaceNotation\CleanNamespaceFixer +PhpCsFixer\Fixer\NamespaceNotation\NoLeadingNamespaceWhitespaceFixer +PhpCsFixer\Fixer\NamespaceNotation\SingleBlankLineBeforeNamespaceFixer +PhpCsFixer\Fixer\Operator\AssignNullCoalescingToCoalesceEqualFixer +PhpCsFixer\Fixer\Operator\BinaryOperatorSpacesFixer +PhpCsFixer\Fixer\Operator\ConcatSpaceFixer +PhpCsFixer\Fixer\Operator\IncrementStyleFixer +PhpCsFixer\Fixer\Operator\LongToShorthandOperatorFixer +PhpCsFixer\Fixer\Operator\NewWithParenthesesFixer +PhpCsFixer\Fixer\Operator\NoSpaceAroundDoubleColonFixer +PhpCsFixer\Fixer\Operator\NoUselessConcatOperatorFixer +PhpCsFixer\Fixer\Operator\NoUselessNullsafeOperatorFixer +PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer +PhpCsFixer\Fixer\Operator\ObjectOperatorWithoutWhitespaceFixer +PhpCsFixer\Fixer\Operator\OperatorLinebreakFixer +PhpCsFixer\Fixer\Operator\StandardizeIncrementFixer +PhpCsFixer\Fixer\Operator\StandardizeNotEqualsFixer +PhpCsFixer\Fixer\Operator\TernaryOperatorSpacesFixer +PhpCsFixer\Fixer\Operator\TernaryToNullCoalescingFixer +PhpCsFixer\Fixer\Operator\UnaryOperatorSpacesFixer +PhpCsFixer\Fixer\PhpTag\BlankLineAfterOpeningTagFixer +PhpCsFixer\Fixer\PhpTag\FullOpeningTagFixer +PhpCsFixer\Fixer\PhpTag\LinebreakAfterOpeningTagFixer +PhpCsFixer\Fixer\PhpTag\NoClosingTagFixer +PhpCsFixer\Fixer\PhpUnit\PhpUnitMethodCasingFixer +PhpCsFixer\Fixer\PhpUnit\PhpUnitSetUpTearDownVisibilityFixer +PhpCsFixer\Fixer\Phpdoc\AlignMultilineCommentFixer +PhpCsFixer\Fixer\Phpdoc\GeneralPhpdocAnnotationRemoveFixer +PhpCsFixer\Fixer\Phpdoc\GeneralPhpdocTagRenameFixer +PhpCsFixer\Fixer\Phpdoc\NoBlankLinesAfterPhpdocFixer +PhpCsFixer\Fixer\Phpdoc\NoEmptyPhpdocFixer +PhpCsFixer\Fixer\Phpdoc\NoSuperfluousPhpdocTagsFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocAlignFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocIndentFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocInlineTagNormalizerFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocLineSpanFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocNoAccessFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocNoAliasTagFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocNoDuplicateTypesFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocNoEmptyReturnFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocNoPackageFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocNoUselessInheritdocFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocOrderByValueFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocOrderFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocReturnSelfReferenceFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocScalarFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocSeparationFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocSingleLineVarSpacingFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocSummaryFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocTagCasingFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocTagTypeFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocToCommentFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocTrimConsecutiveBlankLineSeparationFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocTrimFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocTypesFixer +PhpCsFixer\Fixer\Phpdoc\PhpdocVarWithoutNameFixer +PhpCsFixer\Fixer\ReturnNotation\NoUselessReturnFixer +PhpCsFixer\Fixer\ReturnNotation\SimplifiedNullReturnFixer +PhpCsFixer\Fixer\Semicolon\MultilineWhitespaceBeforeSemicolonsFixer +PhpCsFixer\Fixer\Semicolon\NoEmptyStatementFixer +PhpCsFixer\Fixer\Semicolon\NoSinglelineWhitespaceBeforeSemicolonsFixer +PhpCsFixer\Fixer\Semicolon\SpaceAfterSemicolonFixer +PhpCsFixer\Fixer\StringNotation\ExplicitStringVariableFixer +PhpCsFixer\Fixer\StringNotation\HeredocToNowdocFixer +PhpCsFixer\Fixer\StringNotation\NoBinaryStringFixer +PhpCsFixer\Fixer\StringNotation\SingleQuoteFixer +PhpCsFixer\Fixer\Whitespace\ArrayIndentationFixer +PhpCsFixer\Fixer\Whitespace\BlankLineBeforeStatementFixer +PhpCsFixer\Fixer\Whitespace\BlankLineBetweenImportGroupsFixer +PhpCsFixer\Fixer\Whitespace\CompactNullableTypeDeclarationFixer +PhpCsFixer\Fixer\Whitespace\IndentationTypeFixer +PhpCsFixer\Fixer\Whitespace\LineEndingFixer +PhpCsFixer\Fixer\Whitespace\MethodChainingIndentationFixer +PhpCsFixer\Fixer\Whitespace\NoExtraBlankLinesFixer +PhpCsFixer\Fixer\Whitespace\NoSpacesAroundOffsetFixer +PhpCsFixer\Fixer\Whitespace\NoTrailingWhitespaceFixer +PhpCsFixer\Fixer\Whitespace\NoWhitespaceInBlankLineFixer +PhpCsFixer\Fixer\Whitespace\SingleBlankLineAtEofFixer +PhpCsFixer\Fixer\Whitespace\SpacesInsideParenthesesFixer +PhpCsFixer\Fixer\Whitespace\StatementIndentationFixer +PhpCsFixer\Fixer\Whitespace\TypeDeclarationSpacesFixer +PhpCsFixer\Fixer\Whitespace\TypesSpacesFixer +Symplify\CodingStandard\Fixer\Annotation\RemoveEventSubscriberDescriptionFixer +Symplify\CodingStandard\Fixer\Annotation\RemoveMethodNameDuplicateDescriptionFixer +Symplify\CodingStandard\Fixer\Annotation\RemovePHPStormAnnotationFixer +Symplify\CodingStandard\Fixer\Annotation\RemovePropertyVariableNameDescriptionFixer +Symplify\CodingStandard\Fixer\ArrayNotation\ArrayListItemNewlineFixer +Symplify\CodingStandard\Fixer\ArrayNotation\ArrayOpenerAndCloserNewlineFixer +Symplify\CodingStandard\Fixer\ArrayNotation\StandaloneLineInMultilineArrayFixer +Symplify\CodingStandard\Fixer\Commenting\AddMissingParamNameFixer +Symplify\CodingStandard\Fixer\Commenting\AddMissingVarNameFixer +Symplify\CodingStandard\Fixer\Commenting\DoubleAsteriskInlineVarFixer +Symplify\CodingStandard\Fixer\Commenting\FixParamNameTypoFixer +Symplify\CodingStandard\Fixer\Commenting\FixTagTypoFixer +Symplify\CodingStandard\Fixer\Commenting\MergeDocBlockStartFixer +Symplify\CodingStandard\Fixer\Commenting\ParamReturnAndVarTagMalformsFixer +Symplify\CodingStandard\Fixer\Commenting\RemoveDeadParamFixer +Symplify\CodingStandard\Fixer\Commenting\RemoveDeadVarThisFixer +Symplify\CodingStandard\Fixer\Commenting\RemoveParamNameReferenceFixer +Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousReturnNameFixer +Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousVarNameFixer +Symplify\CodingStandard\Fixer\Commenting\RemoveUselessDefaultCommentFixer +Symplify\CodingStandard\Fixer\Commenting\SingleLineInlineVarDocBlockFixer +Symplify\CodingStandard\Fixer\Commenting\SwitchedTypeAndNameFixer +Symplify\CodingStandard\Fixer\Commenting\TypeToVarTagFixer +Symplify\CodingStandard\Fixer\Spacing\MethodChainingNewlineFixer +Symplify\CodingStandard\Fixer\Spacing\NoBlankLineBetweenImportsFixer +Symplify\CodingStandard\Fixer\Spacing\SpaceAfterCommaHereNowDocFixer +Symplify\CodingStandard\Fixer\Spacing\StandaloneLinePlainConstructorParamFixer +Symplify\CodingStandard\Fixer\Spacing\StandaloneLinePromotedPropertyFixer +Symplify\CodingStandard\Fixer\Spacing\StandaloneLineRequiredParamFixer +Symplify\CodingStandard\Fixer\Spacing\StandaloneLineSymfonyAttributeParamFixer +Symplify\CodingStandard\Fixer\Strict\BlankLineAfterStrictTypesFixer diff --git a/blink/ecs-rules.txt b/blink/ecs-rules.txt new file mode 100644 index 0000000000..c279bf6215 --- /dev/null +++ b/blink/ecs-rules.txt @@ -0,0 +1,43 @@ +ECS rules NOT yet implemented in blink +======================================= + +Source: every Fixer/Sniff referenced across ecs-src config/set/* and +src/Config/Level/*, listed in ecs-all-fixers.txt. + +208 rules configured in ECS. The live implemented/missing counts are in the +README (bin/rule-counts.sh keeps them current). Regenerate the list below with: + + comm -23 <(sort -u blink/ecs-all-fixers.txt) \ + <(./blink list-checkers | sed 's/^[[:space:]]*-[[:space:]]*//' | sort -u) \ + | sed -E 's#.*\\##' + +A few "missing" names below are already implemented under the fixer's newer +PHP-CS-Fixer name and only differ by alias: + CurlyBracesPositionFixer -> BracesPositionFixer (done) + CompactNullableTypehintFixer -> CompactNullableTypeDeclarationFixer (done) + NoSpacesInsideParenthesisFixer -> SpacesInsideParenthesesFixer (done) + FunctionTypehintSpaceFixer -> TypeDeclarationSpacesFixer (done, deprecated alias) + +PsrAutoloadingFixer cannot be ported: blink fixers receive only a token stream, +not the file path the rule needs. + + +NOT YET IMPLEMENTED (behavior) +------------------------------ +control structures: NoBreakComment +functions: LambdaNotUsedImport +class: ModifierKeywords +operators: OperatorLinebreak +types: FullyQualifiedStrictTypes +phpdoc: PhpdocSeparation, PhpdocToComment, GeneralPhpdocTagRename, + ParamReturnAndVarTagMalforms +doctrine annotations: DoctrineAnnotationArrayAssignment, DoctrineAnnotationIndentation + (need an annotation parser) +symplify custom: StandaloneLinePlainConstructorParam, StandaloneLineRequiredParam, + StandaloneLineSymfonyAttributeParam + + +RECENTLY IMPLEMENTED +-------------------- +ControlStructureBraces, NoAlternativeSyntax, NoUnneededBraces +(NoUnneededCurlyBraces), NoUnreachableDefaultArgumentValue. diff --git a/blink/go.mod b/blink/go.mod new file mode 100644 index 0000000000..fb5c4a990f --- /dev/null +++ b/blink/go.mod @@ -0,0 +1,3 @@ +module blink + +go 1.26 diff --git a/blink/internal/config/config.go b/blink/internal/config/config.go new file mode 100644 index 0000000000..60f9944989 --- /dev/null +++ b/blink/internal/config/config.go @@ -0,0 +1,122 @@ +// Package config mirrors ECSConfig: paths to scan, skip patterns, active rules. +// A config can be built fluently in Go or loaded from a blink.json file. +package config + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + + "blink/internal/fixer" + "blink/internal/fixer/rules" + "blink/internal/set" +) + +type Config struct { + Paths []string + Skip []string // filepath.Match globs tested against each path + Rules []fixer.Fixer + Jobs int // parallel workers +} + +// Configure returns a config seeded with the default psr12 + common sets, +// echoing ECSConfig::configure()->withPreparedSets(psr12: true, common: true). +func Configure() *Config { + return &Config{ + Paths: []string{"."}, + Rules: set.Default(), + Jobs: runtime.NumCPU(), + } +} + +func (c *Config) WithPaths(paths ...string) *Config { + if len(paths) > 0 { + c.Paths = paths + } + return c +} + +func (c *Config) WithSkip(patterns ...string) *Config { + c.Skip = append(c.Skip, patterns...) + return c +} + +func (c *Config) WithRules(rs ...fixer.Fixer) *Config { + c.Rules = rs + return c +} + +// file is the on-disk blink.json shape. +type file struct { + Paths []string `json:"paths"` + Skip []string `json:"skip"` + Sets []string `json:"sets"` + Level map[string]int `json:"level"` + Rules []string `json:"rules"` +} + +// Load reads and resolves a blink.json config file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var f file + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("config %s: %w", path, err) + } + return resolve(f) +} + +// resolve turns a config file into a Config, collecting rules from sets, levels +// and explicit names, de-duplicated and kept in canonical (safest-first) order. +func resolve(f file) (*Config, error) { + c := &Config{Paths: f.Paths, Skip: f.Skip, Jobs: runtime.NumCPU()} + if len(c.Paths) == 0 { + c.Paths = []string{"."} + } + + wanted := map[string]bool{} + selective := false + + for _, name := range f.Sets { + fixers, ok := set.Get(name) + if !ok { + return nil, fmt.Errorf("unknown set %q", name) + } + selective = true + for _, fx := range fixers { + wanted[fx.Name()] = true + } + } + + for name, n := range f.Level { + if name != "spaces" { + return nil, fmt.Errorf("unknown level %q (only \"spaces\" is supported)", name) + } + selective = true + for _, fx := range set.SpacesLevel(n) { + wanted[fx.Name()] = true + } + } + + for _, name := range f.Rules { + if _, ok := rules.ByName(name); !ok { + return nil, fmt.Errorf("unknown rule %q", name) + } + selective = true + wanted[name] = true + } + + if !selective { + c.Rules = set.Default() + return c, nil + } + for _, fx := range rules.All() { + if wanted[fx.Name()] { + c.Rules = append(c.Rules, fx) + } + } + return c, nil +} diff --git a/blink/internal/config/config_test.go b/blink/internal/config/config_test.go new file mode 100644 index 0000000000..6904d8b1f2 --- /dev/null +++ b/blink/internal/config/config_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "blink/internal/fixer/rules" + "blink/internal/set" +) + +func write(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "blink.json") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadDefaultsToDefaultSet(t *testing.T) { + c, err := Load(write(t, `{}`)) + if err != nil { + t.Fatal(err) + } + if len(c.Rules) != len(set.Default()) { + t.Fatalf("empty config should enable the default psr12+common set, got %d", len(c.Rules)) + } + if len(c.Paths) != 1 || c.Paths[0] != "." { + t.Fatalf("default path should be '.', got %v", c.Paths) + } +} + +func TestLoadSet(t *testing.T) { + c, err := Load(write(t, `{"sets":["spaces"],"paths":["src"],"skip":["*/Fixture/*"]}`)) + if err != nil { + t.Fatal(err) + } + if len(c.Rules) != len(rules.SpacingFixers()) { + t.Fatalf("spaces set should enable the spacing rules, got %d", len(c.Rules)) + } + if len(c.Paths) != 1 || c.Paths[0] != "src" { + t.Fatalf("paths not read: %v", c.Paths) + } + if len(c.Skip) != 1 { + t.Fatalf("skip not read: %v", c.Skip) + } +} + +func TestLoadLevelIsPrefix(t *testing.T) { + c, err := Load(write(t, `{"level":{"spaces":3}}`)) + if err != nil { + t.Fatal(err) + } + if len(c.Rules) != 3 { + t.Fatalf("level 3 should give 3 rules, got %d", len(c.Rules)) + } + // resolve keeps canonical (All) order; the 3 chosen must be the first 3 + // spacing rules, appearing in All in the same relative order + spacing := rules.SpacingFixers() + wantNames := map[string]bool{spacing[0].Name(): true, spacing[1].Name(): true, spacing[2].Name(): true} + for _, f := range c.Rules { + if !wantNames[f.Name()] { + t.Fatalf("level 3 selected unexpected rule %s", f.Name()) + } + } +} + +func TestLoadExplicitRule(t *testing.T) { + name := rules.All()[0].Name() + c, err := Load(write(t, `{"rules":["`+jsonEscape(name)+`"]}`)) + if err != nil { + t.Fatal(err) + } + if len(c.Rules) != 1 || c.Rules[0].Name() != name { + t.Fatalf("explicit rule not selected: %v", c.Rules) + } +} + +func TestLoadUnknownErrors(t *testing.T) { + cases := []string{ + `{"sets":["nope"]}`, + `{"level":{"psr12":1}}`, + `{"rules":["Nope\\Fixer"]}`, + } + for _, body := range cases { + if _, err := Load(write(t, body)); err == nil { + t.Errorf("expected error for %s", body) + } + } +} + +// jsonEscape escapes backslashes for embedding a PHP FQCN in a JSON literal. +func jsonEscape(s string) string { + out := make([]rune, 0, len(s)*2) + for _, r := range s { + if r == '\\' { + out = append(out, '\\', '\\') + continue + } + out = append(out, r) + } + return string(out) +} diff --git a/blink/internal/config/ecsconfig.go b/blink/internal/config/ecsconfig.go new file mode 100644 index 0000000000..57ca619f8b --- /dev/null +++ b/blink/internal/config/ecsconfig.go @@ -0,0 +1,129 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "sort" + + "blink/internal/fixer/rules" +) + +// Turbo mode: ECS dumps its resolved ecs.php configuration to JSON (paths, rules, +// skips) with `ecs dump-config`, and blink consumes it here. Every blink fixer +// is named by its PHP-CS-Fixer FQCN, which is exactly the class ECS reports, so +// the rules map by name without a translation table. + +// ecsConfiguredRule is one ECS sniff or fixer: a fully qualified class name and +// its configuration. +type ecsConfiguredRule struct { + Class string `json:"class"` + Config map[string]any `json:"config"` +} + +// ecsSkip is one ECS skip entry: a path to exclude, a rule to disable, or a rule +// disabled only on some paths. +type ecsSkip struct { + Path string `json:"path"` + Class string `json:"class"` + Paths []string `json:"paths"` +} + +// ecsFile is the on-disk shape of `ecs dump-config` output. +type ecsFile struct { + PHPVersion string `json:"php_version"` + Paths []string `json:"paths"` + Rules []ecsConfiguredRule `json:"rules"` + Skips []ecsSkip `json:"skips"` +} + +// ECSResolution reports how an ECS config mapped onto blink, so a turbo run is +// never silently narrower than the config it stands in for. +type ECSResolution struct { + // Total is the number of ECS rules in the config. + Total int + // Mapped is the number of rules resolved to a blink fixer. + Mapped int + // Unsupported are rule classes blink has no fixer for. + Unsupported []string + // ConfigIgnored are mapped rules that carried configuration; blink applies + // its built-in behaviour for the fixer rather than the ECS options. + ConfigIgnored []string + // PerPathSkips are class-on-path skips applied project-wide, since blink + // cannot yet skip a single rule on a subset of files. + PerPathSkips []string +} + +// LoadECS reads an `ecs dump-config` JSON file and resolves it to a blink +// config, returning a report of what could not be mapped. +func LoadECS(path string) (*Config, *ECSResolution, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + + var f ecsFile + if err := json.Unmarshal(data, &f); err != nil { + return nil, nil, fmt.Errorf("ECS config %s: %w", path, err) + } + + return resolveECS(f) +} + +// resolveECS turns an ECS dump-config into a Config plus a mapping report. +func resolveECS(f ecsFile) (*Config, *ECSResolution, error) { + config := &Config{Paths: f.Paths, Jobs: runtime.NumCPU()} + if len(config.Paths) == 0 { + config.Paths = []string{"."} + } + + resolution := &ECSResolution{Total: len(f.Rules)} + + skippedClasses := map[string]bool{} + for _, skip := range f.Skips { + switch { + case skip.Class != "" && skip.Path == "" && len(skip.Paths) == 0: + skippedClasses[skip.Class] = true + case skip.Class != "": + // A rule disabled only on some paths: blink cannot skip a single rule + // on a subset of files, so note it and let the rule run everywhere + // rather than drop it project-wide. + resolution.PerPathSkips = append(resolution.PerPathSkips, skip.Class) + default: + if skip.Path != "" { + config.Skip = append(config.Skip, skip.Path) + } + config.Skip = append(config.Skip, skip.Paths...) + } + } + + seen := map[string]bool{} + for _, rule := range f.Rules { + if skippedClasses[rule.Class] { + continue + } + + fixer, ok := rules.ByName(rule.Class) + if !ok { + resolution.Unsupported = append(resolution.Unsupported, rule.Class) + continue + } + + if len(rule.Config) > 0 { + resolution.ConfigIgnored = append(resolution.ConfigIgnored, rule.Class) + } + + if !seen[rule.Class] { + seen[rule.Class] = true + resolution.Mapped++ + config.Rules = append(config.Rules, fixer) + } + } + + sort.Strings(resolution.Unsupported) + sort.Strings(resolution.ConfigIgnored) + sort.Strings(resolution.PerPathSkips) + + return config, resolution, nil +} diff --git a/blink/internal/config/ecsconfig_test.go b/blink/internal/config/ecsconfig_test.go new file mode 100644 index 0000000000..5b168623f0 --- /dev/null +++ b/blink/internal/config/ecsconfig_test.go @@ -0,0 +1,161 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// esc doubles backslashes so an FQCN can be embedded in a JSON string literal. +func esc(s string) string { + return strings.ReplaceAll(s, "\\", "\\\\") +} + +const ( + knownRuleA = `PhpCsFixer\Fixer\Alias\NoMixedEchoPrintFixer` + knownRuleB = `PhpCsFixer\Fixer\Alias\NoAliasFunctionsFixer` + unknownRule = `PhpCsFixer\Fixer\Nonexistent\FooBarFixer` +) + +func writeECS(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "ecs-config.json") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadECSMapsRulesByClassName(t *testing.T) { + c, resolution, err := LoadECS(writeECS(t, `{ + "paths": ["src"], + "rules": [ + {"class": "`+esc(knownRuleA)+`", "config": {}}, + {"class": "`+esc(knownRuleB)+`", "config": {}} + ] + }`)) + if err != nil { + t.Fatal(err) + } + + if len(c.Rules) != 2 { + t.Fatalf("expected 2 mapped rules, got %d", len(c.Rules)) + } + if resolution.Mapped != 2 || resolution.Total != 2 { + t.Fatalf("resolution mapped/total = %d/%d, want 2/2", resolution.Mapped, resolution.Total) + } + if len(c.Paths) != 1 || c.Paths[0] != "src" { + t.Fatalf("paths not read: %v", c.Paths) + } +} + +func TestLoadECSReportsUnknownRule(t *testing.T) { + c, resolution, err := LoadECS(writeECS(t, `{ + "rules": [ + {"class": "`+esc(knownRuleA)+`", "config": {}}, + {"class": "`+esc(unknownRule)+`", "config": {}} + ] + }`)) + if err != nil { + t.Fatal(err) + } + + if len(c.Rules) != 1 { + t.Fatalf("expected 1 mapped rule, got %d", len(c.Rules)) + } + if len(resolution.Unsupported) != 1 || resolution.Unsupported[0] != unknownRule { + t.Fatalf("unsupported = %v, want [%s]", resolution.Unsupported, unknownRule) + } +} + +func TestLoadECSDefaultsPathToDot(t *testing.T) { + c, _, err := LoadECS(writeECS(t, `{"rules": []}`)) + if err != nil { + t.Fatal(err) + } + if len(c.Paths) != 1 || c.Paths[0] != "." { + t.Fatalf("default path should be '.', got %v", c.Paths) + } +} + +func TestLoadECSSkips(t *testing.T) { + c, resolution, err := LoadECS(writeECS(t, `{ + "rules": [{"class": "`+esc(knownRuleA)+`", "config": {}}], + "skips": [ + {"path": "*/Legacy/*"}, + {"paths": ["*/Generated/*"]}, + {"class": "`+esc(knownRuleA)+`"} + ] + }`)) + if err != nil { + t.Fatal(err) + } + + // the whole-class skip drops the only rule + if len(c.Rules) != 0 { + t.Fatalf("expected the class skip to drop the rule, got %d rules", len(c.Rules)) + } + if len(c.Skip) != 2 { + t.Fatalf("expected 2 skip globs, got %v", c.Skip) + } + if resolution.Mapped != 0 { + t.Fatalf("mapped = %d, want 0 (rule was skipped)", resolution.Mapped) + } +} + +func TestLoadECSPerPathSkipIsCaveat(t *testing.T) { + c, resolution, err := LoadECS(writeECS(t, `{ + "rules": [{"class": "`+esc(knownRuleA)+`", "config": {}}], + "skips": [ + {"class": "`+esc(knownRuleA)+`", "paths": ["*/tests/*"]} + ] + }`)) + if err != nil { + t.Fatal(err) + } + + // per-path skip does not drop the rule; it is reported as a caveat instead + if len(c.Rules) != 1 { + t.Fatalf("expected the rule to still run, got %d rules", len(c.Rules)) + } + if len(resolution.PerPathSkips) != 1 || resolution.PerPathSkips[0] != knownRuleA { + t.Fatalf("per-path skips = %v, want [%s]", resolution.PerPathSkips, knownRuleA) + } +} + +func TestLoadECSConfigIgnoredNote(t *testing.T) { + _, resolution, err := LoadECS(writeECS(t, `{ + "rules": [{"class": "`+esc(knownRuleA)+`", "config": {"some": "option"}}] + }`)) + if err != nil { + t.Fatal(err) + } + if len(resolution.ConfigIgnored) != 1 || resolution.ConfigIgnored[0] != knownRuleA { + t.Fatalf("config-ignored = %v, want [%s]", resolution.ConfigIgnored, knownRuleA) + } +} + +func TestLoadECSDeduplicatesRules(t *testing.T) { + c, resolution, err := LoadECS(writeECS(t, `{ + "rules": [ + {"class": "`+esc(knownRuleA)+`", "config": {}}, + {"class": "`+esc(knownRuleA)+`", "config": {}} + ] + }`)) + if err != nil { + t.Fatal(err) + } + if len(c.Rules) != 1 || resolution.Mapped != 1 { + t.Fatalf("expected dedup to 1 rule, got %d rules / mapped %d", len(c.Rules), resolution.Mapped) + } +} + +func TestLoadECSErrors(t *testing.T) { + if _, _, err := LoadECS(filepath.Join(t.TempDir(), "missing.json")); err == nil { + t.Error("want error for missing file") + } + if _, _, err := LoadECS(writeECS(t, `{not json`)); err == nil { + t.Error("want error for invalid json") + } +} diff --git a/blink/internal/diff/diff.go b/blink/internal/diff/diff.go new file mode 100644 index 0000000000..ae95d80b04 --- /dev/null +++ b/blink/internal/diff/diff.go @@ -0,0 +1,148 @@ +// Package diff produces a unified text diff in the shape PHP-CS-Fixer emits +// (an "--- Original / +++ New" header and "@@ @@" hunks), so the reporter can +// wrap it exactly like ECS. +package diff + +import "strings" + +const context = 3 + +type opKind int + +const ( + equal opKind = iota + del + add +) + +type op struct { + kind opKind + text string +} + +// Unified returns a unified diff of before vs after, or "" if they are equal. +func Unified(before, after string) string { + if before == after { + return "" + } + a := splitLines(before) + b := splitLines(after) + ops := diffLines(a, b) + + var out strings.Builder + out.WriteString("--- Original\n+++ New\n") + for _, h := range hunks(ops) { + out.WriteString("@@ @@\n") + for _, o := range h { + switch o.kind { + case equal: + out.WriteString(" " + o.text + "\n") + case del: + out.WriteString("-" + o.text + "\n") + case add: + out.WriteString("+" + o.text + "\n") + } + } + } + return out.String() +} + +func splitLines(s string) []string { + s = strings.ReplaceAll(s, "\r\n", "\n") + lines := strings.Split(s, "\n") + // a trailing newline yields a final empty element; drop it for diffing + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +// diffLines is a straightforward LCS diff, adequate for small source files. +func diffLines(a, b []string) []op { + n, m := len(a), len(b) + lcs := make([][]int, n+1) + for i := range lcs { + lcs[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + lcs[i][j] = lcs[i+1][j+1] + 1 + } else if lcs[i+1][j] >= lcs[i][j+1] { + lcs[i][j] = lcs[i+1][j] + } else { + lcs[i][j] = lcs[i][j+1] + } + } + } + + var ops []op + i, j := 0, 0 + for i < n && j < m { + switch { + case a[i] == b[j]: + ops = append(ops, op{equal, a[i]}) + i++ + j++ + case lcs[i+1][j] >= lcs[i][j+1]: + ops = append(ops, op{del, a[i]}) + i++ + default: + ops = append(ops, op{add, b[j]}) + j++ + } + } + for ; i < n; i++ { + ops = append(ops, op{del, a[i]}) + } + for ; j < m; j++ { + ops = append(ops, op{add, b[j]}) + } + return ops +} + +// hunks groups ops into change regions, each padded with up to `context` equal +// lines, dropping long stretches of unchanged code between them. +func hunks(ops []op) [][]op { + changed := make([]bool, len(ops)) + any := false + for i, o := range ops { + if o.kind != equal { + changed[i] = true + any = true + } + } + if !any { + return nil + } + + keep := make([]bool, len(ops)) + for i, c := range changed { + if !c { + continue + } + lo := max(i-context, 0) + hi := i + context + if hi >= len(ops) { + hi = len(ops) - 1 + } + for k := lo; k <= hi; k++ { + keep[k] = true + } + } + + var result [][]op + var cur []op + for i := range ops { + if keep[i] { + cur = append(cur, ops[i]) + } else if len(cur) > 0 { + result = append(result, cur) + cur = nil + } + } + if len(cur) > 0 { + result = append(result, cur) + } + return result +} diff --git a/blink/internal/diff/diff_test.go b/blink/internal/diff/diff_test.go new file mode 100644 index 0000000000..897f6682f2 --- /dev/null +++ b/blink/internal/diff/diff_test.go @@ -0,0 +1,28 @@ +package diff + +import ( + "strings" + "testing" +) + +func TestUnifiedEqual(t *testing.T) { + if got := Unified("a\n", "a\n"); got != "" { + t.Fatalf("equal inputs must yield empty diff, got %q", got) + } +} + +func TestUnifiedChange(t *testing.T) { + got := Unified("= 0 && s.At(open).Kind != token.Punct { + open = nextSignificantIndex(s, open) + } + if open >= 0 && s.At(open).Value == "&" { + open = nextSignificantIndex(s, open) + } + if open < 0 || s.At(open).Value != "(" { + return nil + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + return nil + } + var names []string + depth := 0 + for k := open + 1; k < closeIdx; k++ { + if s.At(k).Kind == token.Punct { + switch s.At(k).Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + } + continue + } + if depth == 0 && s.At(k).Kind == token.Variable { + names = append(names, s.At(k).Value) + } + } + return names +} + +// addParamNames appends the positional parameter name to each "@param " +// line that has no variable name. +func addParamNames(d *docblock, names []string) bool { + changed := false + idx := 0 + for i, l := range d.inner { + trimmed := strings.TrimLeft(l.content, " ") + if !strings.HasPrefix(strings.ToLower(trimmed), "@param") { + continue + } + rest := trimmed[len("@param"):] + if rest == "" || (rest[0] != ' ' && rest[0] != '\t') { + continue // "@paramX" + } + fields := strings.Fields(rest) + pos := idx + idx++ + if len(fields) != 1 { + continue // no type, or already has a name / description + } + if pos >= len(names) { + continue + } + d.inner[i].content = strings.TrimRight(l.content, " \t") + " " + names[pos] + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/alias.go b/blink/internal/fixer/rules/alias.go new file mode 100644 index 0000000000..eeda9e00cc --- /dev/null +++ b/blink/internal/fixer/rules/alias.go @@ -0,0 +1,162 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoMixedEchoPrintFixer.php +// +// NoMixedEchoPrint rewrites the "print" language construct to "echo" when it is +// used as a statement (the default "use: echo"). Uses of print as an expression +// ("$x = print 'y'") are left alone. +type NoMixedEchoPrint struct{} + +func (NoMixedEchoPrint) Name() string { + return `PhpCsFixer\Fixer\Alias\NoMixedEchoPrintFixer` +} + +func (NoMixedEchoPrint) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoMixedEchoPrintFixer.php" +} + +func (NoMixedEchoPrint) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "print" { + continue + } + prev, ok := prevSignificant(s, i) + if !ok { + continue + } + // "print" is a statement only after ; { } ) an open tag, or "else" + convertible := prev.Kind == token.OpenTag || + (prev.Kind == token.Punct && (prev.Value == ";" || prev.Value == "{" || prev.Value == "}" || prev.Value == ")")) || + (prev.Kind == token.Keyword && strings.ToLower(prev.Value) == "else") + if !convertible { + continue + } + s.SetValue(i, "echo") + changed = true + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasFunctionsFixer.php +// +// NoAliasFunctions replaces internal function aliases with their canonical name +// ("sizeof" -> "count", "join" -> "implode"). It covers the default sets +// (@internal, @IMAP, @pg) and only rewrites global function calls, leaving +// methods, declarations and namespaced names alone. +type NoAliasFunctions struct{} + +func (NoAliasFunctions) Name() string { + return `PhpCsFixer\Fixer\Alias\NoAliasFunctionsFixer` +} + +func (NoAliasFunctions) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasFunctionsFixer.php" +} + +// functionAliases are the default sets (@internal, @IMAP, @pg) from PHP-CS-Fixer. +var functionAliases = map[string]string{ + // @internal + "diskfreespace": "disk_free_space", + "dns_check_record": "checkdnsrr", + "dns_get_mx": "getmxrr", + "session_commit": "session_write_close", + "stream_register_wrapper": "stream_wrapper_register", + "set_file_buffer": "stream_set_write_buffer", + "socket_set_blocking": "stream_set_blocking", + "socket_get_status": "stream_get_meta_data", + "socket_set_timeout": "stream_set_timeout", + "socket_getopt": "socket_get_option", + "socket_setopt": "socket_set_option", + "chop": "rtrim", + "close": "closedir", + "doubleval": "floatval", + "fputs": "fwrite", + "get_required_files": "get_included_files", + "ini_alter": "ini_set", + "is_double": "is_float", + "is_integer": "is_int", + "is_long": "is_int", + "is_real": "is_float", + "is_writeable": "is_writable", + "join": "implode", + "key_exists": "array_key_exists", + "magic_quotes_runtime": "set_magic_quotes_runtime", + "pos": "current", + "show_source": "highlight_file", + "sizeof": "count", + "strchr": "strstr", + "user_error": "trigger_error", + // @IMAP + "imap_create": "imap_createmailbox", + "imap_fetchtext": "imap_body", + "imap_header": "imap_headerinfo", + "imap_listmailbox": "imap_list", + "imap_listsubscribed": "imap_lsub", + "imap_rename": "imap_renamemailbox", + "imap_scan": "imap_listscan", + "imap_scanmailbox": "imap_listscan", + // @pg + "pg_exec": "pg_query", +} + +func (NoAliasFunctions) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Ident { + continue + } + canonical, ok := functionAliases[strings.ToLower(t.Value)] + if !ok { + continue + } + if nextSignificantValue(s, i) != "(" { + continue // not a call + } + if !isGlobalFunctionCall(s, i) { + continue + } + s.SetValue(i, canonical) + changed = true + } + return changed +} + +// isGlobalFunctionCall reports whether the name at index i is a call to a global +// function - not a method, a declaration, a "new", or a namespaced name. +func isGlobalFunctionCall(s *tokens.Stream, i int) bool { + prev, ok := prevSignificant(s, i) + if !ok { + return true + } + if prev.Kind == token.Punct { + switch prev.Value { + case "->", "?->", "::": + return false // method or static call + case `\`: + // qualified name: namespaced when a name precedes the backslash, + // global when the backslash is leading ("\sizeof(") + bsIdx := prevSignificantIndex(s, i) + if before, ok2 := prevSignificant(s, bsIdx); ok2 && before.Kind == token.Ident { + return false + } + return true + } + } + if prev.Kind == token.Keyword { + switch strings.ToLower(prev.Value) { + case "function", "const", "new", "goto": + return false // declaration, constant, instantiation or label + } + } + return true +} diff --git a/blink/internal/fixer/rules/alias_lang_construct.go b/blink/internal/fixer/rules/alias_lang_construct.go new file mode 100644 index 0000000000..bcd9052da6 --- /dev/null +++ b/blink/internal/fixer/rules/alias_lang_construct.go @@ -0,0 +1,53 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasLanguageConstructCallFixer.php +// +// NoAliasLanguageConstructCall replaces the "die" alias with the master language +// construct "exit". Only the bare construct is touched: a member call ($x->die()) +// or a namespaced/name position is left alone. +type NoAliasLanguageConstructCall struct{} + +func (NoAliasLanguageConstructCall) Name() string { + return `PhpCsFixer\Fixer\Alias\NoAliasLanguageConstructCallFixer` +} + +func (NoAliasLanguageConstructCall) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasLanguageConstructCallFixer.php" +} + +func (NoAliasLanguageConstructCall) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + // "die"/"exit" are language constructs; this lexer tokenizes them as Ident + if t.Kind != token.Ident || strings.ToLower(t.Value) != "die" { + continue + } + // a member name ($x->die, Foo::die) or a namespaced/declared name is not + // the language construct + if memberPrev(s, i) { + continue + } + if prev, ok := prevSignificant(s, i); ok { + if prev.Kind == token.Punct && prev.Value == `\` { + continue + } + if prev.Kind == token.Keyword { + switch strings.ToLower(prev.Value) { + case "function", "const", "class", "namespace", "use": + continue + } + } + } + s.SetValue(i, "exit") + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/array_indentation.go b/blink/internal/fixer/rules/array_indentation.go new file mode 100644 index 0000000000..035ffbb381 --- /dev/null +++ b/blink/internal/fixer/rules/array_indentation.go @@ -0,0 +1,227 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/ArrayIndentationFixer.php +// +// ArrayIndentation indents each element of a multi-line array exactly one level +// past the array's own indentation, and re-indents multi-line elements by the +// same delta. It only rewrites whitespace (never adds or removes tokens), so it +// preserves how many items share a line and is a no-op on ECS-formatted code. +// This is a faithful port of PHP-CS-Fixer's scope-stack algorithm. +type ArrayIndentation struct{} + +func (ArrayIndentation) Name() string { + return `PhpCsFixer\Fixer\Whitespace\ArrayIndentationFixer` +} + +func (ArrayIndentation) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/ArrayIndentationFixer.php" +} + +const arrayIndentUnit = " " + +type aiScope struct { + kind string // "array" or "expression" + endIndex int + initialIndent string + newIndent string // expression only +} + +func (ArrayIndentation) Fix(s *tokens.Stream) bool { + changed := false + lastIndent := "" + var scopes []aiScope + prevLineInitialIndent := "" + prevLineNewIndent := "" + + for index := 0; index < s.Len(); index++ { + cur := len(scopes) - 1 + t := s.At(index) + + if t.Kind == token.Comment || t.Kind == token.DocComment { + continue + } + + if end, ok := aiArrayOpen(s, index); ok { + scopes = append(scopes, aiScope{kind: "array", endIndex: end, initialIndent: lastIndent}) + continue + } + + if t.Kind == token.Whitespace && hasNewline(t.Value) { + lastIndent = aiExtractIndent(t.Value) + } + + if cur < 0 { + continue + } + sc := &scopes[cur] + + if t.Kind == token.Whitespace { + if !hasNewline(t.Value) { + continue + } + var content string + if sc.kind == "array" { + indent := false + for k := index + 1; k < sc.endIndex; k++ { + kt := s.At(k) + nonTrivia := kt.Kind != token.Whitespace && kt.Kind != token.Comment && kt.Kind != token.DocComment + if nonTrivia || (kt.Kind == token.Whitespace && hasNewline(kt.Value)) { + indent = true + break + } + } + extra := "" + if indent { + extra = arrayIndentUnit + } + content = aiReindentArray(t.Value, sc.initialIndent+extra) + prevLineInitialIndent = aiExtractIndent(t.Value) + prevLineNewIndent = aiExtractIndent(content) + } else { + content = aiReindentExpression(t.Value, sc.initialIndent, sc.newIndent) + } + if content != t.Value { + s.SetValue(index, content) + changed = true + } + lastIndent = aiExtractIndent(content) + continue + } + + if index == sc.endIndex { + for len(scopes) > 0 && index == scopes[len(scopes)-1].endIndex { + scopes = scopes[:len(scopes)-1] + } + continue + } + + if t.Kind == token.Punct && t.Value == "," { + continue + } + + if sc.kind != "expression" { + end := aiExpressionEnd(s, index, sc.endIndex) + if end != index { + scopes = append(scopes, aiScope{ + kind: "expression", endIndex: end, + initialIndent: prevLineInitialIndent, newIndent: prevLineNewIndent, + }) + } + } + } + return changed +} + +// aiArrayOpen reports whether the token at index opens an array literal - "[" as +// a literal (not access), or "(" right after "array"/"list" - and returns its +// matching close index. +func aiArrayOpen(s *tokens.Stream, index int) (int, bool) { + t := s.At(index) + if t.Kind != token.Punct { + return 0, false + } + switch t.Value { + case "[": + if !isArrayLiteralOpen(s, index) { + return 0, false + } + end := s.MatchForward(index) + if end < 0 { + return 0, false + } + return end, true + case "(": + p := sigPrev(s, index) + if p >= 0 && s.At(p).Kind == token.Keyword { + lv := strings.ToLower(s.At(p).Value) + if lv == "array" || lv == "list" { + end := s.MatchForward(index) + if end >= 0 { + return end, true + } + } + } + } + return 0, false +} + +// isArrayLiteralOpen reports whether "[" at open starts an array literal rather +// than an array access ($a[0], foo()[0]). +func isArrayLiteralOpen(s *tokens.Stream, open int) bool { + p := sigPrev(s, open) + if p < 0 { + return true + } + t := s.At(p) + switch t.Kind { + case token.Variable, token.Ident, token.String, token.Number: + return false + case token.Punct: + switch t.Value { + case ")", "]", "}": + return false + } + } + return true +} + +// aiExpressionEnd finds the end of the array element expression starting at index, +// up to the next top-level comma (or the array's end), skipping nested blocks. +func aiExpressionEnd(s *tokens.Stream, index, parentEnd int) int { + end := -1 + for k := index + 1; k < parentEnd; k++ { + kt := s.At(k) + if kt.Kind == token.Punct && (kt.Value == "(" || kt.Value == "{" || kt.Value == "[") { + if be := s.MatchForward(k); be >= 0 { + k = be + } + continue + } + if kt.Kind == token.Punct && kt.Value == "," { + end = sigPrev(s, k) + break + } + } + if end >= 0 { + return end + } + return sigPrev(s, parentEnd) +} + +// aiExtractIndent returns the horizontal whitespace after the last newline. +func aiExtractIndent(ws string) string { + if i := strings.LastIndexByte(ws, '\n'); i >= 0 { + return ws[i+1:] + } + return ws +} + +// aiReindentArray keeps the newline run of a whitespace token and sets the line's +// indentation to target. +func aiReindentArray(ws, target string) string { + if i := strings.LastIndexByte(ws, '\n'); i >= 0 { + return ws[:i+1] + target + } + return ws +} + +// aiReindentExpression shifts a continuation line: if its indent begins with the +// expression's original indent, that prefix is swapped for the new indent. +func aiReindentExpression(ws, initialIndent, newIndent string) string { + i := strings.LastIndexByte(ws, '\n') + if i < 0 { + return ws + } + line := ws[i+1:] + if !strings.HasPrefix(line, initialIndent) { + return ws + } + return ws[:i+1] + newIndent + line[len(initialIndent):] +} diff --git a/blink/internal/fixer/rules/array_indentation_test.go b/blink/internal/fixer/rules/array_indentation_test.go new file mode 100644 index 0000000000..fbac89beb8 --- /dev/null +++ b/blink/internal/fixer/rules/array_indentation_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestArrayIndentation(t *testing.T) { + f := ArrayIndentation{} + + // nested array under-indented: each level indented exactly once + got, changed := apply(t, f, " [\n 'baz' => true,\n ],\n];\n") + if want := " [\n 'baz' => true,\n ],\n];\n"; !changed || got != want { + t.Fatalf("nested: changed=%v got=%q", changed, got) + } + + // grouping is preserved (multiple items per line only get re-indented) + got, changed = apply(t, f, " 1,\n];\n"); changed { + t.Fatal("correctly indented array must be a no-op") + } +} diff --git a/blink/internal/fixer/rules/array_list_item_newline.go b/blink/internal/fixer/rules/array_list_item_newline.go new file mode 100644 index 0000000000..7f97987ac7 --- /dev/null +++ b/blink/internal/fixer/rules/array_list_item_newline.go @@ -0,0 +1,102 @@ +package rules + +import ( + "blink/internal/token" + "blink/internal/tokens" +) + +// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/ArrayListItemNewlineFixer.php +// +// ArrayListItemNewline puts each item of an associative array literal (one that +// contains a "=>") on its own line, with "[" and "]" on their own lines. Plain +// list arrays (no "=>") are left as they are. array_indentation then aligns the +// result, so this fixer only needs to establish the one-item-per-line structure. +type ArrayListItemNewline struct{} + +func (ArrayListItemNewline) Name() string { + return `Symplify\CodingStandard\Fixer\ArrayNotation\ArrayListItemNewlineFixer` +} + +func (ArrayListItemNewline) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/ArrayListItemNewlineFixer.php" +} + +func (ArrayListItemNewline) Fix(s *tokens.Stream) bool { + changed := false + for open := 0; open < s.Len(); open++ { + if s.At(open).Kind != token.Punct || s.At(open).Value != "[" { + continue + } + if !isArrayLiteralOpen(s, open) { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 || sigNext(s, open) == closeIdx { + continue // empty [] + } + if arrayTopLevelMultiline(s, open, closeIdx) { + continue // items already on their own lines: array_indentation aligns it + } + if !arrayHasTopLevelArrow(s, open, closeIdx) { + continue // plain list array: leave inline + } + // a multiline array carries a trailing comma; add it before reflowing so + // the last item ends up on its own line with the comma + if last := sigPrev(s, closeIdx); last > open && s.At(last).Value != "," { + s.InsertAt(last+1, token.Token{Kind: token.Punct, Value: ","}) + closeIdx++ + changed = true + } + if reflowParen(s, open, closeIdx) { + changed = true + } + } + return changed +} + +// arrayTopLevelMultiline reports whether the array already has a newline at its +// own nesting level (an item on its own line). Newlines only inside a nested +// element (e.g. a match block) do not count. +func arrayTopLevelMultiline(s *tokens.Stream, open, closeIdx int) bool { + depth := 0 + for j := open + 1; j < closeIdx; j++ { + t := s.At(j) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + continue + case ")", "]", "}": + depth-- + continue + } + } + if depth == 0 && t.Kind == token.Whitespace && hasNewline(t.Value) { + return true + } + } + return false +} + +// arrayHasTopLevelArrow reports whether a "=>" appears at the array's own nesting +// level (an associative key or an arrow-function element). +func arrayHasTopLevelArrow(s *tokens.Stream, open, closeIdx int) bool { + depth := 0 + for j := open + 1; j < closeIdx; j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case "=>": + if depth == 0 { + return true + } + } + } + return false +} diff --git a/blink/internal/fixer/rules/array_list_item_newline_test.go b/blink/internal/fixer/rules/array_list_item_newline_test.go new file mode 100644 index 0000000000..e64315ccc3 --- /dev/null +++ b/blink/internal/fixer/rules/array_list_item_newline_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestArrayListItemNewline(t *testing.T) { + f := ArrayListItemNewline{} + + // associative single-line array -> one item per line, trailing comma, [] on own lines + got, changed := apply(t, f, " 1, 'y' => 2];") + if want := " 1,\n 'y' => 2,\n];"; !changed || got != want { + t.Fatalf("assoc: changed=%v got=%q", changed, got) + } + + // single-element associative array is still split + got, changed = apply(t, f, " 1];") + if want := " 1,\n];"; !changed || got != want { + t.Fatalf("single: changed=%v got=%q", changed, got) + } + + // a plain list array (no "=>") stays inline + if _, changed := apply(t, f, " 1,\n];"); changed { + t.Fatal("already-multiline array must be a no-op") + } +} diff --git a/blink/internal/fixer/rules/array_notation.go b/blink/internal/fixer/rules/array_notation.go new file mode 100644 index 0000000000..fdf98c0bae --- /dev/null +++ b/blink/internal/fixer/rules/array_notation.go @@ -0,0 +1,167 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// convertLongArray rewrites a long "name(...)" construct to "[...]", where name +// is "array" or "list". Method/constant uses (after -> ?-> ::) are skipped. +func convertLongArray(s *tokens.Stream, name string) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword && t.Kind != token.Ident { + continue + } + if strings.ToLower(t.Value) != name { + continue + } + if prev, ok := prevSignificant(s, i); ok { + switch prev.Value { + case "->", "?->", "::": + continue + } + } + j := skipWhitespace(s, i+1) + if j >= s.Len() || s.At(j).Kind != token.Punct || s.At(j).Value != "(" { + continue + } + closeIdx := s.MatchForward(j) + if closeIdx < 0 { + continue + } + s.SetValue(closeIdx, "]") + s.SetValue(j, "[") + for k := j - 1; k >= i; k-- { + s.RemoveAt(k) + } + changed = true + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/ArraySyntaxFixer.php +// +// ArraySyntax rewrites long "array(...)" to the short "[...]" form. +type ArraySyntax struct{} + +func (ArraySyntax) Name() string { + return `PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer` +} + +func (ArraySyntax) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/ArraySyntaxFixer.php" +} + +func (ArraySyntax) Fix(s *tokens.Stream) bool { return convertLongArray(s, "array") } + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ListNotation/ListSyntaxFixer.php +// +// ListSyntax rewrites "list(...)" to the short "[...]" form. +type ListSyntax struct{} + +func (ListSyntax) Name() string { + return `PhpCsFixer\Fixer\ListNotation\ListSyntaxFixer` +} + +func (ListSyntax) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ListNotation/ListSyntaxFixer.php" +} + +func (ListSyntax) Fix(s *tokens.Stream) bool { return convertLongArray(s, "list") } + +// enclosingIsArray reports whether the innermost open bracket at index i is "[". +func topBracketIsArray(stack []string) bool { + return len(stack) > 0 && stack[len(stack)-1] == "[" +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoWhitespaceBeforeCommaInArrayFixer.php +// +// NoWhitespaceBeforeCommaInArray removes single-line whitespace before a comma +// inside an array. +type NoWhitespaceBeforeCommaInArray struct{} + +func (NoWhitespaceBeforeCommaInArray) Name() string { + return `PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceBeforeCommaInArrayFixer` +} + +func (NoWhitespaceBeforeCommaInArray) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoWhitespaceBeforeCommaInArrayFixer.php" +} + +func (NoWhitespaceBeforeCommaInArray) Fix(s *tokens.Stream) bool { + changed := false + var stack []string + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + stack = append(stack, t.Value) + case ")", "]", "}": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + case ",": + if topBracketIsArray(stack) && i > 0 && + s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + i-- + changed = true + } + } + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/WhitespaceAfterCommaInArrayFixer.php +// +// WhitespaceAfterCommaInArray ensures a single space after a comma inside an +// array (unless a newline follows, or the comma is a trailing one before "]"). +type WhitespaceAfterCommaInArray struct{} + +func (WhitespaceAfterCommaInArray) Name() string { + return `PhpCsFixer\Fixer\ArrayNotation\WhitespaceAfterCommaInArrayFixer` +} + +func (WhitespaceAfterCommaInArray) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/WhitespaceAfterCommaInArrayFixer.php" +} + +func (WhitespaceAfterCommaInArray) Fix(s *tokens.Stream) bool { + changed := false + var stack []string + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + stack = append(stack, t.Value) + case ")", "]", "}": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + case ",": + if !topBracketIsArray(stack) || i+1 >= s.Len() { + continue + } + next := s.At(i + 1) + if next.Kind == token.Whitespace { + continue // already spaced or newline + } + if next.Kind == token.Punct && next.Value == "]" { + continue // trailing comma + } + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + i++ + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/array_opener_and_closer_newline.go b/blink/internal/fixer/rules/array_opener_and_closer_newline.go new file mode 100644 index 0000000000..417f85ecf4 --- /dev/null +++ b/blink/internal/fixer/rules/array_opener_and_closer_newline.go @@ -0,0 +1,61 @@ +package rules + +import ( + "blink/internal/token" + "blink/internal/tokens" +) + +// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/ArrayNotation/ArrayOpenerAndCloserNewlineFixer.php +// +// ArrayOpenerAndCloserNewline puts the "[" and "]" of an associative array (one +// with a top-level "=>") on their own lines, leaving the items untouched. It +// skips an array whose first element is itself an array opener. +type ArrayOpenerAndCloserNewline struct{} + +func (ArrayOpenerAndCloserNewline) Name() string { + return `Symplify\CodingStandard\Fixer\ArrayNotation\ArrayOpenerAndCloserNewlineFixer` +} + +func (ArrayOpenerAndCloserNewline) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/ArrayNotation/ArrayOpenerAndCloserNewlineFixer.php" +} + +func (ArrayOpenerAndCloserNewline) Fix(s *tokens.Stream) bool { + changed := false + // high-to-low so inserts inside inner arrays don't shift outer indices we + // have yet to reach + for open := s.Len() - 1; open >= 0; open-- { + if s.At(open).Kind != token.Punct || s.At(open).Value != "[" { + continue + } + if !isArrayLiteralOpen(s, open) { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + continue + } + first := sigNext(s, open) + if first < 0 || first == closeIdx { + continue // no items + } + // first element is itself an array opener: leave it + if s.At(first).Kind == token.Punct && s.At(first).Value == "[" && isArrayLiteralOpen(s, first) { + continue + } + if !arrayHasTopLevelArrow(s, open, closeIdx) { + continue // not associative + } + // closer before opener, so the opener insert does not shift the closer. + // only act when the border is not already on its own line. + closerOnOwnLine := closeIdx > 0 && s.At(closeIdx-1).Kind == token.Whitespace && hasNewline(s.At(closeIdx-1).Value) + if !closerOnOwnLine && editSlotBefore(s, closeIdx, "\n") { + changed = true + } + openerOnOwnLine := open+1 < s.Len() && s.At(open+1).Kind == token.Whitespace && hasNewline(s.At(open+1).Value) + if !openerOnOwnLine && editSlotAfter(s, open, "\n") { + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/array_opener_and_closer_newline_test.go b/blink/internal/fixer/rules/array_opener_and_closer_newline_test.go new file mode 100644 index 0000000000..294d694159 --- /dev/null +++ b/blink/internal/fixer/rules/array_opener_and_closer_newline_test.go @@ -0,0 +1,31 @@ +package rules + +import "testing" + +func TestArrayOpenerAndCloserNewline(t *testing.T) { + f := ArrayOpenerAndCloserNewline{} + + // associative array: "[" and "]" go on their own lines, items untouched + got, changed := apply(t, f, " 1, \"y\" => 2];\n") + if want := " 1, \"y\" => 2\n];\n"; !changed || got != want { + t.Fatalf("assoc: changed=%v got=%q", changed, got) + } + + // plain list (no "=>") is left inline + if _, changed := apply(t, f, " 1];\n$a = [1, 2, 3];\n"); changed { + got, _ := apply(t, f, " 3];\n"); changed { + t.Fatal("array whose first element is an array must be skipped") + } + + // already on own lines: no-op + if _, changed := apply(t, f, " 1,\n];\n"); changed { + t.Fatal("already-broken array must be a no-op") + } +} diff --git a/blink/internal/fixer/rules/batch10_test.go b/blink/internal/fixer/rules/batch10_test.go new file mode 100644 index 0000000000..33df97e624 --- /dev/null +++ b/blink/internal/fixer/rules/batch10_test.go @@ -0,0 +1,103 @@ +package rules + +import "testing" + +func TestTrailingCommaInMultiline(t *testing.T) { + got, changed := apply(t, TrailingCommaInMultiline{}, " b -> c();") + if want := "b->c();"; !changed || got != want { + t.Fatalf("changed=%v got=%q want=%q", changed, got, want) + } +} + +func TestNativeFunctionCasing(t *testing.T) { + got, changed := apply(t, NativeFunctionCasing{}, "Count();"); changed { + t.Fatal("method call must not be lowercased") + } +} + +func TestIntegerLiteralCase(t *testing.T) { + got, changed := apply(t, IntegerLiteralCase{}, "" (closing tag) is left alone + if _, changed := apply(t, LinebreakAfterOpeningTag{}, ""); changed { + t.Fatal("lone open/close tag must not change") + } +} + +func TestNullableTypeDeclaration(t *testing.T) { + got, changed := apply(t, NullableTypeDeclaration{}, "foo()\n->bar()\n ->baz();\n") + if want := "foo()\n ->bar()\n ->baz();\n"; !changed || got != want { + t.Fatalf("changed=%v got=%q want=%q", changed, got, want) + } + // already aligned: no-op + if _, changed := apply(t, MethodChainingIndentation{}, "foo()\n ->bar();\n"); changed { + t.Fatal("already aligned chain must not change") + } +} diff --git a/blink/internal/fixer/rules/batchD_test.go b/blink/internal/fixer/rules/batchD_test.go new file mode 100644 index 0000000000..3a658d5ca5 --- /dev/null +++ b/blink/internal/fixer/rules/batchD_test.go @@ -0,0 +1,38 @@ +package rules + +import "testing" + +func TestSimplifiedNullReturn(t *testing.T) { + // no return type -> simplified + got, changed := apply(t, SimplifiedNullReturn{}, " simplified + got, changed = apply(t, SimplifiedNullReturn{}, " simplified + got, changed = apply(t, SimplifiedNullReturn{}, " kept + if _, changed := apply(t, SimplifiedNullReturn{}, " kept + if _, changed := apply(t, SimplifiedNullReturn{}, " no-op + if _, changed := apply(t, SimplifiedNullReturn{}, " simplified + got, changed = apply(t, SimplifiedNullReturn{}, "": true, + "<=": true, ">=": true, "<=>": true, "<": true, ">": true, + "&&": true, "||": true, "??": true, "=>": true, +} + +// BinaryOperatorSpaces normalizes spacing to a single space around binary +// operators (assignment "=", arrow "=>", comparison and logical operators). +// Whitespace spanning a newline is left alone to preserve alignment. Reference +// assignment ("=& $x") and declare() headers are skipped. +type BinaryOperatorSpaces struct{} + +func (BinaryOperatorSpaces) Name() string { + return `PhpCsFixer\Fixer\Operator\BinaryOperatorSpacesFixer` +} + +func (BinaryOperatorSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/BinaryOperatorSpacesFixer.php" +} + +func (BinaryOperatorSpaces) Fix(s *tokens.Stream) bool { + return normalizeSpaceAround(s, func(s *tokens.Stream, i int) bool { + t := s.At(i) + if t.Kind != token.Punct { + return false + } + if t.Value == "=" { + // leave reference assignment ("=& $x") and declare(...) headers alone + return nextSignificantValue(s, i) != "&" && !insideDeclareArgs(s, i) + } + return binaryOperators[t.Value] + }) +} diff --git a/blink/internal/fixer/rules/blank_line_after_opening_tag.go b/blink/internal/fixer/rules/blank_line_after_opening_tag.go new file mode 100644 index 0000000000..18ec3be91b --- /dev/null +++ b/blink/internal/fixer/rules/blank_line_after_opening_tag.go @@ -0,0 +1,41 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpTag/BlankLineAfterOpeningTagFixer.php +// +// BlankLineAfterOpeningTag ensures one blank line after the opening "= s.Len() { + return false + } + ws := s.At(semi + 1) + if ws.Kind != token.Whitespace || !strings.Contains(ws.Value, "\n") { + return false + } + // a closing tag or EOF right after needs no blank line + if semi+2 >= s.Len() || s.At(semi+2).Kind == token.CloseTag { + return false + } + indent := ws.Value[strings.LastIndexByte(ws.Value, '\n')+1:] + want := "\n\n" + indent + if ws.Value != want { + s.SetValue(semi+1, want) + return true + } + return false + } + return false +} + +func declareIsStrictTypes(s *tokens.Stream, open, close int) bool { + for k := open + 1; k < close; k++ { + if s.At(k).Kind == token.Ident && strings.EqualFold(s.At(k).Value, "strict_types") { + return true + } + } + return false +} diff --git a/blink/internal/fixer/rules/blank_line_before_statement.go b/blink/internal/fixer/rules/blank_line_before_statement.go new file mode 100644 index 0000000000..b6a10f12f7 --- /dev/null +++ b/blink/internal/fixer/rules/blank_line_before_statement.go @@ -0,0 +1,130 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBeforeStatementFixer.php +// +// BlankLineBeforeStatement puts an empty line before each configured statement. +// It mirrors the default set: break, continue, declare, return, throw, try. A +// statement that opens its block (preceded by "{" or ":") keeps no blank line; +// an attached comment above the statement is hoisted with it. +type BlankLineBeforeStatement struct{} + +func (BlankLineBeforeStatement) Name() string { + return `PhpCsFixer\Fixer\Whitespace\BlankLineBeforeStatementFixer` +} + +func (BlankLineBeforeStatement) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBeforeStatementFixer.php" +} + +var blankLineBeforeStatementKeywords = map[string]bool{ + "break": true, "continue": true, "declare": true, + "return": true, "throw": true, "try": true, +} + +func (BlankLineBeforeStatement) Fix(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i > 0; i-- { + t := s.At(i) + if t.Kind != token.Keyword || !blankLineBeforeStatementKeywords[strings.ToLower(t.Value)] { + continue + } + insertIdx := blbsInsertIndex(s, i) + prevNW := prevNonWhitespaceIdx(s, insertIdx) + if prevNW >= 0 && blbsShouldAdd(s, prevNW) { + if blbsInsert(s, insertIdx) { + changed = true + } + } + i = prevNW + if i < 1 { + break + } + } + return changed +} + +// prevNonWhitespaceIdx returns the previous token that is not whitespace +// (comments included, unlike prevSignificantIndex). +func prevNonWhitespaceIdx(s *tokens.Stream, i int) int { + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind != token.Whitespace { + return j + } + } + return -1 +} + +func countNewlines(v string) int { return strings.Count(v, "\n") } + +func isCommentTok(t token.Token) bool { + return t.Kind == token.Comment || t.Kind == token.DocComment +} + +// blbsInsertIndex hoists the insertion point above a comment that sits directly +// on the line before the statement, matching getInsertBlankLineIndex. +func blbsInsertIndex(s *tokens.Stream, index int) int { + for index > 0 { + if s.At(index-1).Kind == token.Whitespace && countNewlines(s.At(index-1).Value) > 1 { + break + } + prevIndex := prevNonWhitespaceIdx(s, index) + if prevIndex < 0 || !isCommentTok(s.At(prevIndex)) { + break + } + if prevIndex-1 < 0 || s.At(prevIndex-1).Kind != token.Whitespace { + break + } + if countNewlines(s.At(prevIndex-1).Value) != 1 { + break + } + index = prevIndex + } + return index +} + +// blbsShouldAdd reports whether the statement follows another statement or a +// block close ("}"), so an empty line belongs before it. +func blbsShouldAdd(s *tokens.Stream, prevNonWhitespace int) bool { + prev := s.At(prevNonWhitespace) + if isCommentTok(prev) { + for j := prevNonWhitespace - 1; j >= 0; j-- { + if strings.Contains(s.At(j).Value, "\n") { + return false + } + if s.At(j).Kind == token.Whitespace || isCommentTok(s.At(j)) { + continue + } + return s.At(j).Kind == token.Punct && (s.At(j).Value == ";" || s.At(j).Value == "}") + } + return false + } + return prev.Kind == token.Punct && (prev.Value == ";" || prev.Value == "}") +} + +// blbsInsert ensures exactly one empty line before index. Returns whether it +// changed anything (keeps the fixer idempotent). +func blbsInsert(s *tokens.Stream, index int) bool { + prevIndex := index - 1 + if prevIndex >= 0 && s.At(prevIndex).Kind == token.Whitespace { + v := s.At(prevIndex).Value + switch countNewlines(v) { + case 0: + s.SetValue(prevIndex, strings.TrimRight(v, " \t")+"\n\n") + return true + case 1: + s.SetValue(prevIndex, "\n"+v) + return true + default: + return false + } + } + s.InsertAt(index, token.Token{Kind: token.Whitespace, Value: "\n\n"}) + return true +} diff --git a/blink/internal/fixer/rules/blank_lines.go b/blink/internal/fixer/rules/blank_lines.go new file mode 100644 index 0000000000..521748f933 --- /dev/null +++ b/blink/internal/fixer/rules/blank_lines.go @@ -0,0 +1,44 @@ +package rules + +import ( + "regexp" + + "blink/internal/token" + "blink/internal/tokens" +) + +var threeOrMoreNewlines = regexp.MustCompile(`\n{3,}`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoExtraBlankLinesFixer.php +// +// NoExtraBlankLines collapses two or more consecutive blank lines into one. It +// runs after no_whitespace_in_blank_line, so blank lines are already bare. +type NoExtraBlankLines struct{} + +func (NoExtraBlankLines) Name() string { + return `PhpCsFixer\Fixer\Whitespace\NoExtraBlankLinesFixer` +} + +func (NoExtraBlankLines) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoExtraBlankLinesFixer.php" +} + +func (NoExtraBlankLines) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Whitespace { + continue + } + // merge any adjacent whitespace tokens (e.g. left by import removal) so + // a run of blank lines lives in one token the regex can collapse + for i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace { + s.SetValue(i, s.At(i).Value+s.At(i+1).Value) + s.RemoveAt(i + 1) + } + if v := threeOrMoreNewlines.ReplaceAllString(s.At(i).Value, "\n\n"); v != s.At(i).Value { + s.SetValue(i, v) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/blocks.go b/blink/internal/fixer/rules/blocks.go new file mode 100644 index 0000000000..03e37f4a33 --- /dev/null +++ b/blink/internal/fixer/rules/blocks.go @@ -0,0 +1,147 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// braceKind is the construct an opening "{" belongs to. It is the small scope +// model the structural fixers reason over. +type braceKind int + +const ( + braceOther braceKind = iota // free block, trait adaptation, match arm, ... + braceClassLike // class / interface / trait / enum body + braceFunctionDecl // named function or method body + braceClosure // anonymous function body + braceControl // if / for / while / switch / try / ... +) + +var controlKeywords = map[string]bool{ + "if": true, "elseif": true, "else": true, "for": true, "foreach": true, + "while": true, "do": true, "switch": true, "try": true, "catch": true, + "finally": true, "match": true, +} + +// classifyBrace determines what construct the "{" at brace opens, and returns +// the index of the governing keyword (for indentation). It walks the header +// backwards, jumping over (...) groups, until it meets a construct keyword or a +// statement boundary. +func classifyBrace(s *tokens.Stream, brace int) (braceKind, int) { + for j := brace - 1; j >= 0; j-- { + t := s.At(j) + switch t.Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + if t.Kind == token.Punct { + switch t.Value { + case ")": + open := s.MatchBackward(j) + if open < 0 { + return braceOther, -1 + } + j = open // loop's j-- steps before the "(" + continue + case ";", "{", "}": + return braceOther, -1 // statement boundary before any keyword + } + continue + } + if t.Kind == token.Keyword { + lw := strings.ToLower(t.Value) + switch { + case classLikeKeywords[lw]: + return braceClassLike, j + case lw == "function": + return functionBraceKind(s, j), j + case controlKeywords[lw]: + return braceControl, j + } + } + } + return braceOther, -1 +} + +// functionBraceKind tells a named function/method (has a name) from a closure +// (a "(" directly follows "function", optionally after a "&"). +func functionBraceKind(s *tokens.Stream, fn int) braceKind { + k := skipWhitespace(s, fn+1) + if k < s.Len() && s.At(k).Kind == token.Punct && s.At(k).Value == "&" { + k = skipWhitespace(s, k+1) + } + if k < s.Len() && s.At(k).Kind == token.Punct && s.At(k).Value == "(" { + return braceClosure + } + return braceFunctionDecl +} + +// classMemberStarts returns the index of the first significant token of each +// direct member declaration in the class body opened at open. Nested bodies +// (method bodies, defaults, argument lists) are skipped. +func classMemberStarts(s *tokens.Stream, open int) []int { + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + return nil + } + var starts []int + expect := true + for k := open + 1; k < closeIdx; k++ { + t := s.At(k) + if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment { + continue + } + if expect { + starts = append(starts, k) + expect = false + } + if t.Kind == token.Punct { + switch t.Value { + case "{", "(", "[": + if m := s.MatchForward(k); m > 0 { + wasBody := t.Value == "{" + k = m + if wasBody { + expect = true // a method body closed; next token starts a member + } + } + case ";": + expect = true + } + } + } + return starts +} + +// braceDepthAt returns the number of "{" still open before index idx. +func braceDepthAt(s *tokens.Stream, idx int) int { + d := 0 + for j := range idx { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case "{": + d++ + case "}": + if d > 0 { + d-- + } + } + } + return d +} + +// lineIndent returns the leading indentation of the line containing token idx. +func lineIndent(s *tokens.Stream, idx int) string { + for j := idx; j >= 0; j-- { + if s.At(j).Kind == token.Whitespace { + if v := s.At(j).Value; strings.Contains(v, "\n") { + return v[strings.LastIndexByte(v, '\n')+1:] + } + } + } + return "" +} diff --git a/blink/internal/fixer/rules/braces_position.go b/blink/internal/fixer/rules/braces_position.go new file mode 100644 index 0000000000..f1556ce681 --- /dev/null +++ b/blink/internal/fixer/rules/braces_position.go @@ -0,0 +1,112 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/BracesPositionFixer.php +// +// BracesPosition places opening braces per PSR-12: classes/interfaces/traits/ +// enums and named functions/methods get their "{" on the next line, aligned +// with the declaration; control structures keep it on the same line after a +// single space. Closures and free blocks are left alone. +type BracesPosition struct{} + +func (BracesPosition) Name() string { + return `PhpCsFixer\Fixer\Basic\BracesPositionFixer` +} + +func (BracesPosition) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/BracesPositionFixer.php" +} + +func (BracesPosition) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "{" { + continue + } + kind, kw := classifyBrace(s, i) + + // an empty class/function body already collapsed to "{}" stays on its + // line - ECS keeps "class A {}" as-is (single_line_empty_body owns the + // collapse); only reflow a body with content + if (kind == braceClassLike || kind == braceFunctionDecl) && s.MatchForward(i) == i+1 { + continue + } + + nextLine := false + switch kind { + case braceClassLike: + // an anonymous class ("new class ... {") keeps its opening brace on + // the same line (PHP-CS-Fixer anonymous_classes_opening_brace default) + if kw >= 0 { + if prev, ok := prevSignificant(s, kw); ok && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") { + nextLine = false + break + } + } + nextLine = true + case braceFunctionDecl: + // PSR-12 keeps "){" on one line when the signature is multiline + nextLine = !funcSignatureMultiline(s, i) + case braceControl: + nextLine = false + default: + continue + } + + want := " " + if nextLine { + // depth-based so it stays correct regardless of the header's own + // (possibly wrong) indentation; statement_indentation fixes the rest + want = "\n" + strings.Repeat(" ", braceDepthAt(s, i)) + } + if i > 0 && s.At(i-1).Kind == token.Whitespace { + if s.At(i-1).Value != want { + s.SetValue(i-1, want) + changed = true + } + } else { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: want}) + i++ + changed = true + } + } + return changed +} + +// funcSignatureMultiline reports whether the parameter list of the function +// whose body opens at brace spans more than one line. +func funcSignatureMultiline(s *tokens.Stream, brace int) bool { + closeParen := -1 + for j := brace - 1; j >= 0; j-- { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + if t.Value == ")" { + closeParen = j + break + } + if t.Value == "{" || t.Value == "}" || t.Value == ";" { + return false + } + } + if closeParen < 0 { + return false + } + open := s.MatchBackward(closeParen) + if open < 0 { + return false + } + for k := open; k <= closeParen; k++ { + if s.At(k).Kind == token.Whitespace && hasNewline(s.At(k).Value) { + return true + } + } + return false +} diff --git a/blink/internal/fixer/rules/braces_position_test.go b/blink/internal/fixer/rules/braces_position_test.go new file mode 100644 index 0000000000..6ee343ba55 --- /dev/null +++ b/blink/internal/fixer/rules/braces_position_test.go @@ -0,0 +1,90 @@ +package rules + +import ( + "testing" + + "blink/internal/lexer" + "blink/internal/token" + "blink/internal/tokens" +) + +func lexStream(src string) *tokens.Stream { return tokens.New(lexer.Lex(src)) } + +func firstBrace(s *tokens.Stream) int { + for i := range s.Len() { + if s.At(i).Kind == token.Punct && s.At(i).Value == "{" { + return i + } + } + return -1 +} + +func TestBracesPositionClassAndFunction(t *testing.T) { + got, changed := apply(t, BracesPosition{}, "= 'A' && c <= 'Z') || c >= 0x80 { + return strings.ToLower(s) + } + } + return s +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseKeywordsFixer.php +// +// LowercaseKeywords lowercases PHP keywords (FUNCTION -> function). +type LowercaseKeywords struct{} + +func (LowercaseKeywords) Name() string { + return `PhpCsFixer\Fixer\Casing\LowercaseKeywordsFixer` +} + +func (LowercaseKeywords) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseKeywordsFixer.php" +} + +func (LowercaseKeywords) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + // a keyword-spelled constant name in assignment position (e.g. + // "const string RETURN = ...") must not be lowercased + if nextSignificantValue(s, i) == "=" { + continue + } + // a keyword-spelled class/member name (e.g. "Enum" in "extends Enum", + // "Spatie\Enum\Enum", "class Enum", or a named argument "instanceOf:") is + // an identifier, not a keyword + if keywordUsedAsIdentifier(s, i) { + continue + } + if lower := lowerASCII(t.Value); lower != t.Value { + s.SetValue(i, lower) + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ConstantCaseFixer.php +// +// ConstantCase lowercases the true, false and null constants. +type ConstantCase struct{} + +func (ConstantCase) Name() string { + return `PhpCsFixer\Fixer\Casing\ConstantCaseFixer` +} + +func (ConstantCase) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ConstantCaseFixer.php" +} + +func (ConstantCase) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Ident { + continue + } + lower := lowerASCII(t.Value) + if lower != "true" && lower != "false" && lower != "null" { + continue + } + if memberPrev(s, i) { + continue // $obj->true, Foo::null - a member name, not the constant + } + if lower != t.Value { + s.SetValue(i, lower) + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseStaticReferenceFixer.php +// +// LowercaseStaticReference lowercases self, static and parent. +type LowercaseStaticReference struct{} + +func (LowercaseStaticReference) Name() string { + return `PhpCsFixer\Fixer\Casing\LowercaseStaticReferenceFixer` +} + +func (LowercaseStaticReference) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseStaticReferenceFixer.php" +} + +func (LowercaseStaticReference) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Ident && t.Kind != token.Keyword { + continue + } + lower := lowerASCII(t.Value) + if lower != "self" && lower != "static" && lower != "parent" { + continue + } + // SELF/PARENT/STATIC can be constant names: "ObjectReference::SELF" (member + // access) or "const string PARENT = ..." (declaration) - not the keyword + if memberPrev(s, i) || nextSignificantValue(s, i) == "=" { + continue + } + if lower != t.Value { + s.SetValue(i, lower) + changed = true + } + } + return changed +} + +// keywordUsedAsIdentifier reports whether a keyword-spelled token is actually a +// class or member name (the lexer marks context-sensitive words like "enum" as +// keywords even when used as identifiers). +func keywordUsedAsIdentifier(s *tokens.Stream, i int) bool { + if prev, ok := prevSignificant(s, i); ok { + if prev.Kind == token.Punct { + switch prev.Value { + case `\`, "->", "?->", "::": + return true + } + } + if prev.Kind == token.Keyword { + switch strings.ToLower(prev.Value) { + case "extends", "implements", "new", "instanceof", + "class", "interface", "trait", "enum", "function", "const", + "namespace", "use", "as", "goto", "insteadof": + return true + } + } + } + if n := nextSignificantIndex(s, i); n >= 0 && s.At(n).Kind == token.Punct { + switch s.At(n).Value { + case `\`, "::", ":": + return true + } + } + return false +} diff --git a/blink/internal/fixer/rules/casing2.go b/blink/internal/fixer/rules/casing2.go new file mode 100644 index 0000000000..ebc5868e22 --- /dev/null +++ b/blink/internal/fixer/rules/casing2.go @@ -0,0 +1,127 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// nativeFunctions is a curated set of common built-in functions. It is +// intentionally partial: names not listed are left untouched (never miscased). +var nativeFunctions = map[string]bool{ + "strlen": true, "count": true, "sizeof": true, "is_array": true, "is_string": true, + "is_int": true, "is_integer": true, "is_bool": true, "is_null": true, "is_object": true, + "is_callable": true, "is_numeric": true, "is_float": true, "is_a": true, "is_iterable": true, + "array_map": true, "array_filter": true, "array_merge": true, "array_keys": true, + "array_values": true, "array_key_exists": true, "in_array": true, "implode": true, + "explode": true, "str_replace": true, "str_repeat": true, "substr": true, "strpos": true, + "stripos": true, "strrpos": true, "strtolower": true, "strtoupper": true, "ucfirst": true, + "lcfirst": true, "ucwords": true, "trim": true, "ltrim": true, "rtrim": true, + "sprintf": true, "printf": true, "vsprintf": true, "number_format": true, + "json_encode": true, "json_decode": true, "preg_match": true, "preg_replace": true, + "preg_split": true, "preg_match_all": true, "preg_quote": true, "sort": true, "rsort": true, + "usort": true, "uasort": true, "uksort": true, "ksort": true, "krsort": true, "asort": true, + "arsort": true, "array_push": true, "array_pop": true, "array_shift": true, + "array_unshift": true, "array_slice": true, "array_splice": true, "array_reverse": true, + "array_unique": true, "array_flip": true, "array_combine": true, "array_column": true, + "array_sum": true, "array_product": true, "array_reduce": true, "array_search": true, + "array_fill": true, "array_diff": true, "array_intersect": true, "array_pad": true, + "array_chunk": true, "array_key_first": true, "array_key_last": true, "max": true, + "min": true, "abs": true, "ceil": true, "floor": true, "round": true, "intval": true, + "floatval": true, "strval": true, "boolval": true, "gettype": true, "settype": true, + "function_exists": true, "method_exists": true, "class_exists": true, "interface_exists": true, + "property_exists": true, "defined": true, "define": true, "constant": true, + "call_user_func": true, "call_user_func_array": true, "func_get_args": true, + "func_num_args": true, "compact": true, "extract": true, "print_r": true, "var_dump": true, + "var_export": true, "serialize": true, "unserialize": true, "base64_encode": true, + "base64_decode": true, "md5": true, "sha1": true, "hash": true, "dechex": true, "hexdec": true, + "date": true, "time": true, "mktime": true, "strtotime": true, "microtime": true, + "str_pad": true, "str_split": true, "str_contains": true, "str_starts_with": true, + "str_ends_with": true, "wordwrap": true, "nl2br": true, "htmlspecialchars": true, + "htmlentities": true, "strip_tags": true, "addslashes": true, "stripslashes": true, + "ord": true, "chr": true, "intdiv": true, "fmod": true, "pow": true, + "sqrt": true, "rand": true, "mt_rand": true, "random_int": true, "array_is_list": true, +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionCasingFixer.php +// +// NativeFunctionCasing lowercases calls to native PHP functions (a curated +// subset; unknown names are left untouched, never miscased). +type NativeFunctionCasing struct{} + +func (NativeFunctionCasing) Name() string { + return `PhpCsFixer\Fixer\Casing\NativeFunctionCasingFixer` +} + +func (NativeFunctionCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionCasingFixer.php" +} + +func (NativeFunctionCasing) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Ident { + continue + } + lower := strings.ToLower(t.Value) + if !nativeFunctions[lower] || t.Value == lower { + continue + } + // must be a function call, not a method or a namespaced name + if prev, ok := prevSignificant(s, i); ok { + switch strings.ToLower(prev.Value) { + case "->", "?->", "::", `\`, "function", "new": + continue + } + } + if nextSignificantValue(s, i) != "(" { + continue + } + s.SetValue(i, lower) + changed = true + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/IntegerLiteralCaseFixer.php +// +// IntegerLiteralCase lowercases the prefix and digits of hex/binary/octal +// integer literals ("0XFF" -> "0xff"). +type IntegerLiteralCase struct{} + +func (IntegerLiteralCase) Name() string { + return `PhpCsFixer\Fixer\Casing\IntegerLiteralCaseFixer` +} + +func (IntegerLiteralCase) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/IntegerLiteralCaseFixer.php" +} + +func (IntegerLiteralCase) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Number || len(t.Value) < 2 || t.Value[0] != '0' { + continue + } + var fixed string + switch t.Value[1] { + case 'x', 'X': + // prefix lowercase, hex digits uppercase ("0Xff" -> "0xFF") + fixed = "0x" + strings.ToUpper(t.Value[2:]) + case 'b', 'B': + fixed = "0b" + t.Value[2:] + case 'o', 'O': + fixed = "0o" + t.Value[2:] + default: + continue + } + if fixed != t.Value { + s.SetValue(i, fixed) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/cast_casing.go b/blink/internal/fixer/rules/cast_casing.go new file mode 100644 index 0000000000..43472eef12 --- /dev/null +++ b/blink/internal/fixer/rules/cast_casing.go @@ -0,0 +1,73 @@ +package rules + +import ( + "strings" + + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/LowercaseCastFixer.php +// +// LowercaseCast lowercases the type inside a cast: "(INT)$x" -> "(int)$x". +type LowercaseCast struct{} + +func (LowercaseCast) Name() string { + return `PhpCsFixer\Fixer\CastNotation\LowercaseCastFixer` +} + +func (LowercaseCast) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/LowercaseCastFixer.php" +} + +func (LowercaseCast) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + typeIdx, _, ok := castAt(s, i) + if !ok { + continue + } + v := s.At(typeIdx).Value + if lower := strings.ToLower(v); lower != v { + s.SetValue(typeIdx, lower) + changed = true + } + } + return changed +} + +var shortScalarCast = map[string]string{ + "integer": "int", + "boolean": "bool", + "double": "float", + "real": "float", + "binary": "string", +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/ShortScalarCastFixer.php +// +// ShortScalarCast rewrites long cast names to the short form: "(integer)" -> +// "(int)", "(boolean)" -> "(bool)", "(double)"/"(real)" -> "(float)". +type ShortScalarCast struct{} + +func (ShortScalarCast) Name() string { + return `PhpCsFixer\Fixer\CastNotation\ShortScalarCastFixer` +} + +func (ShortScalarCast) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/ShortScalarCastFixer.php" +} + +func (ShortScalarCast) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + typeIdx, _, ok := castAt(s, i) + if !ok { + continue + } + if short, found := shortScalarCast[strings.ToLower(s.At(typeIdx).Value)]; found { + s.SetValue(typeIdx, short) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/cast_spaces.go b/blink/internal/fixer/rules/cast_spaces.go new file mode 100644 index 0000000000..d4a01d73b3 --- /dev/null +++ b/blink/internal/fixer/rules/cast_spaces.go @@ -0,0 +1,104 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +var castTypes = map[string]bool{ + "int": true, "integer": true, "bool": true, "boolean": true, + "float": true, "double": true, "real": true, "string": true, + "array": true, "object": true, "unset": true, "binary": true, +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/CastSpacesFixer.php +// +// CastSpaces normalizes a type cast to no inner spaces and a single space after: +// "(int)$x" and "( int )$x" both become "(int) $x". A flat token stream cannot +// see PHP cast tokens, so casts are detected heuristically as "(" type ")" not +// preceded by a call, index or value. +type CastSpaces struct{} + +func (CastSpaces) Name() string { + return `PhpCsFixer\Fixer\CastNotation\CastSpacesFixer` +} + +func (CastSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/CastSpacesFixer.php" +} + +func (CastSpaces) Fix(s *tokens.Stream) bool { + changed := false + i := 0 + for i < s.Len() { + if s.At(i).Kind != token.Punct || s.At(i).Value != "(" { + i++ + continue + } + + open := i + j := open + 1 + openWS := -1 + if j < s.Len() && s.At(j).Kind == token.Whitespace { + openWS = j + j++ + } + if j >= s.Len() || !isCastType(s.At(j)) { + i++ + continue + } + j++ + closeWS := -1 + if j < s.Len() && s.At(j).Kind == token.Whitespace { + closeWS = j + j++ + } + if j >= s.Len() || s.At(j).Kind != token.Punct || s.At(j).Value != ")" { + i++ + continue + } + closeIdx := j + + // skip when "(" follows a call, index or value: then it is grouping, not a cast + if prev, ok := prevSignificant(s, open); ok { + if prev.Kind == token.Ident || prev.Kind == token.Variable || + prev.Value == ")" || prev.Value == "]" { + i++ + continue + } + } + + // mutate high index to low so earlier indices stay valid + if closeIdx+1 < s.Len() { + next := s.At(closeIdx + 1) + if next.Kind == token.Whitespace { + if !hasNewline(next.Value) && next.Value != " " { + s.SetValue(closeIdx+1, " ") + changed = true + } + } else { + s.InsertAt(closeIdx+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + } + if closeWS >= 0 { + s.RemoveAt(closeWS) + changed = true + } + if openWS >= 0 { + s.RemoveAt(openWS) + changed = true + } + i++ + } + return changed +} + +func isCastType(t token.Token) bool { + if t.Kind != token.Ident && t.Kind != token.Keyword { + return false + } + return castTypes[strings.ToLower(t.Value)] +} diff --git a/blink/internal/fixer/rules/class_definition.go b/blink/internal/fixer/rules/class_definition.go new file mode 100644 index 0000000000..aa8810c9e3 --- /dev/null +++ b/blink/internal/fixer/rules/class_definition.go @@ -0,0 +1,82 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassDefinitionFixer.php +// +// ClassDefinition normalizes spacing in a class/interface/trait/enum header: +// exactly one space after the class-like keyword and around "extends" and +// "implements". Newlines (multiline implements lists) are left untouched, and +// the brace is left to braces_position. +type ClassDefinition struct{} + +func (ClassDefinition) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\ClassDefinitionFixer` +} + +func (ClassDefinition) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassDefinitionFixer.php" +} + +func (ClassDefinition) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || !classLikeKeywords[strings.ToLower(t.Value)] { + continue + } + if memberPrev(s, i) { + continue // ::class + } + if prev, ok := prevSignificant(s, i); ok && strings.ToLower(prev.Value) == "new" { + continue // anonymous class + } + if normalizeHeaderSpacing(s, i) { + changed = true + } + } + return changed +} + +// normalizeHeaderSpacing collapses space runs to one after the class-like +// keyword at kw and around extends/implements, up to the opening "{". +func normalizeHeaderSpacing(s *tokens.Stream, kw int) bool { + changed := collapseSpace(s, kw+1) + for j := kw + 1; j < s.Len(); j++ { + t := s.At(j) + if t.Kind == token.Punct && t.Value == "{" { + break + } + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "extends", "implements": + if collapseSpace(s, j-1) { + changed = true + } + if collapseSpace(s, j+1) { + changed = true + } + } + } + } + return changed +} + +// collapseSpace turns a single-line whitespace token at i into exactly one +// space. Multiline whitespace and non-whitespace are left alone. +func collapseSpace(s *tokens.Stream, i int) bool { + if i < 0 || i >= s.Len() { + return false + } + t := s.At(i) + if t.Kind != token.Whitespace || hasNewline(t.Value) || t.Value == " " { + return false + } + s.SetValue(i, " ") + return true +} diff --git a/blink/internal/fixer/rules/class_elements.go b/blink/internal/fixer/rules/class_elements.go new file mode 100644 index 0000000000..80e59dad5b --- /dev/null +++ b/blink/internal/fixer/rules/class_elements.go @@ -0,0 +1,161 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleClassElementPerStatementFixer.php +// +// SingleClassElementPerStatement splits a multi-element property or constant +// declaration into one per line: "public $a, $b;" -> "public $a;\npublic $b;". +type SingleClassElementPerStatement struct{} + +func (SingleClassElementPerStatement) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\SingleClassElementPerStatementFixer` +} + +func (SingleClassElementPerStatement) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleClassElementPerStatementFixer.php" +} + +func (SingleClassElementPerStatement) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "{" { + continue + } + if kind, _ := classifyBrace(s, i); kind != braceClassLike { + continue + } + for _, m := range slices.Backward(classMemberStarts(s, i)) { + if splitClassElement(s, m) { + changed = true + } + } + } + return changed +} + +func splitClassElement(s *tokens.Stream, m int) bool { + // step over leading modifiers + k := m + for k < s.Len() && s.At(k).Kind == token.Keyword && memberModifiers[strings.ToLower(s.At(k).Value)] { + k = skipWhitespace(s, k+1) + } + if k >= s.Len() { + return false + } + + isConst := false + if s.At(k).Kind == token.Keyword { + switch strings.ToLower(s.At(k).Value) { + case "const": + isConst = true + default: + return false // function / use / case / unknown + } + } + + semi := memberEndSemi(s, m) + if semi < 0 { + return false + } + + firstElem := -1 + if isConst { + firstElem = skipWhitespace(s, k+1) + } else { + depth := 0 + for x := m; x < semi; x++ { + t := s.At(x) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[": + depth++ + case ")", "]": + depth-- + } + } + if depth == 0 && t.Kind == token.Variable { + firstElem = x + break + } + } + } + if firstElem < 0 || firstElem >= semi { + return false + } + + var commas []int + depth := 0 + for x := firstElem; x < semi; x++ { + t := s.At(x) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[": + depth++ + case ")", "]": + depth-- + case ",": + if depth == 0 { + commas = append(commas, x) + } + } + } + if len(commas) == 0 { + return false + } + + prefix := make([]token.Token, 0, firstElem-m) + for x := m; x < firstElem; x++ { + prefix = append(prefix, s.At(x)) + } + indent := lineIndent(s, m) + + bounds := append([]int{firstElem - 1}, commas...) + bounds = append(bounds, semi) + var repl []token.Token + for b := 0; b+1 < len(bounds); b++ { + var part []token.Token + for x := bounds[b] + 1; x < bounds[b+1]; x++ { + part = append(part, s.At(x)) + } + part = trimWhitespace(part) + if b > 0 { + repl = append(repl, token.Token{Kind: token.Whitespace, Value: "\n" + indent}) + } + repl = append(repl, prefix...) + repl = append(repl, part...) + repl = append(repl, token.Token{Kind: token.Punct, Value: ";"}) + } + s.ReplaceRange(m, semi, repl) + return true +} + +// memberEndSemi returns the ";" that ends the member starting at m, skipping +// nested groups. Returns -1 if a "}" is reached first (e.g. a method body). +func memberEndSemi(s *tokens.Stream, m int) int { + for k := m; k < s.Len(); k++ { + t := s.At(k) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + if mm := s.MatchForward(k); mm > 0 { + k = mm + } + case ";": + return k + case "}": + return -1 + } + } + return -1 +} diff --git a/blink/internal/fixer/rules/class_members_test.go b/blink/internal/fixer/rules/class_members_test.go new file mode 100644 index 0000000000..4710c673aa --- /dev/null +++ b/blink/internal/fixer/rules/class_members_test.go @@ -0,0 +1,52 @@ +package rules + +import "testing" + +func TestVisibilityRequired(t *testing.T) { + got, changed := apply(t, VisibilityRequired{}, "= 0 && i < s.Len() && s.At(i-1).Kind == token.Whitespace && s.At(i).Kind == token.Whitespace { + s.SetValue(i-1, s.At(i-1).Value+s.At(i).Value) + s.RemoveAt(i) + } + i-- + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentSpacingFixer.php +// +// SingleLineCommentSpacing puts a space after "//" or "#" in a single-line +// comment ("//foo" -> "// foo"). Attributes ("#[...]") are left alone. +type SingleLineCommentSpacing struct{} + +func (SingleLineCommentSpacing) Name() string { + return `PhpCsFixer\Fixer\Comment\SingleLineCommentSpacingFixer` +} + +func (SingleLineCommentSpacing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentSpacingFixer.php" +} + +func (SingleLineCommentSpacing) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Comment { + continue + } + v := t.Value + // single-line block comment: ensure one space inside "/* ... */" + if strings.HasPrefix(v, "/*") && !strings.HasPrefix(v, "/**") && + strings.HasSuffix(v, "*/") && len(v) >= 4 && !strings.ContainsAny(v, "\n\r") { + inner := v[2 : len(v)-2] + nv := inner + if len(nv) > 0 && nv[0] != ' ' && nv[0] != '\t' { + nv = " " + nv + } + if len(nv) > 0 && nv[len(nv)-1] != ' ' && nv[len(nv)-1] != '\t' { + nv = nv + " " + } + if nv != inner { + s.SetValue(i, "/*"+nv+"*/") + changed = true + } + continue + } + var marker string + switch { + case strings.HasPrefix(v, "//"): + marker = "//" + case strings.HasPrefix(v, "#") && !strings.HasPrefix(v, "#["): + marker = "#" + default: + continue + } + rest := v[len(marker):] + if rest == "" || rest[0] == ' ' || rest[0] == '\t' { + continue + } + s.SetValue(i, marker+" "+rest) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/comments_analyzer.go b/blink/internal/fixer/rules/comments_analyzer.go new file mode 100644 index 0000000000..b867475689 --- /dev/null +++ b/blink/internal/fixer/rules/comments_analyzer.go @@ -0,0 +1,183 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// A partial port of PHP-CS-Fixer's CommentsAnalyzer, enough for PhpdocToComment: +// deciding whether a doc comment is a file header or sits before a structural +// element (a declaration, control statement or documented variable). + +var structuralSkipKeywords = map[string]bool{ + "private": true, "protected": true, "public": true, "var": true, + "function": true, "fn": true, "abstract": true, "const": true, + "namespace": true, "require": true, "require_once": true, + "include": true, "include_once": true, "final": true, "readonly": true, +} + +var classyKeywords = map[string]bool{ + "class": true, "interface": true, "trait": true, "enum": true, +} + +var assignmentOps = map[string]bool{ + "=": true, "+=": true, "-=": true, "*=": true, "/=": true, "%=": true, + "**=": true, "&=": true, "|=": true, "^=": true, "<<=": true, ">>=": true, + "??=": true, ".=": true, +} + +func docIsHeaderComment(s *tokens.Stream, index int) bool { + if sigNext(s, index) < 0 { + return false + } + prev := getPrevNonWhitespace(s, index) + if prev < 0 { + return false + } + if isPunctVal(s, prev, ";") { + braceClose := sigPrev(s, prev) + if !isPunctVal(s, braceClose, ")") { + return false + } + braceOpen := s.MatchBackward(braceClose) + if braceOpen < 0 { + return false + } + declare := sigPrev(s, braceOpen) + if declare < 0 || !kwIs(s.At(declare), "declare") { + return false + } + prev = getPrevNonWhitespace(s, declare) + if prev < 0 { + return false + } + } + return s.At(prev).Kind == token.OpenTag +} + +// docNextTokenIndex mirrors CommentsAnalyzer::getNextTokenIndex - the next +// meaningful token, skipping attributes (already comments here) and stepping +// past a leading "(". +func docNextTokenIndex(s *tokens.Stream, index int) int { + next := sigNext(s, index) + for next >= 0 && isPunctVal(s, next, "(") { + next = sigNext(s, next) + } + return next +} + +func docIsBeforeStructuralElement(s *tokens.Stream, index int) bool { + next := docNextTokenIndex(s, index) + if next < 0 || isPunctVal(s, next, "}") { + return false + } + docContent := s.At(index).Value + if docIsStructuralElement(s, next) { + return true + } + if docIsValidControl(s, docContent, next) { + return true + } + if docIsValidVariable(s, next) { + return true + } + if docIsValidVariableAssignment(s, docContent, next) { + return true + } + if kwIs(s.At(next), "use") { + return true + } + return false +} + +func docIsStructuralElement(s *tokens.Stream, index int) bool { + t := s.At(index) + if t.Kind == token.Keyword { + lv := strings.ToLower(t.Value) + if classyKeywords[lv] || structuralSkipKeywords[lv] { + return true + } + if lv == "case" { + p := prevEnumOrSwitch(s, index) + return p >= 0 && kwIs(s.At(p), "enum") + } + if lv == "static" { + n := sigNext(s, index) + return n < 0 || !isPunctVal(s, n, "::") + } + return false + } + if t.Kind == token.Ident { + c := strings.ToLower(t.Value) + return c == "get" || c == "set" + } + return false +} + +func prevEnumOrSwitch(s *tokens.Stream, index int) int { + for j := index - 1; j >= 0; j-- { + if s.At(j).Kind == token.Keyword { + switch strings.ToLower(s.At(j).Value) { + case "enum", "switch": + return j + } + } + } + return -1 +} + +func docIsValidControl(s *tokens.Stream, docContent string, controlIndex int) bool { + if s.At(controlIndex).Kind != token.Keyword { + return false + } + switch strings.ToLower(s.At(controlIndex).Value) { + case "for", "foreach", "if", "switch", "while": + default: + return false + } + open := sigNext(s, controlIndex) + if open < 0 || !isPunctVal(s, open, "(") { + return false + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + return false + } + for i := open + 1; i < closeIdx; i++ { + if s.At(i).Kind == token.Variable && strings.Contains(docContent, s.At(i).Value) { + return true + } + } + return false +} + +func docIsValidVariable(s *tokens.Stream, index int) bool { + if s.At(index).Kind != token.Variable { + return false + } + n := sigNext(s, index) + return n >= 0 && s.At(n).Kind == token.Punct && assignmentOps[s.At(n).Value] +} + +func docIsValidVariableAssignment(s *tokens.Stream, docContent string, lcIndex int) bool { + var endIdx int + t := s.At(lcIndex) + if t.Kind == token.Keyword && (strings.EqualFold(t.Value, "list") || strings.EqualFold(t.Value, "print") || strings.EqualFold(t.Value, "echo")) { + endIdx = nextPunctOfKind(s, lcIndex, ")") + } else if isPunctVal(s, lcIndex, "[") { + endIdx = s.MatchForward(lcIndex) + } else { + return false + } + if endIdx < 0 { + return false + } + for i := lcIndex + 1; i < endIdx; i++ { + if s.At(i).Kind == token.Variable && strings.Contains(docContent, s.At(i).Value) { + return true + } + } + return false +} diff --git a/blink/internal/fixer/rules/common_batch_test.go b/blink/internal/fixer/rules/common_batch_test.go new file mode 100644 index 0000000000..f313fc7300 --- /dev/null +++ b/blink/internal/fixer/rules/common_batch_test.go @@ -0,0 +1,83 @@ +package rules + +import "testing" + +func TestArraySyntax(t *testing.T) { + got, changed := apply(t, ArraySyntax{}, "array(1);"); changed { + t.Fatal("method call array() must not convert") + } + // a cast and a typehint are not the construct + if _, changed := apply(t, ArraySyntax{}, " $b) {}") + if want := " "if (", "else{" -> "else {"). +type SingleSpaceAroundConstruct struct{} + +func (SingleSpaceAroundConstruct) Name() string { + return `PhpCsFixer\Fixer\LanguageConstruct\SingleSpaceAroundConstructFixer` +} + +func (SingleSpaceAroundConstruct) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/SingleSpaceAroundConstructFixer.php" +} + +func (SingleSpaceAroundConstruct) Fix(s *tokens.Stream) bool { + changed := false + i := 0 + for i < s.Len() { + t := s.At(i) + if t.Kind != token.Keyword || !constructKeywords[strings.ToLower(t.Value)] || i+1 >= s.Len() { + i++ + continue + } + next := s.At(i + 1) + // "static" and "class" directly before "(" are a class reference, not a + // construct: "new static(...)", "new class(...)". They take no space. + kw := strings.ToLower(t.Value) + classRef := kw == "static" || kw == "class" + if next.Kind == token.Whitespace { + if classRef && !hasNewline(next.Value) && i+2 < s.Len() && + s.At(i+2).Kind == token.Punct && s.At(i+2).Value == "(" { + s.RemoveAt(i + 1) // "new static (" -> "new static(" + changed = true + i++ + continue + } + if !hasNewline(next.Value) && next.Value != " " { + s.SetValue(i+1, " ") + changed = true + } + i++ + continue + } + switch next.Value { + case "::", "->", "?->", ";", ",", ")", ":": + // member access, statement end or label - not a construct body + case "(": + if !classRef { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + i++ + } + default: + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + i++ + } + i++ + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoSpacesAfterFunctionNameFixer.php +// +// NoSpacesAfterFunctionName removes whitespace between a function name and its +// opening parenthesis ("foo ()" -> "foo()"). +type NoSpacesAfterFunctionName struct{} + +func (NoSpacesAfterFunctionName) Name() string { + return `PhpCsFixer\Fixer\FunctionNotation\NoSpacesAfterFunctionNameFixer` +} + +func (NoSpacesAfterFunctionName) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoSpacesAfterFunctionNameFixer.php" +} + +func (NoSpacesAfterFunctionName) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Ident { + continue + } + if i+2 < s.Len() && + s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) && + s.At(i+2).Kind == token.Punct && s.At(i+2).Value == "(" { + s.RemoveAt(i + 1) + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SpacesInsideParenthesesFixer.php +// +// NoSpacesInsideParenthesis removes single-line whitespace just inside +// parentheses ("( $a )" -> "($a)"). Newlines are kept for multi-line calls. +type NoSpacesInsideParenthesis struct{} + +func (NoSpacesInsideParenthesis) Name() string { + return `PhpCsFixer\Fixer\Whitespace\SpacesInsideParenthesesFixer` +} + +func (NoSpacesInsideParenthesis) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SpacesInsideParenthesesFixer.php" +} + +func (NoSpacesInsideParenthesis) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind == token.Punct && t.Value == "(" && + i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } + if s.At(i).Kind == token.Punct && s.At(i).Value == ")" && + i >= 1 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + i-- + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/UnaryOperatorSpacesFixer.php +// +// UnaryOperatorSpaces removes whitespace between an operand and ++ or -- +// ("$i ++" -> "$i++"). +type UnaryOperatorSpaces struct{} + +func (UnaryOperatorSpaces) Name() string { + return `PhpCsFixer\Fixer\Operator\UnaryOperatorSpacesFixer` +} + +func (UnaryOperatorSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/UnaryOperatorSpacesFixer.php" +} + +func (UnaryOperatorSpaces) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != "++" && t.Value != "--") { + continue + } + if i >= 2 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) && isOperand(s.At(i-2)) { + s.RemoveAt(i - 1) + i-- + changed = true + } + if i+2 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) && isOperand(s.At(i+2)) { + s.RemoveAt(i + 1) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/control.go b/blink/internal/fixer/rules/control.go new file mode 100644 index 0000000000..c471a74425 --- /dev/null +++ b/blink/internal/fixer/rules/control.go @@ -0,0 +1,40 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ElseifFixer.php +// +// Elseif merges "else if" into "elseif". +type Elseif struct{} + +func (Elseif) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\ElseifFixer` +} + +func (Elseif) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ElseifFixer.php" +} + +func (Elseif) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "else" { + continue + } + if i+2 < s.Len() && + s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) && + s.At(i+2).Kind == token.Keyword && strings.ToLower(s.At(i+2).Value) == "if" { + s.SetValue(i, "elseif") + s.RemoveAt(i + 2) // remove "if" first (higher index) + s.RemoveAt(i + 1) // then the whitespace + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/control_structure_braces.go b/blink/internal/fixer/rules/control_structure_braces.go new file mode 100644 index 0000000000..54bd997f68 --- /dev/null +++ b/blink/internal/fixer/rules/control_structure_braces.go @@ -0,0 +1,248 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureBracesFixer.php +// +// ControlStructureBraces wraps the body of each control structure in braces +// ("if (foo()) echo 'x';" -> "if (foo()) { echo 'x'; }"). It is a no-op on +// already-braced code. Alternative syntax ("if (...): ... endif;") is left to +// NoAlternativeSyntaxFixer, which runs first, so those forms are skipped here. +type ControlStructureBraces struct{} + +func (ControlStructureBraces) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\ControlStructureBracesFixer` +} + +func (ControlStructureBraces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureBracesFixer.php" +} + +var csbControlKeywords = map[string]bool{ + "declare": true, "do": true, "else": true, "elseif": true, "finally": true, + "for": true, "foreach": true, "if": true, "while": true, "try": true, + "catch": true, "switch": true, +} + +var csbAltTerminators = map[string]bool{ + "endif": true, "endwhile": true, "endfor": true, + "endforeach": true, "endswitch": true, "enddeclare": true, +} + +func csbIsControl(t token.Token) bool { + return t.Kind == token.Keyword && csbControlKeywords[strings.ToLower(t.Value)] +} + +// csbIsConstantName reports whether the token at index is a class or global +// constant name - a keyword-spelled identifier declared with `const`, optionally +// preceded by a type (e.g. "const IF", "const string IF"). Scanning back over the +// type tokens, a `const` keyword marks it as a name rather than a control word. +func csbIsConstantName(s *tokens.Stream, index int) bool { + i := prevSignificantIndex(s, index) + for i != -1 { + t := s.At(i) + if t.Kind == token.Keyword && strings.EqualFold(t.Value, "const") { + return true + } + // allow the optional type between `const` and the name (int, ?Foo, A|B, \Ns\C) + if t.Kind == token.Ident || t.Kind == token.Keyword || + (t.Kind == token.Punct && (t.Value == "?" || t.Value == "|" || t.Value == `\`)) { + i = prevSignificantIndex(s, i) + continue + } + return false + } + return false +} + +func csbIsKeyword(t token.Token, word string) bool { + return t.Kind == token.Keyword && strings.EqualFold(t.Value, word) +} + +func csbIsPunct(t token.Token, v string) bool { + return t.Kind == token.Punct && t.Value == v +} + +func (f ControlStructureBraces) Fix(s *tokens.Stream) bool { + changed := false + for index := s.Len() - 1; index >= 0; index-- { + tok := s.At(index) + if !csbIsControl(tok) { + continue + } + + // a keyword-spelled constant name (e.g. "const string IF = ...") is an + // identifier, not a control structure - the lexer still tags it as a + // keyword, so skip it here rather than wrap it in braces + if csbIsConstantName(s, index) { + continue + } + + // "else if" - the "if" is handled on its own, skip the "else" + if csbIsKeyword(tok, "else") { + ni := nextMeaningfulIndex(s, index) + if ni != -1 && csbIsKeyword(s.At(ni), "if") { + continue + } + } + + parenEnd := csbFindParenthesisEnd(s, index) + nextAfter := nextMeaningfulIndex(s, parenEnd) + if nextAfter == -1 { + continue + } + ta := s.At(nextAfter) + + if csbIsPunct(ta, ";") || csbIsPunct(ta, "{") || csbIsPunct(ta, ":") || ta.Kind == token.CloseTag { + continue + } + + // nested control using alternative syntax is normalized elsewhere; skip + if csbIsControl(ta) && csbIsAltSyntax(s, nextAfter) { + continue + } + + statementEnd := csbFindStatementEnd(s, parenEnd) + if statementEnd == -1 { + continue + } + + closing := []token.Token{ + {Kind: token.Whitespace, Value: " "}, + {Kind: token.Punct, Value: "}"}, + } + if !csbIsPunct(s.At(statementEnd), ";") && !csbIsPunct(s.At(statementEnd), "}") { + closing = append([]token.Token{{Kind: token.Punct, Value: ";"}}, closing...) + } + csbInsert(s, statementEnd+1, closing) + csbInsert(s, parenEnd+1, []token.Token{ + {Kind: token.Whitespace, Value: " "}, + {Kind: token.Punct, Value: "{"}, + }) + changed = true + } + return changed +} + +// csbIsAltSyntax reports whether the control at controlIndex opens with the +// alternative ":" syntax after its parenthesis. +func csbIsAltSyntax(s *tokens.Stream, controlIndex int) bool { + pe := csbFindParenthesisEnd(s, controlIndex) + after := nextMeaningfulIndex(s, pe) + return after != -1 && csbIsPunct(s.At(after), ":") +} + +// csbInsert inserts toks at pos, preserving their order. +func csbInsert(s *tokens.Stream, pos int, toks []token.Token) { + for i, t := range toks { + s.InsertAt(pos+i, t) + } +} + +// csbFindParenthesisEnd returns the ")" index of the control's condition, or the +// control token index itself when it has no parenthesis (do/else/try/finally). +func csbFindParenthesisEnd(s *tokens.Stream, controlIndex int) int { + ni := nextMeaningfulIndex(s, controlIndex) + if ni == -1 || !csbIsPunct(s.At(ni), "(") { + return controlIndex + } + end := s.MatchForward(ni) + if end == -1 { + return controlIndex + } + return end +} + +var csbContinuation = map[string][]string{ + "if": {"else", "elseif"}, + "do": {"while"}, + "try": {"catch", "finally"}, +} + +var csbFinalContinuation = map[string][]string{ + "if": {"else"}, + "do": {}, + "try": {"finally"}, +} + +func csbInList(word string, list []string) bool { + for _, w := range list { + if strings.EqualFold(word, w) { + return true + } + } + return false +} + +// csbFindStatementEnd returns the index of the last token of the statement that +// follows the parenthesis, or -1 when it cannot be determined safely. +func csbFindStatementEnd(s *tokens.Stream, parenEnd int) int { + nextIndex := nextMeaningfulIndex(s, parenEnd) + if nextIndex == -1 { + return -1 + } + next := s.At(nextIndex) + + if csbIsPunct(next, "{") { + return s.MatchForward(nextIndex) + } + + if csbIsControl(next) { + pe := csbFindParenthesisEnd(s, nextIndex) + endIndex := csbFindStatementEnd(s, pe) + if endIndex == -1 { + return -1 + } + + opening := strings.ToLower(next.Value) + if opening == "if" || opening == "try" || opening == "do" { + for { + ni := nextMeaningfulIndex(s, endIndex) + if ni != -1 && s.At(ni).Kind == token.Keyword && csbInList(s.At(ni).Value, csbContinuation[opening]) { + pe := csbFindParenthesisEnd(s, ni) + endIndex = csbFindStatementEnd(s, pe) + if endIndex == -1 { + return -1 + } + if csbInList(s.At(ni).Value, csbFinalContinuation[opening]) { + return endIndex + } + } else { + break + } + } + } + return endIndex + } + + index := parenEnd + for { + index++ + if index >= s.Len() { + return -1 + } + tok := s.At(index) + if csbIsPunct(tok, "{") { + be := s.MatchForward(index) + if be == -1 { + return -1 + } + index = be + continue + } + if csbIsPunct(tok, ";") { + return index + } + if tok.Kind == token.CloseTag { + return prevSignificantIndex(s, index) + } + if tok.Kind == token.Keyword && csbAltTerminators[strings.ToLower(tok.Value)] { + return -1 + } + } +} diff --git a/blink/internal/fixer/rules/control_structure_braces_test.go b/blink/internal/fixer/rules/control_structure_braces_test.go new file mode 100644 index 0000000000..8f453bd2ee --- /dev/null +++ b/blink/internal/fixer/rules/control_structure_braces_test.go @@ -0,0 +1,50 @@ +package rules + +import "testing" + +func TestControlStructureBraces(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + // braceless if body gets wrapped + {`for($a);`, `for($a);`, false}, + // try/catch/finally already braced + {` %q changed=%v", got, again, changed2) + } + } +} diff --git a/blink/internal/fixer/rules/control_structure_continuation.go b/blink/internal/fixer/rules/control_structure_continuation.go new file mode 100644 index 0000000000..7f4ce8ddca --- /dev/null +++ b/blink/internal/fixer/rules/control_structure_continuation.go @@ -0,0 +1,71 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureContinuationPositionFixer.php +// +// ControlStructureContinuationPosition puts a control-structure continuation +// keyword on the same line as the preceding "}" (the default "same_line"): +// "}\nelse {" becomes "} else {". It covers else/elseif/catch/finally and the +// "while" of a do-while. +type ControlStructureContinuationPosition struct{} + +func (ControlStructureContinuationPosition) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\ControlStructureContinuationPositionFixer` +} + +func (ControlStructureContinuationPosition) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureContinuationPositionFixer.php" +} + +func (ControlStructureContinuationPosition) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + lw := strings.ToLower(t.Value) + cont := lw == "else" || lw == "elseif" || lw == "catch" || lw == "finally" || lw == "while" + if !cont { + continue + } + // the keyword must directly follow a "}" with a single whitespace token + // carrying a newline (nothing to do when already on the same line, and a + // comment in between is left alone) + pi := prevSignificantIndex(s, i) + if pi < 0 || i != pi+2 { + continue + } + if s.At(pi).Kind != token.Punct || s.At(pi).Value != "}" { + continue + } + ws := s.At(pi + 1) + if ws.Kind != token.Whitespace || !hasNewline(ws.Value) { + continue + } + // a bare "while" is a loop header, not a continuation; only the "while" of + // a do-while (whose "}" closes a "do" block) moves up + if lw == "while" && !closesDoBlock(s, pi) { + continue + } + s.SetValue(pi+1, " ") + changed = true + } + return changed +} + +// closesDoBlock reports whether the "}" at idx closes the body of a "do" block. +func closesDoBlock(s *tokens.Stream, idx int) bool { + open := s.MatchBackward(idx) + if open < 0 { + return false + } + kind, kw := classifyBrace(s, open) + return kind == braceControl && kw >= 0 && strings.ToLower(s.At(kw).Value) == "do" +} diff --git a/blink/internal/fixer/rules/control_structure_tier1_test.go b/blink/internal/fixer/rules/control_structure_tier1_test.go new file mode 100644 index 0000000000..e965e2bab0 --- /dev/null +++ b/blink/internal/fixer/rules/control_structure_tier1_test.go @@ -0,0 +1,74 @@ +package rules + +import "testing" + +func TestControlStructureContinuationPosition(t *testing.T) { + got, changed := apply(t, ControlStructureContinuationPosition{}, " "declare(strict_types=1)". +type DeclareEqualNormalize struct{} + +func (DeclareEqualNormalize) Name() string { + return `PhpCsFixer\Fixer\LanguageConstruct\DeclareEqualNormalizeFixer` +} + +func (DeclareEqualNormalize) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/DeclareEqualNormalizeFixer.php" +} + +func (DeclareEqualNormalize) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || t.Value != "=" || !insideDeclareArgs(s, i) { + continue + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace { + s.RemoveAt(i + 1) + changed = true + } + if i >= 1 && s.At(i-1).Kind == token.Whitespace { + s.RemoveAt(i - 1) + i-- + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/docblock.go b/blink/internal/fixer/rules/docblock.go new file mode 100644 index 0000000000..fc9ae1b6d2 --- /dev/null +++ b/blink/internal/fixer/rules/docblock.go @@ -0,0 +1,71 @@ +package rules + +import "strings" + +// A docblock is modeled as its opening line ("/**"), inner content lines and +// closing line ("*/"). Each inner line keeps its "* " prefix separate +// from the content so fixers can rewrite content and rebuild verbatim. + +type docLine struct { + prefix string // indentation + "*" (+ " " when content follows) + content string +} + +type docblock struct { + open string + inner []docLine + close string + single bool // "/** ... */" on one line +} + +// parseDoc splits a /** ... */ value into content lines. Malformed input returns +// ok=false. Single-line docblocks are supported. +func parseDoc(v string) (docblock, bool) { + if !strings.HasPrefix(v, "/**") || !strings.HasSuffix(v, "*/") || len(v) < 5 { + return docblock{}, false + } + if !strings.Contains(v, "\n") { + body := strings.TrimSpace(v[3 : len(v)-2]) + return docblock{open: "/**", inner: []docLine{{content: body}}, close: "*/", single: true}, true + } + lines := strings.Split(v, "\n") + if len(lines) < 3 { + return docblock{}, false + } + d := docblock{open: lines[0], close: lines[len(lines)-1]} + for _, line := range lines[1 : len(lines)-1] { + star := strings.IndexByte(line, '*') + if star < 0 { + return docblock{}, false + } + prefix := line[:star+1] + rest := line[star+1:] + if strings.HasPrefix(rest, " ") { + prefix += " " + rest = rest[1:] + } + d.inner = append(d.inner, docLine{prefix: prefix, content: rest}) + } + return d, true +} + +func (d docblock) render() string { + if d.single { + if len(d.inner) == 0 || d.inner[0].content == "" { + return "/** */" + } + return "/** " + d.inner[0].content + " */" + } + var b strings.Builder + b.WriteString(d.open) + for _, l := range d.inner { + b.WriteString("\n") + if l.content == "" { + b.WriteString(strings.TrimRight(l.prefix, " ")) + } else { + b.WriteString(l.prefix + l.content) + } + } + b.WriteString("\n" + d.close) + return b.String() +} diff --git a/blink/internal/fixer/rules/docblock_lines.go b/blink/internal/fixer/rules/docblock_lines.go new file mode 100644 index 0000000000..9d892d269a --- /dev/null +++ b/blink/internal/fixer/rules/docblock_lines.go @@ -0,0 +1,131 @@ +package rules + +import ( + "regexp" + "strings" +) + +// A raw-line model of a docblock that mirrors PHP-CS-Fixer's DocBlock/Line: +// the content is split into lines, each keeping its trailing newline, and the +// opening "/**" and closing "*/" are lines too. Rebuilding concatenates them, +// so a removed line (set to "") disappears and addBlank appends a blank line. + +var docLineSplitRe = regexp.MustCompile(`[^\n\r]+(?:\r\n|\r|\n)*`) + +// splitDocLines mirrors DocBlock's Preg::split('/([^\n\r]+\R*)/', ...) so each +// line carries its own trailing newline(s). +func splitDocLines(content string) []string { + return docLineSplitRe.FindAllString(content, -1) +} + +var ( + docLineTagRe = regexp.MustCompile(`\*\s*@`) + docLineUsefulRe = regexp.MustCompile(`\*\s*\S+`) + // the optional trailing newline mirrors PCRE's `$`, which also matches just + // before a single newline at the end of the subject + docLineAddBlankRe = regexp.MustCompile(`^([\t ]*\*)[^\r\n]*(\r?\n)(?:\r?\n)?$`) +) + +func lineContainsTag(line string) bool { + return docLineTagRe.MatchString(line) +} + +func lineContainsUsefulContent(line string) bool { + if !docLineUsefulRe.MatchString(line) { + return false + } + replaced := make([]rune, 0, len(line)) + for _, r := range line { + if r == '/' || r == '*' { + replaced = append(replaced, ' ') + } else { + replaced = append(replaced, r) + } + } + for _, r := range replaced { + if r != ' ' && r != '\t' && r != '\n' && r != '\r' && r != '\v' && r != '\f' { + return true + } + } + return false +} + +// lineAddBlank appends a blank doc line ("*") after line, +// mirroring Line::addBlank. +func lineAddBlank(line string) string { + m := docLineAddBlankRe.FindStringSubmatch(line) + if m == nil { + return line + } + return line + m[1] + m[2] +} + +// docAnnotation is a span of lines beginning at a tag line. +type docAnnotation struct { + start int + end int + name string +} + +// docAnnotations mirrors DocBlock::getAnnotations - each annotation runs from +// its "@tag" line through its continuation lines. +func docAnnotations(lines []string) []docAnnotation { + var anns []docAnnotation + total := len(lines) + for index := 0; index < total; index++ { + if !lineContainsTag(lines[index]) { + continue + } + length := findAnnotationLength(lines, index) + anns = append(anns, docAnnotation{ + start: index, + end: index + length - 1, + name: annotationTagName(lines[index : index+length]), + }) + index += length - 1 + } + return anns +} + +// findAnnotationLength mirrors DocBlock::findAnnotationLength. +func findAnnotationLength(lines []string, start int) int { + index := start + for { + index++ + if index >= len(lines) { + break + } + line := lines[index] + if lineContainsTag(line) { + break + } + if !lineContainsUsefulContent(line) { + if index+1 >= len(lines) || !lineContainsUsefulContent(lines[index+1]) || lineContainsTag(lines[index+1]) { + break + } + } + } + return index - start +} + +// tagNameRe matches the @tag name; the trailing boundary (whitespace, end or +// "(") is checked separately since Go's regexp has no lookahead. +var tagNameRe = regexp.MustCompile(`@([a-zA-Z0-9_\-]+)`) + +func annotationTagName(lines []string) string { + content := strings.Join(lines, "") + loc := tagNameRe.FindStringSubmatchIndex(content) + if loc == nil { + return "" + } + name := content[loc[2]:loc[3]] + after := loc[3] + if after >= len(content) { + return name + } + c := content[after] + if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f' || c == '(' { + return name + } + return "" +} diff --git a/blink/internal/fixer/rules/doctrine_annotation.go b/blink/internal/fixer/rules/doctrine_annotation.go new file mode 100644 index 0000000000..ee32582852 --- /dev/null +++ b/blink/internal/fixer/rules/doctrine_annotation.go @@ -0,0 +1,258 @@ +package rules + +import ( + "strings" + + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationSpacesFixer.php +// +// DoctrineAnnotationSpaces normalises whitespace in a Doctrine annotation: +// no space before the "(", none just inside "(" / ")", none around an argument +// "=" and none before a ",", while an array-assignment "=" (inside "{ }") gets a +// single space on each side. String contents are left untouched. +// +// Only single-line annotations whose name starts uppercase or is namespaced +// (e.g. @ORM\Column, @Route, @Assert\NotBlank) are handled - that keeps plain +// phpdoc tags (@param, @return, ...) and multi-line annotations untouched, which +// is a safe under-fire rather than a risky guess. +type DoctrineAnnotationSpaces struct{} + +func (DoctrineAnnotationSpaces) Name() string { + return `PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationSpacesFixer` +} + +func (DoctrineAnnotationSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationSpacesFixer.php" +} + +func (DoctrineAnnotationSpaces) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i := range d.inner { + fixed, ok := fixDoctrineSpacesLine(d.inner[i].content) + if ok && fixed != d.inner[i].content { + d.inner[i].content = fixed + changed = true + } + } + return changed + }) +} + +// fixDoctrineSpacesLine rewrites a single doc line that holds one complete +// Doctrine annotation. It returns ok=false when the line is not such an +// annotation (leaving it untouched). +func fixDoctrineSpacesLine(content string) (string, bool) { + lead := content[:len(content)-len(strings.TrimLeft(content, " \t"))] + rest := content[len(lead):] + if !strings.HasPrefix(rest, "@") { + return content, false + } + // annotation name: @Name or @Ns\Name + j := 1 + for j < len(rest) && isAnnotationNameByte(rest[j]) { + j++ + } + name := rest[1:j] + if name == "" || !isDoctrineAnnotationName(name) { + return content, false + } + // optional spaces, then the opening "(" + k := j + for k < len(rest) && (rest[k] == ' ' || rest[k] == '\t') { + k++ + } + if k >= len(rest) || rest[k] != '(' { + return content, false // no argument list - nothing to space + } + closeIdx := matchAnnotationParen(rest, k) + if closeIdx < 0 { + return content, false // unbalanced - multi-line annotation, skip + } + // only trailing whitespace may follow the closing ")" + if strings.TrimRight(rest[closeIdx+1:], " \t") != "" { + return content, false + } + inner := normalizeDoctrineInner(rest[k+1 : closeIdx]) + rebuilt := lead + "@" + name + "(" + inner + ")" + rest[closeIdx+1:] + return rebuilt, true +} + +func isAnnotationNameByte(b byte) bool { + return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '\\' +} + +// isDoctrineAnnotationName reports whether name looks like a Doctrine annotation +// (starts uppercase or is namespaced) rather than a lowercase phpdoc tag. +func isDoctrineAnnotationName(name string) bool { + if strings.Contains(name, "\\") { + return true + } + return name[0] >= 'A' && name[0] <= 'Z' +} + +// matchAnnotationParen returns the index of the ")" matching the "(" at open, +// honouring double-quoted strings, or -1 when unbalanced within the string. +func matchAnnotationParen(s string, open int) int { + depth := 0 + inStr := false + for i := open; i < len(s); i++ { + c := s[i] + if inStr { + if c == '\\' { + i++ + continue + } + if c == '"' { + inStr = false + } + continue + } + switch c { + case '"': + inStr = true + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +// doctrineToken is a non-space unit of annotation content plus the original +// whitespace that followed it. +type doctrineToken struct { + text string + isPunct bool + gap string // whitespace following this token +} + +// normalizeDoctrineInner applies the spacing rules to the content between the +// outermost parentheses. +func normalizeDoctrineInner(inner string) string { + toks := tokenizeDoctrine(inner) + if len(toks) == 0 { + return "" + } + // brace depth in front of each token (used to tell an array "=" apart from + // an argument "=") + depth := make([]int, len(toks)) + d := 0 + for i, t := range toks { + depth[i] = d + if t.isPunct && t.text == "{" { + d++ + } else if t.isPunct && t.text == "}" { + if d > 0 { + d-- + } + } + } + var b strings.Builder + for i, t := range toks { + b.WriteString(t.text) + if i == len(toks)-1 { + break + } + next := toks[i+1] + b.WriteString(gapBetween(t, next, depth[i], depth[i+1])) + } + return b.String() +} + +// gapBetween decides the whitespace between two adjacent tokens. +func gapBetween(a, next doctrineToken, aDepth, nextDepth int) string { + // no space just inside parentheses, nor before a comma + if a.isPunct && a.text == "(" { + return "" + } + if next.isPunct && next.text == ")" { + return "" + } + if next.isPunct && next.text == "," { + return "" + } + // one space after a comma (add when missing; existing spacing is kept) + if a.isPunct && a.text == "," { + if a.gap == "" { + return " " + } + return a.gap + } + // an "=" is an array assignment when inside "{ }" (one space each side), + // otherwise an argument assignment (no space) + if a.isPunct && a.text == "=" { + if aDepth > 0 { + return " " + } + return "" + } + if next.isPunct && next.text == "=" { + if nextDepth > 0 { + return " " + } + return "" + } + return a.gap +} + +// tokenizeDoctrine splits annotation content into string-aware tokens, trimming +// the leading and trailing whitespace. +func tokenizeDoctrine(inner string) []doctrineToken { + var toks []doctrineToken + i := 0 + n := len(inner) + for i < n { + c := inner[i] + if c == ' ' || c == '\t' { + // whitespace belongs to the previous token's gap + start := i + for i < n && (inner[i] == ' ' || inner[i] == '\t') { + i++ + } + if len(toks) > 0 { + toks[len(toks)-1].gap = inner[start:i] + } + continue + } + if c == '"' { + start := i + i++ + for i < n { + if inner[i] == '\\' { + i += 2 + continue + } + if inner[i] == '"' { + i++ + break + } + i++ + } + toks = append(toks, doctrineToken{text: inner[start:i]}) + continue + } + if strings.IndexByte("(){},=", c) >= 0 { + toks = append(toks, doctrineToken{text: string(c), isPunct: true}) + i++ + continue + } + // a plain run: identifier, number, ::, etc. + start := i + for i < n { + ch := inner[i] + if ch == ' ' || ch == '\t' || ch == '"' || strings.IndexByte("(){},=", ch) >= 0 { + break + } + i++ + } + toks = append(toks, doctrineToken{text: inner[start:i]}) + } + return toks +} diff --git a/blink/internal/fixer/rules/doctrine_annotation_array_assignment.go b/blink/internal/fixer/rules/doctrine_annotation_array_assignment.go new file mode 100644 index 0000000000..30573f26bb --- /dev/null +++ b/blink/internal/fixer/rules/doctrine_annotation_array_assignment.go @@ -0,0 +1,43 @@ +package rules + +import "blink/internal/tokens" + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationArrayAssignmentFixer.php +// +// DoctrineAnnotationArrayAssignment makes array assignments inside a Doctrine +// annotation use the configured operator ("=" by default), so `{bar : "baz"}` +// becomes `{bar = "baz"}`. +type DoctrineAnnotationArrayAssignment struct{} + +func (DoctrineAnnotationArrayAssignment) Name() string { + return `PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationArrayAssignmentFixer` +} + +func (DoctrineAnnotationArrayAssignment) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationArrayAssignmentFixer.php" +} + +func (DoctrineAnnotationArrayAssignment) Fix(s *tokens.Stream) bool { + return applyToDoctrineAnnotations(s, func(toks []daToken) bool { + changed := false + var scopes []string + for i := range toks { + switch toks[i].typ { + case daTOpenParen: + scopes = append(scopes, "annotation") + case daTOpenCurly: + scopes = append(scopes, "array") + case daTCloseParen, daTCloseCurly: + if len(scopes) > 0 { + scopes = scopes[:len(scopes)-1] + } + case daTEquals, daTColon: + if len(scopes) > 0 && scopes[len(scopes)-1] == "array" && toks[i].content != "=" { + toks[i].content = "=" + changed = true + } + } + } + return changed + }) +} diff --git a/blink/internal/fixer/rules/doctrine_annotation_array_assignment_test.go b/blink/internal/fixer/rules/doctrine_annotation_array_assignment_test.go new file mode 100644 index 0000000000..e17e2336ba --- /dev/null +++ b/blink/internal/fixer/rules/doctrine_annotation_array_assignment_test.go @@ -0,0 +1,28 @@ +package rules + +import "testing" + +func TestDoctrineAnnotationArrayAssignment(t *testing.T) { + f := DoctrineAnnotationArrayAssignment{} + + // a colon inside an annotation array becomes an equals + got, changed := apply(t, f, " 0 + + if indentLevel > 0 && (delta < 0 || mixedBraces) { + indentLevel-- + } + + repl := "${1}" + strings.Repeat(" ", 4*indentLevel+1) + if daIndentRe.MatchString(toks[index].content) { + fixed := daIndentRe.ReplaceAllString(toks[index].content, repl) + if fixed != toks[index].content { + toks[index].content = fixed + changed = true + } + } + + if delta > 0 || mixedBraces { + indentLevel++ + } + } + return changed + }) +} + +func daLineBracesCount(toks []daToken, index int) (int, int) { + opening, closing := 0, 0 + for i := index + 1; i < len(toks); i++ { + if toks[i].typ == daTNone && strings.Contains(toks[i].content, "\n") { + break + } + switch toks[i].typ { + case daTOpenParen, daTOpenCurly: + opening++ + case daTCloseParen, daTCloseCurly: + if opening > 0 { + opening-- + } else { + closing++ + } + } + } + return opening, closing +} + +type daSpan struct{ start, end int } + +func daIndentationCanBeFixed(toks []daToken, newLineTokenIndex int, positions []daSpan) bool { + for _, p := range positions { + if newLineTokenIndex >= p.start && newLineTokenIndex <= p.end { + return true + } + } + if newLineTokenIndex+1 < len(toks) { + if strings.Contains(toks[newLineTokenIndex+1].content, "\n") { + return false + } + return toks[newLineTokenIndex+1].typ == daTAt + } + return false +} diff --git a/blink/internal/fixer/rules/doctrine_annotation_indentation_test.go b/blink/internal/fixer/rules/doctrine_annotation_indentation_test.go new file mode 100644 index 0000000000..0fb9bc77c3 --- /dev/null +++ b/blink/internal/fixer/rules/doctrine_annotation_indentation_test.go @@ -0,0 +1,18 @@ +package rules + +import "testing" + +func TestDoctrineAnnotationIndentation(t *testing.T) { + f := DoctrineAnnotationIndentation{} + + // annotation continuation lines get four-space indentation + got, changed := apply(t, f, "= 2 { + value = strings.ReplaceAll(value[1:len(value)-1], `""`, `"`) + } else { + value = "" + } + } + toks = append(toks, daToken{typ: typ, content: value, pos: gs}) + break + } + } + return toks +} + +// daGetType mirrors DocLexer::getType; for a string it also returns the +// unescaped content (outer quotes removed, "" -> "). +func daGetType(value string) int { + if value == "" { + return daTNone + } + if value[0] == '"' { + return daTString + } + if t, ok := daNoCase[value]; ok { + return t + } + if value[0] == '_' || value[0] == '\\' || isAsciiLetter(value[0]) { + return daTIdentifier + } + if daIsNumeric(value) { + if strings.ContainsAny(value, ".eE") { + return daTFloat + } + return daTInteger + } + return daTNone +} + +func isAsciiLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +func daIsNumeric(v string) bool { + if v == "" { + return false + } + i := 0 + if v[0] == '+' || v[0] == '-' { + i++ + } + digits, dot, e := false, false, false + for ; i < len(v); i++ { + c := v[i] + switch { + case c >= '0' && c <= '9': + digits = true + case c == '.' && !dot && !e: + dot = true + case (c == 'e' || c == 'E') && !e && digits: + e = true + if i+1 < len(v) && (v[i+1] == '+' || v[i+1] == '-') { + i++ + } + default: + return false + } + } + return digits +} + +// daStringPos scans "@" occurrences preceded by whitespace or start, the same +// filter Tokens::createFromDocComment applies. +func daIsSpaceByte(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\v' || b == '\f' +} + +// createFromDocComment mirrors Tokens::createFromDocComment. +func daCreateFromDocComment(content string, ignoredTags map[string]bool) []daToken { + var toks []daToken + ignoredTextPosition := 0 + currentPosition := 0 + for { + nextAt := strings.IndexByte(content[currentPosition:], '@') + if nextAt < 0 { + break + } + nextAt += currentPosition + if nextAt != 0 && !daIsSpaceByte(content[nextAt-1]) { + currentPosition = nextAt + 1 + continue + } + + scanned := daScan(content[nextAt:]) + var used []daToken + nbScannedTokensToUse := 0 + nbScopes := 0 + var lastToken daToken + for index := range scanned { + tk := scanned[index] + lastToken = tk + if index == 0 && tk.typ != daTAt { + break + } + if index == 1 { + if tk.typ != daTIdentifier || ignoredTags[tk.content] { + break + } + nbScannedTokensToUse = 2 + } + if index >= 2 && nbScopes == 0 && tk.typ != daTNone && tk.typ != daTOpenParen { + break + } + used = append(used, tk) + if tk.typ == daTOpenParen { + nbScopes++ + } else if tk.typ == daTCloseParen { + nbScopes-- + if nbScopes == 0 { + nbScannedTokensToUse = len(used) + break + } + } + } + + if nbScopes != 0 { + break + } + + if nbScannedTokensToUse != 0 { + if ignoredTextLength := nextAt - ignoredTextPosition; ignoredTextLength != 0 { + toks = append(toks, daToken{typ: daTNone, content: content[ignoredTextPosition : ignoredTextPosition+ignoredTextLength]}) + } + lastTokenEndIndex := 0 + var last daToken + for _, st := range used[:nbScannedTokensToUse] { + tk := st + if st.typ == daTString { + tk = daToken{typ: st.typ, content: `"` + strings.ReplaceAll(st.content, `"`, `""`) + `"`, pos: st.pos} + } + if missing := tk.pos - lastTokenEndIndex; missing > 0 { + toks = append(toks, daToken{typ: daTNone, content: content[nextAt+lastTokenEndIndex : nextAt+lastTokenEndIndex+missing]}) + } + toks = append(toks, daToken{typ: tk.typ, content: tk.content}) + lastTokenEndIndex = tk.pos + len(tk.content) + last = tk + } + currentPosition = nextAt + last.pos + len(last.content) + ignoredTextPosition = currentPosition + } else { + _ = lastToken + currentPosition = nextAt + 1 + } + } + + if ignoredTextPosition < len(content) { + toks = append(toks, daToken{typ: daTNone, content: content[ignoredTextPosition:]}) + } + return toks +} + +func daGetCode(toks []daToken) string { + var b strings.Builder + for _, t := range toks { + b.WriteString(t.content) + } + return b.String() +} + +var daAnnotationEndRe = regexp.MustCompile(`^(\r?\n\s*\*\s*)*\s*$`) + +// daGetAnnotationEnd mirrors Tokens::getAnnotationEnd; returns -1 for "null". +func daGetAnnotationEnd(toks []daToken, index int) int { + current := -1 + if index+2 < len(toks) { + if toks[index+2].typ == daTOpenParen { + current = index + 2 + } else if index+3 < len(toks) && toks[index+2].typ == daTNone && + toks[index+3].typ == daTOpenParen && daAnnotationEndRe.MatchString(toks[index+2].content) { + current = index + 3 + } + } + if current >= 0 { + level := 0 + for ; current < len(toks); current++ { + switch toks[current].typ { + case daTOpenParen: + level++ + case daTCloseParen: + level-- + } + if level == 0 { + return current + } + } + return -1 + } + return index + 1 +} + +// doctrineIgnoredTags is the default ignored_tags list shared by the fixers. +var doctrineIgnoredTags = buildStringSet([]string{ + "abstract", "access", "code", "deprec", "encode", "exception", "final", "ingroup", + "inheritdoc", "inheritDoc", "magic", "name", "toc", "tutorial", "private", "static", + "staticvar", "staticVar", "throw", "api", "author", "category", "copyright", "deprecated", + "example", "filesource", "global", "ignore", "internal", "license", "link", "method", + "package", "param", "property", "property-read", "property-write", "return", "see", "since", + "source", "subpackage", "throws", "todo", "TODO", "usedBy", "uses", "var", "version", + "after", "afterClass", "backupGlobals", "backupStaticAttributes", "before", "beforeClass", + "codeCoverageIgnore", "codeCoverageIgnoreStart", "codeCoverageIgnoreEnd", "covers", + "coversDefaultClass", "coversNothing", "dataProvider", "depends", "expectedException", + "expectedExceptionCode", "expectedExceptionMessage", "expectedExceptionMessageRegExp", + "group", "large", "medium", "preserveGlobalState", "requires", "runTestsInSeparateProcesses", + "runInSeparateProcess", "small", "test", "testdox", "ticket", "SuppressWarnings", + "noinspection", "package_version", "enduml", "startuml", "psalm", "phpstan", "template", + "fix", "FIXME", "fixme", "override", +}) + +func buildStringSet(items []string) map[string]bool { + m := make(map[string]bool, len(items)) + for _, it := range items { + m[it] = true + } + return m +} + +// classModifierKw are the class modifiers skipped before a "class" keyword. +var classModifierKw = map[string]bool{"abstract": true, "final": true, "readonly": true} + +// memberModifierKw mirrors MODIFIER_KINDS for skipping to a class member. +var memberModifierKw = map[string]bool{ + "public": true, "protected": true, "private": true, "final": true, + "abstract": true, "readonly": true, +} + +// docAnnotationEligible mirrors nextElementAcceptsDoctrineAnnotations: the +// docblock at index must precede a class or a member of a class (not interface, +// trait or enum). +func docAnnotationEligible(s *tokens.Stream, index int) bool { + i := sigNext(s, index) + for i >= 0 && s.At(i).Kind == token.Keyword && classModifierKw[strings.ToLower(s.At(i).Value)] { + i = sigNext(s, i) + } + if i < 0 { + return false + } + if s.At(i).Kind == token.Keyword && strings.ToLower(s.At(i).Value) == "class" { + return true + } + // skip member modifiers / type tokens, then require an enclosing "class" body + for i >= 0 && s.At(i).Kind == token.Keyword && memberModifierKw[strings.ToLower(s.At(i).Value)] { + i = sigNext(s, i) + } + if i < 0 { + return false + } + return enclosingClassIsClass(s, i) +} + +// enclosingClassIsClass reports whether the nearest enclosing "{ }" block around +// idx is a class body (not interface/trait/enum). +func enclosingClassIsClass(s *tokens.Stream, idx int) bool { + depth := 0 + for j := idx - 1; j >= 0; j-- { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case "}": + depth++ + case "{": + if depth == 0 { + kind, kw := classifyBrace(s, j) + return kind == braceClassLike && kw >= 0 && strings.ToLower(s.At(kw).Value) == "class" + } + depth-- + } + } + return false +} + +// applyToDoctrineAnnotations runs fn over each eligible docblock's parsed +// annotation tokens, replacing the docblock when fn mutates them. +func applyToDoctrineAnnotations(s *tokens.Stream, fn func(toks []daToken) bool) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.DocComment { + continue + } + if !docAnnotationEligible(s, i) { + continue + } + toks := daCreateFromDocComment(s.At(i).Value, doctrineIgnoredTags) + if fn(toks) { + rebuilt := daGetCode(toks) + if rebuilt != s.At(i).Value { + s.SetValue(i, rebuilt) + changed = true + } + } + } + return changed +} diff --git a/blink/internal/fixer/rules/doctrine_annotation_test.go b/blink/internal/fixer/rules/doctrine_annotation_test.go new file mode 100644 index 0000000000..7ea2561b45 --- /dev/null +++ b/blink/internal/fixer/rules/doctrine_annotation_test.go @@ -0,0 +1,49 @@ +package rules + +import "testing" + +func TestDoctrineAnnotationSpaces(t *testing.T) { + cases := []struct{ in, want string }{ + // space before "(" and just inside "(" / ")" removed + {` "{$a}", +// "$a->b" -> "{$a->b}", "$a[key]" -> "{$a['key']}". Already-explicit ("{$a}", +// "${a}"), single-quoted strings and nowdocs are left untouched. +type ExplicitStringVariable struct{} + +func (ExplicitStringVariable) Name() string { + return `PhpCsFixer\Fixer\StringNotation\ExplicitStringVariableFixer` +} + +func (ExplicitStringVariable) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/ExplicitStringVariableFixer.php" +} + +func (ExplicitStringVariable) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + if s.At(i).Kind != token.String { + continue + } + v := s.At(i).Value + start, ok := interpolatedBody(v) + if !ok { + continue + } + end := len(v) - suffixLen(v, start) + if end < start { + continue + } + body, hit := wrapInterpolations(v[start:end]) + if hit { + s.SetValue(i, v[:start]+body+v[end:]) + changed = true + } + } + return changed +} + +// interpolatedBody returns the offset where the interpolated body begins for a +// double-quoted string or a heredoc (not a nowdoc or single-quoted string), and +// whether the token is one that interpolates at all. +func interpolatedBody(v string) (int, bool) { + if len(v) > 0 && v[0] == '"' { + return 1, true + } + if len(v) >= 3 && v[0] == '<' && v[1] == '<' && v[2] == '<' { + p := 3 + for p < len(v) && (v[p] == ' ' || v[p] == '\t') { + p++ + } + if p < len(v) && v[p] == '\'' { + return 0, false // nowdoc + } + // heredoc: body starts after the opener line + for p < len(v) && v[p] != '\n' { + p++ + } + if p < len(v) { + return p + 1, true + } + } + return 0, false +} + +// suffixLen is the length of the trailing delimiter after the body: 1 for the +// closing quote of a double-quoted string, or the closing heredoc label line. +func suffixLen(v string, bodyStart int) int { + if v[0] == '"' { + return 1 + } + // heredoc: the closing label sits on the last line; the body ends at the + // last newline before it. + last := -1 + for i := bodyStart; i < len(v); i++ { + if v[i] == '\n' { + last = i + } + } + if last < 0 { + return 0 + } + return len(v) - last // include the newline and the closing label +} + +func wrapInterpolations(b string) (string, bool) { + out := make([]byte, 0, len(b)+8) + changed := false + i := 0 + for i < len(b) { + c := b[i] + if c == '\\' && i+1 < len(b) { + out = append(out, c, b[i+1]) + i += 2 + continue + } + // already-explicit "{$...}" - copy the balanced block verbatim + if c == '{' && i+1 < len(b) && b[i+1] == '$' { + depth := 0 + j := i + for j < len(b) { + if b[j] == '{' { + depth++ + } else if b[j] == '}' { + depth-- + if depth == 0 { + j++ + break + } + } + j++ + } + out = append(out, b[i:j]...) + i = j + continue + } + if c == '$' && i+1 < len(b) { + // "${...}" complex syntax - leave as is + if b[i+1] == '{' { + out = append(out, '$', '{') + i += 2 + continue + } + // "$$x" - first $ was literal; do not start an interpolation here + if i > 0 && b[i-1] == '$' { + out = append(out, c) + i++ + continue + } + if isNameStart(b[i+1]) { + expr, end := parseSimpleVar(b, i) + out = append(out, '{') + out = append(out, expr...) + out = append(out, '}') + i = end + changed = true + continue + } + } + out = append(out, c) + i++ + } + return string(out), changed +} + +// parseSimpleVar reads a PHP simple-syntax interpolation starting at the "$" at +// index i ("$name", "$name->prop", "$name[index]") and returns the expression +// to place inside braces, quoting a bareword array key, plus the end offset. +func parseSimpleVar(b string, i int) ([]byte, int) { + j := i + 1 + for j < len(b) && isNameByte(b[j]) { + j++ + } + expr := make([]byte, 0, j-i+4) + expr = append(expr, b[i:j]...) // "$name" + + switch { + case j+2 < len(b) && b[j] == '-' && b[j+1] == '>' && isNameStart(b[j+2]): + k := j + 2 + for k < len(b) && isNameByte(b[k]) { + k++ + } + expr = append(expr, b[j:k]...) // "->prop" + return expr, k + case j < len(b) && b[j] == '[': + k := j + 1 + for k < len(b) && b[k] != ']' { + k++ + } + if k < len(b) && b[k] == ']' { + idx := b[j+1 : k] + expr = append(expr, '[') + expr = append(expr, quoteIndex(idx)...) + expr = append(expr, ']') + return expr, k + 1 + } + } + return expr, j +} + +// quoteIndex mirrors PHP's simple-syntax array access inside interpolation: a +// numeric or variable index is kept, a bareword key is single-quoted. +func quoteIndex(idx string) []byte { + if len(idx) == 0 { + return []byte(idx) + } + if idx[0] == '$' { + return []byte(idx) + } + numeric := true + s := idx + if s[0] == '-' { + s = s[1:] + } + if len(s) == 0 { + numeric = false + } + for k := 0; k < len(s); k++ { + if s[k] < '0' || s[k] > '9' { + numeric = false + break + } + } + if numeric { + return []byte(idx) + } + out := make([]byte, 0, len(idx)+2) + out = append(out, '\'') + out = append(out, idx...) + out = append(out, '\'') + return out +} + +func isNameStart(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80 +} + +func isNameByte(c byte) bool { + return isNameStart(c) || (c >= '0' && c <= '9') +} diff --git a/blink/internal/fixer/rules/explicit_string_variable_test.go b/blink/internal/fixer/rules/explicit_string_variable_test.go new file mode 100644 index 0000000000..0670e62ad1 --- /dev/null +++ b/blink/internal/fixer/rules/explicit_string_variable_test.go @@ -0,0 +1,39 @@ +package rules + +import "testing" + +func TestExplicitStringVariable(t *testing.T) { + f := ExplicitStringVariable{} + + cases := []struct { + src, want string + changed bool + }{ + {`b";`, `b}";`, true}, + {`b->c";`, `b}->c";`, true}, // one level only + {`` types) +// rather than a full recursive TypeExpression parser; it matches ECS on the +// verified corpora, but a sufficiently exotic unparseable type construct +// could still differ. +type FullyQualifiedStrictTypes struct{} + +func (FullyQualifiedStrictTypes) Name() string { + return `PhpCsFixer\Fixer\Import\FullyQualifiedStrictTypesFixer` +} + +func (FullyQualifiedStrictTypes) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/FullyQualifiedStrictTypesFixer.php" +} + +var fqReservedTypes = map[string]bool{ + "array": true, "bool": true, "callable": true, "false": true, "float": true, + "int": true, "iterable": true, "list": true, "mixed": true, "never": true, + "null": true, "object": true, "parent": true, "resource": true, "self": true, + "static": true, "string": true, "true": true, "void": true, +} + +// fqUses holds the class-import map for one namespace. Like ECS's $uses it is +// keyed by full name, so importing the same class twice keeps only the last +// alias; the caches are then derived from that (build). +type fqUses struct { + longs []string // full names in first-occurrence order + longToShort map[string]string + nameByShortLower map[string]string // lower(short) -> long + shortByName map[string]string // long -> short + shortByNormalized map[string]string // normalizeFqcn(long) -> short +} + +func fqNewUses() *fqUses { + return &fqUses{ + longToShort: map[string]string{}, + nameByShortLower: map[string]string{}, + shortByName: map[string]string{}, + shortByNormalized: map[string]string{}, + } +} + +func (u *fqUses) add(long, short string) { + if _, ok := u.longToShort[long]; !ok { + u.longs = append(u.longs, long) + } + u.longToShort[long] = short +} + +// build derives the caches from the (full-name-keyed) import map. +func (u *fqUses) build() { + for _, long := range u.longs { + short := u.longToShort[long] + u.nameByShortLower[strings.ToLower(short)] = long + u.shortByName[long] = short + u.shortByNormalized[fqNormalize(long)] = short + } +} + +func fqNormalize(input string) string { + bs := strings.LastIndexByte(input, '\\') + if bs < 0 { + return strings.ToLower(input) + } + return input[:bs+1] + strings.ToLower(input[bs+1:]) +} + +func fqIsReserved(symbol string, reserved map[string]bool) bool { + if strings.Contains(symbol, "\\") { + return false + } + if fqReservedTypes[strings.ToLower(symbol)] { + return true + } + return reserved[symbol] +} + +// resolveSymbol resolves an absolute or relative symbol to a normalized FQCN. +func fqResolveSymbol(symbol string, u *fqUses, ns string, reserved map[string]bool) string { + if strings.HasPrefix(symbol, "\\") { + return symbol[1:] + } + if fqIsReserved(symbol, reserved) { + return symbol + } + first, rest, hasRest := strings.Cut(symbol, "\\") + if long, ok := u.nameByShortLower[strings.ToLower(first)]; ok { + if hasRest { + return long + "\\" + rest + } + return long + } + if ns != "" { + return ns + "\\" + symbol + } + return symbol +} + +// shortenSymbol shortens a normalized FQCN as much as the namespace + uses allow. +func fqShortenSymbol(fqcn string, u *fqUses, ns string, reserved map[string]bool) string { + if fqIsReserved(fqcn, reserved) { + return fqcn + } + res := "" + found := false + iMin := 0 + + if ns != "" && strings.HasPrefix(fqcn, ns+"\\") { + tmpRes := fqcn[len(ns)+1:] + firstSeg, _, _ := strings.Cut(tmpRes, "\\") + if _, collide := u.nameByShortLower[strings.ToLower(firstSeg)]; !collide && !fqIsReserved(tmpRes, reserved) { + res = tmpRes + found = true + iMin = strings.Count(ns, "\\") + 1 + } + } + + tmp := fqcn + for i := strings.Count(fqcn, "\\"); i >= iMin; i-- { + if short, ok := u.shortByName[tmp]; ok { + tmpRes := short + fqcn[len(tmp):] + if !fqIsReserved(tmpRes, reserved) { + res = tmpRes + found = true + break + } + } + if i > 0 { + if bs := strings.LastIndexByte(tmp, '\\'); bs >= 0 { + tmp = tmp[:bs] + } + } + } + + if !found { + if short, ok := u.shortByNormalized[fqNormalize(fqcn)]; ok && !fqIsReserved(short, reserved) { + res = short + found = true + } + } + + if !found { + res = fqcn + firstSeg, _, _ := strings.Cut(res, "\\") + _, collide := u.nameByShortLower[strings.ToLower(firstSeg)] + if ns != "" || collide { + res = "\\" + res + } + } + return res +} + +// determineShortType returns the shortened rendering of typeName, or "" + false +// when it is already shortest (no change). +func fqDetermineShort(typeName string, u *fqUses, ns string, reserved map[string]bool) (string, bool) { + fqcn := fqResolveSymbol(typeName, u, ns, reserved) + shortened := fqShortenSymbol(fqcn, u, ns, reserved) + if shortened == typeName { + return "", false + } + return shortened, true +} + +type fqReplace struct { + start, end int // inclusive token range of the name run + repl string // rendered replacement (name-run splice) + raw *token.Token // when set, replace [start,end] with this single token +} + +func (FullyQualifiedStrictTypes) Fix(s *tokens.Stream) bool { + regions := fqNamespaceRegions(s) + var repls []fqReplace + for _, reg := range regions { + repls = append(repls, fqProcessNamespace(s, reg)...) + } + if len(repls) == 0 { + return false + } + for _, r := range slices.Backward(repls) { + fqApply(s, r) + } + return true +} + +func fqApply(s *tokens.Stream, r fqReplace) { + if r.raw != nil { + s.ReplaceRange(r.start, r.end, []token.Token{*r.raw}) + return + } + s.ReplaceRange(r.start, r.end, fqStringToTokens(r.repl)) +} + +// fqStringToTokens mirrors namespacedStringToTokens. +func fqStringToTokens(input string) []token.Token { + var out []token.Token + if strings.HasPrefix(input, "\\") { + out = append(out, token.Token{Kind: token.Punct, Value: "\\"}) + input = input[1:] + } + parts := strings.Split(input, "\\") + for i, p := range parts { + out = append(out, token.Token{Kind: token.Ident, Value: p}) + if i != len(parts)-1 { + out = append(out, token.Token{Kind: token.Punct, Value: "\\"}) + } + } + return out +} + +type fqRegion struct { + name string + start, end int // token range [start, end) of the namespace scope +} + +// fqNamespaceRegions returns the namespace scopes, mirroring +// getNamespaceDeclarations: one global region when there is no namespace. +func fqNamespaceRegions(s *tokens.Stream) []fqRegion { + var decls []int + for i := 0; i < s.Len(); i++ { + if kwIs(s.At(i), "namespace") { + // `namespace\foo(...)` relative call is not a declaration + n := sigNext(s, i) + if n >= 0 && s.At(n).Kind == token.Punct && s.At(n).Value == "\\" { + continue + } + decls = append(decls, i) + } + } + if len(decls) == 0 { + return []fqRegion{{name: "", start: 0, end: s.Len()}} + } + var regions []fqRegion + for k, d := range decls { + name, bodyStart, semiOrBrace := fqReadNamespaceName(s, d) + if semiOrBrace < 0 { + continue + } + if s.At(semiOrBrace).Value == "{" { + end := s.MatchForward(semiOrBrace) + if end < 0 { + end = s.Len() + } + regions = append(regions, fqRegion{name: name, start: bodyStart, end: end}) + } else { + end := s.Len() + if k+1 < len(decls) { + end = decls[k+1] + } + regions = append(regions, fqRegion{name: name, start: bodyStart, end: end}) + } + } + return regions +} + +// fqReadNamespaceName reads the name after a `namespace` keyword, returning the +// name, the index just after the `;`/`{`, and the index of that `;`/`{`. +func fqReadNamespaceName(s *tokens.Stream, kw int) (name string, bodyStart, term int) { + var b strings.Builder + i := sigNext(s, kw) + for i >= 0 && i < s.Len() { + t := s.At(i) + if t.Kind == token.Ident { + b.WriteString(t.Value) + i = sigNext(s, i) + continue + } + if t.Kind == token.Punct && t.Value == "\\" { + b.WriteByte('\\') + i = sigNext(s, i) + continue + } + if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{") { + return b.String(), i + 1, i + } + break + } + return "", -1, -1 +} diff --git a/blink/internal/fixer/rules/fully_qualified_strict_types_phpdoc.go b/blink/internal/fixer/rules/fully_qualified_strict_types_phpdoc.go new file mode 100644 index 0000000000..b8c4a8e270 --- /dev/null +++ b/blink/internal/fixer/rules/fully_qualified_strict_types_phpdoc.go @@ -0,0 +1,274 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Default phpdoc_tags whose type expression is shortened. +var fqPhpdocTags = map[string]bool{ + "param": true, "phpstan-param": true, "phpstan-property": true, + "phpstan-property-read": true, "phpstan-property-write": true, + "phpstan-return": true, "phpstan-var": true, "property": true, + "property-read": true, "property-write": true, "psalm-param": true, + "psalm-property": true, "psalm-property-read": true, "psalm-property-write": true, + "psalm-return": true, "psalm-var": true, "return": true, "see": true, + "throws": true, "var": true, +} + +var fqDocKeywords = map[string]bool{ + "min": true, "max": true, "class-string": true, "int": true, "positive-int": true, + "negative-int": true, "non-empty-string": true, "numeric-string": true, + "array-key": true, "scalar": true, "non-empty-array": true, "non-empty-list": true, + "key-of": true, "value-of": true, "literal-string": true, "callable-string": true, + "double": true, "boolean": true, "integer": true, "this": true, "$this": true, + "lowercase-string": true, "non-falsy-string": true, "truthy-string": true, +} + +func fqHSpace(c byte) bool { return c == ' ' || c == '\t' } +func fqWS(c byte) bool { + return c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == ' ' +} +func fqTagChar(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' +} +func fqNameStart(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c >= 0x80 +} +func fqNameChar(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c >= 0x80 +} + +// fqDocTemplateNames collects @template/@type declared identifiers (reserved). +func fqDocTemplateNames(content string) []string { + var out []string + b := []byte(content) + n := len(b) + prefixes := [][]byte{[]byte("psalm-"), []byte("phpstan-")} + kws := [][]byte{ + []byte("template-covariant"), []byte("template-contravariant"), + []byte("template"), []byte("import-type"), []byte("type"), + } + for i := range n { + if b[i] != '*' { + continue + } + j := i + 1 + for j < n && fqHSpace(b[j]) { + j++ + } + if j >= n || b[j] != '@' { + continue + } + rest := b[j+1:] + lower := []byte(strings.ToLower(string(rest))) + off := 0 + for _, pfx := range prefixes { + if fqHasPrefix(lower, pfx) { + off = len(pfx) + break + } + } + after := lower[off:] + var matched []byte + for _, kw := range kws { + if fqHasPrefix(after, kw) { + matched = kw + break + } + } + if matched == nil { + continue + } + p := j + 1 + off + len(matched) + hs := false + for p < n && fqHSpace(b[p]) { + p++ + hs = true + } + if hs && p < n && fqNameStart(b[p]) { + st := p + p++ + for p < n && fqNameChar(b[p]) { + p++ + } + out = append(out, string(b[st:p])) + } + } + return out +} + +func fqHasPrefix(b, pfx []byte) bool { + if len(b) < len(pfx) { + return false + } + for i := range pfx { + if b[i] != pfx[i] { + return false + } + } + return true +} + +// fqPhpDoc shortens class names inside each allowed docblock tag's type. +func fqPhpDoc(s *tokens.Stream, idx int, uses *fqUses, ns string, reserved map[string]bool) (fqReplace, bool) { + content := s.At(idx).Value + newContent := fqPhpDocContent(content, uses, ns, reserved) + if newContent == content { + return fqReplace{}, false + } + tok := token.Token{Kind: token.DocComment, Value: newContent} + return fqReplace{start: idx, end: idx, raw: &tok}, true +} + +// fqPhpDocContent mirrors ECS's `([*{]\h*@)(tag)(\h+)(type)` replace over the +// allowed tags, using an explicit scanner (identical in go and rust). +func fqPhpDocContent(content string, uses *fqUses, ns string, reserved map[string]bool) string { + b := []byte(content) + n := len(b) + var out []byte + i := 0 + for i < n { + if b[i] == '*' || b[i] == '{' { + j := i + 1 + for j < n && fqHSpace(b[j]) { + j++ + } + if j < n && b[j] == '@' { + g1End := j + 1 + t := g1End + for t < n && fqTagChar(b[t]) { + t++ + } + if t > g1End { + tag := b[g1End:t] + h := t + for h < n && fqHSpace(b[h]) { + h++ + } + if h > t && h < n && !fqWS(b[h]) && b[h] != '*' { + typeStart := h + te := h + 1 + for te < n && !fqWS(b[te]) { + te++ + } + out = append(out, b[i:g1End]...) + out = append(out, tag...) + out = append(out, b[t:h]...) + if fqPhpdocTags[strings.ToLower(string(tag))] { + out = append(out, []byte(fqShortenDocType(string(b[typeStart:te]), uses, ns, reserved))...) + } else { + out = append(out, b[typeStart:te]...) + } + i = te + continue + } + } + } + } + out = append(out, b[i]) + i++ + } + return string(out) +} + +// fqShortenDocType shortens each class-name atom in a type expression, skipping +// variables ($x), string keys, array-shape keys and `::` members. +func fqShortenDocType(typeStr string, uses *fqUses, ns string, reserved map[string]bool) string { + // a wildcard type argument (`*`, e.g. `Foo<*>` or `Rel`) makes the + // whole type expression unparseable for ECS's TypeExpression, which then + // leaves it entirely fully qualified. + if strings.Contains(typeStr, "*") { + return typeStr + } + b := []byte(typeStr) + n := len(b) + var out []byte + i := 0 + inString := byte(0) + for i < n { + c := b[i] + if inString != 0 { + out = append(out, c) + if c == inString { + inString = 0 + } + i++ + continue + } + if c == '\'' || c == '"' { + inString = c + out = append(out, c) + i++ + continue + } + // try to read an atom at i + j := i + if b[j] == '\\' && j+1 < n && fqNameStart(b[j+1]) { + j++ + } + if j < n && fqNameStart(b[j]) { + start := i + j++ + for j < n && fqNameChar(b[j]) { + j++ + } + for j < n && b[j] == '\\' && j+1 < n && fqNameStart(b[j+1]) { + j++ + for j < n && fqNameChar(b[j]) { + j++ + } + } + atom := string(b[start:j]) + if fqDocAtomShortenable(b, start, j, n) { + out = append(out, []byte(fqMapDocAtom(atom, uses, ns, reserved))...) + } else { + out = append(out, b[start:j]...) + } + i = j + continue + } + out = append(out, c) + i++ + } + return string(out) +} + +// fqDocAtomShortenable guards against non-type atoms. +func fqDocAtomShortenable(b []byte, start, end, n int) bool { + // preceded by `$` (variable) or `:` (::member or value separator artefact) + if start > 0 { + p := b[start-1] + if p == '$' || p == ':' { + return false + } + } + // array-shape / object-shape key: atom immediately followed by a single `:` + if end < n && b[end] == ':' && (end+1 >= n || b[end+1] != ':') { + return false + } + // generic base (`Foo<...>`): ECS shortens some but keeps others (wildcards, + // multi-arg spaced generics its parser rejects); leaving every generic base + // fully qualified is byte-safe and never over-shortens. (Valid callable + // bases `Closure(...)` are not guarded - ECS shortens those.) + if end < n && b[end] == '<' { + return false + } + return true +} + +func fqMapDocAtom(atom string, uses *fqUses, ns string, reserved map[string]bool) string { + if !strings.Contains(atom, "\\") { + lower := strings.ToLower(atom) + if fqReservedTypes[lower] || fqDocKeywords[lower] { + return atom + } + } + repl, changed := fqDetermineShort(atom, uses, ns, reserved) + if !changed { + return atom + } + return repl +} diff --git a/blink/internal/fixer/rules/fully_qualified_strict_types_test.go b/blink/internal/fixer/rules/fully_qualified_strict_types_test.go new file mode 100644 index 0000000000..8d5027204f --- /dev/null +++ b/blink/internal/fixer/rules/fully_qualified_strict_types_test.go @@ -0,0 +1,36 @@ +package rules + +import "testing" + +func TestFullyQualifiedStrictTypes(t *testing.T) { + f := FullyQualifiedStrictTypes{} + + // code positions: extends/new/instanceof/::/catch, aliased + unimported + got, changed := apply(t, f, "= 0 && s.At(p).Kind == token.Ident { + if r, ok := fqPrevName(s, i, uses, reg.name, reserved); ok { + add([]fqReplace{r}) + } + } + case t.Kind == token.Punct && t.Value == "::": + if r, ok := fqPrevName(s, i, uses, reg.name, reserved); ok { + add([]fqReplace{r}) + } + case t.Kind == token.Keyword: + switch strings.ToLower(t.Value) { + case "function", "fn": + add(fqFunction(s, i, reg.end, uses, reg.name, reserved)) + case "catch": + add(fqCatch(s, i, uses, reg.name, reserved)) + case "extends", "implements": + add(fqExtendsImplements(s, i, uses, reg.name, reserved)) + case "new", "instanceof": + if r, ok := fqNextName(s, i, uses, reg.name, reserved); ok { + add([]fqReplace{r}) + } + case "use": + // only trait-use (inside a class body) shortens; a top-level + // `use` is an import declaration and must never be touched + if depth >= 1 { + if r, ok := fqNextName(s, i, uses, reg.name, reserved); ok { + add([]fqReplace{r}) + } + } + } + case t.Kind == token.DocComment: + for _, id := range fqDocTemplateNames(t.Value) { + reserved[id] = true + } + if r, ok := fqPhpDoc(s, i, uses, reg.name, reserved); ok { + add([]fqReplace{r}) + } + } + } + return repls +} + +// fqCollectUsesRegion builds the class-import map for a namespace region: `use` +// imports at the region base depth (not `use function`/`use const`, closure use +// or grouped use). +func fqCollectUsesRegion(s *tokens.Stream, reg fqRegion) *fqUses { + u := fqNewUses() + depth := 0 + for i := reg.start; i < reg.end && i < s.Len(); i++ { + t := s.At(i) + if t.Kind == token.Punct { + switch t.Value { + case "{": + depth++ + case "}": + depth-- + } + continue + } + if depth != 0 || !kwIs(t, "use") { + continue + } + n := sigNext(s, i) + if n < 0 { + continue + } + if s.At(n).Kind == token.Keyword { + lv := strings.ToLower(s.At(n).Value) + if lv == "function" || lv == "const" { + continue + } + } + fqcn, alias, end, ok := fqReadImport(s, n) + if !ok { + continue + } + i = end + short := alias + if short == "" { + if idx := strings.LastIndexByte(fqcn, '\\'); idx >= 0 { + short = fqcn[idx+1:] + } else { + short = fqcn + } + } + u.add(fqcn, short) + } + u.build() + return u +} + +// fqReadImport reads `Name\Space[ as Alias];` from the first name token. +func fqReadImport(s *tokens.Stream, start int) (fqcn, alias string, end int, ok bool) { + var b strings.Builder + i := start + if isPunctVal(s, i, "\\") { + i++ + } + expectName := true + for i < s.Len() { + t := s.At(i) + switch t.Kind { + case token.Whitespace, token.Comment, token.DocComment: + i++ + continue + } + if expectName { + if t.Kind != token.Ident { + return "", "", 0, false + } + b.WriteString(t.Value) + expectName = false + i++ + continue + } + if t.Kind == token.Punct && t.Value == "\\" { + b.WriteByte('\\') + expectName = true + i++ + continue + } + if t.Kind == token.Punct && t.Value == ";" { + return b.String(), "", i, true + } + if kwIs(t, "as") { + a := sigNext(s, i) + if a < 0 || s.At(a).Kind != token.Ident { + return "", "", 0, false + } + semi := sigNext(s, a) + if semi < 0 || !isPunctVal(s, semi, ";") { + return "", "", 0, false + } + return b.String(), s.At(a).Value, semi, true + } + return "", "", 0, false + } + return "", "", 0, false +} + +// fqReadRunForward reads the contiguous Ident/`\` name run starting at start. +func fqReadRunForward(s *tokens.Stream, start int) (content string, end int, ok bool) { + if start < 0 || start >= s.Len() { + return "", 0, false + } + t := s.At(start) + if t.Kind != token.Ident && (t.Kind != token.Punct || t.Value != "\\") { + return "", 0, false + } + var b strings.Builder + i := start + last := start + for i < s.Len() { + ct := s.At(i) + if ct.Kind == token.Ident { + b.WriteString(ct.Value) + last = i + i++ + continue + } + if ct.Kind == token.Punct && ct.Value == "\\" { + b.WriteByte('\\') + last = i + i++ + continue + } + break + } + return b.String(), last, true +} + +// fqReadRunBackward reads the contiguous Ident/`\` name run ending at end. +func fqReadRunBackward(s *tokens.Stream, end int) (content string, start int, ok bool) { + if end < 0 || end >= s.Len() { + return "", 0, false + } + i := end + first := end + for i >= 0 { + ct := s.At(i) + if ct.Kind == token.Ident || (ct.Kind == token.Punct && ct.Value == "\\") { + first = i + i-- + continue + } + break + } + var b strings.Builder + for j := first; j <= end; j++ { + b.WriteString(s.At(j).Value) + } + if b.Len() == 0 { + return "", 0, false + } + return b.String(), first, true +} + +// fqNextName shortens the name run immediately after a keyword. +func fqNextName(s *tokens.Stream, kw int, uses *fqUses, ns string, reserved map[string]bool) (fqReplace, bool) { + n := sigNext(s, kw) + if n < 0 { + return fqReplace{}, false + } + content, end, ok := fqReadRunForward(s, n) + if !ok { + return fqReplace{}, false + } + repl, changed := fqDetermineShort(content, uses, ns, reserved) + if !changed { + return fqReplace{}, false + } + return fqReplace{start: n, end: end, repl: repl}, true +} + +// fqPrevName shortens the name run immediately before a token (a variable or `::`). +func fqPrevName(s *tokens.Stream, idx int, uses *fqUses, ns string, reserved map[string]bool) (fqReplace, bool) { + p := sigPrev(s, idx) + if p < 0 || s.At(p).Kind != token.Ident { + return fqReplace{}, false + } + content, start, ok := fqReadRunBackward(s, p) + if !ok { + return fqReplace{}, false + } + // a name after an object operator (`$this->grammar::`) is a member, not a class + if b := sigPrev(s, start); b >= 0 && s.At(b).Kind == token.Punct && (s.At(b).Value == "->" || s.At(b).Value == "?->") { + return fqReplace{}, false + } + repl, changed := fqDetermineShort(content, uses, ns, reserved) + if !changed { + return fqReplace{}, false + } + return fqReplace{start: start, end: p, repl: repl}, true +} + +// fqExtendsImplements shortens each type in an extends/implements list. +func fqExtendsImplements(s *tokens.Stream, kw int, uses *fqUses, ns string, reserved map[string]bool) []fqReplace { + var out []fqReplace + i := sigNext(s, kw) + for i >= 0 && i < s.Len() { + t := s.At(i) + if t.Kind == token.Punct && t.Value == "{" { + break + } + if t.Kind == token.Keyword { + break // extends X implements Y - the other keyword handles its list + } + if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == "\\") { + content, end, ok := fqReadRunForward(s, i) + if ok { + if repl, changed := fqDetermineShort(content, uses, ns, reserved); changed { + out = append(out, fqReplace{start: i, end: end, repl: repl}) + } + i = sigNext(s, end) + continue + } + } + i = sigNext(s, i) + } + return out +} + +// fqCatch shortens each exception type in a catch clause. +func fqCatch(s *tokens.Stream, kw int, uses *fqUses, ns string, reserved map[string]bool) []fqReplace { + open := sigNext(s, kw) + if open < 0 || !isPunctVal(s, open, "(") { + return nil + } + var out []fqReplace + i := sigNext(s, open) + for i >= 0 && i < s.Len() { + t := s.At(i) + if (t.Kind == token.Punct && t.Value == ")") || t.Kind == token.Variable { + break + } + if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == "\\") { + content, end, ok := fqReadRunForward(s, i) + if ok { + if repl, changed := fqDetermineShort(content, uses, ns, reserved); changed { + out = append(out, fqReplace{start: i, end: end, repl: repl}) + } + i = sigNext(s, end) + continue + } + } + i = sigNext(s, i) + } + return out +} + +// fqFunction shortens parameter and return types of a function/closure/arrow fn. +func fqFunction(s *tokens.Stream, kw, regionEnd int, uses *fqUses, ns string, reserved map[string]bool) []fqReplace { + open := fqNextPunct(s, kw, "(") + if open < 0 { + return nil + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + return nil + } + var out []fqReplace + // parameters: each type run inside (...) that precedes a $var, & or ... + out = append(out, fqTypeRunsIn(s, open+1, closeIdx-1, uses, ns, reserved, true)...) + // return type: after `)` an optional `:` then a type run up to `{` or `;` + c := sigNext(s, closeIdx) + if c >= 0 && isPunctVal(s, c, ":") { + te := fqReturnTypeEnd(s, c, regionEnd) + if te > c { + out = append(out, fqTypeRunsIn(s, sigNext(s, c), te, uses, ns, reserved, false)...) + } + } + return out +} + +func fqReturnTypeEnd(s *tokens.Stream, colon, regionEnd int) int { + for i := colon + 1; i < s.Len() && i < regionEnd; i++ { + t := s.At(i) + if t.Kind == token.Punct && (t.Value == "{" || t.Value == ";") { + return sigPrev(s, i) + } + } + return -1 +} + +// fqTypeRunsIn scans a range for class-name atoms in type position: name runs +// that are part of a type (skip runs used as default values, constants, etc.). +// A name run counts when it is preceded by a type boundary (`(` `,` `|` `&` `?` +// `:` start) and, for parameters, followed by `$var`/`&`/`...`/`|`/`&`/`)`. +func fqTypeRunsIn(s *tokens.Stream, from, to int, uses *fqUses, ns string, reserved map[string]bool, params bool) []fqReplace { + var out []fqReplace + i := from + for i >= 0 && i <= to && i < s.Len() { + t := s.At(i) + if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == "\\") { + // must be a type: previous meaningful token is a type boundary + p := sigPrev(s, i) + if p >= 0 && fqIsTypeBoundaryBefore(s, p) { + content, end, ok := fqReadRunForward(s, i) + if ok && fqIsTypeBoundaryAfter(s, end, params) { + if repl, changed := fqDetermineShort(content, uses, ns, reserved); changed { + out = append(out, fqReplace{start: i, end: end, repl: repl}) + } + i = sigNext(s, end) + continue + } + } + } + i = sigNext(s, i) + } + return out +} + +func fqIsTypeBoundaryBefore(s *tokens.Stream, p int) bool { + t := s.At(p) + if t.Kind == token.Punct { + switch t.Value { + case "(", ",", "|", "&", "?", ":": + return true + } + } + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "public", "protected", "private", "readonly", "static", "var", "const", "function", "fn": + return true + } + } + return false +} + +func fqIsTypeBoundaryAfter(s *tokens.Stream, end int, params bool) bool { + n := sigNext(s, end) + if n < 0 { + return true + } + t := s.At(n) + if t.Kind == token.Variable { + return true + } + if t.Kind == token.Punct { + switch t.Value { + case "|", "&", ")", "{", ";", ",": + return true + case "...": + return true + } + } + // `&` variadic/reference before var + return false +} + +func fqNextPunct(s *tokens.Stream, from int, v string) int { + for i := from + 1; i < s.Len(); i++ { + if s.At(i).Kind == token.Punct && s.At(i).Value == v { + return i + } + if s.At(i).Kind == token.Punct && (s.At(i).Value == ";" || s.At(i).Value == "{") { + return -1 + } + } + return -1 +} diff --git a/blink/internal/fixer/rules/function_to_constant.go b/blink/internal/fixer/rules/function_to_constant.go new file mode 100644 index 0000000000..54b7c542f5 --- /dev/null +++ b/blink/internal/fixer/rules/function_to_constant.go @@ -0,0 +1,103 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/FunctionToConstantFixer.php +// +// FunctionToConstant replaces a configured function call with its constant +// equivalent: pi() -> M_PI, phpversion() -> PHP_VERSION, php_sapi_name() -> +// PHP_SAPI, get_called_class() -> static::class, get_class($this) -> +// static::class. +type FunctionToConstant struct{} + +func (FunctionToConstant) Name() string { + return `PhpCsFixer\Fixer\LanguageConstruct\FunctionToConstantFixer` +} + +func (FunctionToConstant) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/FunctionToConstantFixer.php" +} + +var noArgConstants = map[string]string{ + "pi": "M_PI", + "phpversion": "PHP_VERSION", + "php_sapi_name": "PHP_SAPI", +} + +func (FunctionToConstant) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Ident { + continue + } + name := strings.ToLower(s.At(i).Value) + _, isNoArg := noArgConstants[name] + if !isNoArg && name != "get_called_class" && name != "get_class" { + continue + } + // a plain function call: not a method/static call, not a declaration, + // and optionally prefixed by a single leading "\" + start := i + if prev, ok := prevSignificant(s, i); ok { + switch { + case prev.Kind == token.Punct && (prev.Value == "->" || prev.Value == "?->" || prev.Value == "::"): + continue + case prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "function"): + continue + case prev.Kind == token.Punct && prev.Value == `\`: + pj := prevSignificantIndex(s, i) + if before, ok := prevSignificant(s, pj); ok && (before.Kind == token.Ident || before.Value == `\`) { + continue // namespaced call, not the global function + } + start = pj + } + } + open := nextSignificantIndex(s, i) + if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + continue + } + var repl []token.Token + switch { + case isNoArg: + if nextSignificantIndex(s, open) != closeIdx { + continue // has arguments + } + repl = []token.Token{{Kind: token.Ident, Value: noArgConstants[name]}} + case name == "get_called_class": + if nextSignificantIndex(s, open) != closeIdx { + continue + } + repl = staticClassTokens() + case name == "get_class": + arg := nextSignificantIndex(s, open) + if arg < 0 || s.At(arg).Kind != token.Variable || !strings.EqualFold(s.At(arg).Value, "$this") { + continue + } + if nextSignificantIndex(s, arg) != closeIdx { + continue // more than just $this + } + repl = staticClassTokens() + } + s.ReplaceRange(start, closeIdx, repl) + changed = true + i = start + } + return changed +} + +func staticClassTokens() []token.Token { + return []token.Token{ + {Kind: token.Keyword, Value: "static"}, + {Kind: token.Punct, Value: "::"}, + {Kind: token.Ident, Value: "class"}, + } +} diff --git a/blink/internal/fixer/rules/gen2_casing.go b/blink/internal/fixer/rules/gen2_casing.go new file mode 100644 index 0000000000..368ff85943 --- /dev/null +++ b/blink/internal/fixer/rules/gen2_casing.go @@ -0,0 +1,196 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// builtinClassNames maps the lowercased name of a well-known internal PHP class +// to its canonical casing. Only reserved/built-in names appear here: a user +// class cannot be declared with any of these names, so a fully-qualified +// reference to one is unambiguous. +var builtinClassNames = map[string]string{ + // core + "stdclass": "stdClass", + "closure": "Closure", + "generator": "Generator", + "fiber": "Fiber", + "weakmap": "WeakMap", + "weakreference": "WeakReference", + "stringable": "Stringable", + "throwable": "Throwable", + "traversable": "Traversable", + "iterator": "Iterator", + "iteratoraggregate": "IteratorAggregate", + "arrayaccess": "ArrayAccess", + "countable": "Countable", + "jsonserializable": "JsonSerializable", + "unitenum": "UnitEnum", + "backedenum": "BackedEnum", + "attribute": "Attribute", + // exceptions / errors + "exception": "Exception", + "errorexception": "ErrorException", + "error": "Error", + "typeerror": "TypeError", + "valueerror": "ValueError", + "argumentcounterror": "ArgumentCountError", + "arithmeticerror": "ArithmeticError", + "divisionbyzeroerror": "DivisionByZeroError", + "unhandledmatcherror": "UnhandledMatchError", + "jsonexception": "JsonException", + "logicexception": "LogicException", + "badfunctioncallexception": "BadFunctionCallException", + "badmethodcallexception": "BadMethodCallException", + "domainexception": "DomainException", + "invalidargumentexception": "InvalidArgumentException", + "lengthexception": "LengthException", + "outofrangeexception": "OutOfRangeException", + "runtimeexception": "RuntimeException", + "outofboundsexception": "OutOfBoundsException", + "overflowexception": "OverflowException", + "rangeexception": "RangeException", + "underflowexception": "UnderflowException", + "unexpectedvalueexception": "UnexpectedValueException", + // SPL data structures / iterators + "arrayobject": "ArrayObject", + "arrayiterator": "ArrayIterator", + "splstack": "SplStack", + "splqueue": "SplQueue", + "spldoublylinkedlist": "SplDoublyLinkedList", + "splfixedarray": "SplFixedArray", + "splheap": "SplHeap", + "splminheap": "SplMinHeap", + "splmaxheap": "SplMaxHeap", + "splpriorityqueue": "SplPriorityQueue", + "splobjectstorage": "SplObjectStorage", + "splfileinfo": "SplFileInfo", + "splfileobject": "SplFileObject", + "spltempfileobject": "SplTempFileObject", + // date / time + "datetime": "DateTime", + "datetimeimmutable": "DateTimeImmutable", + "datetimeinterface": "DateTimeInterface", + "dateinterval": "DateInterval", + "dateperiod": "DatePeriod", + "datetimezone": "DateTimeZone", + // reflection + "reflectionclass": "ReflectionClass", + "reflectionmethod": "ReflectionMethod", + "reflectionproperty": "ReflectionProperty", + "reflectionfunction": "ReflectionFunction", + "reflectionparameter": "ReflectionParameter", + "reflectionnamedtype": "ReflectionNamedType", + "reflectionexception": "ReflectionException", + "reflectionenum": "ReflectionEnum", +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ClassReferenceNameCasingFixer.php +// +// ClassReferenceNameCasing normalises the casing of a reference to an internal +// PHP class to its canonical spelling ("\exception" -> "\Exception"). To stay +// provable on flat tokens it only touches a fully-qualified reference ("\Name") +// to a known built-in class, which is global and unaffected by namespace or use +// imports, so the correct casing is unambiguous. +type ClassReferenceNameCasing struct{} + +func (ClassReferenceNameCasing) Name() string { + return `PhpCsFixer\Fixer\Casing\ClassReferenceNameCasingFixer` +} + +func (ClassReferenceNameCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ClassReferenceNameCasingFixer.php" +} + +func (ClassReferenceNameCasing) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Ident { + continue + } + canonical, ok := builtinClassNames[strings.ToLower(t.Value)] + if !ok || canonical == t.Value { + continue + } + // only a fully-qualified reference "\Name" is unambiguously the global + // built-in class + bs := prevSignificantIndex(s, i) + if bs < 0 || s.At(bs).Kind != token.Punct || s.At(bs).Value != `\` { + continue + } + // "\Name\..." - the name heads a namespace, it is not the class itself + if n := nextSignificantIndex(s, i); n >= 0 && s.At(n).Kind == token.Punct && s.At(n).Value == `\` { + continue + } + // "Foo\Name" - part of a namespaced name, not a global built-in + before := prevSignificantIndex(s, bs) + if before >= 0 && s.At(before).Kind == token.Ident { + continue + } + if !isClassReferencePosition(s, before, i) { + continue + } + s.SetValue(i, canonical) + changed = true + } + return changed +} + +// isClassReferencePosition mirrors the remaining guards of the PHP fixer for the +// name at nameIdx, given the significant token before its leading "\" (before, +// or -1). It rejects positions where a same-spelled function call, constant or +// assignment target - rather than a class name - would sit. +func isClassReferencePosition(s *tokens.Stream, before, nameIdx int) bool { + next := nextSignificantIndex(s, nameIdx) + + // a name wrapped by matching block edges (e.g. "[\Name]") is ambiguous + if before >= 0 && next >= 0 { + if isBlockOpenOrComma(s.At(before)) && isBlockCloseOrComma(s.At(next)) { + return false + } + } + + // "new \Name" is always a class reference + if before >= 0 && s.At(before).Kind == token.Keyword && strings.EqualFold(s.At(before).Value, "new") { + return true + } + + // "\Name(" / "\Name;" / "\Name=" / "\Name?>" reads as a call, constant or + // target, never a class name + if next >= 0 { + nt := s.At(next) + if nt.Kind == token.CloseTag { + return false + } + switch nt.Value { + case "(", ";", "=": + return false + } + } + return true +} + +func isBlockOpenOrComma(t token.Token) bool { + if t.Kind != token.Punct { + return false + } + switch t.Value { + case ",", "(", "[", "{": + return true + } + return false +} + +func isBlockCloseOrComma(t token.Token) bool { + if t.Kind != token.Punct { + return false + } + switch t.Value { + case ",", ")", "]", "}": + return true + } + return false +} diff --git a/blink/internal/fixer/rules/gen2_casing_test.go b/blink/internal/fixer/rules/gen2_casing_test.go new file mode 100644 index 0000000000..1cd69abc32 --- /dev/null +++ b/blink/internal/fixer/rules/gen2_casing_test.go @@ -0,0 +1,62 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen2CasingClassReferenceNameCasing(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + // fully-qualified built-in class references are recased + {"exception;", "exception;", false}, + {" %q", got, again) + } + } +} + +func TestGen2CasingSourceURLs(t *testing.T) { + fixers := []fixer.Fixer{ + ClassReferenceNameCasing{}, + } + for _, f := range fixers { + if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want { + t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want) + } + } +} diff --git a/blink/internal/fixer/rules/gen2_comment.go b/blink/internal/fixer/rules/gen2_comment.go new file mode 100644 index 0000000000..a97d3ecd0e --- /dev/null +++ b/blink/internal/fixer/rules/gen2_comment.go @@ -0,0 +1,82 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/AlignMultilineCommentFixer.php +// +// AlignMultilineComment re-indents the continuation "*" lines of a multi-line +// block comment or docblock so each "*" sits one space to the right of the +// opening "/*". Only the leading whitespace of lines whose trimmed content +// starts with "*" is rewritten; free-form lines are left untouched. +type AlignMultilineComment struct{} + +func (AlignMultilineComment) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\AlignMultilineCommentFixer` +} + +func (AlignMultilineComment) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/AlignMultilineCommentFixer.php" +} + +func (AlignMultilineComment) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Comment && t.Kind != token.DocComment { + continue + } + v := t.Value + if !strings.Contains(v, "\n") { + continue + } + indent, ok := commentOpeningIndent(s, i) + if !ok { + continue + } + nv := realignCommentLines(v, indent) + if nv != v { + s.SetValue(i, nv) + changed = true + } + } + return changed +} + +// commentOpeningIndent returns the indentation of the comment's opening line, +// taken from the whitespace token right before it. ok is false when the comment +// is not preceded by a line break (an inline comment), which must be left alone. +func commentOpeningIndent(s *tokens.Stream, i int) (string, bool) { + whitespace := "" + if prev := i - 1; prev >= 0 && s.At(prev).Kind == token.Whitespace { + whitespace = s.At(prev).Value + } + cut := -1 + for j := 0; j < len(whitespace); j++ { + if whitespace[j] == '\n' || whitespace[j] == '\r' { + cut = j + } + } + if cut < 0 { + return "", false + } + return whitespace[cut+1:], true +} + +// realignCommentLines rewrites the leading whitespace of every interior line +// whose trimmed content starts with "*", aligning it to indent + one space. +func realignCommentLines(v, indent string) string { + lines := strings.Split(v, "\n") + for k := 1; k < len(lines); k++ { + trimmed := strings.TrimLeft(lines[k], " \t") + if !strings.HasPrefix(trimmed, "*") { + continue + } + lines[k] = indent + " " + trimmed + } + return strings.Join(lines, "\n") +} diff --git a/blink/internal/fixer/rules/gen2_comment_test.go b/blink/internal/fixer/rules/gen2_comment_test.go new file mode 100644 index 0000000000..70ccbb5026 --- /dev/null +++ b/blink/internal/fixer/rules/gen2_comment_test.go @@ -0,0 +1,60 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen2Comment(t *testing.T) { + // misaligned docblock at column 0 -> aligned under the first "*" + got, changed := apply(t, AlignMultilineComment{}, "= 0 && lvaluePrefix(s.At(p).Value) { + continue + } + + // Right side must start with the same variable. + rv := sigNext(s, i) + if rv < 0 || s.At(rv).Kind != token.Variable || s.At(rv).Value != s.At(lv).Value { + continue + } + + // Then the null-coalescing operator (single Punct token). + opIdx := sigNext(s, rv) + if opIdx < 0 || s.At(opIdx).Kind != token.Punct || s.At(opIdx).Value != "??" { + continue + } + + // Then a single operand and a terminating ';'. + operandIdx := sigNext(s, opIdx) + if operandIdx < 0 || !shortOperand(s.At(operandIdx)) { + continue + } + endIdx := sigNext(s, operandIdx) + if endIdx < 0 || s.At(endIdx).Value != ";" { + continue + } + + // Refuse if a comment sits inside the rewritten span. + if !spanClean(s, i, opIdx) { + continue + } + + // `= $a ??` becomes `??=`: retag the `=` and drop the duplicate operand + // and operator. + s.SetValue(i, "??=") + for k := opIdx; k >= i+1; k-- { + s.RemoveAt(k) + } + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/gen2_ops_test.go b/blink/internal/fixer/rules/gen2_ops_test.go new file mode 100644 index 0000000000..80140b2571 --- /dev/null +++ b/blink/internal/fixer/rules/gen2_ops_test.go @@ -0,0 +1,72 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen2OpsAssignNullCoalescing(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + {"x = $o->x ?? $b;", "x = $o->x ?? $b;", false}, + // plain assignment, no coalescing + {"x = $o->x ?? $b;\n", + } + for _, f := range fixers { + for _, src := range corpus { + once, _ := apply(t, f, src) + twice, changed := apply(t, f, once) + if changed || once != twice { + t.Errorf("%s not idempotent: src=%q once=%q twice=%q", f.Name(), src, once, twice) + } + } + } +} diff --git a/blink/internal/fixer/rules/gen2_phpdoc.go b/blink/internal/fixer/rules/gen2_phpdoc.go new file mode 100644 index 0000000000..4d4a5a276d --- /dev/null +++ b/blink/internal/fixer/rules/gen2_phpdoc.go @@ -0,0 +1,197 @@ +package rules + +import ( + "regexp" + "sort" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocIndentFixer.php +// +// PhpdocIndent aligns a docblock's "*" continuation lines and closing "*/" to +// the indentation of the code the docblock documents, one space after the +// opening "/**"'s column. +type PhpdocIndent struct{} + +func (PhpdocIndent) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocIndentFixer` +} + +func (PhpdocIndent) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocIndentFixer.php" +} + +func (PhpdocIndent) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.DocComment { + continue + } + if !docblockAtLineStart(s, i) { + continue + } + // align to the element the docblock documents (the next line), not the + // docblock's own possibly-wrong indent + indent, ok := docblockTargetIndent(s, i) + if !ok { + continue + } + d, ok := parseDoc(t.Value) + if !ok || d.single { + continue + } + if reindentDocblock(&d, indent) { + s.SetValue(i, d.render()) + changed = true + } + // move the opening "/**" line to the same indent + if s.At(i-1).Kind == token.Whitespace { + v := s.At(i - 1).Value + nl := strings.LastIndexByte(v, '\n') + if nl >= 0 && v[nl+1:] != indent { + s.SetValue(i-1, v[:nl+1]+indent) + changed = true + } + } + } + return changed +} + +// docblockAtLineStart reports whether the docblock at i begins its own line. +func docblockAtLineStart(s *tokens.Stream, i int) bool { + if i == 0 { + return false + } + prev := s.At(i - 1) + return prev.Kind == token.Whitespace && strings.IndexByte(prev.Value, '\n') >= 0 +} + +// docblockTargetIndent returns the indentation of the structural element the +// docblock at i documents - the indent of the line holding the next significant +// token. ok is false when there is none. +func docblockTargetIndent(s *tokens.Stream, i int) (string, bool) { + if i+1 >= s.Len() || s.At(i+1).Kind != token.Whitespace { + return "", false + } + v := s.At(i + 1).Value + nl := strings.LastIndexByte(v, '\n') + if nl < 0 { + return "", false + } + return v[nl+1:], true +} + +// docblockLineIndent returns the indentation of the line the docblock at index i +// sits on, taken from the trailing whitespace of the preceding whitespace token. +// ok is false when the docblock is not at the start of its line. +func docblockLineIndent(s *tokens.Stream, i int) (string, bool) { + if i == 0 { + return "", false + } + prev := s.At(i - 1) + if prev.Kind != token.Whitespace { + return "", false + } + nl := strings.LastIndexByte(prev.Value, '\n') + if nl < 0 { + return "", false + } + return prev.Value[nl+1:], true +} + +// reindentDocblock rewrites every interior line's "*" and the closing "*/" so +// the star sits one column past the indent. Reports whether anything changed. +func reindentDocblock(d *docblock, indent string) bool { + changed := false + for k, l := range d.inner { + star := strings.IndexByte(l.prefix, '*') + if star < 0 { + continue + } + newPrefix := indent + " *" + l.prefix[star+1:] + if newPrefix != l.prefix { + d.inner[k].prefix = newPrefix + changed = true + } + } + if strings.TrimSpace(d.close) == "*/" { + newClose := indent + " */" + if newClose != d.close { + d.close = newClose + changed = true + } + } + return changed +} + +// coversRe matches a "@covers" annotation line, capturing the value after the +// tag. The value is required so "@coversNothing"/"@coversDefaultClass" and a +// bare "@covers" are left untouched. +var coversRe = regexp.MustCompile(`^@covers\s+(\S.*?)\s*$`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderByValueFixer.php +// +// PhpdocOrderByValue sorts the annotations of the configured tag by their value. +// The default tag set is ["covers"]; a contiguous run of "@covers" lines is +// stable-sorted alphabetically (case-insensitively) by the value after the tag. +type PhpdocOrderByValue struct{} + +func (PhpdocOrderByValue) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocOrderByValueFixer` +} + +func (PhpdocOrderByValue) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderByValueFixer.php" +} + +func (PhpdocOrderByValue) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + n := len(d.inner) + for start := 0; start < n; { + if coversValue(d.inner[start]) == "" { + start++ + continue + } + end := start + 1 + for end < n && coversValue(d.inner[end]) != "" { + end++ + } + if end-start > 1 && sortCoversRun(d.inner[start:end]) { + changed = true + } + start = end + } + return changed + }) +} + +// coversValue returns the lowercased value of a "@covers" annotation line, or "" +// when the line is not a "@covers" annotation with a value. +func coversValue(l docLine) string { + m := coversRe.FindStringSubmatch(strings.TrimSpace(l.content)) + if m == nil { + return "" + } + return strings.ToLower(m[1]) +} + +// sortCoversRun stable-sorts a run of "@covers" lines by their value and reports +// whether the order changed. +func sortCoversRun(run []docLine) bool { + keys := make([]string, len(run)) + for i, l := range run { + keys[i] = coversValue(l) + } + if sort.StringsAreSorted(keys) { + return false + } + sort.SliceStable(run, func(i, j int) bool { + return coversValue(run[i]) < coversValue(run[j]) + }) + return true +} diff --git a/blink/internal/fixer/rules/gen2_phpdoc_test.go b/blink/internal/fixer/rules/gen2_phpdoc_test.go new file mode 100644 index 0000000000..e5e5890006 --- /dev/null +++ b/blink/internal/fixer/rules/gen2_phpdoc_test.go @@ -0,0 +1,78 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen2PhpdocIndent(t *testing.T) { + f := PhpdocIndent{} + + // misaligned continuation "*" and closing "*/" inside a class (4-space indent) + assertFix(t, f, + "= 0 { + if st.Kind == token.Punct && (st.Value == "(" || st.Value == ",") { + return true + } + return st.Kind == token.Keyword && isVisibilityModifier(st.Value) + } + // typed property: the type run starts after a visibility/modifier keyword + return st.Kind == token.Keyword && isVisibilityModifier(st.Value) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypesSpacesFixer.php +// +// TypesSpaces removes the single-line whitespace around a "|"/"&" that joins type +// names in a union/intersection ("int | string" -> "int|string", "A & B" -> +// "A&B"). It acts only in confirmed type positions (parameter, return, typed +// property) and never on a bitwise "|"/"&". Line breaks are preserved. +type TypesSpaces struct{} + +func (TypesSpaces) Name() string { + return `PhpCsFixer\Fixer\Whitespace\TypesSpacesFixer` +} + +func (TypesSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypesSpacesFixer.php" +} + +func (TypesSpaces) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != "|" && t.Value != "&") { + continue + } + if !isTypeUnionOperator(s, i) { + continue + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } + if i-1 >= 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + i-- + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/gen2_types_test.go b/blink/internal/fixer/rules/gen2_types_test.go new file mode 100644 index 0000000000..1ea3b118b4 --- /dev/null +++ b/blink/internal/fixer/rules/gen2_types_test.go @@ -0,0 +1,66 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen2TypesTypesSpaces(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + // parameter union / intersection + {" %q", got, again) + } + } +} + +func TestGen2TypesSourceURLs(t *testing.T) { + fixers := []fixer.Fixer{ + TypesSpaces{}, + } + for _, f := range fixers { + if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want { + t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want) + } + } +} diff --git a/blink/internal/fixer/rules/gen3_class_sep.go b/blink/internal/fixer/rules/gen3_class_sep.go new file mode 100644 index 0000000000..cdba02094f --- /dev/null +++ b/blink/internal/fixer/rules/gen3_class_sep.go @@ -0,0 +1,219 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// class member kinds for the separation rule, mirroring PHP-CS-Fixer's element +// types (const|method|property|trait_import|case). +const ( + sepUnknown = iota + sepConst + sepMethod + sepProperty + sepTraitImport + sepCase +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassAttributesSeparationFixer.php +// +// ClassAttributesSeparation normalizes the blank lines between two consecutive +// top-level class members per the fixer's DEFAULT config: one blank line between +// methods, properties and consts; none between trait imports or enum cases. It +// is conservative - see the caveats on Fix. +type ClassAttributesSeparation struct{} + +func (ClassAttributesSeparation) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\ClassAttributesSeparationFixer` +} + +func (ClassAttributesSeparation) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassAttributesSeparationFixer.php" +} + +// Fix only ever ADJUSTS the newline count inside an existing whitespace token +// between two classified members; it never inserts, removes or moves code. It +// covers two gap shapes and skips everything else: +// - a plain whitespace gap (already spanning a line) between two members; +// - a member whose leading trivia is only docblocks/attributes: the blank line +// is placed above that block, leaving the block attached. +// +// Gaps that glue members on one line, contain plain comments, or hold a detached +// docblock are left untouched. The first member (after "{") and the last member's +// gap to "}" are left to the blank-line-after-opening / brace fixers. +func (ClassAttributesSeparation) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "{" { + continue + } + if kind, _ := classifyBrace(s, i); kind != braceClassLike { + continue + } + starts := classMemberStarts(s, i) + for k := 0; k+1 < len(starts); k++ { + if fixMemberGap(s, starts[k], starts[k+1]) { + changed = true + } + } + } + return changed +} + +// fixMemberGap normalizes the whitespace separating the member at prevStart from +// the one at nextStart. Only whitespace values change, so token indices stay +// stable across calls. Returns whether it changed anything. +func fixMemberGap(s *tokens.Stream, prevStart, nextStart int) bool { + prevEnd := memberSpanEnd(s, prevStart) + if prevEnd < 0 || prevEnd >= nextStart { + return false + } + nextType := classSepMemberType(s, nextStart) + if nextType == sepUnknown { + return false + } + + firstTrivia := -1 + wsCount, wsIdx := 0, -1 + for j := prevEnd + 1; j < nextStart; j++ { + t := s.At(j) + switch { + case t.Kind == token.Whitespace: + wsCount++ + wsIdx = j + case t.Kind == token.DocComment || isAttribute(t): + if firstTrivia < 0 { + firstTrivia = j + } + case t.Kind == token.Comment: + return false // plain comment: ambiguous ownership, leave alone + default: + return false + } + } + + if firstTrivia < 0 { + // pure whitespace gap + if wsCount != 1 || !hasNewline(s.At(wsIdx).Value) { + return false + } + prevType := classSepMemberType(s, prevStart) + return setNewlineCount(s, wsIdx, requiredNewlines(s, prevStart, prevType, nextType)) + } + + // docblock/attribute block leading the next member: blank line goes above it. + // Require exactly one whitespace token (ws0) between prevEnd and the block, and + // the block itself contiguous (single-newline whitespace throughout) so it is + // unambiguously attached to nextStart. + if firstTrivia != prevEnd+2 { + return false + } + ws0 := prevEnd + 1 + if s.At(ws0).Kind != token.Whitespace || !hasNewline(s.At(ws0).Value) { + return false + } + for j := firstTrivia + 1; j < nextStart; j++ { + if t := s.At(j); t.Kind == token.Whitespace && strings.Count(t.Value, "\n") != 1 { + return false + } + } + return setNewlineCount(s, ws0, 2) +} + +// requiredNewlines returns the number of newlines wanted in a plain whitespace +// gap above a member of nextType, following the fixer's default spacing. +func requiredNewlines(s *tokens.Stream, prevStart, prevType, nextType int) int { + if nextType == sepTraitImport || nextType == sepCase { + // SPACING_NONE: tight only between same-type members whose upper element + // carries no docblock/attribute of its own. + if prevType == nextType && !hasDocOrAttrAbove(s, prevStart) { + return 1 + } + return 2 + } + return 2 // SPACING_ONE for const / method / property +} + +// classSepMemberType classifies the member starting at m as one of the sep* +// kinds, or sepUnknown when it cannot be told apart safely. +func classSepMemberType(s *tokens.Stream, m int) int { + end := memberSpanEnd(s, m) + if end < 0 { + return sepUnknown + } + k := m + for k <= end && s.At(k).Kind == token.Keyword && memberModifiers[strings.ToLower(s.At(k).Value)] { + k = skipWhitespace(s, k+1) + } + if k > end { + return sepUnknown + } + if s.At(k).Kind == token.Keyword { + switch strings.ToLower(s.At(k).Value) { + case "use": + return sepTraitImport + case "const": + return sepConst + case "function": + return sepMethod + case "case": + return sepCase + default: + return sepUnknown + } + } + for x := k; x <= end; x++ { + t := s.At(x) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + if mm := s.MatchForward(x); mm > 0 { + x = mm + continue + } + } + } + if t.Kind == token.Variable { + return sepProperty + } + } + return sepUnknown +} + +// hasDocOrAttrAbove reports whether the token directly above start (skipping +// whitespace) is a docblock or an attribute. +func hasDocOrAttrAbove(s *tokens.Stream, start int) bool { + j := start - 1 + for j >= 0 && s.At(j).Kind == token.Whitespace { + j-- + } + if j < 0 { + return false + } + t := s.At(j) + return t.Kind == token.DocComment || isAttribute(t) +} + +func isAttribute(t token.Token) bool { + return t.Kind == token.Comment && strings.HasPrefix(t.Value, "#[") +} + +// setNewlineCount rewrites the whitespace token at idx to hold exactly req +// newlines while keeping the trailing line indentation. Returns whether it +// changed the value. +func setNewlineCount(s *tokens.Stream, idx, req int) bool { + v := s.At(idx).Value + indent := "" + if nl := strings.LastIndexByte(v, '\n'); nl >= 0 { + indent = v[nl+1:] + } + want := strings.Repeat("\n", req) + indent + if v == want { + return false + } + s.SetValue(idx, want) + return true +} diff --git a/blink/internal/fixer/rules/gen3_class_sep_test.go b/blink/internal/fixer/rules/gen3_class_sep_test.go new file mode 100644 index 0000000000..96ed4ba5cc --- /dev/null +++ b/blink/internal/fixer/rules/gen3_class_sep_test.go @@ -0,0 +1,179 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen3ClassSepMethods(t *testing.T) { + src := "= s.Len() { + return false + } + n := s.At(i + 1) + if n.Kind == token.Whitespace { + if hasNewline(n.Value) || n.Value == " " { + return false + } + s.SetValue(i+1, " ") + return true + } + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + return true +} + +// fnEnsureSingleSpaceBefore forces exactly one single-line space between the token +// at i and the one before it. A newline between them is left intact. +func fnEnsureSingleSpaceBefore(s *tokens.Stream, i int) bool { + if i-1 < 0 { + return false + } + p := s.At(i - 1) + if p.Kind == token.Whitespace { + if hasNewline(p.Value) || p.Value == " " { + return false + } + s.SetValue(i-1, " ") + return true + } + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + return true +} + +// fnGlueAfter removes a single-line whitespace token immediately after i, gluing +// the token at i to the following significant token. A newline is left intact. +func fnGlueAfter(s *tokens.Stream, i int) bool { + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + return true + } + return false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/FunctionDeclarationFixer.php +// +// FunctionDeclaration normalizes the spacing of a function, method or closure +// declaration (DEFAULT config, a @PSR12/@PhpCsFixer rule): one space after the +// "function" keyword, no space between a function name and its "(", one space +// between "function" and "(" for a closure, and one space around a closure's +// "use". A leading by-reference "&" is glued to the name ("function &foo()"), and +// a static closure keeps one space after "static". Only "function" declarations +// are touched, never a call; arrow "fn" and the "{"/"=>" and inner-parenthesis +// spacing (owned by other fixers) are left alone. +type FunctionDeclaration struct{} + +func (FunctionDeclaration) Name() string { + return `PhpCsFixer\Fixer\FunctionNotation\FunctionDeclarationFixer` +} + +func (FunctionDeclaration) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/FunctionDeclarationFixer.php" +} + +func (FunctionDeclaration) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + switch strings.ToLower(t.Value) { + case "function": + if fixFunctionDeclaration(s, i) { + changed = true + } + case "fn": + // arrow function: one space between "fn" and "(" (closure_fn_spacing) + if n := nextSignificantIndex(s, i); n >= 0 && + s.At(n).Kind == token.Punct && s.At(n).Value == "(" && + fnEnsureSingleSpaceAfter(s, i) { + changed = true + } + } + } + return changed +} + +// fixFunctionDeclaration normalizes the single declaration whose "function" +// keyword is at fi. Mutations happen only at indices >= fi, so fi and the outer +// loop stay valid. +func fixFunctionDeclaration(s *tokens.Stream, fi int) bool { + startParen := findFunctionParamOpen(s, fi) + if startParen < 0 { + return false + } + + // classify: optional leading "&", optional name directly before "(" + a := nextSignificantIndex(s, fi) + ampIndex := -1 + if a >= 0 && s.At(a).Kind == token.Punct && s.At(a).Value == "&" { + ampIndex = a + a = nextSignificantIndex(s, a) + } + nameIndex := -1 + if a >= 0 && a < startParen { + if s.At(a).Kind == token.Ident && nextSignificantIndex(s, a) == startParen { + nameIndex = a + } else { + return false // unexpected token between "function" and "(" + } + } + named := nameIndex >= 0 + + changed := false + + // closure "use": one space on each side (processed first, rightmost region) + if !named { + if closeParen := s.MatchForward(startParen); closeParen >= 0 { + u := nextSignificantIndex(s, closeParen) + if u >= 0 && s.At(u).Kind == token.Keyword && strings.ToLower(s.At(u).Value) == "use" { + if fnEnsureSingleSpaceAfter(s, u) { + changed = true + } + if fnEnsureSingleSpaceBefore(s, u) { + changed = true + } + } + } + } + + // named declaration: glue name (and any "&") to the "(" + if named { + if fnGlueAfter(s, nameIndex) { + changed = true + } + if ampIndex >= 0 && fnGlueAfter(s, ampIndex) { + changed = true + } + } + + // one space after the "function" keyword (before name, "&" or "(") + if fnEnsureSingleSpaceAfter(s, fi) { + changed = true + } + + // static closure: one space between "static" and "function" + if !named { + if p := prevSignificantIndex(s, fi); p >= 0 && + s.At(p).Kind == token.Keyword && strings.ToLower(s.At(p).Value) == "static" { + if fi-1 >= 0 && s.At(fi-1).Kind == token.Whitespace && + !hasNewline(s.At(fi-1).Value) && s.At(fi-1).Value != " " { + s.SetValue(fi-1, " ") + changed = true + } + } + } + + return changed +} + +// findFunctionParamOpen returns the index of the "(" that opens the parameter +// list of the "function" declaration at fi, or -1 when this "function" is not a +// declaration (e.g. a "use function" import) or has no parameter list. Only +// whitespace, a name and a leading "&" may appear before the "(". +func findFunctionParamOpen(s *tokens.Stream, fi int) int { + for j := fi + 1; j < s.Len(); j++ { + t := s.At(j) + switch t.Kind { + case token.Whitespace, token.Ident: + continue + case token.Punct: + switch t.Value { + case "(": + return j + case "&": + continue + default: + return -1 + } + default: + return -1 + } + } + return -1 +} diff --git a/blink/internal/fixer/rules/gen3_func_test.go b/blink/internal/fixer/rules/gen3_func_test.go new file mode 100644 index 0000000000..c86eb0f1e1 --- /dev/null +++ b/blink/internal/fixer/rules/gen3_func_test.go @@ -0,0 +1,65 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen3FuncFunctionDeclaration(t *testing.T) { + f := FunctionDeclaration{} + cases := []struct { + src, want string + changed bool + }{ + // named function: collapse space after keyword, glue name to "(" + {" $x * 2;", " $x * 2;", true}, + {" $x * 2;", " $x * 2;", false}, + {" $x;", " $x;", false}, + // newline between keyword and name is kept intact + {" %q (changed=%v)", got, again, ch2) + } + } +} + +func TestGen3FuncSourceURL(t *testing.T) { + f := FunctionDeclaration{} + if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want { + t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want) + } +} diff --git a/blink/internal/fixer/rules/gen3_types.go b/blink/internal/fixer/rules/gen3_types.go new file mode 100644 index 0000000000..0d766e3850 --- /dev/null +++ b/blink/internal/fixer/rules/gen3_types.go @@ -0,0 +1,192 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// isTypeDeclarationVariable reports whether the Variable at v is preceded by a +// type declaration: either a function/method/closure parameter type, or a typed +// property (a type run that begins after a visibility/modifier keyword). p is the +// significant token before v and must already be a type-name token. +func isTypeDeclarationVariable(s *tokens.Stream, v, p int) bool { + if enclosingFuncParamOpen(s, v) >= 0 { + return true + } + boundary := typeRunBoundaryPrev(s, p) + if boundary < 0 { + return false + } + bt := s.At(boundary) + return bt.Kind == token.Keyword && isVisibilityModifier(bt.Value) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypeDeclarationSpacesFixer.php +// +// TypeDeclarationSpaces forces exactly one space between a type declaration and +// the "$variable" it declares, in function parameters and typed properties +// ("int$x" and "int $x" -> "int $x"), in function parameters and typed +// properties. This is the canonical rule replacing the deprecated +// function_typehint_space; it also covers native types that lex as keywords +// ("array", "callable"). Line breaks between type and variable are preserved. +type TypeDeclarationSpaces struct{} + +func (TypeDeclarationSpaces) Name() string { + return `PhpCsFixer\Fixer\Whitespace\TypeDeclarationSpacesFixer` +} + +func (TypeDeclarationSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypeDeclarationSpacesFixer.php" +} + +func (TypeDeclarationSpaces) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Variable { + continue + } + p := prevSignificantIndex(s, i) + if p < 0 || !isTypeNameToken(s.At(p)) { + continue + } + if !isTypeDeclarationVariable(s, i, p) { + continue + } + // type token abuts the variable: insert a single space + if p == i-1 { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + continue + } + // type, single whitespace, variable: collapse to a single space + if p == i-2 && s.At(i-1).Kind == token.Whitespace { + ws := s.At(i - 1).Value + if ws != " " && !hasNewline(ws) { + s.SetValue(i-1, " ") + changed = true + } + } + } + return changed +} + +// hasNullDefault reports whether the parameter variable at v has a default value +// that is exactly "null" (the "=" then "null" then the parameter's "," or ")"). +func hasNullDefault(s *tokens.Stream, v int) bool { + eq := nextSignificantIndex(s, v) + if eq < 0 || s.At(eq).Kind != token.Punct || s.At(eq).Value != "=" { + return false + } + nv := nextSignificantIndex(s, eq) + if nv < 0 || s.At(nv).Kind != token.Ident || strings.ToLower(s.At(nv).Value) != "null" { + return false + } + after := nextSignificantIndex(s, nv) + if after < 0 { + return false + } + at := s.At(after) + return at.Kind == token.Punct && (at.Value == "," || at.Value == ")") +} + +// typeRunStart walks left from the type's last token at end across the type parts +// ("\", names, and the operators "?", "|", "&") and returns the index of the +// type's first token together with whether the run contains a union/intersection +// ("|" or "&") or a nullable marker ("?"). +func typeRunStart(s *tokens.Stream, end int) (start int, hasUnion, hasNullable bool) { + start = end + for { + pp := prevSignificantIndex(s, start) + if pp < 0 { + break + } + pt := s.At(pp) + if pt.Kind == token.Punct { + switch pt.Value { + case "|", "&": + hasUnion = true + start = pp + continue + case "?": + hasNullable = true + start = pp + continue + case `\`: + start = pp + continue + } + break + } + if isTypeNameToken(pt) { + start = pp + continue + } + break + } + return start, hasUnion, hasNullable +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php +// +// NullableTypeDeclarationForDefaultNullValue prepends "?" to a non-nullable +// parameter type whose default value is null ("function f(int $x = null)" -> +// "function f(?int $x = null)"). It acts only inside function/method/closure +// parameter lists and skips a type that is already nullable, "mixed", standalone +// "null", or a union/intersection (which would need "|null", not a "?" prefix). +type NullableTypeDeclarationForDefaultNullValue struct{} + +func (NullableTypeDeclarationForDefaultNullValue) Name() string { + return `PhpCsFixer\Fixer\FunctionNotation\NullableTypeDeclarationForDefaultNullValueFixer` +} + +func (NullableTypeDeclarationForDefaultNullValue) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php" +} + +func (NullableTypeDeclarationForDefaultNullValue) Fix(s *tokens.Stream) bool { + changed := false + // Right-to-left so an inserted "?" (always left of the current index) never + // disturbs positions still to be scanned. + for i := s.Len() - 1; i >= 0; i-- { + if s.At(i).Kind != token.Variable { + continue + } + if enclosingFuncParamOpen(s, i) < 0 { + continue + } + if !hasNullDefault(s, i) { + continue + } + p := prevSignificantIndex(s, i) + if p < 0 { + continue + } + // step over a by-reference "&" to reach the type's last token + if s.At(p).Kind == token.Punct && s.At(p).Value == "&" { + p = prevSignificantIndex(s, p) + if p < 0 { + continue + } + } + if !isTypeNameToken(s.At(p)) { + continue + } + start, hasUnion, hasNullable := typeRunStart(s, p) + if hasNullable || hasUnion { + continue + } + // atomic single type: skip "mixed" and standalone "null" + if start == p { + switch strings.ToLower(s.At(p).Value) { + case "mixed", "null": + continue + } + } + s.InsertAt(start, token.Token{Kind: token.Punct, Value: "?"}) + changed = true + i = start + } + return changed +} diff --git a/blink/internal/fixer/rules/gen3_types_test.go b/blink/internal/fixer/rules/gen3_types_test.go new file mode 100644 index 0000000000..72d750615b --- /dev/null +++ b/blink/internal/fixer/rules/gen3_types_test.go @@ -0,0 +1,105 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen3TypesTypeDeclarationSpaces(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + // parameters + {" $x;", " $x;", true}, + // typed properties + {" %q", got, again) + } + } +} + +func TestGen3TypesNullableTypeDeclarationForDefaultNullValue(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + // happy paths + {" %q", got, again) + } + } +} + +func TestGen3TypesSourceURLs(t *testing.T) { + fixers := []fixer.Fixer{ + TypeDeclarationSpaces{}, + NullableTypeDeclarationForDefaultNullValue{}, + } + for _, f := range fixers { + if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want { + t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want) + } + } +} diff --git a/blink/internal/fixer/rules/gen4_a.go b/blink/internal/fixer/rules/gen4_a.go new file mode 100644 index 0000000000..5cbf621aef --- /dev/null +++ b/blink/internal/fixer/rules/gen4_a.go @@ -0,0 +1,238 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentStyleFixer.php +// +// SingleLineCommentStyle normalizes single-line comments to the "//" form. A "#" +// line comment becomes "//" (an "#[" attribute is never touched), and a genuinely +// single-line "/* ... */" block comment at the end of its line becomes "// ...". +// Multi-line "/* */" blocks and doc comments are left alone. +type SingleLineCommentStyle struct{} + +func (SingleLineCommentStyle) Name() string { + return `PhpCsFixer\Fixer\Comment\SingleLineCommentStyleFixer` +} + +func (SingleLineCommentStyle) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentStyleFixer.php" +} + +func (SingleLineCommentStyle) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + // only plain comments (T_COMMENT); doc blocks keep their "/** */" form + if s.At(i).Kind != token.Comment { + continue + } + v := s.At(i).Value + + // hash style: "# foo" -> "// foo"; "#[...]" is an attribute, not a comment + if strings.HasPrefix(v, "#") { + if len(v) >= 2 && v[1] == '[' { + continue + } + s.SetValue(i, "//"+v[1:]) + changed = true + continue + } + + // asterisk style: only a genuinely single-line "/* ... */" block + if !strings.HasPrefix(v, "/*") || len(v) < 4 { + continue + } + if strings.ContainsAny(v, "\n\r") { + continue // conservative: never touch a multi-line block comment + } + content := v[2 : len(v)-2] + if strings.Contains(content, "?>") { + continue + } + // the comment must sit at the end of its line, otherwise turning it into + // a "//" comment would swallow the code that follows on the same line + if next := i + 1; next < s.Len() { + nt := s.At(next) + if nt.Kind != token.Whitespace || !strings.ContainsAny(nt.Value, "\n\r") { + continue + } + } + + inner := strings.Trim(content, " \t\r\n\f\v*") + nv := "//" + if inner != "" { + nv = "// " + inner + } + s.SetValue(i, nv) + changed = true + + // drop the leading indentation the following whitespace inherited from the + // now-removed closing "*/", matching PHP-CS-Fixer + if next := i + 1; next < s.Len() { + nt := s.At(next) + if trimmed := strings.TrimLeft(nt.Value, " \t"); trimmed != nt.Value { + s.SetValue(next, trimmed) + } + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoNullPropertyInitializationFixer.php +// +// NoNullPropertyInitialization removes an explicit "= null" default from an +// untyped class or trait property: "public $x = null;" -> "public $x;". Typed +// properties, constants and parameter defaults are left untouched. +type NoNullPropertyInitialization struct{} + +func (NoNullPropertyInitialization) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\NoNullPropertyInitializationFixer` +} + +func (NoNullPropertyInitialization) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoNullPropertyInitializationFixer.php" +} + +// propertyModifiers are the keywords that may lead a property declaration. A type +// declaration is not among them, so an untyped property is one whose modifiers are +// followed directly by the variable. +var propertyModifiers = map[string]bool{ + "public": true, "protected": true, "private": true, + "static": true, "var": true, "final": true, + "abstract": true, "readonly": true, +} + +func (NoNullPropertyInitialization) Fix(s *tokens.Stream) bool { + var remove []int + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + // only class and trait bodies hold properties; enums and interfaces do not + lw := strings.ToLower(t.Value) + if lw != "class" && lw != "trait" { + continue + } + open := classBodyBrace(s, i) + if open < 0 { + continue + } + for _, m := range classMemberStarts(s, open) { + remove = append(remove, nullDefaultsInProperty(s, m)...) + } + } + if len(remove) == 0 { + return false + } + // remove high-to-low so the lower indices stay valid; ranges never overlap + for a := 0; a < len(remove); a++ { + for b := a + 1; b < len(remove); b++ { + if remove[b] > remove[a] { + remove[a], remove[b] = remove[b], remove[a] + } + } + } + for _, idx := range remove { + s.RemoveAt(idx) + } + return true +} + +// classBodyBrace returns the index of the "{" opening the body of the class-like +// keyword at kw, or -1 when there is none before the statement ends. +func classBodyBrace(s *tokens.Stream, kw int) int { + for j := kw + 1; j < s.Len(); j++ { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case "{": + return j + case ";": + return -1 + } + } + return -1 +} + +// nullDefaultsInProperty returns the token indices to drop so that the untyped +// property declaration starting at member loses its "= null" defaults. It returns +// nil for anything that is not an untyped property (a method, const, or a typed +// property whose type sits between the modifiers and the variable). +func nullDefaultsInProperty(s *tokens.Stream, member int) []int { + i := member + // skip leading modifier keywords; a type declaration is not a modifier, so + // reaching a non-modifier that is not the variable means this is not an + // untyped property (method, const, typed property, ...) + for i >= 0 && i < s.Len() { + t := s.At(i) + if (t.Kind == token.Keyword || t.Kind == token.Ident) && propertyModifiers[strings.ToLower(t.Value)] { + i = nextMeaningfulIndex(s, i) + continue + } + break + } + if i < 0 || s.At(i).Kind != token.Variable { + return nil + } + + var remove []int + for { + varIdx := i + eq := nextMeaningfulIndex(s, varIdx) + if eq < 0 || s.At(eq).Kind != token.Punct || s.At(eq).Value != "=" { + return remove + } + valIdx := nextMeaningfulIndex(s, eq) + if valIdx >= 0 && s.At(valIdx).Kind == token.Punct && s.At(valIdx).Value == `\` { + valIdx = nextMeaningfulIndex(s, valIdx) + } + if valIdx < 0 || !isNullLiteral(s.At(valIdx)) { + return remove + } + // clear everything between the variable and the null, keeping newline + // whitespace and comments so surrounding layout survives + for k := varIdx + 1; k <= valIdx; k++ { + tk := s.At(k) + if tk.Kind == token.Comment || tk.Kind == token.DocComment { + continue + } + if tk.Kind == token.Whitespace && strings.Contains(tk.Value, "\n") { + continue + } + remove = append(remove, k) + } + after := nextMeaningfulIndex(s, valIdx) + if after < 0 || s.At(after).Kind != token.Punct || s.At(after).Value != "," { + return remove + } + next := nextMeaningfulIndex(s, after) + if next < 0 || s.At(next).Kind != token.Variable { + return remove + } + i = next + } +} + +// isNullLiteral reports whether t is the null constant (case-insensitive). +func isNullLiteral(t token.Token) bool { + return (t.Kind == token.Ident || t.Kind == token.Keyword) && strings.EqualFold(t.Value, "null") +} + +// nextMeaningfulIndex returns the index of the first token after i that is not +// whitespace or a comment, or -1 if there is none. +func nextMeaningfulIndex(s *tokens.Stream, i int) int { + for j := i + 1; j < s.Len(); j++ { + k := s.At(j).Kind + if k == token.Whitespace || k == token.Comment || k == token.DocComment { + continue + } + return j + } + return -1 +} diff --git a/blink/internal/fixer/rules/gen4_a_test.go b/blink/internal/fixer/rules/gen4_a_test.go new file mode 100644 index 0000000000..83deae4864 --- /dev/null +++ b/blink/internal/fixer/rules/gen4_a_test.go @@ -0,0 +1,134 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen4ASingleLineCommentStyle(t *testing.T) { + f := SingleLineCommentStyle{} + + // hash comment -> // comment + assertFix(t, f, " // comment + assertFix(t, f, " b */\n"); changed { + t.Fatalf("block comment with ?> must not change: got=%q", got) + } +} + +func TestGen4ANoNullPropertyInitialization(t *testing.T) { + f := NoNullPropertyInitialization{} + + // untyped property loses its null default + assertFix(t, f, + " 1 +} + +// isDynamicVarPrefix reports whether the significant token at p makes the +// variable after it an indirect (dynamic) access that should be braced: the bare +// "$" of a variable-variable, or an object operator ("->" / "?->") introducing a +// dynamic property/method name. +func isDynamicVarPrefix(t token.Token) bool { + if isLoneDollar(t) { + return true + } + return t.Kind == token.Punct && (t.Value == "->" || t.Value == "?->") +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/ExplicitIndirectVariableFixer.php +// +// ExplicitIndirectVariable braces indirect variables so their meaning is +// explicit: "$$foo" -> "${$foo}", "$$foo['bar']" -> "${$foo}['bar']", +// "$foo->$bar" -> "$foo->{$bar}". Only the "$name" directly after a bare "$" or +// an object operator is wrapped; any following "[...]" or "->x" stays outside the +// braces, so the rewrite is a pure clarification with no change in behaviour. +type ExplicitIndirectVariable struct{} + +func (ExplicitIndirectVariable) Name() string { + return `PhpCsFixer\Fixer\LanguageConstruct\ExplicitIndirectVariableFixer` +} + +func (ExplicitIndirectVariable) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/ExplicitIndirectVariableFixer.php" +} + +func (ExplicitIndirectVariable) Fix(s *tokens.Stream) bool { + changed := false + // Right-to-left so the two inserted braces (always at/after the current + // index) never disturb positions still to be scanned. + for i := s.Len() - 1; i >= 0; i-- { + if !isRealVariable(s.At(i)) { + continue + } + p := prevSignificantIndex(s, i) + if p < 0 || !isDynamicVarPrefix(s.At(p)) { + continue + } + s.InsertAt(i+1, token.Token{Kind: token.Punct, Value: "}"}) + s.InsertAt(i, token.Token{Kind: token.Punct, Value: "{"}) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/gen4_b_test.go b/blink/internal/fixer/rules/gen4_b_test.go new file mode 100644 index 0000000000..cb23b72cd5 --- /dev/null +++ b/blink/internal/fixer/rules/gen4_b_test.go @@ -0,0 +1,63 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen4BExplicitIndirectVariable(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + // core variable-variable + {"bar;", "bar;", true}, + // dynamic property access via object operator + {"$bar;", "{$bar};", true}, + {"$bar['baz'];", "{$bar}['baz'];", true}, + {"$cb($x);", "{$cb}($x);", true}, + // nullsafe operator + {"$bar;", "{$bar};", true}, + // nested variable-variable: only the concrete "$name" is braced + {"{$bar};", "{$bar};", false}, + // a plain variable is untouched + {"bar;", "bar;", false}, + // static/method calls with plain names are untouched + {"bar();", "bar();", false}, + } + for _, c := range cases { + got, changed := apply(t, ExplicitIndirectVariable{}, c.src) + if got != c.want || changed != c.changed { + t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want) + } + if again, _ := apply(t, ExplicitIndirectVariable{}, got); again != got { + t.Fatalf("not idempotent: %q -> %q", got, again) + } + } +} + +func TestGen4BSourceURLs(t *testing.T) { + fixers := []fixer.Fixer{ + ExplicitIndirectVariable{}, + } + for _, f := range fixers { + if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want { + t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want) + } + } +} diff --git a/blink/internal/fixer/rules/gen4_phpdoc.go b/blink/internal/fixer/rules/gen4_phpdoc.go new file mode 100644 index 0000000000..af2d3b84c2 --- /dev/null +++ b/blink/internal/fixer/rules/gen4_phpdoc.go @@ -0,0 +1,80 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocLineSpanFixer.php +// +// PhpdocLineSpan expands a single-line docblock that documents a class member +// into a multi-line one, matching the fixer's default config (const, method and +// property all default to "multi"). +type PhpdocLineSpan struct{} + +func (PhpdocLineSpan) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocLineSpanFixer` +} + +func (PhpdocLineSpan) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocLineSpanFixer.php" +} + +func (PhpdocLineSpan) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.DocComment { + continue + } + d, ok := parseDoc(t.Value) + if !ok || !d.single { + continue + } + if !documentsMember(s, i) { + continue + } + indent, ok := docblockLineIndent(s, i) + if !ok { + continue + } + expandDocblock(&d, indent) + s.SetValue(i, d.render()) + changed = true + } + return changed +} + +// documentsMember reports whether the token after the docblock at index i is a +// visibility keyword (public/private/protected) or "var", which unambiguously +// introduce a class member (property or method). Only whitespace is skipped, so +// an intervening attribute is a no-op. Bare const/function without visibility is +// intentionally excluded - on flat tokens it cannot be told from a top-level +// const or a free function, which the real fixer (class-scope only) leaves alone. +func documentsMember(s *tokens.Stream, i int) bool { + j := skipWhitespace(s, i+1) + if j >= s.Len() { + return false + } + next := s.At(j) + if next.Kind != token.Keyword { + return false + } + lw := strings.ToLower(next.Value) + return visibilityModifiers[lw] || lw == "var" +} + +// expandDocblock rewrites a single-line docblock into a multi-line one, keeping +// the content verbatim and aligning the "*" one column past the given indent. +func expandDocblock(d *docblock, indent string) { + content := "" + if len(d.inner) > 0 { + content = d.inner[0].content + } + d.single = false + d.open = "/**" + d.inner = []docLine{{prefix: indent + " * ", content: content}} + d.close = indent + " */" +} diff --git a/blink/internal/fixer/rules/gen4_phpdoc_test.go b/blink/internal/fixer/rules/gen4_phpdoc_test.go new file mode 100644 index 0000000000..b1ead0b2f3 --- /dev/null +++ b/blink/internal/fixer/rules/gen4_phpdoc_test.go @@ -0,0 +1,61 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen4PhpdocLineSpan(t *testing.T) { + f := PhpdocLineSpan{} + + // single-line property docblock is expanded to multi-line (4-space indent) + assertFix(t, f, + "include", "Foo::for") rather than a language +// keyword. +func isMemberAccessPrev(s *tokens.Stream, i int) bool { + p := sigPrev(s, i) + if p < 0 { + return false + } + pt := s.At(p) + return pt.Kind == token.Punct && (pt.Value == "->" || pt.Value == "?->" || pt.Value == "::") +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/IncludeFixer.php +// +// Include normalizes the whitespace between an include/require construct and its +// argument to exactly one space ("require \"x\"" -> "require \"x\""). Only the +// spacing part of the upstream fixer is implemented; the paren-form +// ("include(\"x\")") is left untouched because removing those parentheses cannot +// be proven safe on a flat token stream. +type Include struct{} + +func (Include) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\IncludeFixer` +} + +func (Include) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/IncludeFixer.php" +} + +func (Include) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || !includeKeywords[strings.ToLower(t.Value)] { + continue + } + if isMemberAccessPrev(s, i) { + continue + } + n := i + 1 + if n >= s.Len() { + continue + } + nt := s.At(n) + if nt.Kind == token.Whitespace { + if hasNewline(nt.Value) { + continue // keep multi-line spacing (alignment) untouched + } + j := sigNext(s, i) + if j < 0 || (s.At(j).Kind == token.Punct && s.At(j).Value == "(") { + continue // paren form: leave the parentheses alone + } + if nt.Value != " " { + s.SetValue(n, " ") + changed = true + } + continue + } + // Directly adjacent argument: insert a single space. Only the token kinds + // that can legally abut the keyword are handled ("include$x", "include'x'", + // "include\\Foo"); the paren form is skipped. + switch { + case nt.Kind == token.Variable || nt.Kind == token.String: + case nt.Kind == token.Punct && nt.Value == `\`: + default: + continue + } + s.InsertAt(n, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + return changed +} + +// loopBodyKeywords are the loops EmptyLoopBody acts on (for/foreach/while). +var loopBodyKeywords = map[string]bool{ + "for": true, "foreach": true, "while": true, +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopBodyFixer.php +// +// EmptyLoopBody rewrites an empty braced loop body to a semicolon, matching the +// upstream default style "semicolon" ("while ($x) {}" -> "while ($x);"). A +// do-while is naturally excluded (its "while (...)" is followed by ";", not +// "{"), and a body holding a comment is kept. +type EmptyLoopBody struct{} + +func (EmptyLoopBody) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\EmptyLoopBodyFixer` +} + +func (EmptyLoopBody) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopBodyFixer.php" +} + +func (EmptyLoopBody) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || !loopBodyKeywords[strings.ToLower(t.Value)] { + continue + } + if isMemberAccessPrev(s, i) { + continue + } + open := sigNext(s, i) + if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + continue + } + closeParen := s.MatchForward(open) + if closeParen < 0 { + continue + } + braceOpen := sigNext(s, closeParen) + if braceOpen < 0 || s.At(braceOpen).Kind != token.Punct || s.At(braceOpen).Value != "{" { + continue + } + // The body is empty only when the next non-whitespace token (comments are + // NOT skipped, so a commented body is preserved) is the closing brace. + braceClose := nextSignificantIndex(s, braceOpen) + if braceClose < 0 || s.At(braceClose).Kind != token.Punct || s.At(braceClose).Value != "}" { + continue + } + // Replace "{" with ";" and drop the "}" plus the whitespace-only interior, + // so "{ }" collapses cleanly to ";". + s.SetValue(braceOpen, ";") + for k := braceClose; k > braceOpen; k-- { + s.RemoveAt(k) + } + changed = true + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopConditionFixer.php +// +// EmptyLoopCondition rewrites an empty for-loop header to while(true), matching +// the upstream default style "while" ("for (;;)" -> "while (true)"). Only this +// for-form is implemented; the do-while conversion of the upstream fixer is not, +// and a header containing a comment is skipped to avoid reordering it. +type EmptyLoopCondition struct{} + +func (EmptyLoopCondition) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\EmptyLoopConditionFixer` +} + +func (EmptyLoopCondition) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopConditionFixer.php" +} + +func (EmptyLoopCondition) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "for" { + continue + } + if isMemberAccessPrev(s, i) { + continue + } + open := sigNext(s, i) + if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + continue + } + closeParen := s.MatchForward(open) + if closeParen < 0 { + continue + } + // Empty condition: "( ; ; )" - exactly two semicolons and nothing else. + a := sigNext(s, open) + if a < 0 || s.At(a).Kind != token.Punct || s.At(a).Value != ";" { + continue + } + b := sigNext(s, a) + if b < 0 || s.At(b).Kind != token.Punct || s.At(b).Value != ";" { + continue + } + if c := sigNext(s, b); c != closeParen { + continue + } + // Only rewrite when the header holds no comment, so nothing is dropped or + // reordered by the replacement. + if !spanClean(s, i, closeParen) { + continue + } + s.ReplaceRange(i, closeParen, []token.Token{ + {Kind: token.Keyword, Value: "while"}, + {Kind: token.Whitespace, Value: " "}, + {Kind: token.Punct, Value: "("}, + {Kind: token.Ident, Value: "true"}, + {Kind: token.Punct, Value: ")"}, + }) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/gen5_lang_test.go b/blink/internal/fixer/rules/gen5_lang_test.go new file mode 100644 index 0000000000..e37a701126 --- /dev/null +++ b/blink/internal/fixer/rules/gen5_lang_test.go @@ -0,0 +1,122 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen5LangInclude(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + // collapse multiple spaces to one + {`include ("x");`, `include ("x");`, false}, + } + for _, c := range cases { + got, changed := apply(t, Include{}, c.src) + if got != c.want || changed != c.changed { + t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want) + } + if again, changed2 := apply(t, Include{}, got); changed2 || again != got { + t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2) + } + } +} + +func TestGen5LangEmptyLoopBody(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + // empty braced body -> semicolon (upstream default "semicolon") + {`while();`, `while();`, false}, + } + for _, c := range cases { + got, changed := apply(t, EmptyLoopBody{}, c.src) + if got != c.want || changed != c.changed { + t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want) + } + if again, changed2 := apply(t, EmptyLoopBody{}, got); changed2 || again != got { + t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2) + } + } +} + +func TestGen5LangEmptyLoopCondition(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + // empty for header -> while (true) (upstream default "while") + {`for(;;);`, `for(;;);`, false}, + } + for _, c := range cases { + got, changed := apply(t, EmptyLoopCondition{}, c.src) + if got != c.want || changed != c.changed { + t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want) + } + if again, changed2 := apply(t, EmptyLoopCondition{}, got); changed2 || again != got { + t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2) + } + } +} + +// TestGen5LangSourceURLs verifies each SourceURL matches SourceURLFor(Name()). +func TestGen5LangSourceURLs(t *testing.T) { + for _, f := range []fixer.Fixer{Include{}, EmptyLoopBody{}, EmptyLoopCondition{}} { + if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want { + t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want) + } + } +} diff --git a/blink/internal/fixer/rules/gen5_phpdoc_misc.go b/blink/internal/fixer/rules/gen5_phpdoc_misc.go new file mode 100644 index 0000000000..cd86ea9a67 --- /dev/null +++ b/blink/internal/fixer/rules/gen5_phpdoc_misc.go @@ -0,0 +1,76 @@ +package rules + +import ( + "regexp" + "strings" + + "blink/internal/tokens" +) + +// phpdocReturnSelfRefMap is the fixer's default "replacements" map, verbatim from +// source. Keys are matched case-insensitively (the fixer lowercases the type). +var phpdocReturnSelfRefMap = map[string]string{ + "this": "$this", + "@this": "$this", + "$self": "self", + "@self": "self", + "$static": "static", + "@static": "static", +} + +var phpdocReturnSelfRefRe = regexp.MustCompile(`(?i)^(@return\s+)(\S+)(.*)$`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocReturnSelfReferenceFixer.php +// +// PhpdocReturnSelfReference normalizes self-reference return types in "@return" +// (this -> $this, @self -> self, $static -> static, ...). Each union member that +// exactly matches an alias (case-insensitive) is replaced; the description after +// the type is preserved. +type PhpdocReturnSelfReference struct{} + +func (PhpdocReturnSelfReference) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocReturnSelfReferenceFixer` +} + +func (PhpdocReturnSelfReference) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocReturnSelfReferenceFixer.php" +} + +func (PhpdocReturnSelfReference) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + trimmed := strings.TrimLeft(l.content, " ") + m := phpdocReturnSelfRefRe.FindStringSubmatch(trimmed) + if m == nil { + continue + } + newType, ok := normalizeReturnSelfType(m[2]) + if !ok { + continue + } + lead := l.content[:len(l.content)-len(trimmed)] + d.inner[i].content = lead + m[1] + newType + m[3] + changed = true + } + return changed + }) +} + +// normalizeReturnSelfType replaces each union member equal (case-insensitive) to +// a configured alias. Rejoining with "|" is lossless, so non-matching or complex +// types round-trip unchanged; ok is false when nothing was replaced. +func normalizeReturnSelfType(typ string) (string, bool) { + parts := strings.Split(typ, "|") + changed := false + for i, p := range parts { + if repl, ok := phpdocReturnSelfRefMap[strings.ToLower(p)]; ok { + parts[i] = repl + changed = true + } + } + if !changed { + return "", false + } + return strings.Join(parts, "|"), true +} diff --git a/blink/internal/fixer/rules/gen5_phpdoc_misc_test.go b/blink/internal/fixer/rules/gen5_phpdoc_misc_test.go new file mode 100644 index 0000000000..045cd30cfa --- /dev/null +++ b/blink/internal/fixer/rules/gen5_phpdoc_misc_test.go @@ -0,0 +1,74 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen5PhpdocMiscReturnSelfReference(t *testing.T) { + f := PhpdocReturnSelfReference{} + + // this -> $this + assertFix(t, f, + " self + assertFix(t, f, + " static + assertFix(t, f, + "', ')', ']', '}': + if depth > 0 { + depth-- + } + case '|': + if depth == 0 { + parts = append(parts, typ[start:i]) + start = i + 1 + } + } + } + return append(parts, typ[start:]) +} + +// normalizePhpdocCompare mirrors the fixer's /^\(*\??\\\?/ normalization used +// only to detect a null member; the member's original spelling is preserved. +func normalizePhpdocCompare(t string) string { + t = strings.TrimLeft(t, "(") + t = strings.TrimPrefix(t, "?") + return strings.TrimPrefix(t, "\\") +} + +// Swaps a `$var`-before-type @var/@type into type-before-`$var`, ported from the +// fixer's Preg replace (\h -> [ \t] for RE2). +var phpdocVarOrderRe = regexp.MustCompile(`(?i)(@(?:type|var)\s*)(\$\S+)([ \t]+)([^$](?:[^<\s]|<[^>]*>)*)(\s|\*)`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarAnnotationCorrectOrderFixer.php +// +// PhpdocVarAnnotationCorrectOrder fixes swapped @var/@type order, rewriting +// "@var $x int" to "@var int $x". Already-correct "@var Type $x" is a no-op. +type PhpdocVarAnnotationCorrectOrder struct{} + +func (PhpdocVarAnnotationCorrectOrder) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocVarAnnotationCorrectOrderFixer` +} + +func (PhpdocVarAnnotationCorrectOrder) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarAnnotationCorrectOrderFixer.php" +} + +func (PhpdocVarAnnotationCorrectOrder) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.DocComment { + continue + } + lower := strings.ToLower(t.Value) + if !strings.Contains(lower, "@var") && !strings.Contains(lower, "@type") { + continue + } + newVal := phpdocVarOrderRe.ReplaceAllString(t.Value, "${1}${4}${3}${2}${5}") + if newVal != t.Value { + s.SetValue(i, newVal) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/gen5_phpdoc_order_test.go b/blink/internal/fixer/rules/gen5_phpdoc_order_test.go new file mode 100644 index 0000000000..4dc4de06cd --- /dev/null +++ b/blink/internal/fixer/rules/gen5_phpdoc_order_test.go @@ -0,0 +1,98 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGen5PhpdocOrderTypesOrder(t *testing.T) { + f := PhpdocTypesOrder{} + + // null moves to the end, other members keep order: string|null|int -> string|int|null + assertFix(t, f, + "|null $x\n */\nfunction f($x){}", + "|null $x\n */\nfunction f($x){}", false) + + // DNF is skipped (contains '(') + assertFix(t, f, + " */\n$foo = [];", + " $foo */\n$foo = [];", true) + + // already correct - no-op + assertFix(t, f, + "= 0 { + break + } + continue + } + if strings.HasPrefix(c, "@") { + break + } + if first < 0 { + first = i + } + last = i + } + return first, last +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagTypeFixer.php +// +// PhpdocTagType normalizes a standalone inline "{@inheritDoc}" to its annotation +// form "@inheritDoc" (the default tags => ['inheritDoc' => 'annotation']). Inline +// occurrences inside descriptive text are left as-is. +type PhpdocTagType struct{} + +func (PhpdocTagType) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocTagTypeFixer` +} + +func (PhpdocTagType) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagTypeFixer.php" +} + +var inlineInheritDocRe = regexp.MustCompile(`^\{@([a-zA-Z]+)\}$`) + +func (PhpdocTagType) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + trimmed := strings.TrimLeft(l.content, " ") + m := inlineInheritDocRe.FindStringSubmatch(strings.TrimRight(trimmed, " ")) + if m == nil || !strings.EqualFold(m[1], "inheritDoc") { + continue + } + lead := l.content[:len(l.content)-len(strings.TrimLeft(l.content, " "))] + d.inner[i].content = lead + "@" + m[1] + changed = true + } + return changed + }) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderFixer.php +// +// PhpdocOrder orders @param, @throws and @return tags into that sequence (the +// build's default). Only those tag blocks are permuted; summary, blank lines and +// other tags keep their positions. +type PhpdocOrder struct{} + +func (PhpdocOrder) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocOrderFixer` +} + +func (PhpdocOrder) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderFixer.php" +} + +var phpdocTagNameRe = regexp.MustCompile(`^@([a-zA-Z][a-zA-Z0-9_-]*)`) + +// phpdocOrderRank returns the sort rank of a tag and whether it is one of the +// reordered tags. phpstan-/psalm- prefixed variants share their base rank. +func phpdocOrderRank(tag string) (int, bool) { + base := strings.ToLower(tag) + base = strings.TrimPrefix(base, "phpstan-") + base = strings.TrimPrefix(base, "psalm-") + switch base { + case "param": + return 0, true + case "throws": + return 1, true + case "return": + return 2, true + } + return 0, false +} + +type phpdocBlock struct { + lines []docLine + rank int + target bool +} + +func (PhpdocOrder) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + if d.single { + return false + } + blocks := splitPhpdocBlocks(d.inner) + + var targetPos []int + var targetBlocks []phpdocBlock + for i, b := range blocks { + if b.target { + targetPos = append(targetPos, i) + targetBlocks = append(targetBlocks, b) + } + } + if len(targetBlocks) < 2 { + return false + } + sorted := make([]phpdocBlock, len(targetBlocks)) + copy(sorted, targetBlocks) + stableSortByRank(sorted) + if sameBlockOrder(targetBlocks, sorted) { + return false + } + for k, pos := range targetPos { + blocks[pos] = sorted[k] + } + var inner []docLine + for _, b := range blocks { + inner = append(inner, b.lines...) + } + d.inner = inner + return true + }) +} + +// splitPhpdocBlocks groups inner lines: each "@tag" line plus its continuation +// lines forms one block; every other line is its own block. +func splitPhpdocBlocks(inner []docLine) []phpdocBlock { + var blocks []phpdocBlock + for i := 0; i < len(inner); { + c := strings.TrimSpace(inner[i].content) + if m := phpdocTagNameRe.FindStringSubmatch(c); m != nil { + rank, target := phpdocOrderRank(m[1]) + j := i + 1 + for j < len(inner) { + cj := strings.TrimSpace(inner[j].content) + if cj == "" || strings.HasPrefix(cj, "@") { + break + } + j++ + } + blocks = append(blocks, phpdocBlock{lines: inner[i:j], rank: rank, target: target}) + i = j + continue + } + blocks = append(blocks, phpdocBlock{lines: inner[i : i+1]}) + i++ + } + return blocks +} + +// stableSortByRank orders blocks by rank, keeping equal-rank blocks in place. +func stableSortByRank(blocks []phpdocBlock) { + for i := 1; i < len(blocks); i++ { + for j := i; j > 0 && blocks[j-1].rank > blocks[j].rank; j-- { + blocks[j-1], blocks[j] = blocks[j], blocks[j-1] + } + } +} + +func sameBlockOrder(a, b []phpdocBlock) bool { + for i := range a { + if len(a[i].lines) != len(b[i].lines) { + return false + } + for k := range a[i].lines { + if a[i].lines[k] != b[i].lines[k] { + return false + } + } + } + return true +} diff --git a/blink/internal/fixer/rules/gen6_phpdoc_test.go b/blink/internal/fixer/rules/gen6_phpdoc_test.go new file mode 100644 index 0000000000..9e422daa38 --- /dev/null +++ b/blink/internal/fixer/rules/gen6_phpdoc_test.go @@ -0,0 +1,68 @@ +package rules + +import "testing" + +func TestNoAliasLanguageConstructCall(t *testing.T) { + got, changed := apply(t, NoAliasLanguageConstructCall{}, "die(); Foo::die();"); changed { + t.Fatal("exit and member die() must not change") + } +} + +func TestBlankLineBeforeStatement(t *testing.T) { + got, changed := apply(t, BlankLineBeforeStatement{}, " "[]"). Only "[]" is handled; "array()" is left alone. +type NoWhitespaceInEmptyArray struct{} + +func (NoWhitespaceInEmptyArray) Name() string { + return `PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceInEmptyArrayFixer` +} + +func (NoWhitespaceInEmptyArray) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoWhitespaceInEmptyArrayFixer.php" +} + +func (NoWhitespaceInEmptyArray) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i+2 < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "[" { + continue + } + if s.At(i+1).Kind != token.Whitespace { + continue + } + if s.At(i+2).Kind != token.Punct || s.At(i+2).Value != "]" { + continue + } + s.RemoveAt(i + 1) + changed = true + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NormalizeIndexBraceFixer.php +// +// NormalizeIndexBrace converts the deprecated curly-brace array index access +// ("$a{0}" -> "$a[0]"). To never touch a real block "{", it only converts a +// brace pair that directly follows a value that can be indexed: a variable, a +// string literal or a closing "]". +type NormalizeIndexBrace struct{} + +func (NormalizeIndexBrace) Name() string { + return `PhpCsFixer\Fixer\ArrayNotation\NormalizeIndexBraceFixer` +} + +func (NormalizeIndexBrace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NormalizeIndexBraceFixer.php" +} + +func (NormalizeIndexBrace) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "{" { + continue + } + if !isIndexBraceTarget(s, i) { + continue + } + closeIdx := s.MatchForward(i) + if closeIdx < 0 { + continue + } + s.SetValue(closeIdx, "]") + s.SetValue(i, "[") + changed = true + } + return changed +} + +// isIndexBraceTarget reports whether the "{" at i is an array index brace, based +// on the preceding significant token. A variable name (not the "$" of the "${}" +// variable-variable form), a string literal or a "]" mean an index access. +func isIndexBraceTarget(s *tokens.Stream, i int) bool { + prev, ok := prevSignificant(s, i) + if !ok { + return false + } + switch { + case prev.Kind == token.Variable && prev.Value != "$": + return true + case prev.Kind == token.String: + return true + case prev.Kind == token.Punct && prev.Value == "]": + return true + } + return false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoMultilineWhitespaceAroundDoubleArrowFixer.php +// +// NoMultilineWhitespaceAroundDoubleArrow collapses newline-containing whitespace +// directly around "=>" to a single space. Single-line spacing is left alone, and +// a line comment before, or any comment after, the arrow is respected. +type NoMultilineWhitespaceAroundDoubleArrow struct{} + +func (NoMultilineWhitespaceAroundDoubleArrow) Name() string { + return `PhpCsFixer\Fixer\ArrayNotation\NoMultilineWhitespaceAroundDoubleArrowFixer` +} + +func (NoMultilineWhitespaceAroundDoubleArrow) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoMultilineWhitespaceAroundDoubleArrowFixer.php" +} + +func (NoMultilineWhitespaceAroundDoubleArrow) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "=>" { + continue + } + // before: skip when a line comment precedes the whitespace + if i-2 < 0 || !isLineComment(s.At(i-2)) { + changed = collapseArrowWhitespace(s, i-1) || changed + } + // after: skip when any comment follows the whitespace + if i+2 >= s.Len() || !isComment(s.At(i+2)) { + changed = collapseArrowWhitespace(s, i+1) || changed + } + } + return changed +} + +// collapseArrowWhitespace turns a newline-containing whitespace token into a +// single space, matching PHP-CS-Fixer's rtrim(...)." " on the arrow neighbour. +func collapseArrowWhitespace(s *tokens.Stream, i int) bool { + if i < 0 || i >= s.Len() { + return false + } + t := s.At(i) + if t.Kind != token.Whitespace || !hasNewline(t.Value) { + return false + } + s.SetValue(i, " ") + return true +} + +func isComment(t token.Token) bool { + return t.Kind == token.Comment || t.Kind == token.DocComment +} + +func isLineComment(t token.Token) bool { + return t.Kind == token.Comment && (strings.HasPrefix(t.Value, "//") || strings.HasPrefix(t.Value, "#")) +} diff --git a/blink/internal/fixer/rules/gen_arrays_test.go b/blink/internal/fixer/rules/gen_arrays_test.go new file mode 100644 index 0000000000..8732532e27 --- /dev/null +++ b/blink/internal/fixer/rules/gen_arrays_test.go @@ -0,0 +1,126 @@ +package rules + +import ( + "testing" + + fixerpkg "blink/internal/fixer" +) + +func TestGenArraysNoWhitespaceInEmptyArray(t *testing.T) { + got, changed := apply(t, NoWhitespaceInEmptyArray{}, "\n 1,\n];") + if want := " 1,\n];"; !changed || got != want { + t.Fatalf("after: changed=%v got=%q want=%q", changed, got, want) + } + // newline before the arrow collapses to a single space + got, changed = apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, " 1,\n];") + if want := " 1,\n];"; !changed || got != want { + t.Fatalf("before: changed=%v got=%q want=%q", changed, got, want) + } + // single-line spacing is left alone (that is another fixer's job) + if _, changed := apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, " 1];"); changed { + t.Fatal("single-line spacing must not change") + } + // already normalized: no-op + if _, changed := apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, " 1];"); changed { + t.Fatal("normalized arrow must not change") + } + // a comment right after the arrow is respected + if _, changed := apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, "\n // c\n 1];"); changed { + t.Fatal("comment after arrow must be preserved") + } +} + +// TestGenArraysSourceURLs verifies every SourceURL matches SourceURLFor(Name()). +func TestGenArraysSourceURLs(t *testing.T) { + fixers := []fixerpkg.Fixer{ + NoWhitespaceInEmptyArray{}, + NormalizeIndexBrace{}, + NoMultilineWhitespaceAroundDoubleArrow{}, + } + for _, f := range fixers { + if got, want := f.SourceURL(), fixerpkg.SourceURLFor(f.Name()); got != want { + t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want) + } + } +} + +// TestGenArraysIdempotent guarantees a second pass is a no-op. +func TestGenArraysIdempotent(t *testing.T) { + fixers := []fixerRule{ + NoWhitespaceInEmptyArray{}, + NormalizeIndexBrace{}, + NoMultilineWhitespaceAroundDoubleArrow{}, + } + corpus := []string{ + "\n 1,\n 'y'\n => 2,\n];\n", + } + for _, f := range fixers { + for _, src := range corpus { + once, _ := apply(t, f, src) + twice, changed := apply(t, f, once) + if changed || once != twice { + t.Errorf("%T not idempotent\n src: %q\n once: %q\n twice:%q", f, src, once, twice) + } + } + } +} diff --git a/blink/internal/fixer/rules/gen_control.go b/blink/internal/fixer/rules/gen_control.go new file mode 100644 index 0000000000..01d157ad6d --- /dev/null +++ b/blink/internal/fixer/rules/gen_control.go @@ -0,0 +1,121 @@ +package rules + +import ( + "strconv" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchContinueToBreakFixer.php +// +// SwitchContinueToBreak rewrites a "continue" that targets a switch into +// "break". A "continue" targeting an enclosing loop is left untouched. +type SwitchContinueToBreak struct{} + +func (SwitchContinueToBreak) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\SwitchContinueToBreakFixer` +} + +func (SwitchContinueToBreak) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchContinueToBreakFixer.php" +} + +func (SwitchContinueToBreak) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "continue" { + continue + } + level, ok := continueLevel(s, i) + if !ok { + continue + } + targets := enclosingLoopsAndSwitches(s, i) + if level <= len(targets) && targets[level-1] == "switch" { + s.SetValue(i, "break") + changed = true + } + } + return changed +} + +// continueLevel returns the loop level a "continue" at i targets: 1 for a bare +// "continue;" (or "continue 1;"/"continue 0;"), n for "continue n;". ok is +// false when i is not a plain continue statement (e.g. an unparsable level, or a +// level not immediately followed by ";"). +func continueLevel(s *tokens.Stream, i int) (int, bool) { + j := meaningfulAfter(s, i) + if j < 0 { + return 0, false + } + tj := s.At(j) + if tj.Kind == token.Punct && tj.Value == ";" { + return 1, true // bare "continue" == "continue 1" + } + if tj.Kind != token.Number { + return 0, false + } + if k := meaningfulAfter(s, j); k < 0 || s.At(k).Kind != token.Punct || s.At(k).Value != ";" { + return 0, false + } + n, err := strconv.ParseInt(strings.ReplaceAll(tj.Value, "_", ""), 0, 64) + if err != nil || n < 0 { + return 0, false + } + if n == 0 { + n = 1 // "continue 0" behaves like "continue 1" + } + return int(n), true +} + +// enclosingLoopsAndSwitches lists the loop and switch constructs enclosing token +// i, innermost first (each entry is "loop" or "switch"). "if"/"try"/"catch" and +// free blocks are transparent to continue and skipped. The walk stops at a +// function, closure or class boundary a continue cannot cross. +func enclosingLoopsAndSwitches(s *tokens.Stream, i int) []string { + var out []string + depth := 0 + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case "}": + depth++ + case "{": + if depth > 0 { + depth-- + continue + } + kind, kw := classifyBrace(s, j) + switch kind { + case braceFunctionDecl, braceClosure, braceClassLike: + return out + case braceControl: + switch strings.ToLower(s.At(kw).Value) { + case "switch": + out = append(out, "switch") + case "for", "foreach", "while", "do": + out = append(out, "loop") + } + } + } + } + return out +} + +// meaningfulAfter returns the first index after i that is not whitespace or a +// comment, or -1 if there is none. +func meaningfulAfter(s *tokens.Stream, i int) int { + for j := i + 1; j < s.Len(); j++ { + switch s.At(j).Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + return j + } + return -1 +} diff --git a/blink/internal/fixer/rules/gen_control_test.go b/blink/internal/fixer/rules/gen_control_test.go new file mode 100644 index 0000000000..1f64e81fd3 --- /dev/null +++ b/blink/internal/fixer/rules/gen_control_test.go @@ -0,0 +1,114 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGenControlSwitchContinueToBreak(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + // bare continue directly in a switch case -> break + { + " break 2 + { + "continue(); break; }", + "continue(); break; }", + false, + }, + // no switch anywhere: plain loop continue untouched + { + " %q changed=%v", got, again, changed2) + } + } +} + +// TestGenControlSourceURLs verifies each SourceURL matches SourceURLFor(Name()). +func TestGenControlSourceURLs(t *testing.T) { + for _, f := range []fixer.Fixer{SwitchContinueToBreak{}} { + if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want { + t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want) + } + } +} diff --git a/blink/internal/fixer/rules/gen_imports.go b/blink/internal/fixer/rules/gen_imports.go new file mode 100644 index 0000000000..7e5b2c417d --- /dev/null +++ b/blink/internal/fixer/rules/gen_imports.go @@ -0,0 +1,167 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnneededImportAliasFixer.php +// +// NoUnneededImportAlias drops an alias that equals the imported name's last +// segment: "use App\Foo as Foo;" -> "use App\Foo;" (comparison is case-sensitive). +type NoUnneededImportAlias struct{} + +func (NoUnneededImportAlias) Name() string { + return `PhpCsFixer\Fixer\Import\NoUnneededImportAliasFixer` +} + +func (NoUnneededImportAlias) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnneededImportAliasFixer.php" +} + +func (NoUnneededImportAlias) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" { + continue + } + j := skipWhitespace(s, i+1) + if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + continue // closure use + } + semi := -1 + for k := i + 1; k < s.Len(); k++ { + if s.At(k).Kind == token.Punct && s.At(k).Value == ";" { + semi = k + break + } + } + if semi < 0 { + continue + } + if removeUnneededAliases(s, i, semi) { + changed = true + } + } + return changed +} + +// removeUnneededAliases strips "as X" occurrences in the use statement spanning +// (from, semi) where X equals the preceding path segment. Returns true on change. +func removeUnneededAliases(s *tokens.Stream, from, semi int) bool { + changed := false + k := from + 1 + end := semi + for k < end { + if s.At(k).Kind == token.Keyword && strings.ToLower(s.At(k).Value) == "as" { + seg := k - 1 + for seg > from && s.At(seg).Kind == token.Whitespace { + seg-- + } + alias := skipWhitespace(s, k+1) + if seg > from && alias < end && + s.At(seg).Kind == token.Ident && s.At(alias).Kind == token.Ident && + s.At(seg).Value == s.At(alias).Value { + for r := alias; r >= seg+1; r-- { + s.RemoveAt(r) + } + end -= alias - seg + k = seg + 1 + changed = true + continue + } + } + k++ + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/CleanNamespaceFixer.php +// +// CleanNamespace removes whitespace and comments between the parts of a +// namespace or use path: "A \ B" -> "A\B". +type CleanNamespace struct{} + +func (CleanNamespace) Name() string { + return `PhpCsFixer\Fixer\NamespaceNotation\CleanNamespaceFixer` +} + +func (CleanNamespace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/CleanNamespaceFixer.php" +} + +func (CleanNamespace) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + lw := strings.ToLower(t.Value) + if lw != "namespace" && lw != "use" { + continue + } + j := skipWhitespace(s, i+1) + if lw == "use" && j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + continue // closure use + } + end := s.Len() + for k := i + 1; k < s.Len(); k++ { + if s.At(k).Kind == token.Punct && (s.At(k).Value == ";" || s.At(k).Value == "{") { + end = k + break + } + } + if collapseNamespaceSpaces(s, i, end) { + changed = true + } + } + return changed +} + +// collapseNamespaceSpaces removes whitespace/comment tokens in (from, end) that +// sit directly between two path tokens (an identifier or a "\"). +func collapseNamespaceSpaces(s *tokens.Stream, from, end int) bool { + changed := false + k := from + 1 + for k < end { + t := s.At(k) + if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment { + p := prevPathToken(s, k, from) + n := nextPathToken(s, k, end) + if p >= 0 && n < end && isPathToken(s.At(p)) && isPathToken(s.At(n)) { + s.RemoveAt(k) + end-- + changed = true + continue + } + } + k++ + } + return changed +} + +func isPathToken(t token.Token) bool { + return t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == `\`) +} + +func prevPathToken(s *tokens.Stream, i, from int) int { + for i--; i > from; i-- { + if k := s.At(i).Kind; k != token.Whitespace && k != token.Comment && k != token.DocComment { + return i + } + } + return -1 +} + +func nextPathToken(s *tokens.Stream, i, end int) int { + for i++; i < end; i++ { + if k := s.At(i).Kind; k != token.Whitespace && k != token.Comment && k != token.DocComment { + return i + } + } + return end +} diff --git a/blink/internal/fixer/rules/gen_imports_test.go b/blink/internal/fixer/rules/gen_imports_test.go new file mode 100644 index 0000000000..d522770a32 --- /dev/null +++ b/blink/internal/fixer/rules/gen_imports_test.go @@ -0,0 +1,87 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGenImportsNoUnneededImportAlias(t *testing.T) { + got, changed := apply(t, NoUnneededImportAlias{}, " %q", once, twice) + } +} + +func TestGenImportsCleanNamespace(t *testing.T) { + got, changed := apply(t, CleanNamespace{}, " %q", once, twice) + } +} + +// TestGenImportsSourceURLs verifies each SourceURL matches SourceURLFor(Name()). +func TestGenImportsSourceURLs(t *testing.T) { + for _, f := range []fixer.Fixer{NoUnneededImportAlias{}, CleanNamespace{}} { + if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want { + t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want) + } + } +} diff --git a/blink/internal/fixer/rules/gen_ops_cast.go b/blink/internal/fixer/rules/gen_ops_cast.go new file mode 100644 index 0000000000..f5dd839662 --- /dev/null +++ b/blink/internal/fixer/rules/gen_ops_cast.go @@ -0,0 +1,104 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoShortBoolCastFixer.php +// +// NoShortBoolCast rewrites the double-not short bool cast to an explicit cast: +// "!!$x" -> "(bool) $x". Two adjacent "!" Punct tokens (only whitespace between) +// are the short cast; a comment between them is left untouched. +type NoShortBoolCast struct{} + +func (NoShortBoolCast) Name() string { + return `PhpCsFixer\Fixer\CastNotation\NoShortBoolCastFixer` +} + +func (NoShortBoolCast) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoShortBoolCastFixer.php" +} + +func (NoShortBoolCast) Fix(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i >= 1; i-- { + if !isBang(s.At(i)) { + continue + } + j := prevSignificantIndex(s, i) + if j < 0 || !isBang(s.At(j)) { + continue + } + + s.ReplaceRange(j, i, []token.Token{ + {Kind: token.Punct, Value: "("}, + {Kind: token.Ident, Value: "bool"}, + {Kind: token.Punct, Value: ")"}, + }) + // ensure a single space after the new ")" + after := j + 3 + if after < s.Len() && s.At(after).Kind != token.Whitespace { + s.InsertAt(after, token.Token{Kind: token.Whitespace, Value: " "}) + } + changed = true + i = j + } + return changed +} + +func isBang(t token.Token) bool { + return t.Kind == token.Punct && t.Value == "!" +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoUnsetCastFixer.php +// +// NoUnsetCast replaces an "(unset)" cast assignment with null: "$a = (unset) $b;" +// -> "$a = null;". It applies only to the exact shape "= (unset) $var ;" (or a +// closing tag in place of ";"), since "(unset) $x" always evaluates to null. +type NoUnsetCast struct{} + +func (NoUnsetCast) Name() string { + return `PhpCsFixer\Fixer\CastNotation\NoUnsetCastFixer` +} + +func (NoUnsetCast) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoUnsetCastFixer.php" +} + +func (NoUnsetCast) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + typeIdx, closeIdx, ok := castAt(s, i) + if !ok || strings.ToLower(s.At(typeIdx).Value) != "unset" { + continue + } + + assignIdx := prevSignificantIndex(s, i) + if assignIdx < 0 || s.At(assignIdx).Kind != token.Punct || s.At(assignIdx).Value != "=" { + continue + } + varIdx := nextSignificantIndex(s, closeIdx) + if varIdx < 0 || s.At(varIdx).Kind != token.Variable { + continue + } + afterVar := nextSignificantIndex(s, varIdx) + if afterVar < 0 { + continue + } + if s.At(afterVar).Kind != token.CloseTag && + (s.At(afterVar).Kind != token.Punct || s.At(afterVar).Value != ";") { + continue + } + + repl := []token.Token{{Kind: token.Ident, Value: "null"}} + if s.At(assignIdx+1).Kind != token.Whitespace { + repl = append([]token.Token{{Kind: token.Whitespace, Value: " "}}, repl...) + } + s.ReplaceRange(i, varIdx, repl) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/gen_ops_cast_test.go b/blink/internal/fixer/rules/gen_ops_cast_test.go new file mode 100644 index 0000000000..2970b0fd9e --- /dev/null +++ b/blink/internal/fixer/rules/gen_ops_cast_test.go @@ -0,0 +1,72 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGenOpsCastNoShortBoolCast(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + {" %q changed=%v", got, again, changed2) + } + } +} + +func TestGenOpsCastNoUnsetCast(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + {"", "", true}, + // only "= (unset) $var ;" is rewritten + {" %q changed=%v", got, again, changed2) + } + } +} + +func TestGenOpsCastSourceURLs(t *testing.T) { + for _, f := range []fixer.Fixer{NoShortBoolCast{}, NoUnsetCast{}} { + if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want { + t.Errorf("%s: SourceURL %q inconsistent with name, want %q", f.Name(), f.SourceURL(), want) + } + } +} diff --git a/blink/internal/fixer/rules/gen_ops_inc.go b/blink/internal/fixer/rules/gen_ops_inc.go new file mode 100644 index 0000000000..ef7da34888 --- /dev/null +++ b/blink/internal/fixer/rules/gen_ops_inc.go @@ -0,0 +1,302 @@ +package rules + +import ( + "blink/internal/token" + "blink/internal/tokens" +) + +// This file implements three PHP-CS-Fixer Operator rules on the flat token +// stream. Each is deliberately narrower than the upstream fixer: only the +// token-adjacency cases that are provably semantics-preserving are rewritten, +// everything else is left untouched. Correctness (never corrupt, no-op on +// already-correct code, idempotent) is preferred over completeness. + +// sigPrev returns the index of the first meaningful token before i (skipping +// whitespace and comments), or -1. +func sigPrev(s *tokens.Stream, i int) int { + for j := i - 1; j >= 0; j-- { + switch s.At(j).Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + return j + } + return -1 +} + +// sigNext returns the index of the first meaningful token after i, or -1. +func sigNext(s *tokens.Stream, i int) int { + for j := i + 1; j < s.Len(); j++ { + switch s.At(j).Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + return j + } + return -1 +} + +// spanClean reports whether the inclusive range [a, b] holds no comments, so a +// rewrite of that span cannot silently drop one. +func spanClean(s *tokens.Stream, a, b int) bool { + for j := a; j <= b && j < s.Len(); j++ { + if k := s.At(j).Kind; k == token.Comment || k == token.DocComment { + return false + } + } + return true +} + +// inlineWSBetween reports whether every token strictly between a and b is +// whitespace with no newline (so the two are on the same line, gap removable). +func inlineWSBetween(s *tokens.Stream, a, b int) bool { + for j := a + 1; j < b; j++ { + t := s.At(j) + if t.Kind != token.Whitespace || hasNewline(t.Value) { + return false + } + } + return true +} + +// lvaluePrefix reports whether v attaches the following variable to a larger +// lvalue (member/static/dynamic/reference access), meaning it is not a plain +// standalone variable. +func lvaluePrefix(v string) bool { + switch v { + case "->", "?->", "::", "$", "&": + return true + } + return false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeIncrementFixer.php +// +// StandardizeIncrement rewrites `$i += 1` to `++$i` and `$i -= 1` to `--$i`. +// The pre-increment form is value-equivalent to the compound assignment, so the +// rewrite is safe in any expression position. Only a single plain variable on +// the left is handled; complex lvalues are left to the upstream fixer. +type StandardizeIncrement struct{} + +func (StandardizeIncrement) Name() string { + return `PhpCsFixer\Fixer\Operator\StandardizeIncrementFixer` +} + +func (StandardizeIncrement) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeIncrementFixer.php" +} + +// incExprEnd matches the tokens that may terminate the `1` operand. +func incExprEnd(v string) bool { + switch v { + case ";", ")", "]", ",", ":": + return true + } + return false +} + +func (StandardizeIncrement) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != "+=" && t.Value != "-=") { + continue + } + + // Left side: exactly one plain variable. + lv := sigPrev(s, i) + if lv < 0 || s.At(lv).Kind != token.Variable { + continue + } + if p := sigPrev(s, lv); p >= 0 && lvaluePrefix(s.At(p).Value) { + continue + } + + // Right side: the literal 1, then an expression-end token. + numIdx := sigNext(s, i) + if numIdx < 0 || s.At(numIdx).Kind != token.Number || s.At(numIdx).Value != "1" { + continue + } + endIdx := sigNext(s, numIdx) + if endIdx < 0 || s.At(endIdx).Kind != token.Punct || !incExprEnd(s.At(endIdx).Value) { + continue + } + + // Refuse if a comment sits inside the rewritten span. + if !spanClean(s, lv, numIdx) { + continue + } + + op := "++" + if t.Value == "-=" { + op = "--" + } + + // Drop ` 1` (operator..number), then the inline gap before it, then + // prepend the increment operator to the variable. + for k := numIdx; k >= i; k-- { + s.RemoveAt(k) + } + if lv+1 < s.Len() && s.At(lv+1).Kind == token.Whitespace && !hasNewline(s.At(lv+1).Value) { + s.RemoveAt(lv + 1) + } + s.InsertAt(lv, token.Token{Kind: token.Punct, Value: op}) + changed = true + i = lv + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/IncrementStyleFixer.php +// +// IncrementStyle converts a post-increment statement to pre-increment +// (`$i++;` -> `++$i;`, `$i--;` -> `--$i;`) using the default `pre` style. Only +// standalone statements on a single plain variable are rewritten, where the +// return value is discarded and post/pre are equivalent. +type IncrementStyle struct{} + +func (IncrementStyle) Name() string { + return `PhpCsFixer\Fixer\Operator\IncrementStyleFixer` +} + +func (IncrementStyle) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/IncrementStyleFixer.php" +} + +func (IncrementStyle) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != "++" && t.Value != "--") { + continue + } + + // Operand: exactly one plain variable, on the same line as the operator. + lv := sigPrev(s, i) + if lv < 0 || s.At(lv).Kind != token.Variable { + continue + } + if !inlineWSBetween(s, lv, i) { + continue + } + + // The operand must open a statement (previous meaningful token is a + // boundary) and not be part of a larger lvalue. + p := sigPrev(s, lv) + if p < 0 { + continue + } + pv := s.At(p) + if lvaluePrefix(pv.Value) { + continue + } + if pv.Value != ";" && pv.Value != "{" && pv.Value != "}" && pv.Kind != token.OpenTag { + continue + } + + // The operator must be a standalone statement, terminated by ';'. + endIdx := sigNext(s, i) + if endIdx < 0 || s.At(endIdx).Value != ";" { + continue + } + + // Move the operator in front of the variable, dropping the inline gap. + for k := i; k >= lv+1; k-- { + s.RemoveAt(k) + } + s.InsertAt(lv, token.Token{Kind: token.Punct, Value: t.Value}) + changed = true + i = lv + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/LongToShorthandOperatorFixer.php +// +// LongToShorthandOperator rewrites `$a = $a ;` to +// `$a = ;`. Only the single-variable, single-operand, +// semicolon-terminated form is handled; a lone right operand plus the `;` +// terminator removes any operator-precedence ambiguity. Anything with a chained +// or complex right side is left to the upstream fixer. +type LongToShorthandOperator struct{} + +func (LongToShorthandOperator) Name() string { + return `PhpCsFixer\Fixer\Operator\LongToShorthandOperatorFixer` +} + +func (LongToShorthandOperator) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/LongToShorthandOperatorFixer.php" +} + +// shortOp reports whether v is an operator with a compound-assignment form. +func shortOp(v string) bool { + switch v { + case "+", "-", "*", "/", ".", "%", "&", "|", "^": + return true + } + return false +} + +// shortOperand reports whether t is a single self-contained right operand. +func shortOperand(t token.Token) bool { + switch t.Kind { + case token.Variable, token.Number, token.String, token.Ident: + return true + } + return false +} + +func (LongToShorthandOperator) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || t.Value != "=" { + continue + } + + // Left side: a single plain variable. + lv := sigPrev(s, i) + if lv < 0 || s.At(lv).Kind != token.Variable { + continue + } + if p := sigPrev(s, lv); p >= 0 && lvaluePrefix(s.At(p).Value) { + continue + } + + // Right side must start with the same variable. + rv := sigNext(s, i) + if rv < 0 || s.At(rv).Kind != token.Variable || s.At(rv).Value != s.At(lv).Value { + continue + } + + // Then a compound-capable operator. + opIdx := sigNext(s, rv) + if opIdx < 0 || s.At(opIdx).Kind != token.Punct || !shortOp(s.At(opIdx).Value) { + continue + } + + // Then a single operand and a terminating ';'. + operandIdx := sigNext(s, opIdx) + if operandIdx < 0 || !shortOperand(s.At(operandIdx)) { + continue + } + endIdx := sigNext(s, operandIdx) + if endIdx < 0 || s.At(endIdx).Value != ";" { + continue + } + + // Refuse if a comment sits inside the rewritten span. + if !spanClean(s, i, opIdx) { + continue + } + + // `= $a ` becomes `=`: retag the `=` and drop the duplicate + // operand and operator. + s.SetValue(i, s.At(opIdx).Value+"=") + for k := opIdx; k >= i+1; k-- { + s.RemoveAt(k) + } + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/gen_ops_inc_test.go b/blink/internal/fixer/rules/gen_ops_inc_test.go new file mode 100644 index 0000000000..3585a69eae --- /dev/null +++ b/blink/internal/fixer/rules/gen_ops_inc_test.go @@ -0,0 +1,134 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGenOpsIncStandardizeIncrement(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + {"x += 1;", "x += 1;", false}, + {" not a standalone statement, must not move + {"x++;", "x++;", false}, + {"x += 1;\n$a = $a - $b - $c;\n", + } + for _, f := range fixers { + for _, src := range corpus { + once, _ := apply(t, f, src) + twice, changed := apply(t, f, once) + if changed || once != twice { + t.Errorf("%s not idempotent: src=%q once=%q twice=%q", f.Name(), src, once, twice) + } + } + } +} diff --git a/blink/internal/fixer/rules/gen_phpdoc_tags.go b/blink/internal/fixer/rules/gen_phpdoc_tags.go new file mode 100644 index 0000000000..63bdc0b376 --- /dev/null +++ b/blink/internal/fixer/rules/gen_phpdoc_tags.go @@ -0,0 +1,150 @@ +package rules + +import ( + "regexp" + "strings" + + "blink/internal/tokens" +) + +// inheritDocRe matches an "@inheritdoc" tag token (any case), whether standalone +// ("@inheritdoc") or inline ("{@inheritdoc}"). The trailing \b keeps +// "@inheritdocs" untouched, mirroring the default ['inheritDoc'] tag set. +var inheritDocRe = regexp.MustCompile(`(?i)@inheritdoc\b`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagCasingFixer.php +// +// PhpdocTagCasing fixes the casing of the @inheritDoc tag (the default tag set), +// in both annotation and inline positions. +type PhpdocTagCasing struct{} + +func (PhpdocTagCasing) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocTagCasingFixer` +} + +func (PhpdocTagCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagCasingFixer.php" +} + +func (PhpdocTagCasing) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + fixed := inheritDocRe.ReplaceAllString(l.content, "@inheritDoc") + if fixed != l.content { + d.inner[i].content = fixed + changed = true + } + } + return changed + }) +} + +// inlineTagRe ports PhpdocInlineTagNormalizerFixer's pattern: it matches an inline +// tag written as "@{tag}" or "{@tag}" (with stray braces or spaces) for a known +// inline tag, capturing the tag word (case preserved) and its inner text. +var inlineTagRe = regexp.MustCompile(`(?i)(?:@\{+|\{+[ \t]*@)[ \t]*(example|id|internal|inheritdoc|inheritdocs|link|source|toc|tutorial|see)\b([^}]*)\}+`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocInlineTagNormalizerFixer.php +// +// PhpdocInlineTagNormalizer normalizes inline tags: it moves "@" inside the +// braces, collapses repeated braces and trims inner spaces ("{ @see X }" -> +// "{@see X}"). The tag word's casing is left untouched (that is PhpdocTagCasing's +// job). +type PhpdocInlineTagNormalizer struct{} + +func (PhpdocInlineTagNormalizer) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocInlineTagNormalizerFixer` +} + +func (PhpdocInlineTagNormalizer) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocInlineTagNormalizerFixer.php" +} + +func (PhpdocInlineTagNormalizer) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + fixed := inlineTagRe.ReplaceAllStringFunc(l.content, func(m string) string { + sub := inlineTagRe.FindStringSubmatch(m) + tag := sub[1] + doc := strings.TrimSpace(sub[2]) + if doc == "" { + return "{@" + tag + "}" + } + return "{@" + tag + " " + doc + "}" + }) + if fixed != l.content { + d.inner[i].content = fixed + changed = true + } + } + return changed + }) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoDuplicateTypesFixer.php +// +// PhpdocNoDuplicateTypes removes duplicate members from a phpdoc type union +// ("int|int|string" -> "int|string"), comparing case-insensitively and keeping +// the first occurrence. +type PhpdocNoDuplicateTypes struct{} + +func (PhpdocNoDuplicateTypes) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoDuplicateTypesFixer` +} + +func (PhpdocNoDuplicateTypes) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoDuplicateTypesFixer.php" +} + +func (PhpdocNoDuplicateTypes) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + trimmed := strings.TrimLeft(l.content, " ") + m := phpdocTypeTagRe.FindStringSubmatch(trimmed) + if m == nil { + continue + } + newType := dedupeUnionTypes(m[2]) + if newType == m[2] { + continue + } + lead := l.content[:len(l.content)-len(trimmed)] + d.inner[i].content = lead + m[1] + newType + m[3] + changed = true + } + return changed + }) +} + +// dedupeUnionTypes removes case-insensitive duplicate members from a "|" union, +// keeping the first spelling. Types with nested brackets (generics, DNF) are left +// untouched, since a "|" inside them is not a top-level separator. +func dedupeUnionTypes(typ string) string { + nullable := strings.HasPrefix(typ, "?") + body := strings.TrimPrefix(typ, "?") + if strings.ContainsAny(body, "<>(){}[]") { + return typ + } + parts := strings.Split(body, "|") + if len(parts) < 2 { + return typ + } + seen := make(map[string]bool, len(parts)) + kept := parts[:0:0] + for _, p := range parts { + key := strings.ToLower(p) + if seen[key] { + continue + } + seen[key] = true + kept = append(kept, p) + } + out := strings.Join(kept, "|") + if nullable { + out = "?" + out + } + return out +} diff --git a/blink/internal/fixer/rules/gen_phpdoc_tags_test.go b/blink/internal/fixer/rules/gen_phpdoc_tags_test.go new file mode 100644 index 0000000000..78452c1680 --- /dev/null +++ b/blink/internal/fixer/rules/gen_phpdoc_tags_test.go @@ -0,0 +1,96 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGenPhpdocTagsTagCasing(t *testing.T) { + src := " $a\n */\nfunction f($a) {}"); changed { + t.Fatal("nested generic type must not change") + } +} + +// TestGenPhpdocTagsSourceURLs verifies every SourceURL matches SourceURLFor(Name()). +func TestGenPhpdocTagsSourceURLs(t *testing.T) { + fixers := []fixer.Fixer{ + PhpdocTagCasing{}, + PhpdocInlineTagNormalizer{}, + PhpdocNoDuplicateTypes{}, + } + for _, f := range fixers { + if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want { + t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want) + } + } +} diff --git a/blink/internal/fixer/rules/gen_phpdoc_var.go b/blink/internal/fixer/rules/gen_phpdoc_var.go new file mode 100644 index 0000000000..6195be48e5 --- /dev/null +++ b/blink/internal/fixer/rules/gen_phpdoc_var.go @@ -0,0 +1,121 @@ +package rules + +import ( + "regexp" + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// nextCodeIndex returns the first non-whitespace, non-comment token after i, or +// -1. It mirrors PHP-CS-Fixer's getNextMeaningfulToken. +func nextCodeIndex(s *tokens.Stream, i int) int { + for j := i + 1; j < s.Len(); j++ { + switch s.At(j).Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + return j + } + return -1 +} + +func isKeywordValue(t token.Token, vals ...string) bool { + if t.Kind != token.Keyword { + return false + } + return slices.Contains(vals, strings.ToLower(t.Value)) +} + +// isPropertyModifier reports whether t is an access/property modifier keyword, +// i.e. the docblock in front of it documents a class property. +func isPropertyModifier(t token.Token) bool { + return isKeywordValue(t, "private", "protected", "public", "var", "readonly") +} + +var ( + varWithoutNameTagRe = regexp.MustCompile(`(?i)^@(?:var|type)(?:\s|$)`) + varNameRe = regexp.MustCompile(` \$[A-Za-z_\x{0080}-\x{00FF}][A-Za-z0-9_\x{0080}-\x{00FF}]*`) +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarWithoutNameFixer.php +// +// PhpdocVarWithoutName removes the variable name from @var/@type tags in a docblock +// documenting a class property, e.g. "@var int $bar" -> "@var int". Conservative: +// docblocks containing array-shape braces are left untouched. +type PhpdocVarWithoutName struct{} + +func (PhpdocVarWithoutName) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocVarWithoutNameFixer` +} + +func (PhpdocVarWithoutName) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarWithoutNameFixer.php" +} + +func (PhpdocVarWithoutName) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.DocComment { + continue + } + next := nextCodeIndex(s, i) + if next < 0 { + continue + } + // "static public $foo" - skip the leading static. + if isKeywordValue(s.At(next), "static") { + next = nextCodeIndex(s, next) + if next < 0 { + continue + } + } + if !isPropertyModifier(s.At(next)) { + continue + } + d, ok := parseDoc(s.At(i).Value) + if !ok { + continue + } + if docblockHasBraces(d) { + continue + } + if fixVarNames(&d) { + s.SetValue(i, d.render()) + changed = true + } + } + return changed +} + +func docblockHasBraces(d docblock) bool { + for _, l := range d.inner { + if strings.ContainsAny(l.content, "{}") { + return true + } + } + return false +} + +// fixVarNames strips " $name" (but never " $this") from @var/@type lines. +func fixVarNames(d *docblock) bool { + changed := false + for i, l := range d.inner { + if !varWithoutNameTagRe.MatchString(strings.TrimSpace(l.content)) { + continue + } + newContent := varNameRe.ReplaceAllStringFunc(l.content, func(m string) string { + if m == " $this" { + return m + } + return "" + }) + if newContent != l.content { + d.inner[i].content = newContent + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/gen_phpdoc_var_test.go b/blink/internal/fixer/rules/gen_phpdoc_var_test.go new file mode 100644 index 0000000000..0b03641959 --- /dev/null +++ b/blink/internal/fixer/rules/gen_phpdoc_var_test.go @@ -0,0 +1,76 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +// assertFix runs r once and checks the output and changed flag. +func assertFix(t *testing.T, r fixerRule, src, want string, wantChanged bool) { + t.Helper() + got, changed := apply(t, r, src) + if changed != wantChanged || got != want { + t.Fatalf("changed=%v (want %v)\n got: %q\nwant: %q", changed, wantChanged, got, want) + } + // idempotent: a second pass over the output must be a no-op + if got2, changed2 := apply(t, r, got); changed2 || got2 != got { + t.Fatalf("not idempotent: changed=%v got=%q", changed2, got2) + } +} + +func TestGenPhpdocVarWithoutName(t *testing.T) { + f := PhpdocVarWithoutName{} + + // single-line property docblock + assertFix(t, f, + "= 0 && s.At(q).Kind == token.Punct && s.At(q).Value == "&" { + q = prevSignificantIndex(s, q) + } + return q >= 0 && s.At(q).Kind == token.Keyword && strings.ToLower(s.At(q).Value) == "function" + } + return false +} + +// enclosingFuncParamOpen returns the index of the "(" of the function parameter +// list that directly encloses i, or -1. It stops at a "{", "}" or ";" reached at +// paren depth zero so it never crosses out of the signature into a body. +func enclosingFuncParamOpen(s *tokens.Stream, i int) int { + depth := 0 + for j := i - 1; j >= 0; j-- { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case ")": + depth++ + case "(": + if depth == 0 { + if isFunctionParamOpen(s, j) { + return j + } + return -1 + } + depth-- + case "{", "}", ";": + if depth == 0 { + return -1 + } + } + } + return -1 +} + +// isNullableTypePos reports whether the "?" at i is a nullable-type marker rather +// than a ternary. A nullable "?" follows a type-position token (":", "(", ",", +// "|" or a visibility/modifier keyword); a ternary "?" follows an expression. +func isNullableTypePos(s *tokens.Stream, i int) bool { + p := prevSignificantIndex(s, i) + if p < 0 { + return false + } + pt := s.At(p) + if pt.Kind == token.Punct { + switch pt.Value { + case ":", "(", ",", "|": + return true + } + return false + } + if pt.Kind == token.Keyword { + switch strings.ToLower(pt.Value) { + case "public", "private", "protected", "readonly", "static", "var": + return true + } + } + return false +} + +// isTypeNameToken reports whether t can name a type (a class name / Ident, or a +// native type that happens to lex as a Keyword). +func isTypeNameToken(t token.Token) bool { + if t.Kind == token.Ident { + return true + } + if t.Kind == token.Keyword { + return nativeTypeNames[strings.ToLower(t.Value)] + } + return false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/CompactNullableTypeDeclarationFixer.php +// +// CompactNullableTypeDeclaration removes the whitespace between a nullable "?" +// and its type ("? int" -> "?int"). It only touches a "?" in a type position, so +// ternary, elvis ("?:"), null-coalesce ("??") and nullsafe ("?->") are left alone. +type CompactNullableTypeDeclaration struct{} + +func (CompactNullableTypeDeclaration) Name() string { + return `PhpCsFixer\Fixer\Whitespace\CompactNullableTypeDeclarationFixer` +} + +func (CompactNullableTypeDeclaration) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/CompactNullableTypeDeclarationFixer.php" +} + +func (CompactNullableTypeDeclaration) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || t.Value != "?" { + continue + } + if !isNullableTypePos(s, i) { + continue + } + n := nextSignificantIndex(s, i) + if n < 0 || !isTypeNameToken(s.At(n)) { + continue + } + // drop the single-line whitespace between "?" and the type + for j := n - 1; j > i; j-- { + if s.At(j).Kind == token.Whitespace && !hasNewline(s.At(j).Value) { + s.RemoveAt(j) + changed = true + } + } + } + return changed +} + +// inReturnType reports whether the type token at i sits in a return-type +// position, i.e. scanning left across type parts ("?", "|", "&", "\", names) +// reaches a return-type colon. +func inReturnType(s *tokens.Stream, i int) bool { + j := i + for { + p := prevSignificantIndex(s, j) + if p < 0 { + return false + } + pt := s.At(p) + if pt.Kind == token.Punct && pt.Value == ":" { + return isReturnTypeColon(s, p) + } + if pt.Kind == token.Ident || + (pt.Kind == token.Punct && (pt.Value == "?" || pt.Value == "|" || pt.Value == "&" || pt.Value == `\`)) { + j = p + continue + } + return false + } +} + +// isFunctionSignatureType reports whether the token at i is a type in a function +// signature: a parameter type inside the parameter list, or a return type. +func isFunctionSignatureType(s *tokens.Stream, i int) bool { + if enclosingFuncParamOpen(s, i) >= 0 && typeContextPrev(s, i) && typeContextNext(s, i) { + return true + } + return inReturnType(s, i) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionTypeDeclarationCasingFixer.php +// +// NativeFunctionTypeDeclarationCasing lowercases native types used in function +// parameter and return type declarations ("function f(INT $x): STRING" -> "int", +// "string"). It only rewrites reserved native type names, which cannot name a +// class, and only in a function-signature type position. +type NativeFunctionTypeDeclarationCasing struct{} + +func (NativeFunctionTypeDeclarationCasing) Name() string { + return `PhpCsFixer\Fixer\Casing\NativeFunctionTypeDeclarationCasingFixer` +} + +func (NativeFunctionTypeDeclarationCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionTypeDeclarationCasingFixer.php" +} + +func (NativeFunctionTypeDeclarationCasing) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Ident && t.Kind != token.Keyword { + continue + } + lower := strings.ToLower(t.Value) + if !nativeTypeNames[lower] || t.Value == lower { + continue + } + if !isFunctionSignatureType(s, i) { + continue + } + s.SetValue(i, lower) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/gen_types_test.go b/blink/internal/fixer/rules/gen_types_test.go new file mode 100644 index 0000000000..9de7e66685 --- /dev/null +++ b/blink/internal/fixer/rules/gen_types_test.go @@ -0,0 +1,73 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestGenTypesCompactNullableTypeDeclaration(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + {"c;", "c;", false}, + } + for _, c := range cases { + got, changed := apply(t, CompactNullableTypeDeclaration{}, c.src) + if got != c.want || changed != c.changed { + t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want) + } + if again, _ := apply(t, CompactNullableTypeDeclaration{}, got); again != got { + t.Fatalf("not idempotent: %q -> %q", got, again) + } + } +} + +func TestGenTypesNativeFunctionTypeDeclarationCasing(t *testing.T) { + cases := []struct { + src, want string + changed bool + }{ + {" %q", got, again) + } + } +} + +func TestGenTypesSourceURLs(t *testing.T) { + fixers := []fixer.Fixer{ + CompactNullableTypeDeclaration{}, + NativeFunctionTypeDeclarationCasing{}, + } + for _, f := range fixers { + if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want { + t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want) + } + } +} diff --git a/blink/internal/fixer/rules/gen_ws.go b/blink/internal/fixer/rules/gen_ws.go new file mode 100644 index 0000000000..91bcc71874 --- /dev/null +++ b/blink/internal/fixer/rules/gen_ws.go @@ -0,0 +1,198 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/EncodingFixer.php +// +// Encoding removes a leading UTF-8 BOM (EF BB BF) from the start of the file. +type Encoding struct{} + +const utf8BOM = "\uFEFF" + +func (Encoding) Name() string { + return `PhpCsFixer\Fixer\Basic\EncodingFixer` +} + +func (Encoding) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/EncodingFixer.php" +} + +func (Encoding) Fix(s *tokens.Stream) bool { + if s.Len() == 0 { + return false + } + v := s.At(0).Value + if !strings.HasPrefix(v, utf8BOM) { + return false + } + rest := v[len(utf8BOM):] + if rest == "" { + s.RemoveAt(0) + } else { + s.SetValue(0, rest) + } + return true +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/DeclareParenthesesFixer.php +// +// DeclareParentheses removes whitespace around a declare() header's parentheses: +// "declare ( strict_types=1 )" -> "declare(strict_types=1)". +type DeclareParentheses struct{} + +func (DeclareParentheses) Name() string { + return `PhpCsFixer\Fixer\LanguageConstruct\DeclareParenthesesFixer` +} + +func (DeclareParentheses) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/DeclareParenthesesFixer.php" +} + +func (DeclareParentheses) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || !strings.EqualFold(t.Value, "declare") { + continue + } + op := i + 1 + wsBetween := -1 + if op < s.Len() && s.At(op).Kind == token.Whitespace { + wsBetween = op + op++ + } + if op >= s.Len() || s.At(op).Kind != token.Punct || s.At(op).Value != "(" { + continue + } + cp := s.MatchForward(op) + if cp < 0 { + continue + } + // collect whitespace to drop, delete high-to-low to keep indices valid + var drop []int + if cp-1 > op && s.At(cp-1).Kind == token.Whitespace { + drop = append(drop, cp-1) + } + if op+1 < cp && s.At(op+1).Kind == token.Whitespace { + drop = append(drop, op+1) + } + if wsBetween >= 0 { + drop = append(drop, wsBetween) + } + for j := 0; j < len(drop); j++ { + for k := j + 1; k < len(drop); k++ { + if drop[k] > drop[j] { + drop[j], drop[k] = drop[k], drop[j] + } + } + } + for _, idx := range drop { + s.RemoveAt(idx) + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/MultilineCommentOpeningClosingFixer.php +// +// MultilineCommentOpeningClosing normalizes block-comment delimiters: an opening +// with extra asterisks ("/***") collapses to "/*" (doc blocks keep their "/**"), +// and a closing with extra asterisks ("***/") collapses to "*/". +type MultilineCommentOpeningClosing struct{} + +func (MultilineCommentOpeningClosing) Name() string { + return `PhpCsFixer\Fixer\Comment\MultilineCommentOpeningClosingFixer` +} + +func (MultilineCommentOpeningClosing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/MultilineCommentOpeningClosingFixer.php" +} + +func (MultilineCommentOpeningClosing) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Comment && t.Kind != token.DocComment { + continue + } + v := t.Value + if !strings.HasPrefix(v, "/*") { + continue + } + nv := v + // the opening fix applies to plain comments only, never to doc blocks + if !isDocBlockOpening(nv) { + nv = fixCommentOpening(nv) + } + nv = fixCommentClosing(nv) + if nv != v { + s.SetValue(i, nv) + changed = true + } + } + return changed +} + +// isDocBlockOpening mirrors PHP's tokenizer: "/**" followed by a non-asterisk, +// non-slash character is a doc block; "/***" or "/**/" is a plain comment. +func isDocBlockOpening(v string) bool { + return len(v) >= 4 && v[:3] == "/**" && v[3] != '*' && v[3] != '/' +} + +// fixCommentOpening implements /^\/\*{2,}(?!\/)/ -> "/*". +func fixCommentOpening(v string) string { + if !strings.HasPrefix(v, "/*") { + return v + } + a := 0 + for 1+a < len(v) && v[1+a] == '*' { + a++ + } + if a < 2 { + return v + } + var next byte + if 1+a < len(v) { + next = v[1+a] + } + if next != '/' { + return "/*" + v[1+a:] + } + if a >= 3 { + return "/*" + v[a:] + } + return v +} + +// fixCommentClosing implements /(? "*/". +func fixCommentClosing(v string) string { + if !strings.HasSuffix(v, "/") { + return v + } + k := len(v) - 2 + b := 0 + for k >= 0 && v[k] == '*' { + b++ + k-- + } + if b < 2 { + return v + } + var prev byte + if k >= 0 { + prev = v[k] + } + if prev != '/' { + return v[:k+1] + "*/" + } + if b >= 3 { + return v[:k+2] + "*/" + } + return v +} diff --git a/blink/internal/fixer/rules/gen_ws_test.go b/blink/internal/fixer/rules/gen_ws_test.go new file mode 100644 index 0000000000..1e8da7ce49 --- /dev/null +++ b/blink/internal/fixer/rules/gen_ws_test.go @@ -0,0 +1,69 @@ +package rules + +import "testing" + +func TestGenWsEncoding(t *testing.T) { + got, changed := apply(t, Encoding{}, "\uFEFFdeclare ( 1 );"); changed { + t.Fatal("method call declare() must not change") + } + idempotent(t, DeclareParentheses{}, " "use Foo\Bar;", including "use function" / "use const". +type NoLeadingImportSlash struct{} + +func (NoLeadingImportSlash) Name() string { + return `PhpCsFixer\Fixer\Import\NoLeadingImportSlashFixer` +} + +func (NoLeadingImportSlash) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoLeadingImportSlashFixer.php" +} + +func (NoLeadingImportSlash) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" { + continue + } + j := skipWhitespace(s, i+1) + if j < s.Len() && s.At(j).Kind == token.Keyword { + if lw := strings.ToLower(s.At(j).Value); lw == "function" || lw == "const" { + j = skipWhitespace(s, j+1) + } + } + if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == `\` { + s.RemoveAt(j) + changed = true + } + } + return changed +} + +func skipWhitespace(s *tokens.Stream, i int) int { + for i < s.Len() && s.At(i).Kind == token.Whitespace { + i++ + } + return i +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleImportPerStatementFixer.php +// +// SingleImportPerStatement splits a grouped import into one per line: +// "use A, B;" -> "use A;\nuse B;". Group imports ("use A\{B, C};") and closure +// "use (...)" are left alone. +type SingleImportPerStatement struct{} + +func (SingleImportPerStatement) Name() string { + return `PhpCsFixer\Fixer\Import\SingleImportPerStatementFixer` +} + +func (SingleImportPerStatement) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleImportPerStatementFixer.php" +} + +func (SingleImportPerStatement) Fix(s *tokens.Stream) bool { + return splitUseStatements(s, false) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleTraitInsertPerStatementFixer.php +// +// SingleTraitInsertPerStatement splits a grouped trait use inside a class body +// into one per line: "use A, B;" -> "use A;\nuse B;". +type SingleTraitInsertPerStatement struct{} + +func (SingleTraitInsertPerStatement) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\SingleTraitInsertPerStatementFixer` +} + +func (SingleTraitInsertPerStatement) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleTraitInsertPerStatementFixer.php" +} + +func (SingleTraitInsertPerStatement) Fix(s *tokens.Stream) bool { + return splitUseStatements(s, true) +} + +// splitUseStatements splits grouped "use" statements one per line. inClass picks +// the scope: false handles top-level imports, true handles in-class trait use. +func splitUseStatements(s *tokens.Stream, inClass bool) bool { + changed := false + i := 0 + for i < s.Len() { + if s.At(i).Kind != token.Keyword || strings.ToLower(s.At(i).Value) != "use" { + i++ + continue + } + next, c := splitUseAt(s, i, inClass) + i = next + if c { + changed = true + } + } + return changed +} + +// splitUseAt splits the "use" statement at index i when its scope matches +// inClass. It returns the index to continue from and whether it changed. +func splitUseAt(s *tokens.Stream, i int, inClass bool) (int, bool) { + if inClassLikeBody(s, i) != inClass { + return i + 1, false + } + + j := skipWhitespace(s, i+1) + modifier := "" + if j < s.Len() && s.At(j).Kind == token.Keyword { + if lw := strings.ToLower(s.At(j).Value); lw == "function" || lw == "const" { + modifier = s.At(j).Value + j = skipWhitespace(s, j+1) + } + } + if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + return i + 1, false // closure "use (...)" + } + + semi, group := -1, false + var commas []int + for k := j; k < s.Len(); k++ { + if s.At(k).Kind != token.Punct { + continue + } + switch s.At(k).Value { + case "{": + group = true + case ";": + semi = k + case ",": + commas = append(commas, k) + } + if group || semi >= 0 { + break + } + } + if group || semi < 0 || len(commas) == 0 { + return i + 1, false + } + + indent := indentBefore(s, i) + parts := splitImportParts(s, j, semi, commas) + + var repl []token.Token + for p, part := range parts { + if p > 0 { + repl = append(repl, token.Token{Kind: token.Whitespace, Value: "\n" + indent}) + } + repl = append(repl, token.Token{Kind: token.Keyword, Value: "use"}, token.Token{Kind: token.Whitespace, Value: " "}) + if modifier != "" { + repl = append(repl, token.Token{Kind: token.Keyword, Value: modifier}, token.Token{Kind: token.Whitespace, Value: " "}) + } + repl = append(repl, part...) + repl = append(repl, token.Token{Kind: token.Punct, Value: ";"}) + } + s.ReplaceRange(i, semi, repl) + return i + len(repl), true +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleLineAfterImportsFixer.php +// +// SingleLineAfterImports ensures one blank line after the last import in a block +// of consecutive top-level use statements. +type SingleLineAfterImports struct{} + +func (SingleLineAfterImports) Name() string { + return `PhpCsFixer\Fixer\Import\SingleLineAfterImportsFixer` +} + +func (SingleLineAfterImports) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleLineAfterImportsFixer.php" +} + +func (SingleLineAfterImports) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" { + continue + } + if inClassLikeBody(s, i) { + continue // trait use + } + j := skipWhitespace(s, i+1) + if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + continue // closure use + } + semi := -1 + for k := i + 1; k < s.Len(); k++ { + if s.At(k).Kind == token.Punct && s.At(k).Value == ";" { + semi = k + break + } + } + if semi < 0 || semi+1 >= s.Len() { + continue + } + nx := skipWhitespace(s, semi+1) + if nx >= s.Len() { + continue + } + next := s.At(nx) + // only the last import in a block, and only when code follows + if next.Kind == token.Keyword && strings.ToLower(next.Value) == "use" { + continue + } + if next.Kind == token.Punct && next.Value == "}" { + continue + } + if s.At(semi+1).Kind == token.Whitespace && + hasNewline(s.At(semi+1).Value) && s.At(semi+1).Value != "\n\n" { + s.SetValue(semi+1, "\n\n") + changed = true + } + } + return changed +} + +// indentBefore returns the indentation (spaces/tabs after the last newline) of +// the token preceding index i. +func indentBefore(s *tokens.Stream, i int) string { + if i == 0 || s.At(i-1).Kind != token.Whitespace { + return "" + } + v := s.At(i - 1).Value + if idx := strings.LastIndexByte(v, '\n'); idx >= 0 { + return v[idx+1:] + } + return "" +} + +// splitImportParts slices tokens (j, semi) at the given comma indices, trimming +// surrounding whitespace from each part. +func splitImportParts(s *tokens.Stream, j, semi int, commas []int) [][]token.Token { + bounds := append([]int{j - 1}, commas...) + bounds = append(bounds, semi) + var parts [][]token.Token + for b := 0; b+1 < len(bounds); b++ { + var part []token.Token + for k := bounds[b] + 1; k < bounds[b+1]; k++ { + part = append(part, s.At(k)) + } + parts = append(parts, trimWhitespace(part)) + } + return parts +} + +// inClassLikeBody reports whether index i is directly inside a class, interface, +// trait or enum body (as opposed to file scope or a bracketed namespace). +func inClassLikeBody(s *tokens.Stream, i int) bool { + depth := 0 + for j := i - 1; j >= 0; j-- { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "}": + depth++ + case "{": + if depth == 0 { + return braceOpensClassLike(s, j) + } + depth-- + } + } + return false +} + +// braceOpensClassLike reports whether the "{" at index brace opens a class-like +// body, by finding a class/interface/trait/enum keyword in its header. +func braceOpensClassLike(s *tokens.Stream, brace int) bool { + for j := brace - 1; j >= 0; j-- { + t := s.At(j) + if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{" || t.Value == "}") { + return false + } + if t.Kind == token.Keyword && classLikeKeywords[strings.ToLower(t.Value)] { + return true + } + } + return false +} + +func trimWhitespace(toks []token.Token) []token.Token { + for len(toks) > 0 && toks[0].Kind == token.Whitespace { + toks = toks[1:] + } + for len(toks) > 0 && toks[len(toks)-1].Kind == token.Whitespace { + toks = toks[:len(toks)-1] + } + return toks +} diff --git a/blink/internal/fixer/rules/imports_order.go b/blink/internal/fixer/rules/imports_order.go new file mode 100644 index 0000000000..c26667b968 --- /dev/null +++ b/blink/internal/fixer/rules/imports_order.go @@ -0,0 +1,238 @@ +package rules + +import ( + "sort" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +type importStmt struct { + start, semi int + kind string // "class", "function" or "const" + pathKey string // lowercased imported path, for alphabetical ordering +} + +// importSortKey returns the lowercased imported path of a use statement (after an +// optional "function"/"const", up to ";" or " as "), used as the alphabetical +// sort key by ECS's default OrderedImports (sort_algorithm: alpha). +func importSortKey(s *tokens.Stream, useIdx, semi int) string { + j := skipWhitespace(s, useIdx+1) + if j < s.Len() && s.At(j).Kind == token.Keyword { + if lw := strings.ToLower(s.At(j).Value); lw == "function" || lw == "const" { + j = skipWhitespace(s, j+1) + } + } + var b strings.Builder + for k := j; k < semi; k++ { + t := s.At(k) + if t.Kind == token.Keyword && strings.ToLower(t.Value) == "as" { + break + } + if t.Kind == token.Whitespace { + continue + } + b.WriteString(t.Value) + } + // compare segment by segment: "\" must sort before any other character so + // "Rector\Php\X" precedes "Rector\Php71\Y" (ECS's alpha comparator) + return strings.ReplaceAll(strings.ToLower(b.String()), `\`, "\x00") +} + +// importKindRank orders use statements by kind for ECS's default imports_order +// (['class', 'function', 'const']). +func importKindRank(kind string) int { + switch kind { + case "function": + return 1 + case "const": + return 2 + default: // class + return 0 + } +} + +func useKind(s *tokens.Stream, useIdx int) string { + j := skipWhitespace(s, useIdx+1) + if j < s.Len() && s.At(j).Kind == token.Keyword { + switch strings.ToLower(s.At(j).Value) { + case "function": + return "function" + case "const": + return "const" + } + } + return "class" +} + +// collectImportRun gathers a maximal run of consecutive top-level use statements +// starting at start, separated only by whitespace. Group imports and closure use +// stop the run. +func collectImportRun(s *tokens.Stream, start int) []importStmt { + var stmts []importStmt + k := start + for k < s.Len() { + t := s.At(k) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, k) { + break + } + j := skipWhitespace(s, k+1) + if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + break // closure use + } + semi, group := -1, false + for m := k + 1; m < s.Len(); m++ { + if s.At(m).Kind != token.Punct { + continue + } + if s.At(m).Value == "{" { + group = true + break + } + if s.At(m).Value == ";" { + semi = m + break + } + } + if group || semi < 0 { + break + } + stmts = append(stmts, importStmt{start: k, semi: semi, kind: useKind(s, k), pathKey: importSortKey(s, k, semi)}) + n := skipWhitespace(s, semi+1) + if n < s.Len() && s.At(n).Kind == token.Keyword && strings.ToLower(s.At(n).Value) == "use" { + k = n + continue + } + break + } + return stmts +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/OrderedImportsFixer.php +// +// OrderedImports sorts a run of use statements by kind (class, then function, +// then const) and alphabetically by imported path within each kind +// (case-insensitive), matching ECS's psr12 config (sort_algorithm: alpha, +// imports_order: ['class', 'function', 'const']). +type OrderedImports struct{} + +func (OrderedImports) Name() string { + return `PhpCsFixer\Fixer\Import\OrderedImportsFixer` +} + +func (OrderedImports) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/OrderedImportsFixer.php" +} + +func (OrderedImports) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, i) { + i++ + continue + } + run := collectImportRun(s, i) + if len(run) < 2 { + i++ + continue + } + newEnd, c := reorderImports(s, run) + if c { + changed = true + } + i = newEnd + } + return changed +} + +func reorderImports(s *tokens.Stream, run []importStmt) (int, bool) { + first := run[0].start + last := run[len(run)-1].semi + indent := indentBefore(s, first) + + ordered := append([]importStmt(nil), run...) + sort.SliceStable(ordered, func(a, b int) bool { + // ECS default imports_order: class, then function, then const; alpha within + if ra, rb := importKindRank(ordered[a].kind), importKindRank(ordered[b].kind); ra != rb { + return ra < rb + } + return ordered[a].pathKey < ordered[b].pathKey + }) + + var repl []token.Token + for p, st := range ordered { + if p > 0 { + repl = append(repl, token.Token{Kind: token.Whitespace, Value: "\n" + indent}) + } + for k := st.start; k <= st.semi; k++ { + repl = append(repl, s.At(k)) + } + } + + orig := make([]token.Token, 0, last-first+1) + for k := first; k <= last; k++ { + orig = append(orig, s.At(k)) + } + changed := !tokensEqual(orig, repl) + s.ReplaceRange(first, last, repl) + return first + len(repl), changed +} + +func tokensEqual(a, b []token.Token) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Kind != b[i].Kind || a[i].Value != b[i].Value { + return false + } + } + return true +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBetweenImportGroupsFixer.php +// +// BlankLineBetweenImportGroups puts a blank line between class, function and +// const import groups. +type BlankLineBetweenImportGroups struct{} + +func (BlankLineBetweenImportGroups) Name() string { + return `PhpCsFixer\Fixer\Whitespace\BlankLineBetweenImportGroupsFixer` +} + +func (BlankLineBetweenImportGroups) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBetweenImportGroupsFixer.php" +} + +func (BlankLineBetweenImportGroups) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, i) { + i++ + continue + } + run := collectImportRun(s, i) + if len(run) >= 2 { + for p := 1; p < len(run); p++ { + if run[p].kind == run[p-1].kind { + continue + } + ws := run[p].start - 1 + if ws >= 0 && s.At(ws).Kind == token.Whitespace && + hasNewline(s.At(ws).Value) && s.At(ws).Value != "\n\n" { + s.SetValue(ws, "\n\n") + changed = true + } + } + } + if len(run) > 0 { + i = run[len(run)-1].semi + 1 + } else { + i++ + } + } + return changed +} diff --git a/blink/internal/fixer/rules/imports_unused.go b/blink/internal/fixer/rules/imports_unused.go new file mode 100644 index 0000000000..f40e84956e --- /dev/null +++ b/blink/internal/fixer/rules/imports_unused.go @@ -0,0 +1,295 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +type importInfo struct { + start, semi int + shortLower string + kind string // "class", "function" or "const" + fullLower string // full imported path, lowercased, no leading "\" + aliased bool +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnusedImportsFixer.php +// +// NoUnusedImports removes a top-level "use" import that is never referenced. It +// matches usage by import kind (class/function/const), treats a name in a comment +// as used (word-boundary), and removes imports of the current namespace. +type NoUnusedImports struct{} + +func (NoUnusedImports) Name() string { + return `PhpCsFixer\Fixer\Import\NoUnusedImportsFixer` +} + +func (NoUnusedImports) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnusedImportsFixer.php" +} + +func (NoUnusedImports) Fix(s *tokens.Stream) bool { + var imports []importInfo + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, i) { + continue + } + j := skipWhitespace(s, i+1) + if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + continue // closure use + } + kind := "class" + if j < s.Len() && s.At(j).Kind == token.Keyword { + switch strings.ToLower(s.At(j).Value) { + case "function": + kind = "function" + case "const": + kind = "const" + } + } + + semi, group := -1, false + for k := i + 1; k < s.Len(); k++ { + if s.At(k).Kind != token.Punct { + continue + } + if s.At(k).Value == "{" { + group = true + break + } + if s.At(k).Value == ";" { + semi = k + break + } + } + if group || semi < 0 { + continue + } + + short, full, aliased := importParts(s, i, semi, kind) + if short == "" { + continue + } + imports = append(imports, importInfo{ + start: i, semi: semi, shortLower: strings.ToLower(short), + kind: kind, fullLower: strings.ToLower(full), aliased: aliased, + }) + i = semi + } + if len(imports) == 0 { + return false + } + + nsLower := strings.ToLower(fileNamespace(s)) + + inImport := func(idx int) bool { + for _, im := range imports { + if idx >= im.start && idx <= im.semi { + return true + } + } + return false + } + + changed := false + for _, im := range slices.Backward(imports) { + // an import of the current namespace ("namespace\Name") is redundant + redundant := !im.aliased && nsLower != "" && + strings.HasPrefix(im.fullLower, nsLower+`\`) && + !strings.Contains(im.fullLower[len(nsLower)+1:], `\`) + if !redundant && importIsUsed(s, im, inImport) { + continue + } + removeImport(s, im) + changed = true + } + return changed +} + +// importIsUsed reports whether the import's short name appears as a matching-kind +// reference or in a comment (word-boundary). +func importIsUsed(s *tokens.Stream, im importInfo, inImport func(int) bool) bool { + for k := 0; k < s.Len(); k++ { + t := s.At(k) + if t.Kind == token.Ident && !inImport(k) && strings.ToLower(t.Value) == im.shortLower { + if usageMatchesKind(s, k, im.kind) { + return true + } + continue + } + // a class name colliding with a PHP keyword (enum, list, ...) is lexed as a + // keyword; count it when used in a class-reference position + if t.Kind == token.Keyword && im.kind == "class" && !inImport(k) && + strings.ToLower(t.Value) == im.shortLower && keywordIsClassRef(s, k) { + return true + } + if (t.Kind == token.Comment || t.Kind == token.DocComment) && commentReferences(t.Value, im.shortLower) { + return true + } + } + return false +} + +// usageMatchesKind reports whether the identifier at k is a reference of the +// given import kind. +func usageMatchesKind(s *tokens.Stream, k int, kind string) bool { + prev, hasPrev := prevSignificant(s, k) + if hasPrev { + if prev.Kind == token.Punct { + switch prev.Value { + case `\`, "->", "?->", "::": + return false + } + } + if prev.Kind == token.Keyword { + switch strings.ToLower(prev.Value) { + case "namespace", "function": + return false + case "const": + if nextSignificantValue(s, k) == "=" { + return false + } + } + } + } + prevNew := hasPrev && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") + fnCall := nextSignificantValue(s, k) == "(" && !prevNew + if kind == "function" { + return fnCall + } + // class and const references are any matching identifier that is not a call + return !fnCall +} + +// commentReferences reports whether short appears in v as a whole word, not +// preceded by an identifier char, "$" or "\". +func commentReferences(v, short string) bool { + lv := strings.ToLower(v) + from := 0 + for { + idx := strings.Index(lv[from:], short) + if idx < 0 { + return false + } + p := from + idx + before := byte(' ') + if p > 0 { + before = lv[p-1] + } + after := byte(' ') + if p+len(short) < len(lv) { + after = lv[p+len(short)] + } + if !isIdentByte(before) && before != '$' && before != '\\' && !isIdentByte(after) { + return true + } + from = p + 1 + } +} + +func keywordIsClassRef(s *tokens.Stream, k int) bool { + if prev, ok := prevSignificant(s, k); ok { + if prev.Kind == token.Keyword { + switch strings.ToLower(prev.Value) { + case "extends", "implements", "new", "instanceof": + return true + } + } + if prev.Kind == token.Punct { + switch prev.Value { + case "(", ",", ":", "|", "&", "?": + return true + } + } + } + if n := nextSignificantIndex(s, k); n >= 0 { + if s.At(n).Kind == token.Punct && s.At(n).Value == "::" { + return true + } + if s.At(n).Kind == token.Variable { + return true + } + } + return false +} + +func isIdentByte(c byte) bool { + return c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +// importParts returns the imported short name, full path and whether it is +// aliased, for the use statement spanning (useIdx, semi). +func importParts(s *tokens.Stream, useIdx, semi int, kind string) (short, full string, aliased bool) { + j := skipWhitespace(s, useIdx+1) + if kind != "class" { + j = skipWhitespace(s, j+1) // skip "function"/"const" + } + var path strings.Builder + alias := "" + inAlias := false + for k := j; k < semi; k++ { + t := s.At(k) + if t.Kind == token.Keyword && strings.ToLower(t.Value) == "as" { + inAlias = true + aliased = true + continue + } + if t.Kind == token.Whitespace { + continue + } + if inAlias { + if t.Kind == token.Ident { + alias = t.Value + } + continue + } + path.WriteString(t.Value) + } + full = strings.TrimPrefix(path.String(), `\`) + if alias != "" { + short = alias + } else if i := strings.LastIndex(full, `\`); i >= 0 { + short = full[i+1:] + } else { + short = full + } + return short, full, aliased +} + +// fileNamespace returns the first top-level namespace name, or "". +func fileNamespace(s *tokens.Stream) string { + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Keyword || strings.ToLower(s.At(i).Value) != "namespace" || memberPrev(s, i) { + continue + } + var b strings.Builder + for k := i + 1; k < s.Len(); k++ { + t := s.At(k) + if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{") { + break + } + if t.Kind == token.Whitespace { + continue + } + b.WriteString(t.Value) + } + return strings.TrimPrefix(b.String(), `\`) + } + return "" +} + +func removeImport(s *tokens.Stream, im importInfo) { + for r := im.semi; r >= im.start; r-- { + s.RemoveAt(r) + } + // drop just the statement's own line break, keeping any blank line + if im.start < s.Len() && s.At(im.start).Kind == token.Whitespace { + if v := s.At(im.start).Value; strings.HasPrefix(v, "\n") { + s.SetValue(im.start, v[1:]) + } + } +} diff --git a/blink/internal/fixer/rules/indentation_braces_test.go b/blink/internal/fixer/rules/indentation_braces_test.go new file mode 100644 index 0000000000..3a2b1f6e9a --- /dev/null +++ b/blink/internal/fixer/rules/indentation_braces_test.go @@ -0,0 +1,33 @@ +package rules + +import "testing" + +func TestStatementIndentation(t *testing.T) { + got, changed := apply(t, StatementIndentation{}, "foo()\n ->bar();\n" + if _, changed := apply(t, StatementIndentation{}, chain); changed { + t.Fatal("method chain must not be reindented") + } + // multiline arguments are continuations - left alone + args := "= 0 { + pv := s.At(pj) + if pv.Kind == token.Punct && (pv.Value == "->" || pv.Value == "?->" || pv.Value == "::" || pv.Value == `\`) { + continue + } + if pv.Kind == token.Keyword && strings.EqualFold(pv.Value, "function") { + continue + } + } + op := nextSignificantIndex(s, i) + if op < 0 || s.At(op).Kind != token.Punct || s.At(op).Value != "(" { + continue + } + cl := s.MatchForward(op) + if cl < 0 { + continue + } + start, neg := i, false + if pj >= 0 && s.At(pj).Kind == token.Punct && s.At(pj).Value == "!" { + neg, start = true, pj + } + sites = append(sites, site{start, op, cl, neg}) + } + if len(sites) == 0 { + return false + } + for _, st := range slices.Backward(sites) { + a, b := st.argOpen+1, st.argClose-1 + for a <= b && s.At(a).Kind == token.Whitespace { + a++ + } + for b >= a && s.At(b).Kind == token.Whitespace { + b-- + } + var arg []token.Token + for x := a; x <= b; x++ { + arg = append(arg, s.At(x)) + } + opTok := "===" + if st.neg { + opTok = "!==" + } + repl := []token.Token{ + {Kind: token.Ident, Value: "null"}, + {Kind: token.Whitespace, Value: " "}, + {Kind: token.Punct, Value: opTok}, + {Kind: token.Whitespace, Value: " "}, + } + if argHasDepth0Operator(arg) { + repl = append(repl, token.Token{Kind: token.Punct, Value: "("}) + repl = append(repl, arg...) + repl = append(repl, token.Token{Kind: token.Punct, Value: ")"}) + } else { + repl = append(repl, arg...) + } + s.ReplaceRange(st.start, st.argClose, repl) + } + return true +} + +// argHasDepth0Operator reports whether the argument expression contains a +// top-level operator (anything but a postfix member/chain), meaning it must keep +// its parentheses to preserve precedence. +func argHasDepth0Operator(arg []token.Token) bool { + depth := 0 + for _, t := range arg { + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + continue + case ")", "]", "}": + depth-- + continue + } + if depth == 0 { + switch t.Value { + case "->", "?->", "::", `\`, "...": + default: + return true + } + } + } + if depth == 0 && t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "and", "or", "xor", "instanceof": + return true + } + } + } + return false +} diff --git a/blink/internal/fixer/rules/is_null_test.go b/blink/internal/fixer/rules/is_null_test.go new file mode 100644 index 0000000000..061c95d91a --- /dev/null +++ b/blink/internal/fixer/rules/is_null_test.go @@ -0,0 +1,33 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestIsNull(t *testing.T) { + f := IsNull{} + + assertFix(t, f, "b());", "b();", true) + assertFix(t, f, "is_null($x);", "is_null($x);", false) + assertFix(t, f, " 0; i-- { + useIdx := lambdaUseIndex(s, i) + if useIdx >= 0 { + fixLambdaImports(s, useIdx) + } + } + compactEmptyTokens(s) + return s.Render() != before +} + +// compactEmptyTokens drops the empty placeholder tokens left by clearing, so the +// rest of the pipeline sees a normal stream. It never changes the rendered bytes. +func compactEmptyTokens(s *tokens.Stream) { + for i := s.Len() - 1; i >= 0; i-- { + if s.At(i).Kind == token.Whitespace && s.At(i).Value == "" { + s.RemoveAt(i) + } + } +} + +// lambdaUseIndex returns the "use" keyword index of the closure declared at +// index, or -1 when index is not a closure with a use-import list. +func lambdaUseIndex(s *tokens.Stream, index int) int { + if !kwIs(s.At(index), "function") || !isLambdaFunction(s, index) { + return -1 + } + u := sigNext(s, index) + if u < 0 { + return -1 + } + if isPunctVal(s, u, "&") { // by-reference return + u = sigNext(s, u) + } + if u < 0 || !isPunctVal(s, u, "(") { + return -1 + } + closeIdx := s.MatchForward(u) + if closeIdx < 0 { + return -1 + } + useIdx := sigNext(s, closeIdx) + if useIdx < 0 || !kwIs(s.At(useIdx), "use") { + return -1 + } + return useIdx +} + +// isLambdaFunction reports whether the "function" at index is anonymous. +func isLambdaFunction(s *tokens.Stream, index int) bool { + n := sigNext(s, index) + return n >= 0 && (isPunctVal(s, n, "(") || isPunctVal(s, n, "&")) +} + +type lambdaImport struct { + name string + idx int +} + +func fixLambdaImports(s *tokens.Stream, useIdx int) { + open := nextPunctOfKind(s, useIdx, "(") + if open < 0 { + return + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + return + } + args := docArgumentSpans(s, open, closeIdx) + imports := filterLambdaImports(s, args) + if len(imports) == 0 { + return + } + notUsed := findNotUsedLambdaImports(s, imports, closeIdx) + if len(notUsed) == 0 { + return + } + if len(notUsed) == len(args) { + clearImportsAndUse(s, useIdx, closeIdx) + return + } + // remove highest index first so lower indices stay valid + for _, im := range slices.Backward(notUsed) { + clearLambdaImport(s, im.idx) + } +} + +// docArgumentSpans splits the paren (open..closeIdx) into top-level argument +// spans (inclusive start/end of the meaningful content between commas). +func docArgumentSpans(s *tokens.Stream, open, closeIdx int) [][2]int { + var spans [][2]int + depth := 0 + start := -1 + flush := func(end int) { + if start < 0 { + return + } + // trim trailing whitespace/comments + e := end + for e >= start && isTrivia(s, e) { + e-- + } + st := start + for st <= e && isTrivia(s, st) { + st++ + } + if st <= e { + spans = append(spans, [2]int{st, e}) + } + start = -1 + } + for j := open + 1; j < closeIdx; j++ { + t := s.At(j) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ",": + if depth == 0 { + flush(j - 1) + continue + } + } + } + if start < 0 && !isTrivia(s, j) { + start = j + } + } + flush(closeIdx - 1) + return spans +} + +func isTrivia(s *tokens.Stream, i int) bool { + k := s.At(i).Kind + return k == token.Whitespace || k == token.Comment || k == token.DocComment +} + +func filterLambdaImports(s *tokens.Stream, args [][2]int) []lambdaImport { + var imports []lambdaImport + for _, sp := range args { + varIdx := -1 + for j := sp[0]; j <= sp[1]; j++ { + if s.At(j).Kind == token.Variable { + varIdx = j + break + } + } + if varIdx < 0 { + continue + } + if p := sigPrev(s, varIdx); p >= 0 && isPunctVal(s, p, "&") { + continue // imported by reference + } + name := s.At(varIdx).Value + if name == "$this" || superGlobalNames[name] { + continue + } + imports = append(imports, lambdaImport{name: name, idx: varIdx}) + } + return imports +} + +func findNotUsedLambdaImports(s *tokens.Stream, imports []lambdaImport, useCloseBrace int) []lambdaImport { + lambdaOpen := nextPunctOfKind(s, useCloseBrace, "{") + if lambdaOpen < 0 { + return nil + } + remaining := make(map[string]bool, len(imports)) + for _, im := range imports { + remaining[im.name] = true + } + level := 0 + for i := lambdaOpen; i < s.Len(); i++ { + t := s.At(i) + if isPunctVal(s, i, "{") { + level++ + continue + } + if isPunctVal(s, i, "}") { + level-- + if level == 0 { + break + } + continue + } + if t.Kind == token.Ident && strings.EqualFold(t.Value, "compact") && lambdaIsGlobalCall(s, i) { + return nil + } + if isLambdaBailoutKeyword(t) { + return nil + } + if isPunctVal(s, i, "$") { + n := sigNext(s, i) + if n >= 0 && (s.At(n).Kind == token.Variable || isPunctVal(s, n, "{")) { + return nil // "$$a" or "${...}" + } + } + if t.Kind == token.Variable && remaining[t.Value] { + delete(remaining, t.Value) + if len(remaining) == 0 { + return nil + } + } + if isClassyKeyword(t) { // anonymous class + j := nextPunctOfKind(s, i, "(", "{") + if j < 0 { + break + } + if isPunctVal(s, j, "(") { + cb := s.MatchForward(j) + if cb < 0 { + break + } + countImportsUsedAsArgument(s, remaining, docArgumentSpans(s, j, cb)) + j = nextPunctOfKind(s, cb, "{") + if j < 0 { + break + } + } + end := s.MatchForward(j) + if end < 0 { + break + } + i = end + continue + } + if kwIs(t, "function") { // nested closure + o := nextPunctOfKind(s, i, "(") + if o < 0 { + break + } + c := s.MatchForward(o) + if c < 0 { + break + } + countImportsUsedAsArgument(s, remaining, docArgumentSpans(s, o, c)) + j := nextUseOrBrace(s, i) + if j < 0 { + break + } + if kwIs(s.At(j), "use") { + o2 := nextPunctOfKind(s, j, "(") + if o2 < 0 { + break + } + c2 := s.MatchForward(o2) + if c2 < 0 { + break + } + countImportsUsedAsArgument(s, remaining, docArgumentSpans(s, o2, c2)) + j = nextPunctOfKind(s, c2, "{") + if j < 0 { + break + } + } + end := s.MatchForward(j) + if end < 0 { + break + } + i = end + continue + } + } + var notUsed []lambdaImport + for _, im := range imports { + if remaining[im.name] { + notUsed = append(notUsed, im) + } + } + return notUsed +} + +func countImportsUsedAsArgument(s *tokens.Stream, remaining map[string]bool, args [][2]int) { + for _, sp := range args { + name := lambdaArgName(s, sp) + if name != "" && remaining[name] { + delete(remaining, name) + } + } +} + +// lambdaArgName returns the argument's variable name when the span reduces to a +// plain (optionally typed / by-ref / defaulted) variable, else "". +func lambdaArgName(s *tokens.Stream, sp [2]int) string { + varIdx := -1 + for j := sp[0]; j <= sp[1]; j++ { + if s.At(j).Kind == token.Variable { + if varIdx >= 0 { + return "" // more than one variable: not a plain name + } + varIdx = j + } + } + if varIdx < 0 { + return "" + } + // anything after the variable other than "= default" disqualifies it + n := sigNext(s, varIdx) + if n >= 0 && n <= sp[1] && !isPunctVal(s, n, "=") { + return "" + } + return s.At(varIdx).Value +} + +func isLambdaBailoutKeyword(t token.Token) bool { + if t.Kind != token.Keyword { + return false + } + switch strings.ToLower(t.Value) { + case "eval", "include", "include_once", "require", "require_once": + return true + } + return false +} + +func isClassyKeyword(t token.Token) bool { + return t.Kind == token.Keyword && classyKeywords[strings.ToLower(t.Value)] +} + +// lambdaIsGlobalCall reports whether the name at i is a global function call +// (reusing isGlobalFunctionCall from the alias fixer) with a following "(". +func lambdaIsGlobalCall(s *tokens.Stream, i int) bool { + n := sigNext(s, i) + return n >= 0 && isPunctVal(s, n, "(") && isGlobalFunctionCall(s, i) +} + +func nextUseOrBrace(s *tokens.Stream, idx int) int { + for j := idx + 1; j < s.Len(); j++ { + if kwIs(s.At(j), "use") { + return j + } + if isPunctVal(s, j, "{") { + return j + } + } + return -1 +} + +// clearLambdaImport removes one imported variable token and its adjoining comma, +// mirroring PhpdocToComment's clearImports. +func clearLambdaImport(s *tokens.Stream, removeIdx int) { + clearTokenAndMergeWS(s, removeIdx) + prev := sigPrev(s, removeIdx) + switch { + case prev >= 0 && isPunctVal(s, prev, ","): + clearTokenAndMergeWS(s, prev) + case prev >= 0 && isPunctVal(s, prev, "("): + clearTokenAndMergeWS(s, sigNext(s, removeIdx)) // the following "," + } +} + +// clearImportsAndUse removes the whole "use (...)" clause. +func clearImportsAndUse(s *tokens.Stream, useIdx, useCloseBrace int) { + for i := useCloseBrace; i >= useIdx; i-- { + if s.At(i).Kind == token.Comment || s.At(i).Kind == token.DocComment { + continue + } + if s.At(i).Kind == token.Whitespace { + pv := getPrevNonWhitespace(s, i) + if pv >= 0 && (s.At(pv).Kind == token.Comment || s.At(pv).Kind == token.DocComment) { + continue + } + } + clearTokenAndMergeWS(s, i) + } +} + +// clearTokenAndMergeWS mirrors Tokens::clearTokenAndMergeSurroundingWhitespace: +// the token becomes an empty whitespace token (renders as nothing) and adjacent +// whitespace is merged, so token indices stay stable. +func clearTokenAndMergeWS(s *tokens.Stream, index int) { + if index < 0 || index >= s.Len() { + return + } + count := s.Len() + clearTokenAt(s, index) + if index == count-1 { + return + } + nextIdx := nonEmptySibling(s, index, 1) + if nextIdx < 0 || s.At(nextIdx).Kind != token.Whitespace { + return + } + prevIdx := nonEmptySibling(s, index, -1) + if prevIdx >= 0 && s.At(prevIdx).Kind == token.Whitespace { + s.SetValue(prevIdx, s.At(prevIdx).Value+s.At(nextIdx).Value) + } else if prevIdx+1 < s.Len() && isEmptyToken(s, prevIdx+1) { + s.Set(prevIdx+1, token.Token{Kind: token.Whitespace, Value: s.At(nextIdx).Value}) + } + clearTokenAt(s, nextIdx) +} + +func clearTokenAt(s *tokens.Stream, i int) { + s.Set(i, token.Token{Kind: token.Whitespace, Value: ""}) +} + +func isEmptyToken(s *tokens.Stream, i int) bool { + return i >= 0 && i < s.Len() && s.At(i).Value == "" +} + +// nonEmptySibling returns the nearest non-empty token index in the given +// direction, skipping empty (cleared) tokens. +func nonEmptySibling(s *tokens.Stream, i, dir int) int { + j := i + dir + for j >= 0 && j < s.Len() { + if s.At(j).Value != "" { + return j + } + j += dir + } + return -1 +} diff --git a/blink/internal/fixer/rules/lambda_not_used_import_test.go b/blink/internal/fixer/rules/lambda_not_used_import_test.go new file mode 100644 index 0000000000..eda893d4f0 --- /dev/null +++ b/blink/internal/fixer/rules/lambda_not_used_import_test.go @@ -0,0 +1,40 @@ +package rules + +import "testing" + +func TestLambdaNotUsedImport(t *testing.T) { + f := LambdaNotUsedImport{} + + // a single unused import removes the whole use() + got, changed := apply(t, f, "" is left untouched. +type LinebreakAfterOpeningTag struct{} + +func (LinebreakAfterOpeningTag) Name() string { + return `PhpCsFixer\Fixer\PhpTag\LinebreakAfterOpeningTagFixer` +} + +func (LinebreakAfterOpeningTag) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpTag/LinebreakAfterOpeningTagFixer.php" +} + +func (LinebreakAfterOpeningTag) Fix(s *tokens.Stream) bool { + if s.Len() < 3 { + return false + } + if s.At(0).Kind != token.OpenTag || s.At(0).Value != " __LINE__). +type MagicConstantCasing struct{} + +func (MagicConstantCasing) Name() string { + return `PhpCsFixer\Fixer\Casing\MagicConstantCasingFixer` +} + +func (MagicConstantCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/MagicConstantCasingFixer.php" +} + +func (MagicConstantCasing) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Ident { + continue + } + if canonical, ok := magicConstants[strings.ToLower(t.Value)]; ok && canonical != t.Value { + s.SetValue(i, canonical) + changed = true + } + } + return changed +} + +var magicMethods = map[string]string{ + "__construct": "__construct", + "__destruct": "__destruct", + "__call": "__call", + "__callstatic": "__callStatic", + "__get": "__get", + "__set": "__set", + "__isset": "__isset", + "__unset": "__unset", + "__sleep": "__sleep", + "__wakeup": "__wakeup", + "__serialize": "__serialize", + "__unserialize": "__unserialize", + "__tostring": "__toString", + "__invoke": "__invoke", + "__set_state": "__set_state", + "__clone": "__clone", + "__debuginfo": "__debugInfo", +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/MagicMethodCasingFixer.php +// +// MagicMethodCasing fixes the casing of magic methods (__CONSTRUCT -> +// __construct, __tostring -> __toString). +type MagicMethodCasing struct{} + +func (MagicMethodCasing) Name() string { + return `PhpCsFixer\Fixer\Casing\MagicMethodCasingFixer` +} + +func (MagicMethodCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/MagicMethodCasingFixer.php" +} + +func (MagicMethodCasing) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Ident { + continue + } + canonical, ok := magicMethods[strings.ToLower(t.Value)] + if !ok || canonical == t.Value { + continue + } + // a magic method is always a declaration or call ("__set("); a constant + // named "__SET" (followed by "=") must not be recased + if nextSignificantValue(s, i) != "(" { + continue + } + s.SetValue(i, canonical) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/method_argument_space.go b/blink/internal/fixer/rules/method_argument_space.go new file mode 100644 index 0000000000..7e9d3a6b7c --- /dev/null +++ b/blink/internal/fixer/rules/method_argument_space.go @@ -0,0 +1,249 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/MethodArgumentSpaceFixer.php +// +// MethodArgumentSpace normalizes single-line spacing around commas inside +// parentheses (calls and signatures): no space before a comma, exactly one +// space after it. Commas inside arrays "[...]", commas followed by a newline +// (multiline alignment) and a trailing comma right before ")" are left alone. +type MethodArgumentSpace struct{} + +func (MethodArgumentSpace) Name() string { + return `PhpCsFixer\Fixer\FunctionNotation\MethodArgumentSpaceFixer` +} + +func (MethodArgumentSpace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/MethodArgumentSpaceFixer.php" +} + +func (MethodArgumentSpace) Fix(s *tokens.Stream) bool { + // ensure_fully_multiline: a call/declaration argument list that already spans + // lines gets one argument per line, "(" and ")" on their own lines. + changed := reflowMultilineArgs(s) + var stack []string + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + stack = append(stack, t.Value) + continue + case ")", "]", "}": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + continue + case ",": + if len(stack) == 0 || stack[len(stack)-1] != "(" { + continue + } + // Multiline arg list: leave alignment untouched. + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && hasNewline(s.At(i+1).Value) { + continue + } + // No space before the comma (single-line only). + if i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + i-- + changed = true + } + // Exactly one space after the comma, except a trailing comma before ")". + if i+1 < s.Len() { + next := s.At(i + 1) + if next.Kind == token.Whitespace { + if next.Value != " " { + s.SetValue(i+1, " ") + changed = true + } + } else if next.Kind != token.Punct || next.Value != ")" { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + i++ + changed = true + } + } + } + } + return changed +} + +// reflowMultilineArgs makes every already-multiline call/declaration argument +// list fully multiline: "(" then each argument on its own line indented one +// level past the call, and ")" on its own line at the call's indentation. +func reflowMultilineArgs(s *tokens.Stream) bool { + changed := false + for open := 0; open < s.Len(); open++ { + if s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 || !argListIsMultiline(s, open, closeIdx) { + continue + } + if !isCallOrDeclParen(s, open) { + continue + } + if sigNext(s, open) == closeIdx { + continue // empty () + } + if reflowParen(s, open, closeIdx) { + changed = true + } + } + return changed +} + +// reflowParen puts each top-level argument of the paren at open on its own line, +// with "(" and ")" on their own lines, indented one level past the call. +func reflowParen(s *tokens.Stream, open, closeIdx int) bool { + changed := false + base := lineIndentBefore(s, open) + argNL := "\n" + base + " " + + var commas []int + depth := 0 + for j := open + 1; j < closeIdx; j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ",": + // skip a trailing comma (nothing but the closer follows it); decided + // now, before edits shift indices + if depth == 0 && sigNext(s, j) != closeIdx { + commas = append(commas, j) + } + } + } + + // apply right-to-left so indices stay valid: ")" first, then commas, then "(" + if editSlotBefore(s, closeIdx, "\n"+base) { + changed = true + } + for _, c := range slices.Backward(commas) { + if editSlotAfter(s, c, argNLAfterComma(s, c, base)) { + changed = true + } + } + if editSlotAfter(s, open, argNL) { + changed = true + } + return changed +} + +// argListIsMultiline reports whether the argument list is split at the top level +// - a newline right after "(" or right after a top-level comma. Newlines that +// only occur inside a nested array/closure argument do not count, matching ECS's +// ensure_fully_multiline trigger. +func argListIsMultiline(s *tokens.Stream, open, closeIdx int) bool { + if n := open + 1; n < closeIdx && s.At(n).Kind == token.Whitespace && hasNewline(s.At(n).Value) { + return true + } + depth := 0 + for j := open + 1; j < closeIdx; j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ",": + if depth == 0 && j+1 < closeIdx && s.At(j+1).Kind == token.Whitespace && hasNewline(s.At(j+1).Value) { + return true + } + } + } + return false +} + +// isCallOrDeclParen reports whether the "(" at open opens a function/method call +// or a function/closure declaration (not a control structure, array or grouping). +func isCallOrDeclParen(s *tokens.Stream, open int) bool { + p := sigPrev(s, open) + if p < 0 { + return false + } + t := s.At(p) + switch t.Kind { + case token.Ident, token.Variable: + return true + case token.Punct: + return t.Value == ")" || t.Value == "]" + case token.Keyword: + lv := strings.ToLower(t.Value) + return lv == "function" || lv == "fn" + } + return false +} + +// lineIndentBefore returns the indentation of the line containing token idx. +func lineIndentBefore(s *tokens.Stream, idx int) string { + for i := idx - 1; i >= 0; i-- { + if t := s.At(i); t.Kind == token.Whitespace && strings.Contains(t.Value, "\n") { + if nl := strings.LastIndexByte(t.Value, '\n'); nl >= 0 { + return t.Value[nl+1:] + } + } + } + return "" +} + +// argNLAfterComma returns the newline+indent to place after a top-level comma +// in a multiline argument list. A blank line an author left between two +// arguments is preserved (ECS keeps blank lines in a multiline argument list and +// only normalizes the indentation), so a comma whose following whitespace holds a +// blank line keeps that blank rather than collapsing to a single newline. +func argNLAfterComma(s *tokens.Stream, comma int, base string) string { + if comma+1 < s.Len() { + ws := s.At(comma + 1) + if ws.Kind == token.Whitespace { + if newlines := strings.Count(ws.Value, "\n"); newlines >= 2 { + return strings.Repeat("\n", newlines) + base + " " + } + } + } + return "\n" + base + " " +} + +// editSlotAfter sets the whitespace immediately after token idx to val. +func editSlotAfter(s *tokens.Stream, idx int, val string) bool { + if idx+1 < s.Len() && s.At(idx+1).Kind == token.Whitespace { + if s.At(idx+1).Value != val { + s.SetValue(idx+1, val) + return true + } + return false + } + s.InsertAt(idx+1, token.Token{Kind: token.Whitespace, Value: val}) + return true +} + +// editSlotBefore sets the whitespace immediately before token idx to val. +func editSlotBefore(s *tokens.Stream, idx int, val string) bool { + if idx > 0 && s.At(idx-1).Kind == token.Whitespace { + if s.At(idx-1).Value != val { + s.SetValue(idx-1, val) + return true + } + return false + } + s.InsertAt(idx, token.Token{Kind: token.Whitespace, Value: val}) + return true +} diff --git a/blink/internal/fixer/rules/method_argument_space_test.go b/blink/internal/fixer/rules/method_argument_space_test.go new file mode 100644 index 0000000000..10e13d4354 --- /dev/null +++ b/blink/internal/fixer/rules/method_argument_space_test.go @@ -0,0 +1,48 @@ +package rules + +import ( + "testing" + + fixerpkg "blink/internal/fixer" +) + +func TestMethodArgumentSpace(t *testing.T) { + cases := []struct { + name string + src string + want string + changed bool + }{ + {"already correct", ""/"?->" that already sits at the +// start of a line to one indentation level (four spaces) past the line the chain +// starts on. It only adjusts the indentation of an existing line break, so it +// never joins or splits calls and is a no-op once the chain is aligned. +type MethodChainingIndentation struct{} + +func (MethodChainingIndentation) Name() string { + return `PhpCsFixer\Fixer\Whitespace\MethodChainingIndentationFixer` +} + +func (MethodChainingIndentation) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/MethodChainingIndentationFixer.php" +} + +func (MethodChainingIndentation) Fix(s *tokens.Stream) bool { + changed := false + for i := 1; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != "->" && t.Value != "?->") { + continue + } + ws := s.At(i - 1) + if ws.Kind != token.Whitespace || !strings.Contains(ws.Value, "\n") { + continue // only operators that already begin a line + } + want := chainFirstLineIndent(s, i) + " " + nl := strings.LastIndexByte(ws.Value, '\n') + got := ws.Value[nl+1:] + if got == want { + continue + } + s.SetValue(i-1, ws.Value[:nl+1]+want) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/method_chaining_newline.go b/blink/internal/fixer/rules/method_chaining_newline.go new file mode 100644 index 0000000000..928ee85b33 --- /dev/null +++ b/blink/internal/fixer/rules/method_chaining_newline.go @@ -0,0 +1,160 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/Spacing/MethodChainingNewlineFixer.php +// +// MethodChainingNewline puts each chained method call on its own line. A "->" +// that directly follows a method call's ")" starts a new line, indented one +// level past the chain. The first call after the chain root (a variable or a +// grouped/constructor expression) stays inline; chains that are call/array +// arguments on the same line, that follow a "::"/"["/"." on the line, that +// follow a multi-line call, or that are already split are left untouched - so it +// is a no-op on code ECS has already formatted. +type MethodChainingNewline struct{} + +func (MethodChainingNewline) Name() string { + return `Symplify\CodingStandard\Fixer\Spacing\MethodChainingNewlineFixer` +} + +func (MethodChainingNewline) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Spacing/MethodChainingNewlineFixer.php" +} + +func (MethodChainingNewline) Fix(s *tokens.Stream) bool { + changed := false + for i := 1; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "->" { + continue + } + prev := sigPrev(s, i) + if prev < 0 || s.At(prev).Kind != token.Punct || s.At(prev).Value != ")" { + continue // "->" must follow a ")" + } + // the ")" must close a method call, not a grouping/constructor paren - + // this keeps the first call after "(new X)" or "(expr)" inline + if !chainIsCallClose(s, prev) { + continue + } + // already split here, or the preceding call spans multiple lines: leave it + if rangeHasNewline(s, prev, i) { + continue + } + if open := s.MatchBackward(prev); open >= 0 && rangeHasNewline(s, open, prev) { + continue + } + // "::", "[", "." or an enclosing "(" earlier on the line means the chain is + // part of a call/array (symplify's isPartOfMethodCallOrArray) - leave it + if chainLineHasBreakingChar(s, prev) { + continue + } + nl := "\n" + chainFirstLineIndent(s, i) + " " + if s.At(i-1).Kind == token.Whitespace { + s.SetValue(i-1, nl) + } else { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: nl}) + i++ + } + changed = true + } + return changed +} + +// chainIsCallClose reports whether the ")" at closeIdx closes a function/method +// call (its "(" follows a name, variable or another call/subscript) rather than +// a grouping or constructor paren. +func chainIsCallClose(s *tokens.Stream, closeIdx int) bool { + open := s.MatchBackward(closeIdx) + if open < 0 { + return false + } + p := sigPrev(s, open) + if p < 0 { + return false + } + t := s.At(p) + if t.Kind == token.Ident || t.Kind == token.Variable { + return true + } + return t.Kind == token.Punct && (t.Value == ")" || t.Value == "]") +} + +// chainFirstLineIndent returns the indentation of the line the chain starts on, +// so every continuation aligns to the same column. It walks back over the whole +// chain expression (jumping across balanced brackets) to its root. +func chainFirstLineIndent(s *tokens.Stream, opIdx int) string { + i := opIdx + root := opIdx + for i > 0 { + t := s.At(i - 1) + switch { + case t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment: + i-- + case t.Kind == token.Ident || t.Kind == token.Variable: + root = i - 1 + i-- + case t.Kind == token.Punct && (t.Value == "->" || t.Value == "?->" || t.Value == "::"): + i-- + case t.Kind == token.Keyword && strings.ToLower(t.Value) == "new": + root = i - 1 + i-- + case t.Kind == token.Punct && (t.Value == ")" || t.Value == "]" || t.Value == "}"): + m := s.MatchBackward(i - 1) + if m < 0 { + return chainBaseIndent(s, root) + } + root = m + i = m + default: + return chainBaseIndent(s, root) // boundary before the chain root + } + } + return chainBaseIndent(s, root) +} + +// chainBaseIndent returns the indentation after the nearest newline before index. +func chainBaseIndent(s *tokens.Stream, index int) string { + for i := index - 1; i >= 0; i-- { + if t := s.At(i); t.Kind == token.Whitespace && strings.Contains(t.Value, "\n") { + if nl := strings.LastIndexByte(t.Value, '\n'); nl >= 0 { + return t.Value[nl+1:] + } + } + } + return "" +} + +// chainLineHasBreakingChar mirrors symplify's isPartOfMethodCallOrArray: walking +// back from the ")" at pos to the start of the line, a "[", "::", "." or "array" +// (or an unmatched enclosing "(") means the chain is treated as part of a call or +// array and is left inline. +func chainLineHasBreakingChar(s *tokens.Stream, pos int) bool { + nesting := 0 + for i := pos; i >= 0; i-- { + t := s.At(i) + if t.Kind == token.Whitespace && strings.Contains(t.Value, "\n") { + return false + } + if t.Kind == token.Punct && (t.Value == "[" || t.Value == "::" || t.Value == ".") { + return true + } + if t.Kind == token.Keyword && strings.ToLower(t.Value) == "array" { + return true + } + if t.Kind == token.Punct && t.Value == ")" { + nesting-- + } else if t.Kind == token.Punct && t.Value == "(" { + if nesting != 0 { + nesting++ + } else { + return true + } + } + } + return false +} diff --git a/blink/internal/fixer/rules/method_chaining_newline_test.go b/blink/internal/fixer/rules/method_chaining_newline_test.go new file mode 100644 index 0000000000..e1daf9bebb --- /dev/null +++ b/blink/internal/fixer/rules/method_chaining_newline_test.go @@ -0,0 +1,44 @@ +package rules + +import "testing" + +func TestMethodChainingNewline(t *testing.T) { + f := MethodChainingNewline{} + + cases := []struct { + src, want string + changed bool + }{ + // plain variable-rooted chain at statement level: split + {"one()->two();", "one()\n ->two();", true}, + {"a()->b()->c();", "a()\n ->b()\n ->c();", true}, + {"one()->two();", "one()\n ->two();", true}, + + // single method call: nothing to split + {"one();", "one();", false}, + + // a "::" earlier on the line suppresses the split (symplify heuristic) + {"one()->two();", "one()->two();", false}, + // a "[" earlier on the line likewise + {"b($m[2])->c();", "b($m[2])->c();", false}, + + // a chain inline inside a call's arguments is left alone + {"a()->b());", "a()->b());", false}, + // grouped root: the first call stays inline, later calls split + {"bar()->baz();", "bar()\n ->baz();", true}, + {"bar();", "bar();", false}, + + // a chain that starts its own line (inside a multi-line expression) splits + {"a($x)->b() ||\n $this->c()->d()\n);", + "a($x)\n ->b() ||\n $this->c()\n ->d()\n);", true}, + + // already multi-line: no-op + {"one()\n ->two();", "one()\n ->two();", false}, + } + for _, c := range cases { + got, changed := apply(t, f, c.src) + if got != c.want || changed != c.changed { + t.Errorf("src=%q\n got=%q changed=%v\nwant=%q changed=%v", c.src, got, changed, c.want, c.changed) + } + } +} diff --git a/blink/internal/fixer/rules/misc_rules.go b/blink/internal/fixer/rules/misc_rules.go new file mode 100644 index 0000000000..5854cbca0c --- /dev/null +++ b/blink/internal/fixer/rules/misc_rules.go @@ -0,0 +1,102 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeNotEqualsFixer.php +// +// StandardizeNotEquals rewrites the "<>" operator to "!=". +type StandardizeNotEquals struct{} + +func (StandardizeNotEquals) Name() string { + return `PhpCsFixer\Fixer\Operator\StandardizeNotEqualsFixer` +} + +func (StandardizeNotEquals) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeNotEqualsFixer.php" +} + +func (StandardizeNotEquals) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + if s.At(i).Kind == token.Punct && s.At(i).Value == "<>" { + s.SetValue(i, "!=") + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Semicolon/NoEmptyStatementFixer.php +// +// NoEmptyStatement removes redundant semicolons ("$a = 1;;" -> "$a = 1;"). The +// empty statements of a for-header (inside "(...)") are left alone. +type NoEmptyStatement struct{} + +func (NoEmptyStatement) Name() string { + return `PhpCsFixer\Fixer\Semicolon\NoEmptyStatementFixer` +} + +func (NoEmptyStatement) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Semicolon/NoEmptyStatementFixer.php" +} + +func (NoEmptyStatement) Fix(s *tokens.Stream) bool { + changed := false + depth := 0 + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[": + depth++ + case ")", "]": + depth-- + case ";": + if depth != 0 { + continue + } + if prev, ok := prevSignificant(s, i); ok && prev.Value == ";" { + s.RemoveAt(i) + i-- + changed = true + } + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/LineEndingFixer.php +// +// LineEnding normalizes CRLF line endings to LF in whitespace and comments. +// String contents (including heredoc) are left untouched. +type LineEnding struct{} + +func (LineEnding) Name() string { + return `PhpCsFixer\Fixer\Whitespace\LineEndingFixer` +} + +func (LineEnding) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/LineEndingFixer.php" +} + +func (LineEnding) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Whitespace && t.Kind != token.Comment && t.Kind != token.DocComment { + continue + } + if v := strings.ReplaceAll(t.Value, "\r\n", "\n"); v != t.Value { + s.SetValue(i, v) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/modifier_keywords.go b/blink/internal/fixer/rules/modifier_keywords.go new file mode 100644 index 0000000000..6eeb61abaf --- /dev/null +++ b/blink/internal/fixer/rules/modifier_keywords.go @@ -0,0 +1,143 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ModifierKeywordsFixer.php +// +// ModifierKeywords gives class constants, properties and methods an explicit +// visibility and orders the modifier keywords canonically: +// inheritance (abstract/final), visibility, scope (static), mutation (readonly), +// then the type declaration and name. +// +// This handles class-body const/property/method members. Constructor promoted +// properties and PHP 8.4 asymmetric set-visibility (public(set)) are left +// untouched; members whose modifier run contains a comment are skipped. +type ModifierKeywords struct{} + +func (ModifierKeywords) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\ModifierKeywordsFixer` +} + +func (ModifierKeywords) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ModifierKeywordsFixer.php" +} + +func (ModifierKeywords) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "{" { + continue + } + if kind, _ := classifyBrace(s, i); kind != braceClassLike { + continue + } + for _, m := range slices.Backward(classMemberStarts(s, i)) { + if orderModifierKeywords(s, m) { + changed = true + } + } + } + return changed +} + +func orderModifierKeywords(s *tokens.Stream, m int) bool { + var absFinal []string + visibility := "" + static := "" + readOnly := "" + hasVisibility := false + + k := m + for k < s.Len() { + t := s.At(k) + if t.Kind == token.Whitespace { + k++ + continue + } + if t.Kind == token.Comment || t.Kind == token.DocComment { + return false // cannot safely reorder across a comment + } + if t.Kind != token.Keyword { + break + } + lw := strings.ToLower(t.Value) + if lw == "use" || lw == "case" { + return false // trait use / enum case is not a member with modifiers + } + if !memberModifiers[lw] { + break // "const", "function" or a type keyword: modifier run ends + } + // set-visibility "public(set)" is not representable on the flat lexer + if next := skipWhitespace(s, k+1); next < s.Len() && s.At(next).Kind == token.Punct && s.At(next).Value == "(" { + return false + } + switch { + case lw == "abstract" || lw == "final": + absFinal = append(absFinal, t.Value) + case lw == "static": + static = t.Value + case lw == "readonly": + readOnly = t.Value + case lw == "var": + visibility = "public" + hasVisibility = true + case visibilityModifiers[lw]: + visibility = t.Value + hasVisibility = true + } + k++ + } + after := k + if after < s.Len() && (s.At(after).Kind == token.Comment || s.At(after).Kind == token.DocComment) { + return false + } + if visibility == "" { + visibility = "public" + } + + ordered := make([]string, 0, len(absFinal)+3) + ordered = append(ordered, absFinal...) + ordered = append(ordered, visibility) + if static != "" { + ordered = append(ordered, static) + } + if readOnly != "" { + ordered = append(ordered, readOnly) + } + + // desired rendering: modifiers single-spaced, one trailing space before the type/name + var want strings.Builder + for _, mod := range ordered { + want.WriteString(mod) + want.WriteString(" ") + } + + // current rendering of the run [m, after) + var cur strings.Builder + for x := m; x < after; x++ { + cur.WriteString(s.At(x).Value) + } + if hasVisibility && cur.String() == want.String() { + return false + } + + repl := make([]token.Token, 0, len(ordered)*2) + for _, mod := range ordered { + repl = append(repl, token.Token{Kind: token.Keyword, Value: mod}) + repl = append(repl, token.Token{Kind: token.Whitespace, Value: " "}) + } + if after == m { + for _, tk := range slices.Backward(repl) { + s.InsertAt(m, tk) + } + return true + } + s.ReplaceRange(m, after-1, repl) + return true +} diff --git a/blink/internal/fixer/rules/modifier_keywords_test.go b/blink/internal/fixer/rules/modifier_keywords_test.go new file mode 100644 index 0000000000..b004df5cf7 --- /dev/null +++ b/blink/internal/fixer/rules/modifier_keywords_test.go @@ -0,0 +1,24 @@ +package rules + +import "testing" + +func TestModifierKeywords(t *testing.T) { + f := ModifierKeywords{} + + // reorders modifiers and adds missing visibility across members + got, changed := apply(t, f, " no change (idempotent) + if _, changed := apply(t, OrderedImports{}, want); changed { + t.Fatal("already-grouped imports should not change") + } +} + +func TestBlankLineBetweenImportGroups(t *testing.T) { + got, changed := apply(t, BlankLineBetweenImportGroups{}, "= 0 && isComment(s.At(i-2)) && startsWithNewline(s.At(prevIdx).Value) { + sig := prevValueTokenIndex(s, i) + s.RemoveAt(i) // drop the ";" + s.RemoveAt(prevIdx) // drop the whitespace + s.InsertAt(sig+1, token.Token{Kind: token.Punct, Value: ";"}) + changed = true + continue + } + // plain case: drop the multi-line whitespace so ";" follows the code + s.RemoveAt(prevIdx) + i-- + changed = true + } + return changed +} + +func startsWithNewline(v string) bool { + return strings.HasPrefix(v, "\n") || strings.HasPrefix(v, "\r") +} + +// prevValueTokenIndex scans back from a ";" for the token the ";" belongs to, +// stopping at a number, name, variable, string or ")" (PHP-CS-Fixer's +// getPreviousSignificantTokenIndex). +func prevValueTokenIndex(s *tokens.Stream, i int) int { + for j := i - 1; j > 0; j-- { + t := s.At(j) + if t.Kind == token.Number || t.Kind == token.Ident || t.Kind == token.Variable || + t.Kind == token.String || (t.Kind == token.Punct && t.Value == ")") { + return j + } + } + return i +} diff --git a/blink/internal/fixer/rules/no_alternative_syntax.go b/blink/internal/fixer/rules/no_alternative_syntax.go new file mode 100644 index 0000000000..84976f11a1 --- /dev/null +++ b/blink/internal/fixer/rules/no_alternative_syntax.go @@ -0,0 +1,167 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoAlternativeSyntaxFixer.php +// +// NoAlternativeSyntax replaces control-structure alternative syntax with braces +// ("if (x): ... endif;" -> "if (x) { ... }"). Matches ECS's default +// (fix_non_monolithic_code=true), so inline-HTML blocks are converted too. +type NoAlternativeSyntax struct{} + +func (NoAlternativeSyntax) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\NoAlternativeSyntaxFixer` +} + +func (NoAlternativeSyntax) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoAlternativeSyntaxFixer.php" +} + +var nasOpenControls = map[string]bool{ + "if": true, "foreach": true, "while": true, "for": true, "switch": true, "declare": true, +} + +var nasEndControls = map[string]bool{ + "endif": true, "endforeach": true, "endwhile": true, "endfor": true, + "endswitch": true, "enddeclare": true, +} + +func nasIsKeyword(t token.Token, set map[string]bool) bool { + return t.Kind == token.Keyword && set[strings.ToLower(t.Value)] +} + +func (NoAlternativeSyntax) Fix(s *tokens.Stream) bool { + changed := false + for index := s.Len() - 1; index >= 0; index-- { + tok := s.At(index) + if tok.Kind == token.Keyword && strings.EqualFold(tok.Value, "elseif") { + changed = nasFixElseif(s, index) || changed + continue + } + if tok.Kind == token.Keyword && strings.EqualFold(tok.Value, "else") { + changed = nasFixElse(s, index) || changed + continue + } + changed = nasFixOpenClose(s, index) || changed + } + return changed +} + +func nasFixOpenClose(s *tokens.Stream, index int) bool { + tok := s.At(index) + if nasIsKeyword(tok, nasOpenControls) { + openIndex := nasNextParen(s, index) + if openIndex == -1 { + return false + } + closeIndex := s.MatchForward(openIndex) + if closeIndex == -1 { + return false + } + afterIndex := nextMeaningfulIndex(s, closeIndex) + if afterIndex == -1 || !nasIsPunct(s.At(afterIndex), ":") { + return false + } + var items []token.Token + if afterIndex-1 >= 0 && s.At(afterIndex-1).Kind != token.Whitespace { + items = append(items, token.Token{Kind: token.Whitespace, Value: " "}) + } + items = append(items, token.Token{Kind: token.Punct, Value: "{"}) + if afterIndex+1 < s.Len() && s.At(afterIndex+1).Kind != token.Whitespace { + items = append(items, token.Token{Kind: token.Whitespace, Value: " "}) + } + nasReplace(s, afterIndex, items) + return true + } + + if !nasIsKeyword(tok, nasEndControls) { + return false + } + nextIndex := nextMeaningfulIndex(s, index) + s.Set(index, token.Token{Kind: token.Punct, Value: "}"}) + if nextIndex != -1 && nasIsPunct(s.At(nextIndex), ";") { + s.RemoveAt(nextIndex) + } + return true +} + +func nasFixElse(s *tokens.Stream, index int) bool { + afterIndex := nextMeaningfulIndex(s, index) + if afterIndex == -1 || !nasIsPunct(s.At(afterIndex), ":") { + return false + } + nasAddBraces(s, token.Token{Kind: token.Keyword, Value: "else"}, index, afterIndex) + return true +} + +func nasFixElseif(s *tokens.Stream, index int) bool { + parenEnd := nasFindParenthesisEnd(s, index) + afterIndex := nextMeaningfulIndex(s, parenEnd) + if afterIndex == -1 || !nasIsPunct(s.At(afterIndex), ":") { + return false + } + nasAddBraces(s, token.Token{Kind: token.Keyword, Value: "elseif"}, index, afterIndex) + return true +} + +// nasAddBraces turns "else :" / "elseif (...) :" into "} else {" / "} elseif (...) {". +func nasAddBraces(s *tokens.Stream, keyword token.Token, index, colonIndex int) { + trailingWs := index+1 < s.Len() && s.At(index+1).Kind != token.Whitespace + open := []token.Token{ + {Kind: token.Punct, Value: "}"}, + {Kind: token.Whitespace, Value: " "}, + keyword, + } + if trailingWs { + open = append(open, token.Token{Kind: token.Whitespace, Value: " "}) + } + nasReplace(s, index, open) + + colonIndex += len(open) - 1 + closing := []token.Token{{Kind: token.Punct, Value: "{"}} + if colonIndex+1 < s.Len() && s.At(colonIndex+1).Kind != token.Whitespace { + closing = append(closing, token.Token{Kind: token.Whitespace, Value: " "}) + } + nasReplace(s, colonIndex, closing) +} + +// nasReplace replaces the single token at pos with items, preserving order. +func nasReplace(s *tokens.Stream, pos int, items []token.Token) { + s.RemoveAt(pos) + for i, t := range items { + s.InsertAt(pos+i, t) + } +} + +// nasNextParen returns the index of the first "(" after index, or -1. +func nasNextParen(s *tokens.Stream, index int) int { + for j := index + 1; j < s.Len(); j++ { + if nasIsPunct(s.At(j), "(") { + return j + } + } + return -1 +} + +func nasIsPunct(t token.Token, v string) bool { + return t.Kind == token.Punct && t.Value == v +} + +// nasFindParenthesisEnd returns the ")" index of the control's condition, or the +// control token index itself when it has no parenthesis. +func nasFindParenthesisEnd(s *tokens.Stream, controlIndex int) int { + ni := nextMeaningfulIndex(s, controlIndex) + if ni == -1 || !nasIsPunct(s.At(ni), "(") { + return controlIndex + } + end := s.MatchForward(ni) + if end == -1 { + return controlIndex + } + return end +} diff --git a/blink/internal/fixer/rules/no_alternative_syntax_test.go b/blink/internal/fixer/rules/no_alternative_syntax_test.go new file mode 100644 index 0000000000..4e3e5ca5aa --- /dev/null +++ b/blink/internal/fixer/rules/no_alternative_syntax_test.go @@ -0,0 +1,44 @@ +package rules + +import "testing" + +func TestNoAlternativeSyntax(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + // if / else / endif + {`hi", "hi", true}, + } + for _, c := range cases { + got, changed := apply(t, NoAlternativeSyntax{}, c.src) + if got != c.want || changed != c.changed { + t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want) + } + if again, changed2 := apply(t, NoAlternativeSyntax{}, got); changed2 || again != got { + t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2) + } + } +} diff --git a/blink/internal/fixer/rules/no_blank_line_between_imports.go b/blink/internal/fixer/rules/no_blank_line_between_imports.go new file mode 100644 index 0000000000..445da079ad --- /dev/null +++ b/blink/internal/fixer/rules/no_blank_line_between_imports.go @@ -0,0 +1,60 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/NoBlankLineBetweenImportsFixer.php +// +// NoBlankLineBetweenImports removes blank lines between consecutive import "use" +// statements. The blank line after the namespace (before the first import) is +// left untouched. +type NoBlankLineBetweenImports struct{} + +func (NoBlankLineBetweenImports) Name() string { + return `Symplify\CodingStandard\Fixer\Spacing\NoBlankLineBetweenImportsFixer` +} + +func (NoBlankLineBetweenImports) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/NoBlankLineBetweenImportsFixer.php" +} + +func (NoBlankLineBetweenImports) Fix(s *tokens.Stream) bool { + // collect import "use" indexes (skip in-class trait use and closure use) + var uses []int + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Keyword || strings.ToLower(s.At(i).Value) != "use" { + continue + } + if inClassLikeBody(s, i) { + continue + } + if j := skipWhitespace(s, i+1); j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + continue + } + uses = append(uses, i) + } + + changed := false + // start at the 2nd use, so the blank line after the namespace stays + for k := 1; k < len(uses); k++ { + useIndex := uses[k] + prev := sigPrev(s, useIndex) + if prev < 0 || s.At(prev).Kind != token.Punct || s.At(prev).Value != ";" { + continue + } + ws := useIndex - 1 + if ws < 0 || s.At(ws).Kind != token.Whitespace { + continue + } + if strings.Count(s.At(ws).Value, "\n") < 2 { + continue + } + s.SetValue(ws, "\n") + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/no_blank_line_between_imports_test.go b/blink/internal/fixer/rules/no_blank_line_between_imports_test.go new file mode 100644 index 0000000000..fec7108bbc --- /dev/null +++ b/blink/internal/fixer/rules/no_blank_line_between_imports_test.go @@ -0,0 +1,23 @@ +package rules + +import "testing" + +func TestNoBlankLineBetweenImports(t *testing.T) { + f := NoBlankLineBetweenImports{} + + // blank lines between consecutive imports collapse; the namespace gap stays + got, changed := apply(t, f, "= 0; i-- { + t := s.At(i) + if kwIs(t, "default") { + if n := sigNext(s, i); n >= 0 && isPunctVal(s, n, "=>") { + continue // "default" of a match expression + } + } else if !kwIs(t, "case") || isEnumCase(s, i, hasEnum) { + continue + } + + colon := nextPunctOfKind(s, i, ":", ";") + if colon < 0 { + continue + } + fixNoBreakCase(s, colon) + } + return s.Render() != before +} + +func fixNoBreakCase(s *tokens.Stream, casePosition int) { + empty := true + fallThrough := true + commentPosition := -1 + + for i := casePosition + 1; i < s.Len(); i++ { + t := s.At(i) + + if t.Kind == token.Keyword { + lv := strings.ToLower(t.Value) + if structureKinds[lv] || lv == "else" || lv == "do" || lv == "class" { + empty = false + i = noBreakStructureEnd(s, i) + continue + } + switch lv { + case "break", "continue", "return", "exit", "die", "goto": + fallThrough = false + continue + case "throw": + prev := sigPrev(s, i) + if prev == casePosition || (prev >= 0 && isThrowStatementStart(s, prev)) { + fallThrough = false + } + continue + case "endswitch": + if commentPosition >= 0 { + removeNoBreakComment(s, commentPosition) + } + return + case "case", "default": + handleNextCase(s, i, empty, fallThrough, commentPosition) + return + } + } + + if isPunctVal(s, i, "}") { + if commentPosition >= 0 { + removeNoBreakComment(s, commentPosition) + } + return + } + + if isNoBreakCommentToken(t) { + commentPosition = i + continue + } + + if t.Kind != token.Comment && t.Kind != token.DocComment && t.Kind != token.Whitespace { + empty = false + } + } +} + +// handleNextCase applies the fall-through decision when the scan reaches the +// following case/default. +func handleNextCase(s *tokens.Stream, casePos int, empty, fallThrough bool, commentPosition int) { + if !empty && fallThrough { + if commentPosition >= 0 && getPrevNonWhitespace(s, casePos) != commentPosition { + removeNoBreakComment(s, commentPosition) + commentPosition = -1 + } + if commentPosition < 0 { + insertNoBreakCommentAt(s, casePos) + } else { + ensureNewLineAt(s, commentPosition) + } + return + } + if commentPosition >= 0 { + removeNoBreakComment(s, commentPosition) + } +} + +func isThrowStatementStart(s *tokens.Stream, prev int) bool { + if s.At(prev).Kind == token.OpenTag { + return true + } + if s.At(prev).Kind == token.Punct { + switch s.At(prev).Value { + case "{", ";", "}": + return true + } + } + return false +} + +func isNoBreakCommentToken(t token.Token) bool { + if t.Kind != token.Comment && t.Kind != token.DocComment { + return false + } + return noBreakCommentRe.MatchString(t.Value) +} + +// noBreakStructureEnd mirrors getStructureEnd: the index of the token closing +// the control structure that starts at position. +func noBreakStructureEnd(s *tokens.Stream, position int) int { + initial := strings.ToLower(s.At(position).Value) + + if structureKinds[initial] { + if op := nextPunctOfKind(s, position, "("); op >= 0 { + position = s.MatchForward(op) + } + } else if initial == "class" { + op := sigNext(s, position) + if op >= 0 && isPunctVal(s, op, "(") { + position = s.MatchForward(op) + } + } + + if initial == "function" { + position = nextPunctOfKind(s, position, "{") + } else { + position = sigNext(s, position) + } + if position < 0 { + return s.Len() - 1 + } + + if !isPunctVal(s, position, "{") { + return nextPunctOfKind(s, position, ";") + } + + position = s.MatchForward(position) + + if initial == "do" { + if op := nextPunctOfKind(s, position, "("); op >= 0 { + position = s.MatchForward(op) + } + return nextPunctOfKind(s, position, ";") + } + return position +} + +func insertNoBreakCommentAt(s *tokens.Stream, casePosition int) { + newlinePosition := ensureNewLineAt(s, casePosition) + content := s.At(newlinePosition).Value + nbNewlines := strings.Count(content, "\n") + + if s.At(newlinePosition).Kind == token.OpenTag && hasNewline(content) { + nbNewlines++ + } else if newlinePosition-1 >= 0 && s.At(newlinePosition-1).Kind == token.OpenTag && hasNewline(s.At(newlinePosition-1).Value) { + nbNewlines++ + if !hasNewline(content) { + s.SetValue(newlinePosition, "\n"+content) + content = s.At(newlinePosition).Value + } + } + + if nbNewlines > 1 { + head, tail := splitTrailingNewline(content) + indent := detectIndent(s, newlinePosition-1) + s.SetValue(newlinePosition, head+"\n"+indent) + newlinePosition++ + s.InsertAt(newlinePosition, token.Token{Kind: token.Whitespace, Value: tail}) + } + + s.InsertAt(newlinePosition, token.Token{Kind: token.Comment, Value: "// " + noBreakCommentText}) + ensureNewLineAt(s, newlinePosition) +} + +// ensureNewLineAt guarantees a newline before position and returns the index of +// the newline token. +func ensureNewLineAt(s *tokens.Stream, position int) int { + content := "\n" + detectIndent(s, position) + if position-1 < 0 { + return position + } + ws := s.At(position - 1) + + if ws.Kind != token.Whitespace { + if ws.Kind == token.OpenTag { + content = stripNewlines(content) + if !hasNewline(ws.Value) { + s.SetValue(position-1, replaceTrailingSpace(ws.Value, "\n")) + } + } + if content != "" { + s.InsertAt(position, token.Token{Kind: token.Whitespace, Value: content}) + return position + } + return position - 1 + } + + if position-2 >= 0 && s.At(position-2).Kind == token.OpenTag && hasNewline(s.At(position-2).Value) { + content = strings.TrimPrefix(content, "\n") + } + if !hasNewline(ws.Value) { + s.SetValue(position-1, content) + } + return position - 1 +} + +func removeNoBreakComment(s *tokens.Stream, commentPosition int) { + var whitespacePosition int + prevNW := getPrevNonWhitespace(s, commentPosition) + afterOpenTag := prevNW >= 0 && s.At(prevNW).Kind == token.OpenTag + + if afterOpenTag { + whitespacePosition = commentPosition + 1 + } else { + whitespacePosition = commentPosition - 1 + } + + if whitespacePosition >= 0 && whitespacePosition < s.Len() && s.At(whitespacePosition).Kind == token.Whitespace { + v := s.At(whitespacePosition).Value + if afterOpenTag { + v = stripLeadingNewlineIndent(v) + } else { + v = stripTrailingNewlineIndent(v) + } + if v == "" { + s.RemoveAt(whitespacePosition) + if whitespacePosition < commentPosition { + commentPosition-- + } + } else { + s.SetValue(whitespacePosition, v) + } + } + + clearAndMergeWhitespace(s, commentPosition) +} + +// isEnumCase reports whether the "case" at caseIndex belongs to an enum. +func isEnumCase(s *tokens.Stream, caseIndex int, hasEnum bool) bool { + if !hasEnum { + return false + } + prev := caseIndex + for { + prev = prevOfKinds(s, prev) + if prev < 0 { + return false + } + if isPunctVal(s, prev, "}") { + prev = s.MatchBackward(prev) + if prev < 0 { + return false + } + continue + } + return kwIs(s.At(prev), "enum") + } +} + +// prevOfKinds finds the previous "}", "enum" or "switch" before idx. +func prevOfKinds(s *tokens.Stream, idx int) int { + for j := idx - 1; j >= 0; j-- { + if isPunctVal(s, j, "}") { + return j + } + if s.At(j).Kind == token.Keyword { + switch strings.ToLower(s.At(j).Value) { + case "enum", "switch": + return j + } + } + } + return -1 +} + +func kwIs(t token.Token, name string) bool { + return t.Kind == token.Keyword && strings.EqualFold(t.Value, name) +} + +func isPunctVal(s *tokens.Stream, i int, v string) bool { + return i >= 0 && i < s.Len() && s.At(i).Kind == token.Punct && s.At(i).Value == v +} + +// nextPunctOfKind scans forward from idx for a punctuation token matching one of +// vals and returns its index, or -1. +func nextPunctOfKind(s *tokens.Stream, idx int, vals ...string) int { + for j := idx + 1; j < s.Len(); j++ { + if s.At(j).Kind != token.Punct { + continue + } + for _, v := range vals { + if s.At(j).Value == v { + return j + } + } + } + return -1 +} + +func getPrevNonWhitespace(s *tokens.Stream, i int) int { + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind != token.Whitespace { + return j + } + } + return -1 +} + +func prevWhitespace(s *tokens.Stream, i int) int { + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind == token.Whitespace { + return j + } + } + return -1 +} + +// detectIndent mirrors WhitespacesAnalyzer::detectIndent. +func detectIndent(s *tokens.Stream, index int) string { + idx := index + for { + wi := prevWhitespace(s, idx) + if wi < 0 { + return "" + } + w := s.At(wi).Value + if strings.Contains(w, "\n") { + parts := strings.Split(w, "\n") + return parts[len(parts)-1] + } + if wi-1 >= 0 { + prev := s.At(wi - 1) + if (prev.Kind == token.OpenTag || prev.Kind == token.Comment) && strings.HasSuffix(prev.Value, "\n") { + parts := strings.Split(w, "\n") + return parts[len(parts)-1] + } + } + idx = wi + } +} + +func splitTrailingNewline(content string) (head, tail string) { + nl := strings.LastIndexByte(content, '\n') + if nl < 0 { + return content, "" + } + return content[:nl], content[nl:] +} + +func stripNewlines(v string) string { + return strings.NewReplacer("\r", "", "\n", "").Replace(v) +} + +func replaceTrailingSpace(v, repl string) string { + trimmed := strings.TrimRight(v, " \t\r\n") + if trimmed == v { + return v + } + return trimmed + repl +} + +func stripLeadingNewlineIndent(v string) string { + i := 0 + if i < len(v) && (v[i] == '\n' || v[i] == '\r') { + for i < len(v) && (v[i] == '\n' || v[i] == '\r') { + i++ + } + for i < len(v) && (v[i] == ' ' || v[i] == '\t') { + i++ + } + return v[i:] + } + return v +} + +func stripTrailingNewlineIndent(v string) string { + end := len(v) + j := end + for j > 0 && (v[j-1] == ' ' || v[j-1] == '\t') { + j-- + } + if j > 0 && (v[j-1] == '\n' || v[j-1] == '\r') { + for j > 0 && (v[j-1] == '\n' || v[j-1] == '\r') { + j-- + } + return v[:j] + } + return v +} + +// clearAndMergeWhitespace removes the token at i and merges surrounding +// whitespace, mirroring Tokens::clearTokenAndMergeSurroundingWhitespace on the +// rendered bytes. +func clearAndMergeWhitespace(s *tokens.Stream, i int) { + s.RemoveAt(i) +} diff --git a/blink/internal/fixer/rules/no_break_comment_test.go b/blink/internal/fixer/rules/no_break_comment_test.go new file mode 100644 index 0000000000..0c15883188 --- /dev/null +++ b/blink/internal/fixer/rules/no_break_comment_test.go @@ -0,0 +1,39 @@ +package rules + +import "testing" + +func TestNoBreakComment(t *testing.T) { + f := NoBreakComment{} + + // fall-through with a non-empty body gains a "// no break" comment + got, changed := apply(t, f, "= 1; i-- { + t := s.At(i) + if t.Kind != token.Punct || t.Value != ";" { + continue + } + prev := s.At(i - 1) + if prev.Kind == token.Whitespace && !strings.ContainsAny(prev.Value, "\n\r") { + s.RemoveAt(i - 1) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/no_superfluous_elseif.go b/blink/internal/fixer/rules/no_superfluous_elseif.go new file mode 100644 index 0000000000..66bde601fb --- /dev/null +++ b/blink/internal/fixer/rules/no_superfluous_elseif.go @@ -0,0 +1,264 @@ +package rules + +import ( + "regexp" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoSuperfluousElseifFixer.php +// +// NoSuperfluousElseif replaces `elseif` with `if` when every preceding branch of +// the if-chain always exits (return/throw/break/continue/exit/goto), so the +// elseif is not actually reachable as an else. It ports the flow analysis from +// AbstractNoUselessElseFixer. +type NoSuperfluousElseif struct{} + +func (NoSuperfluousElseif) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\NoSuperfluousElseifFixer` +} + +func (NoSuperfluousElseif) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoSuperfluousElseifFixer.php" +} + +var nseTrailingIndentRe = regexp.MustCompile(`(?:\r\n|\r|\n)[^\r\n]*$`) + +func (NoSuperfluousElseif) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if nseIsElseif(s, i) && nseIsSuperfluousElse(s, i) { + nseConvertElseifToIf(s, i) + changed = true + } + } + return changed +} + +func nseIsElseif(s *tokens.Stream, i int) bool { + if kwIs(s.At(i), "elseif") { + return true + } + if kwIs(s.At(i), "else") { + n := sigNext(s, i) + return n >= 0 && kwIs(s.At(n), "if") + } + return false +} + +func nseConvertElseifToIf(s *tokens.Stream, index int) { + if kwIs(s.At(index), "else") { + // clearTokenAndMergeSurroundingWhitespace: merge the whitespace on both + // sides of the removed "else" into one; index then lands on the "if". + before, after := index-1, index+1 + if before >= 0 && s.At(before).Kind == token.Whitespace && + after < s.Len() && s.At(after).Kind == token.Whitespace { + s.SetValue(before, s.At(before).Value+s.At(after).Value) + s.RemoveAt(after) + } + s.RemoveAt(index) + } else { + s.Set(index, token.Token{Kind: token.Keyword, Value: "if"}) + } + + whitespace := "" + for previous := index - 1; previous > 0; previous-- { + t := s.At(previous) + if t.Kind == token.Whitespace { + if m := nseTrailingIndentRe.FindString(t.Value); m != "" { + whitespace = m + break + } + } + } + if whitespace == "" { + return + } + + if index-1 < 0 { + return + } + prev := s.At(index - 1) + if prev.Kind != token.Whitespace { + s.InsertAt(index, token.Token{Kind: token.Whitespace, Value: whitespace}) + } else if !strings.ContainsAny(prev.Value, "\r\n") { + s.SetValue(index-1, whitespace) + } +} + +func nseIsSuperfluousElse(s *tokens.Stream, index int) bool { + previousBlockStart := index + for { + var previousBlockEnd int + previousBlockStart, previousBlockEnd = nseGetPreviousBlock(s, previousBlockStart) + if previousBlockStart < 0 || previousBlockEnd < 0 { + return false + } + + previous := previousBlockEnd + if isPunctVal(s, previous, "}") { + previous = sigPrev(s, previous) + } + if previous < 0 { + return false + } + // 'if' block doesn't end with ';' -> keep; empty '{ }' block -> keep + if !isPunctVal(s, previous, ";") || isPunctVal(s, sigPrev(s, previous), "{") { + return false + } + + candidateIndex := nsePrevTokenOfKind(s, previous, nseTerminators) + if candidateIndex < 0 { + return false + } + cand := s.At(candidateIndex) + if isPunctVal(s, candidateIndex, ";") || cand.Kind == token.CloseTag || kwIs(cand, "if") { + return false + } + + if kwIs(cand, "throw") { + pi := sigPrev(s, candidateIndex) + if !isPunctVal(s, pi, ";") && !isPunctVal(s, pi, "{") { + return false + } + } + + if nseIsInConditional(s, candidateIndex, previousBlockStart) || + nseIsInConditionWithoutBraces(s, candidateIndex, previousBlockStart) { + return false + } + + if kwIs(s.At(previousBlockStart), "if") { + break + } + } + return true +} + +func nseGetPreviousBlock(s *tokens.Stream, index int) (int, int) { + closeIdx := sigPrev(s, index) + previous := closeIdx + if closeIdx >= 0 && isPunctVal(s, closeIdx, "}") { + previous = s.MatchBackward(closeIdx) + } + if previous < 0 { + return -1, closeIdx + } + open := nsePrevTokenOfKind(s, previous, nseIfElseElseif) + if open < 0 { + return -1, closeIdx + } + if kwIs(s.At(open), "if") { + elseCandidate := sigPrev(s, open) + if elseCandidate >= 0 && kwIs(s.At(elseCandidate), "else") { + open = elseCandidate + } + } + return open, closeIdx +} + +func nseIsInConditional(s *tokens.Stream, index, lowerLimit int) bool { + candidateIndex := nsePrevTokenOfKind(s, index, nseParenSemiColon) + if candidateIndex < 0 { + return false + } + if isPunctVal(s, candidateIndex, ":") { + return true + } + if !isPunctVal(s, candidateIndex, ")") { + return false + } + open := s.MatchBackward(candidateIndex) + if open < 0 { + return false + } + return sigPrev(s, open) > lowerLimit +} + +func nseIsInConditionWithoutBraces(s *tokens.Stream, index, lowerLimit int) bool { + for index > lowerLimit { + if k := s.At(index).Kind; k == token.Comment || k == token.DocComment || k == token.Whitespace { + index = sigPrev(s, index) + if index < 0 { + return false + } + } + t := s.At(index) + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "if", "elseif", "else": + return true + } + } + if isPunctVal(s, index, ";") { + return false + } + if isPunctVal(s, index, "{") { + index = sigPrev(s, index) + if index >= 0 && kwIs(s.At(index), "do") { + index-- + continue + } + if !isPunctVal(s, index, ")") { + return false + } + index = s.MatchBackward(index) + if index < 0 { + return false + } + index = sigPrev(s, index) + if index >= 0 && (kwIs(s.At(index), "if") || kwIs(s.At(index), "elseif")) { + return false + } + } else if isPunctVal(s, index, ")") { + index = s.MatchBackward(index) + if index < 0 { + return false + } + index = sigPrev(s, index) + } else { + index-- + } + } + return false +} + +// token-kind specs for nsePrevTokenOfKind +type nseKind struct { + kw string // keyword name (lowercase), or "" + punct string // punct value, or "" + close bool // matches token.CloseTag +} + +var nseTerminators = []nseKind{ + {punct: ";"}, {kw: "break"}, {close: true}, {kw: "continue"}, + {kw: "exit"}, {kw: "die"}, {kw: "goto"}, {kw: "if"}, {kw: "return"}, {kw: "throw"}, +} +var nseIfElseElseif = []nseKind{{kw: "if"}, {kw: "else"}, {kw: "elseif"}} +var nseParenSemiColon = []nseKind{{punct: ")"}, {punct: ";"}, {punct: ":"}} + +func nseMatchKind(t token.Token, k nseKind) bool { + if k.close { + return t.Kind == token.CloseTag + } + if k.kw != "" { + return t.Kind == token.Keyword && strings.EqualFold(t.Value, k.kw) + } + return t.Kind == token.Punct && t.Value == k.punct +} + +// nsePrevTokenOfKind mirrors Tokens::getPrevTokenOfKind: scan back from before +// idx for the first token matching any spec. +func nsePrevTokenOfKind(s *tokens.Stream, idx int, kinds []nseKind) int { + for j := idx - 1; j >= 0; j-- { + t := s.At(j) + for _, k := range kinds { + if nseMatchKind(t, k) { + return j + } + } + } + return -1 +} diff --git a/blink/internal/fixer/rules/no_superfluous_elseif_test.go b/blink/internal/fixer/rules/no_superfluous_elseif_test.go new file mode 100644 index 0000000000..3b12395a89 --- /dev/null +++ b/blink/internal/fixer/rules/no_superfluous_elseif_test.go @@ -0,0 +1,33 @@ +package rules + +import "testing" + +func TestNoSuperfluousElseif(t *testing.T) { + f := NoSuperfluousElseif{} + + // preceding branch returns -> elseif becomes if + got, changed := apply(t, f, " unchanged + if _, changed := apply(t, f, " normalized native type ("" if none) + ret string // normalized native return type ("" if none) + hasRet bool + varType string // normalized native type of a documented property + hasVar bool // the docblock documents a property +} + +// signatureAfter finds the function declaration a docblock at index i documents +// and extracts its parameter and return types. +func signatureAfter(s *tokens.Stream, doc int) (funcSig, bool) { + // Walk forward across whitespace, attributes (#[...] Comment) and modifier + // keywords to the "function" keyword. + j := doc + 1 + for j < s.Len() { + t := s.At(j) + switch t.Kind { + case token.Whitespace, token.Comment: + j++ + continue + case token.Keyword: + lv := strings.ToLower(t.Value) + if lv == "function" { + return parseSignature(s, j) + } + if lv == "const" { + return funcSig{}, false + } + if lv == "var" || isFuncModifier(lv) || lv == "readonly" { + j++ + continue + } + // a typed property whose type is a reserved word (e.g. "array") + return parseProperty(s, j) + case token.Ident, token.Punct, token.Variable: + // a property: "Foo $x", "?Foo $x", "\Foo\Bar $x", or untyped "$x" + return parseProperty(s, j) + default: + return funcSig{}, false + } + } + return funcSig{}, false +} + +// parseProperty reads the native type of a property declaration starting at the +// first type (or variable) token, for validating a documenting @var tag. +func parseProperty(s *tokens.Stream, start int) (funcSig, bool) { + var typeToks []string + for k := start; k < s.Len(); k++ { + t := s.At(k) + switch t.Kind { + case token.Whitespace, token.Comment: + continue + case token.Variable: + return funcSig{hasVar: true, varType: normalizeType(strings.Join(typeToks, ""))}, true + case token.Ident, token.Keyword: + typeToks = append(typeToks, t.Value) + case token.Punct: + if t.Value == "?" || t.Value == "|" || t.Value == "&" || t.Value == `\` { + typeToks = append(typeToks, t.Value) + continue + } + return funcSig{}, false + default: + return funcSig{}, false + } + } + return funcSig{}, false +} + +func isFuncModifier(v string) bool { + switch v { + case "public", "private", "protected", "static", "final", "abstract": + return true + } + return false +} + +// parseSignature parses "(...)" params and an optional ": returnType" starting at +// the "function" keyword index. +func parseSignature(s *tokens.Stream, fn int) (funcSig, bool) { + open := nextSignificantIndex(s, fn) + // skip an optional function name and the "&" of a by-ref function + for open >= 0 && s.At(open).Kind != token.Punct { + open = nextSignificantIndex(s, open) + } + if open < 0 || s.At(open).Value == "&" { + open = nextSignificantIndex(s, open) + } + if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + return funcSig{}, false + } + close := s.MatchForward(open) + if close < 0 { + return funcSig{}, false + } + sig := funcSig{params: map[string]string{}} + parseParams(s, open, close, &sig) + + // return type: ": type ..." up to "{" or ";" + c := nextSignificantIndex(s, close) + if c >= 0 && s.At(c).Kind == token.Punct && s.At(c).Value == ":" { + var parts []string + for k := c + 1; k < s.Len(); k++ { + t := s.At(k) + if t.Kind == token.Whitespace { + continue + } + if t.Kind == token.Punct && (t.Value == "{" || t.Value == ";") { + break + } + parts = append(parts, t.Value) + } + sig.ret = normalizeType(strings.Join(parts, "")) + sig.hasRet = true + } + return sig, true +} + +// parseParams collects each parameter's variable name and native type between the +// parameter-list parentheses. +func parseParams(s *tokens.Stream, open, close int, sig *funcSig) { + var typeToks []string + var varName string + depth := 0 + inDefault := false + flush := func() { + if varName != "" { + sig.params[varName] = normalizeType(strings.Join(typeToks, "")) + } + typeToks = nil + varName = "" + inDefault = false + } + for k := open + 1; k < close; k++ { + t := s.At(k) + if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment { + continue + } + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ",": + if depth == 0 { + flush() + continue + } + case "=": + if depth == 0 { + inDefault = true + continue + } + } + if !inDefault && depth == 0 && (t.Value == "?" || t.Value == "|" || t.Value == "&" || t.Value == `\`) { + typeToks = append(typeToks, t.Value) + } + continue + } + if inDefault { + continue + } + switch t.Kind { + case token.Variable: + if depth == 0 { + varName = strings.TrimPrefix(t.Value, "$") + } + case token.Ident, token.Keyword: + if depth == 0 && !isParamModifier(strings.ToLower(t.Value)) { + typeToks = append(typeToks, t.Value) + } + } + } + flush() +} + +func isParamModifier(v string) bool { + switch v { + case "public", "private", "protected", "readonly": + return true + } + return false +} + +// filterSuperfluous drops @param/@return lines made redundant by sig. Reports +// whether anything was removed. +func filterSuperfluous(d *docblock, sig funcSig) bool { + kept := make([]docLine, 0, len(d.inner)) + removed := false + for idx := 0; idx < len(d.inner); idx++ { + l := d.inner[idx] + content := strings.TrimLeft(l.content, " ") + if drop, ok := superfluousTag(content, sig); ok && drop && !hasContinuation(d.inner, idx) { + removed = true + continue + } + kept = append(kept, l) + } + if removed { + d.inner = kept + } + return removed +} + +// hasContinuation reports whether the tag line at idx is followed by a wrapped +// description line (a non-blank, non-tag inner line), which means it carries a +// description and must be kept. +func hasContinuation(inner []docLine, idx int) bool { + if idx+1 >= len(inner) { + return false + } + next := strings.TrimSpace(inner[idx+1].content) + return next != "" && !strings.HasPrefix(next, "@") +} + +// superfluousTag reports whether a @param/@return line is redundant given sig. +func superfluousTag(content string, sig funcSig) (bool, bool) { + if m := nspParamRe.FindStringSubmatch(content); m != nil { + phpType, name, desc := m[1], m[2], strings.TrimSpace(m[3]) + if desc != "" { + return false, true + } + varName := name[strings.IndexByte(name, '$')+1:] + native, ok := sig.params[varName] + if !ok { + return false, true + } + return typeIsSuperfluous(phpType, native), true + } + if m := nspReturnRe.FindStringSubmatch(content); m != nil { + phpType, desc := m[1], strings.TrimSpace(m[2]) + if desc != "" { + return false, true + } + return typeIsSuperfluous(phpType, sig.ret), true + } + if sig.hasVar { + if m := nspVarRe.FindStringSubmatch(content); m != nil { + phpType, desc := m[1], strings.TrimSpace(m[2]) + if desc != "" { + return false, true + } + return typeIsSuperfluous(phpType, sig.varType), true + } + } + return false, false +} + +// typeIsSuperfluous reports whether a phpdoc type adds nothing over the native +// type: it normalizes to exactly the native type and is not more specific (no +// generics/shapes/callable signatures). A "mixed" tag is superfluous only when +// the native type is also mixed; on an untyped param/return "@param mixed" adds +// information and is kept, matching ECS (allow_mixed). +func typeIsSuperfluous(phpType, native string) bool { + if strings.ContainsAny(phpType, "<{(") { + return false + } + if native == "" { + return false + } + return normalizeType(phpType) == native +} + +// normalizeType canonicalizes a type for comparison: lowercase, each union member +// reduced to its short name, "?T" expanded to the "null|t" set, members sorted +// and de-duplicated. +func normalizeType(t string) string { + t = strings.TrimSpace(t) + if t == "" { + return "" + } + nullable := strings.HasPrefix(t, "?") + t = strings.TrimPrefix(t, "?") + seps := func(r rune) bool { return r == '|' || r == '&' } + members := strings.FieldsFunc(t, seps) + set := map[string]struct{}{} + for _, m := range members { + m = strings.ToLower(strings.TrimSpace(m)) + if i := strings.LastIndex(m, `\`); i >= 0 { + m = m[i+1:] + } + if m != "" { + set[m] = struct{}{} + } + } + if nullable { + set["null"] = struct{}{} + } + out := make([]string, 0, len(set)) + for m := range set { + out = append(out, m) + } + sort.Strings(out) + return strings.Join(out, "|") +} diff --git a/blink/internal/fixer/rules/no_superfluous_phpdoc_tags_test.go b/blink/internal/fixer/rules/no_superfluous_phpdoc_tags_test.go new file mode 100644 index 0000000000..9e5d916ba1 --- /dev/null +++ b/blink/internal/fixer/rules/no_superfluous_phpdoc_tags_test.go @@ -0,0 +1,93 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestNoSuperfluousPhpdocTags(t *testing.T) { + f := NoSuperfluousPhpdocTags{} + + // @param equal to the native type, no description -> removed + assertFix(t, f, + " kept + assertFix(t, f, + " $b\n */\nfunction f(array $b) {}", + " $b\n */\nfunction f(array $b) {}", false) + + // @param with a description -> kept + assertFix(t, f, + " kept (ECS allow_mixed: mixed adds info) + assertFix(t, f, + " removed (superfluous) + assertFix(t, f, + " kept (phpdoc adds info) + assertFix(t, f, + " removed + assertFix(t, f, + " kept + assertFix(t, f, + " kept + assertFix(t, f, + "|float\n */\nfunction f(): array|float {}", + "|float\n */\nfunction f(): array|float {}", false) + + // FQ phpdoc type equals the imported short native type -> removed + assertFix(t, f, + " removed + assertFix(t, f, + " removed + assertFix(t, f, + " kept + assertFix(t, f, + " "use A\B;"). The namespaces option is off by default, so +// bracketed namespaces are left untouched. Named NoUnneededCurlyBracesFixer to +// match the set membership (the rule's former name). +type NoUnneededBraces struct{} + +func (NoUnneededBraces) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\NoUnneededCurlyBracesFixer` +} + +func (NoUnneededBraces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoUnneededCurlyBracesFixer.php" +} + +func (NoUnneededBraces) Fix(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i > 0; i-- { + if !nubIsPunct(s.At(i), "{") { + continue + } + prev := nubPrevMeaningful(s, i) + if prev == -1 { + continue + } + if nubIsPunct(s.At(prev), `\`) { + // group import: "use A\{B};" -> "use A\B;" when single element, one line + if nubGroupImportOverComplete(s, i) { + closeIndex := s.MatchForward(i) + if closeIndex != -1 { + s.RemoveAt(closeIndex) + s.RemoveAt(i) + changed = true + } + } + continue + } + if nubRegularOverComplete(s, prev) { + closeIndex := s.MatchForward(i) + if closeIndex != -1 { + s.RemoveAt(closeIndex) + s.RemoveAt(i) + changed = true + } + } + } + return changed +} + +// nubRegularOverComplete reports whether a "{" whose previous meaningful token is +// at prev opens a superfluous block (prev is "{", "}", "= 0; j-- { + k := s.At(j).Kind + if k == token.Whitespace || k == token.Comment || k == token.DocComment { + continue + } + return j + } + return -1 +} diff --git a/blink/internal/fixer/rules/no_unneeded_braces_test.go b/blink/internal/fixer/rules/no_unneeded_braces_test.go new file mode 100644 index 0000000000..70759de6a5 --- /dev/null +++ b/blink/internal/fixer/rules/no_unneeded_braces_test.go @@ -0,0 +1,38 @@ +package rules + +import "testing" + +func TestNoUnneededBraces(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + // standalone block after open tag + {" %q changed=%v", got, again, changed2) + } + } +} diff --git a/blink/internal/fixer/rules/no_unneeded_control_parentheses.go b/blink/internal/fixer/rules/no_unneeded_control_parentheses.go new file mode 100644 index 0000000000..99fb67cbc7 --- /dev/null +++ b/blink/internal/fixer/rules/no_unneeded_control_parentheses.go @@ -0,0 +1,81 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoUnneededControlParenthesesFixer.php +// +// NoUnneededControlParentheses removes a redundant parenthesis pair wrapping the +// whole operand of a control statement: "return ($x);" -> "return $x;". It fires +// for return, echo, print, yield, break, continue, clone and case, only when the +// "(" directly follows the keyword and its matching ")" sits right before the +// statement terminator (";" or, for case, ":"). Nested pairs are peeled in one +// pass. The conservative boundary check leaves anything else (multiple arguments, +// a partial sub-expression, a mid-expression clone) untouched. +type NoUnneededControlParentheses struct{} + +func (NoUnneededControlParentheses) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\NoUnneededControlParenthesesFixer` +} + +func (NoUnneededControlParentheses) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoUnneededControlParenthesesFixer.php" +} + +func (NoUnneededControlParentheses) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + var term string + switch strings.ToLower(t.Value) { + case "return", "echo", "print", "yield", "break", "continue", "clone": + term = ";" + case "case": + term = ":" + default: + continue + } + // a keyword after "->"/"::" is a member name (Foo::case(...)), not a control + // statement + if memberPrev(s, i) { + continue + } + // peel redundant pairs: "return (($x));" -> "return $x;" + for { + j := nextSignificantIndex(s, i) + if j < 0 || s.At(j).Kind != token.Punct || s.At(j).Value != "(" { + break + } + k := s.MatchForward(j) + if k < 0 { + break + } + // the matching ")" must sit right before the terminator, and the pair + // must not be empty + nj := nextSignificantIndex(s, k) + if nj < 0 || s.At(nj).Kind != token.Punct || s.At(nj).Value != term { + break + } + if nextSignificantIndex(s, j) >= k { + break + } + s.RemoveAt(k) + if j == i+1 { + // "return($x)" -> keep a separator so the keyword stays a keyword + s.RemoveAt(j) + s.InsertAt(j, token.Token{Kind: token.Whitespace, Value: " "}) + } else { + s.RemoveAt(j) + } + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/no_unreachable_default_argument_value.go b/blink/internal/fixer/rules/no_unreachable_default_argument_value.go new file mode 100644 index 0000000000..053064b6cd --- /dev/null +++ b/blink/internal/fixer/rules/no_unreachable_default_argument_value.go @@ -0,0 +1,234 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoUnreachableDefaultArgumentValueFixer.php +// +// NoUnreachableDefaultArgumentValue removes default values of arguments that +// precede a required (non-default) argument, since such defaults are never used. +// A "= null" default on a non-nullable typed argument is kept, matching +// PHP-CS-Fixer. +type NoUnreachableDefaultArgumentValue struct{} + +func (NoUnreachableDefaultArgumentValue) Name() string { + return `PhpCsFixer\Fixer\FunctionNotation\NoUnreachableDefaultArgumentValueFixer` +} + +func (NoUnreachableDefaultArgumentValue) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoUnreachableDefaultArgumentValueFixer.php" +} + +type nudArg struct { + start, end int // inclusive token range of the argument + nameIndex int + variadic bool + equalsIdx int // depth-0 "=" after the name, or -1 + defaultEnd int // last meaningful token of the default value + nullDflt bool + hasType bool + nullable bool +} + +func (NoUnreachableDefaultArgumentValue) Fix(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + if !strings.EqualFold(t.Value, "function") && !strings.EqualFold(t.Value, "fn") { + continue + } + if nudFixFunction(s, i) { + changed = true + } + } + return changed +} + +func nudFixFunction(s *tokens.Stream, fnIndex int) bool { + openParen := nudNextParen(s, fnIndex) + if openParen == -1 { + return false + } + closeParen := s.MatchForward(openParen) + if closeParen == -1 { + return false + } + args := nudParseArgs(s, openParen, closeParen) + + changed := false + removeDefault := false + for _, a := range slices.Backward(args) { + if a.variadic { + continue + } + if a.equalsIdx == -1 { + removeDefault = true + continue + } + if !removeDefault { + continue + } + if a.nullDflt && a.hasType && !a.nullable { + continue + } + // remove "= " and the whitespace before "=" + for j := a.defaultEnd; j >= a.equalsIdx; j-- { + s.RemoveAt(j) + } + if a.equalsIdx-1 >= 0 && s.At(a.equalsIdx-1).Kind == token.Whitespace { + prevNonWs := prevSignificantIndex(s, a.equalsIdx-1) + if prevNonWs != -1 && s.At(prevNonWs).Kind != token.Comment { + s.RemoveAt(a.equalsIdx - 1) + } + } + changed = true + } + return changed +} + +// nudParseArgs splits the parameter list (openParen..closeParen) into arguments, +// analysing each. It runs before any mutation, so all indices are valid. +func nudParseArgs(s *tokens.Stream, openParen, closeParen int) []nudArg { + var args []nudArg + depth := 0 + start := openParen + 1 + flush := func(end int) { + if start > end { + return + } + // trim to a non-empty, meaningful range + if a, ok := nudAnalyzeArg(s, start, end); ok { + args = append(args, a) + } + } + for i := openParen + 1; i < closeParen; i++ { + t := s.At(i) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ",": + if depth == 0 { + flush(i - 1) + start = i + 1 + } + } + } + } + flush(closeParen - 1) + return args +} + +func nudAnalyzeArg(s *tokens.Stream, start, end int) (nudArg, bool) { + a := nudArg{start: start, end: end, nameIndex: -1, equalsIdx: -1} + depth := 0 + for i := start; i <= end; i++ { + t := s.At(i) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + continue + case ")", "]", "}": + depth-- + continue + } + } + if depth != 0 { + continue + } + if t.Kind == token.Variable && a.nameIndex == -1 && a.equalsIdx == -1 { + a.nameIndex = i + continue + } + if t.Kind == token.Punct && t.Value == "=" && a.nameIndex != -1 && a.equalsIdx == -1 { + a.equalsIdx = i + continue + } + } + if a.nameIndex == -1 { + return a, false + } + + // variadic: "..." right before the name + prev := nudPrevMeaningfulFrom(s, a.nameIndex, start) + if prev != -1 && s.At(prev).Kind == token.Punct && s.At(prev).Value == "..." { + a.variadic = true + } + + // type: meaningful tokens before the name, excluding "&" and "..." + for i := start; i < a.nameIndex; i++ { + t := s.At(i) + if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment { + continue + } + if t.Kind == token.Punct && (t.Value == "&" || t.Value == "...") { + continue + } + a.hasType = true + if t.Kind == token.Punct && t.Value == "?" { + a.nullable = true + } + if t.Kind == token.Ident && strings.EqualFold(t.Value, "null") { + a.nullable = true + } + } + + // default value range + whether it is exactly "null" + if a.equalsIdx != -1 { + a.defaultEnd = end + for a.defaultEnd > a.equalsIdx { + k := s.At(a.defaultEnd).Kind + if k == token.Whitespace || k == token.Comment || k == token.DocComment { + a.defaultEnd-- + continue + } + break + } + var meaningful []int + for i := a.equalsIdx + 1; i <= end; i++ { + k := s.At(i).Kind + if k == token.Whitespace || k == token.Comment || k == token.DocComment { + continue + } + meaningful = append(meaningful, i) + } + if len(meaningful) == 1 { + v := s.At(meaningful[0]) + if (v.Kind == token.Ident || v.Kind == token.Keyword) && strings.EqualFold(v.Value, "null") { + a.nullDflt = true + } + } + } + return a, true +} + +func nudNextParen(s *tokens.Stream, index int) int { + for j := index + 1; j < s.Len(); j++ { + if s.At(j).Kind == token.Punct && s.At(j).Value == "(" { + return j + } + } + return -1 +} + +func nudPrevMeaningfulFrom(s *tokens.Stream, i, low int) int { + for j := i - 1; j >= low; j-- { + k := s.At(j).Kind + if k == token.Whitespace || k == token.Comment || k == token.DocComment { + continue + } + return j + } + return -1 +} diff --git a/blink/internal/fixer/rules/no_unreachable_default_argument_value_test.go b/blink/internal/fixer/rules/no_unreachable_default_argument_value_test.go new file mode 100644 index 0000000000..fc7057d283 --- /dev/null +++ b/blink/internal/fixer/rules/no_unreachable_default_argument_value_test.go @@ -0,0 +1,35 @@ +package rules + +import "testing" + +func TestNoUnreachableDefaultArgumentValue(t *testing.T) { + cases := []struct { + src string + want string + changed bool + }{ + {` $a;`, ` $a;`, true}, + {` %q changed=%v", got, again, changed2) + } + } +} diff --git a/blink/internal/fixer/rules/no_useless_nullsafe_operator.go b/blink/internal/fixer/rules/no_useless_nullsafe_operator.go new file mode 100644 index 0000000000..b18ee06fae --- /dev/null +++ b/blink/internal/fixer/rules/no_useless_nullsafe_operator.go @@ -0,0 +1,38 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessNullsafeOperatorFixer.php +// +// NoUselessNullsafeOperator turns "$this?->" into "$this->": a nullsafe call on +// $this can never be null, so the operator is useless. +type NoUselessNullsafeOperator struct{} + +func (NoUselessNullsafeOperator) Name() string { + return `PhpCsFixer\Fixer\Operator\NoUselessNullsafeOperatorFixer` +} + +func (NoUselessNullsafeOperator) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessNullsafeOperatorFixer.php" +} + +func (NoUselessNullsafeOperator) Fix(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i >= 0; i-- { + if s.At(i).Kind != token.Punct || s.At(i).Value != "?->" { + continue + } + p := prevSignificantIndex(s, i) + if p < 0 || s.At(p).Kind != token.Variable || !strings.EqualFold(s.At(p).Value, "$this") { + continue + } + s.SetValue(i, "->") + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/no_useless_nullsafe_operator_test.go b/blink/internal/fixer/rules/no_useless_nullsafe_operator_test.go new file mode 100644 index 0000000000..03b9f7d51c --- /dev/null +++ b/blink/internal/fixer/rules/no_useless_nullsafe_operator_test.go @@ -0,0 +1,22 @@ +package rules + +import "testing" + +func TestNoUselessNullsafeOperator(t *testing.T) { + f := NoUselessNullsafeOperator{} + + got, changed := apply(t, f, "parentMethod(); }\n}\n") + if want := "parentMethod(); }\n}\n"; !changed || got != want { + t.Fatalf("this: changed=%v got=%q", changed, got) + } + + // nullsafe on a non-$this variable is left alone + if _, changed := apply(t, f, "b();\n"); changed { + t.Fatal("only $this?-> is rewritten") + } + + // already "->" is a no-op + if _, changed := apply(t, f, "b();\n"); changed { + t.Fatal("plain -> must not change") + } +} diff --git a/blink/internal/fixer/rules/no_useless_return.go b/blink/internal/fixer/rules/no_useless_return.go new file mode 100644 index 0000000000..53e323b8e7 --- /dev/null +++ b/blink/internal/fixer/rules/no_useless_return.go @@ -0,0 +1,58 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ReturnNotation/NoUselessReturnFixer.php +// +// NoUselessReturn removes a bare "return;" that is the last statement of a +// function, method or closure body. +type NoUselessReturn struct{} + +func (NoUselessReturn) Name() string { + return `PhpCsFixer\Fixer\ReturnNotation\NoUselessReturnFixer` +} + +func (NoUselessReturn) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ReturnNotation/NoUselessReturnFixer.php" +} + +func (NoUselessReturn) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Keyword || !strings.EqualFold(s.At(i).Value, "return") { + continue + } + if prev, ok := prevSignificant(s, i); ok && prev.Kind == token.Punct { + switch prev.Value { + case "->", "?->", "::": + continue + } + } + semi := nextSignificantIndex(s, i) + if semi < 0 || s.At(semi).Kind != token.Punct || s.At(semi).Value != ";" { + continue + } + after := nextSignificantIndex(s, semi) + if after < 0 || s.At(after).Kind != token.Punct || s.At(after).Value != "}" { + continue + } + open := s.MatchBackward(after) + if open < 0 { + continue + } + if kind, _ := classifyBrace(s, open); kind != braceFunctionDecl && kind != braceClosure { + continue + } + for k := semi; k >= i; k-- { + s.RemoveAt(k) + } + i-- + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/no_whitespace_in_blank_line.go b/blink/internal/fixer/rules/no_whitespace_in_blank_line.go new file mode 100644 index 0000000000..8c0fdf9add --- /dev/null +++ b/blink/internal/fixer/rules/no_whitespace_in_blank_line.go @@ -0,0 +1,50 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoWhitespaceInBlankLineFixer.php +// +// NoWhitespaceInBlankLine removes spaces and tabs from otherwise-empty lines. +// It only clears interior blank lines inside a whitespace run; the last segment +// (indentation of the next code line) and the first segment (trailing +// whitespace of the previous line) are left to their own fixers. +type NoWhitespaceInBlankLine struct{} + +func (NoWhitespaceInBlankLine) Name() string { + return `PhpCsFixer\Fixer\Whitespace\NoWhitespaceInBlankLineFixer` +} + +func (NoWhitespaceInBlankLine) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoWhitespaceInBlankLineFixer.php" +} + +func (NoWhitespaceInBlankLine) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Whitespace { + continue + } + segments := strings.Split(t.Value, "\n") + if len(segments) <= 2 { + continue // no interior blank line + } + touched := false + for j := 1; j < len(segments)-1; j++ { + if segments[j] != "" { + segments[j] = "" + touched = true + } + } + if touched { + s.SetValue(i, strings.Join(segments, "\n")) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/not_operator_with_successor_space.go b/blink/internal/fixer/rules/not_operator_with_successor_space.go new file mode 100644 index 0000000000..d7a75dfc7a --- /dev/null +++ b/blink/internal/fixer/rules/not_operator_with_successor_space.go @@ -0,0 +1,42 @@ +package rules + +import ( + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NotOperatorWithSuccessorSpaceFixer.php +// +// NotOperatorWithSuccessorSpace ensures exactly one space after the "!" operator +// ("!$a" -> "! $a"). +type NotOperatorWithSuccessorSpace struct{} + +func (NotOperatorWithSuccessorSpace) Name() string { + return `PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer` +} + +func (NotOperatorWithSuccessorSpace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NotOperatorWithSuccessorSpaceFixer.php" +} + +func (NotOperatorWithSuccessorSpace) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "!" { + continue + } + if i+1 >= s.Len() { + continue + } + if s.At(i+1).Kind == token.Whitespace { + if s.At(i+1).Value != " " { + s.SetValue(i+1, " ") + changed = true + } + continue + } + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/nullable_type_declaration.go b/blink/internal/fixer/rules/nullable_type_declaration.go new file mode 100644 index 0000000000..f61f39f411 --- /dev/null +++ b/blink/internal/fixer/rules/nullable_type_declaration.go @@ -0,0 +1,113 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/NullableTypeDeclarationFixer.php +// +// NullableTypeDeclaration standardises a nullable single type to the question +// mark syntax (the fixer default): "int|null" and "null|int" become "?int". It +// fires only for a two-member union whose sole other member is a single type +// name, in a confirmed type position (parameter, return or typed property). +// Unions of three or more members, intersections and DNF types are left alone. +type NullableTypeDeclaration struct{} + +func (NullableTypeDeclaration) Name() string { + return `PhpCsFixer\Fixer\LanguageConstruct\NullableTypeDeclarationFixer` +} + +func (NullableTypeDeclaration) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/NullableTypeDeclarationFixer.php" +} + +func (NullableTypeDeclaration) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || t.Value != "|" { + continue + } + if !isTypeUnionOperator(s, i) { + continue + } + // significant token indices of the whole type run around this "|" + start := typeRunBoundaryPrev(s, i) + end := typeRunBoundaryNext(s, i) + if end < 0 { + continue + } + var parts []int + for j := start + 1; j < end; j++ { + if k := s.At(j).Kind; k == token.Whitespace || k == token.Comment || k == token.DocComment { + continue + } + parts = append(parts, j) + } + if len(parts) == 0 { + continue + } + // exactly one "|" and no "&"/"?" -> a plain two-member union + pipes := 0 + bad := false + for _, p := range parts { + v := s.At(p).Value + if s.At(p).Kind == token.Punct { + switch v { + case "|": + pipes++ + case "&", "?": + bad = true + } + } + } + if bad || pipes != 1 { + continue + } + // split members on the single "|" + var left, right []int + seen := false + for _, p := range parts { + if !seen && s.At(p).Kind == token.Punct && s.At(p).Value == "|" { + seen = true + continue + } + if seen { + right = append(right, p) + } else { + left = append(left, p) + } + } + // one side must be exactly the single token "null"; keep the other as T + var typ []int + switch { + case isNullMember(s, right): + typ = left + case isNullMember(s, left): + typ = right + default: + continue + } + if len(typ) == 0 { + continue + } + // rebuild the run as "?T", dropping the null member, the "|" and any + // internal whitespace; ReplaceRange spans the first..last run token + repl := []token.Token{{Kind: token.Punct, Value: "?"}} + for _, p := range typ { + repl = append(repl, s.At(p)) + } + s.ReplaceRange(parts[0], parts[len(parts)-1], repl) + changed = true + i = parts[0] // continue past the rewritten run + } + return changed +} + +// isNullMember reports whether idxs is a single token spelling "null". +func isNullMember(s *tokens.Stream, idxs []int) bool { + return len(idxs) == 1 && strings.EqualFold(s.At(idxs[0]).Value, "null") +} diff --git a/blink/internal/fixer/rules/offset.go b/blink/internal/fixer/rules/offset.go new file mode 100644 index 0000000000..8505ec1a0e --- /dev/null +++ b/blink/internal/fixer/rules/offset.go @@ -0,0 +1,55 @@ +package rules + +import ( + "blink/internal/token" + "blink/internal/tokens" +) + +// isOffsetOpen reports whether the "[" at open is an array offset access (as +// opposed to an array literal), based on the preceding significant token. +func isOffsetOpen(s *tokens.Stream, open int) bool { + prev, ok := prevSignificant(s, open) + if !ok { + return false + } + if prev.Kind == token.Variable || prev.Kind == token.Ident || prev.Kind == token.String { + return true + } + return prev.Value == ")" || prev.Value == "]" +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoSpacesAroundOffsetFixer.php +// +// NoSpacesAroundOffset removes single-line spaces just inside an array offset +// ("$a[ 0 ]" -> "$a[0]"). Array literals are left alone. +type NoSpacesAroundOffset struct{} + +func (NoSpacesAroundOffset) Name() string { + return `PhpCsFixer\Fixer\Whitespace\NoSpacesAroundOffsetFixer` +} + +func (NoSpacesAroundOffset) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoSpacesAroundOffsetFixer.php" +} + +func (NoSpacesAroundOffset) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "[" || !isOffsetOpen(s, i) { + continue + } + closeIdx := s.MatchForward(i) + if closeIdx < 0 { + continue + } + if closeIdx-1 > i && s.At(closeIdx-1).Kind == token.Whitespace && !hasNewline(s.At(closeIdx-1).Value) { + s.RemoveAt(closeIdx - 1) + changed = true + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/operator_linebreak.go b/blink/internal/fixer/rules/operator_linebreak.go new file mode 100644 index 0000000000..3dcf0ad100 --- /dev/null +++ b/blink/internal/fixer/rules/operator_linebreak.go @@ -0,0 +1,158 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/OperatorLinebreakFixer.php +// +// OperatorLinebreak moves a multiline operator to the beginning of the next line +// (default position). It handles the full operator set, including ":", "?", "|" +// and "&", by classifying those ambiguous tokens the way PHP-CS-Fixer's +// tokenizer transformers + analyzers do: a ":" that is a type-colon, named +// argument, switch case, goto label or alternative-syntax colon is left alone; a +// "?" that is a nullable type marker is left alone; a "|"/"&" that is a union or +// intersection type separator is left alone; and a "&" that is a return-ref or a +// reference is left alone. Object operators "->"/"?->" are not operators here. +type OperatorLinebreak struct{} + +// operatorLinebreakPunct are the always-unambiguous operator tokens. +var operatorLinebreakPunct = map[string]bool{ + "||": true, "&&": true, ".": true, "+": true, "-": true, "*": true, + "/": true, "%": true, "**": true, "==": true, "===": true, "!=": true, + "!==": true, "<>": true, "<": true, ">": true, "<=": true, ">=": true, + "<=>": true, "??": true, "=>": true, "=": true, ".=": true, "+=": true, + "-=": true, "*=": true, "/=": true, "%=": true, "**=": true, "&=": true, + "|=": true, "^=": true, "<<=": true, ">>=": true, "??=": true, "^": true, + "<<": true, ">>": true, "|": true, "&": true, ":": true, "?": true, +} + +func (OperatorLinebreak) Name() string { + return `PhpCsFixer\Fixer\Operator\OperatorLinebreakFixer` +} + +func (OperatorLinebreak) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/OperatorLinebreakFixer.php" +} + +func isOperatorLinebreakToken(t token.Token) bool { + switch t.Kind { + case token.Keyword: + switch strings.ToLower(t.Value) { + case "and", "or", "xor": + return true + } + case token.Punct: + return operatorLinebreakPunct[t.Value] + } + return false +} + +// operatorLinebreakExcluded reports whether the token at i, although in the +// operator set, must be left alone because of its context (mirrors what the +// PHP-CS-Fixer tokenizer transforms into a CT token, plus the fixer's own +// goto-label / reference / alternative-syntax / switch guards). +func operatorLinebreakExcluded(s *tokens.Stream, i int, switchColons map[int]bool) bool { + if s.At(i).Kind != token.Punct { + return false + } + switch s.At(i).Value { + case ":": + return isTypeColon(s, i) || isNamedArgumentColon(s, i) || + belongsToGotoLabel(s, i) || belongsToAlternativeSyntax(s, i) || switchColons[i] + case "?": + return isNullableType(s, i) + case "|": + return isPartOfType(s, i) + case "&": + return isPartOfType(s, i) || isReturnRef(s, i) || isReferenceAmp(s, i) + } + return false +} + +func (OperatorLinebreak) Fix(s *tokens.Stream) bool { + changed := false + switchColons := switchCaseColons(s) + for i := s.Len() - 1; i > 0; i-- { + if !isOperatorLinebreakToken(s.At(i)) { + continue + } + if operatorLinebreakExcluded(s, i, switchColons) { + continue + } + + opIndices := []int{i} + // short-ternary "?:" is moved as a single unit + if s.At(i).Kind == token.Punct && s.At(i).Value == ":" { + if p := sigPrev(s, i); p >= 0 && s.At(p).Kind == token.Punct && s.At(p).Value == "?" { + opIndices = []int{p, i} + } + } + + lo, hi := opIndices[0], opIndices[len(opIndices)-1] + prevM := sigPrev(s, lo) + nextM := sigNext(s, hi) + if prevM < 0 || nextM < 0 { + continue + } + // operator must be surrounded by multiline whitespace + if !operatorSpanMultiline(s, prevM+1, nextM-1) { + continue + } + // already at the beginning of a line if there is no newline after it + if !operatorSpanMultiline(s, hi, nextM-1) { + continue + } + moveOperatorToLineStart(s, opIndices, nextM) + changed = true + i = lo // resume before the moved group + } + return changed +} + +// operatorSpanMultiline mirrors OperatorLinebreak::isMultiline - whether any +// token in the inclusive range [from, to] contains a newline. +func operatorSpanMultiline(s *tokens.Stream, from, to int) bool { + if from < 0 { + from = 0 + } + for j := from; j <= to && j < s.Len(); j++ { + if strings.ContainsAny(s.At(j).Value, "\n\r") { + return true + } + } + return false +} + +// moveOperatorToLineStart mirrors fixMoveToTheBeginning: it clears each operator +// (and the whitespace immediately before it) and re-inserts the operators just +// before the next meaningful token, keeping a single leading space when a space +// preceded the group. +func moveOperatorToLineStart(s *tokens.Stream, opIndices []int, nextM int) { + lo := opIndices[0] + hadSpaceBefore := lo > 0 && s.At(lo-1).Kind == token.Whitespace + + // clone operator tokens and clear them plus the whitespace directly before each + clones := make([]token.Token, len(opIndices)) + for k, idx := range opIndices { + clones[k] = token.Token{Kind: s.At(idx).Kind, Value: s.At(idx).Value} + } + for _, idx := range opIndices { + if idx > 0 && s.At(idx-1).Kind == token.Whitespace { + s.SetValue(idx-1, "") + } + s.SetValue(idx, "") + } + + at := nextM + for _, c := range clones { + s.InsertAt(at, c) + at++ + } + if hadSpaceBefore { + s.InsertAt(at, token.Token{Kind: token.Whitespace, Value: " "}) + } +} diff --git a/blink/internal/fixer/rules/operator_linebreak_ct.go b/blink/internal/fixer/rules/operator_linebreak_ct.go new file mode 100644 index 0000000000..ff7a0f0594 --- /dev/null +++ b/blink/internal/fixer/rules/operator_linebreak_ct.go @@ -0,0 +1,369 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Context classifiers for OperatorLinebreak. Each mirrors a PHP-CS-Fixer +// tokenizer transformer or analyzer so an ambiguous ":" / "?" / "|" / "&" is +// recognised as a type / nullable / union / intersection / reference / label / +// switch / alternative-syntax context and left untouched. + +func olKwLower(t token.Token) string { + if t.Kind != token.Keyword { + return "" + } + return strings.ToLower(t.Value) +} + +// isTypeColon mirrors TypeColonTransformer: a ":" that introduces a return type +// (or an enum backing type). +func isTypeColon(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, ":") { + return false + } + endIndex := sigPrev(s, index) + if endIndex < 0 { + return false + } + if pe := sigPrev(s, endIndex); pe >= 0 && kwIs(s.At(pe), "enum") { + return true + } + if !isPunctVal(s, endIndex, ")") { + return false + } + startIndex := s.MatchBackward(endIndex) + if startIndex < 0 { + return false + } + prevIndex := sigPrev(s, startIndex) + if prevIndex < 0 { + return false + } + if s.At(prevIndex).Kind == token.Ident { + prevIndex = sigPrev(s, prevIndex) + if prevIndex < 0 { + return false + } + } + switch olKwLower(s.At(prevIndex)) { + case "function", "fn", "use": + return true + } + return isPunctVal(s, prevIndex, "&") && isReturnRef(s, prevIndex) +} + +// isNamedArgumentColon mirrors NamedArgumentTransformer. +func isNamedArgumentColon(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, ":") { + return false + } + stringIndex := sigPrev(s, index) + if stringIndex < 0 || s.At(stringIndex).Kind != token.Ident { + return false + } + preString := sigPrev(s, stringIndex) + if preString < 0 { + return false + } + return isPunctVal(s, preString, ",") || isPunctVal(s, preString, "(") +} + +// isNullableType mirrors NullableTypeTransformer. +func isNullableType(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, "?") { + return false + } + prevIndex := sigPrev(s, index) + if prevIndex < 0 { + return false + } + ok := isPunctVal(s, prevIndex, "(") || isPunctVal(s, prevIndex, ",") || isTypeColon(s, prevIndex) + if !ok { + switch olKwLower(s.At(prevIndex)) { + case "public", "protected", "private", "var", "static", "const", "abstract", "final", "readonly": + ok = true + } + } + if !ok { + return false + } + if olKwLower(s.At(prevIndex)) == "static" { + if pp := sigPrev(s, prevIndex); pp >= 0 && kwIs(s.At(pp), "instanceof") { + return false + } + } + return true +} + +// isReturnRef mirrors ReturnRefTransformer: "&" right after function/fn. +func isReturnRef(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, "&") { + return false + } + prev := sigPrev(s, index) + if prev < 0 { + return false + } + return kwIs(s.At(prev), "function") || kwIs(s.At(prev), "fn") +} + +// isReferenceAmp mirrors ReferenceAnalyzer::isReference (the "&" is a reference, +// not bitwise-and), excluding the return-ref case handled separately. +func isReferenceAmp(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, "&") { + return false + } + idx := sigPrev(s, index) + if idx < 0 { + return false + } + t := s.At(idx) + if isPunctVal(s, idx, "=") || isPunctVal(s, idx, "=>") || + kwIs(t, "as") || kwIs(t, "callable") || kwIs(t, "array") { + return true + } + if t.Kind == token.Ident { + idx = sigPrev(s, idx) + if idx < 0 { + return false + } + } + return isPunctVal(s, idx, "(") || isPunctVal(s, idx, ",") || isPunctVal(s, idx, `\`) || + (isPunctVal(s, idx, "?") && isNullableType(s, idx)) +} + +// belongsToGotoLabel mirrors GotoLabelAnalyzer. +func belongsToGotoLabel(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, ":") { + return false + } + prev := sigPrev(s, index) + if prev < 0 || s.At(prev).Kind != token.Ident { + return false + } + prev2 := sigPrev(s, prev) + if prev2 < 0 { + return false + } + return isPunctVal(s, prev2, ":") || isPunctVal(s, prev2, ";") || + isPunctVal(s, prev2, "{") || isPunctVal(s, prev2, "}") || s.At(prev2).Kind == token.OpenTag +} + +// belongsToAlternativeSyntax mirrors AlternativeSyntaxAnalyzer. +func belongsToAlternativeSyntax(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, ":") { + return false + } + prev := sigPrev(s, index) + if prev < 0 { + return false + } + if kwIs(s.At(prev), "else") { + return true + } + if !isPunctVal(s, prev, ")") { + return false + } + open := s.MatchBackward(prev) + if open < 0 { + return false + } + before := sigPrev(s, open) + if before < 0 { + return false + } + switch olKwLower(s.At(before)) { + case "declare", "elseif", "for", "foreach", "if", "switch", "while": + return true + } + return false +} + +// typeSkipToken reports whether j is one of AbstractTypeTransformer::TYPE_TOKENS. +func typeSkipToken(s *tokens.Stream, j int) bool { + t := s.At(j) + switch t.Kind { + case token.Whitespace, token.Comment, token.DocComment, token.Ident: + return true + case token.Punct: + switch t.Value { + case "|", "&", "(", ")", `\`: + return true + } + case token.Keyword: + switch strings.ToLower(t.Value) { + case "callable", "static", "array": + return true + } + } + return false +} + +// notOfKindSiblingType walks from index in direction dir, skipping TYPE_TOKENS. +func notOfKindSiblingType(s *tokens.Stream, index, dir int) int { + for j := index + dir; j >= 0 && j < s.Len(); j += dir { + if !typeSkipToken(s, j) { + return j + } + } + return -1 +} + +func isTypeEndToken(s *tokens.Stream, idx int) bool { + if isPunctVal(s, idx, ")") || isPunctVal(s, idx, `\`) { + return true + } + t := s.At(idx) + if t.Kind == token.Ident { + return true + } + switch olKwLower(t) { + case "callable", "static", "array": + return true + } + return false +} + +// isPartOfType mirrors AbstractTypeTransformer::isPartOfType for a "|"/"&". +func isPartOfType(s *tokens.Stream, index int) bool { + if !isPunctVal(s, index, "|") && !isPunctVal(s, index, "&") { + return false + } + if typeColonIndex := notOfKindSiblingType(s, index, -1); typeColonIndex >= 0 { + tc := s.At(typeColonIndex) + if kwIs(tc, "catch") || kwIs(tc, "const") || isTypeColon(s, typeColonIndex) { + return true + } + } + afterTypeIndex := notOfKindSiblingType(s, index, 1) + if afterTypeIndex < 0 { + return false + } + if isPunctVal(s, afterTypeIndex, "...") { + return true + } + if s.At(afterTypeIndex).Kind != token.Variable { + return false + } + beforeVar := sigPrev(s, afterTypeIndex) + if beforeVar < 0 { + return false + } + if isPunctVal(s, beforeVar, "&") { + prevIndex := getPrevTokenOfKindType(s, index) + return prevIndex >= 0 && (kwIs(s.At(prevIndex), "fn") || kwIs(s.At(prevIndex), "function")) + } + return isTypeEndToken(s, beforeVar) +} + +// getPrevTokenOfKindType scans back for "{", "}", ";", a close tag, or fn/function. +func getPrevTokenOfKindType(s *tokens.Stream, index int) int { + for j := index - 1; j >= 0; j-- { + if isPunctVal(s, j, "{") || isPunctVal(s, j, "}") || isPunctVal(s, j, ";") || + s.At(j).Kind == token.CloseTag || kwIs(s.At(j), "fn") || kwIs(s.At(j), "function") { + return j + } + } + return -1 +} + +// switchCaseColons returns the set of ":" indices that terminate a case/default +// label inside a switch (SwitchAnalyzer::belongsToSwitch). +func switchCaseColons(s *tokens.Stream) map[int]bool { + result := map[int]bool{} + for i := 0; i < s.Len(); i++ { + if !kwIs(s.At(i), "switch") { + continue + } + open := nextPunctOfKind(s, i, "(") + if open < 0 { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + continue + } + bodyStart := sigNext(s, closeIdx) + if bodyStart < 0 { + continue + } + if isPunctVal(s, bodyStart, "{") { + bodyEnd := s.MatchForward(bodyStart) + if bodyEnd < 0 { + continue + } + scanCaseColons(s, bodyStart+1, bodyEnd, result) + } else if isPunctVal(s, bodyStart, ":") { + result[bodyStart] = true + if end := altSwitchEnd(s, bodyStart); end > bodyStart { + scanCaseColons(s, bodyStart+1, end, result) + } + } + } + return result +} + +func scanCaseColons(s *tokens.Stream, from, to int, result map[int]bool) { + depth := 0 + for j := from; j < to && j < s.Len(); j++ { + if isPunctVal(s, j, "{") { + depth++ + continue + } + if isPunctVal(s, j, "}") { + depth-- + continue + } + if depth == 0 && (kwIs(s.At(j), "case") || kwIs(s.At(j), "default")) { + if c := caseColonAfter(s, j, to); c >= 0 { + result[c] = true + } + } + } +} + +// caseColonAfter finds the ":" (or ";") terminating a case/default label. +func caseColonAfter(s *tokens.Stream, caseIdx, limit int) int { + depth := 0 + for j := caseIdx + 1; j < limit && j < s.Len(); j++ { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ":", ";": + if depth == 0 { + if s.At(j).Value == ":" { + return j + } + return -1 + } + } + } + return -1 +} + +// altSwitchEnd finds the endswitch terminating an alternative-syntax switch body. +func altSwitchEnd(s *tokens.Stream, colonIndex int) int { + depth := 0 + for j := colonIndex + 1; j < s.Len(); j++ { + if kwIs(s.At(j), "switch") { + depth++ + continue + } + if kwIs(s.At(j), "endswitch") { + if depth == 0 { + return j + } + depth-- + } + } + return -1 +} diff --git a/blink/internal/fixer/rules/operator_linebreak_test.go b/blink/internal/fixer/rules/operator_linebreak_test.go new file mode 100644 index 0000000000..b4099a56ad --- /dev/null +++ b/blink/internal/fixer/rules/operator_linebreak_test.go @@ -0,0 +1,52 @@ +package rules + +import "testing" + +func TestOperatorLinebreak(t *testing.T) { + f := OperatorLinebreak{} + + got, changed := apply(t, f, "" and "?->" +// ("$a -> b" -> "$a->b"). +type ObjectOperatorWithoutWhitespace struct{} + +func (ObjectOperatorWithoutWhitespace) Name() string { + return `PhpCsFixer\Fixer\Operator\ObjectOperatorWithoutWhitespaceFixer` +} + +func (ObjectOperatorWithoutWhitespace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/ObjectOperatorWithoutWhitespaceFixer.php" +} + +func (ObjectOperatorWithoutWhitespace) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != "->" && t.Value != "?->") { + continue + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } + if i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + i-- + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/ordered_class_elements.go b/blink/internal/fixer/rules/ordered_class_elements.go new file mode 100644 index 0000000000..f20108d798 --- /dev/null +++ b/blink/internal/fixer/rules/ordered_class_elements.go @@ -0,0 +1,193 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +const ( + groupTraitUse = iota + groupConst + groupProperty + groupMethod +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedClassElementsFixer.php +// +// OrderedClassElements sorts direct class members into groups - trait use, +// constants, properties, then methods - keeping the original order within each +// group. It is deliberately conservative: any comment, doc comment or attribute +// in the body makes it skip the whole class so leading trivia is never detached. +type OrderedClassElements struct{} + +func (OrderedClassElements) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\OrderedClassElementsFixer` +} + +func (OrderedClassElements) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedClassElementsFixer.php" +} + +func (OrderedClassElements) Fix(s *tokens.Stream) bool { + changed := false + i := 0 + for i < s.Len() { + t := s.At(i) + if t.Kind == token.Punct && t.Value == "{" { + if kind, _ := classifyBrace(s, i); kind == braceClassLike { + if reorderClassBody(s, i) { + changed = true + i = 0 // members moved; restart - each class sorts once so this ends + continue + } + } + } + i++ + } + return changed +} + +func reorderClassBody(s *tokens.Stream, open int) bool { + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + return false + } + // Any comment/attribute in the body means trivia we cannot safely move. + for k := open + 1; k < closeIdx; k++ { + switch s.At(k).Kind { + case token.Comment, token.DocComment: + return false + } + } + starts := classMemberStarts(s, open) + if len(starts) < 2 { + return false + } + ends := make([]int, len(starts)) + groups := make([]int, len(starts)) + for idx, m := range starts { + end := memberSpanEnd(s, m) + if end < 0 || end >= closeIdx { + return false + } + g := classifyMemberGroup(s, m, end) + if g < 0 { + return false + } + ends[idx] = end + groups[idx] = g + } + // separators between members must be whitespace only + for idx := 0; idx+1 < len(starts); idx++ { + for k := ends[idx] + 1; k < starts[idx+1]; k++ { + if s.At(k).Kind != token.Whitespace { + return false + } + } + } + order := make([]int, 0, len(starts)) + for g := groupTraitUse; g <= groupMethod; g++ { + for idx, gg := range groups { + if gg == g { + order = append(order, idx) + } + } + } + if isIdentityOrder(order) { + return false + } + // reordered member spans joined by the original positional separators + var repl []token.Token + for pos, memberIdx := range order { + for k := starts[memberIdx]; k <= ends[memberIdx]; k++ { + repl = append(repl, s.At(k)) + } + if pos+1 < len(order) { + for k := ends[pos] + 1; k < starts[pos+1]; k++ { + repl = append(repl, s.At(k)) + } + } + } + s.ReplaceRange(starts[0], ends[len(ends)-1], repl) + return true +} + +// memberSpanEnd returns the last token index of the member starting at m: the +// terminating ";" for properties/consts/trait-use, or the matching "}" of a +// method body. Nested (), [] and {} groups are skipped. Returns -1 on trouble. +func memberSpanEnd(s *tokens.Stream, m int) int { + for k := m; k < s.Len(); k++ { + t := s.At(k) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[": + mm := s.MatchForward(k) + if mm < 0 { + return -1 + } + k = mm + case "{": + return s.MatchForward(k) + case ";": + return k + case "}": + return -1 + } + } + return -1 +} + +// classifyMemberGroup returns the ordering group of the member in [m, end], or +// -1 when it is anything unexpected (enum case, unknown token) so the caller +// can skip the whole class. +func classifyMemberGroup(s *tokens.Stream, m, end int) int { + k := m + for k <= end && s.At(k).Kind == token.Keyword && memberModifiers[strings.ToLower(s.At(k).Value)] { + k = skipWhitespace(s, k+1) + } + if k > end { + return -1 + } + if s.At(k).Kind == token.Keyword { + switch strings.ToLower(s.At(k).Value) { + case "use": + return groupTraitUse + case "const": + return groupConst + case "function": + return groupMethod + default: + return -1 + } + } + // property: a variable at the top level before the terminating ";" + for x := k; x <= end; x++ { + t := s.At(x) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + if mm := s.MatchForward(x); mm > 0 { + x = mm + continue + } + } + } + if t.Kind == token.Variable { + return groupProperty + } + } + return -1 +} + +func isIdentityOrder(order []int) bool { + for i, v := range order { + if i != v { + return false + } + } + return true +} diff --git a/blink/internal/fixer/rules/ordered_interfaces.go b/blink/internal/fixer/rules/ordered_interfaces.go new file mode 100644 index 0000000000..d9db644701 --- /dev/null +++ b/blink/internal/fixer/rules/ordered_interfaces.go @@ -0,0 +1,134 @@ +package rules + +import ( + "sort" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedInterfacesFixer.php +// +// OrderedInterfaces sorts the interface list of an "implements" or +// "interface extends" clause. Default config: alpha order, ascending, +// case-insensitive. +type OrderedInterfaces struct{} + +func (OrderedInterfaces) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\OrderedInterfacesFixer` +} + +func (OrderedInterfaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedInterfacesFixer.php" +} + +func (OrderedInterfaces) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if kwIs(t, "implements") { + // ok + } else if kwIs(t, "extends") { + name := sigPrev(s, i) + if name < 0 { + continue + } + iface := sigPrev(s, name) + if iface < 0 || !kwIs(s.At(iface), "interface") { + continue + } + } else { + continue + } + + start := i + 1 + brace := nextPunctOfKind(s, start, "{") + if brace < 0 { + continue + } + end := sigPrev(s, brace) + if end < start { + continue + } + + groups := interfaceGroups(s, start, end) + if len(groups) <= 1 { + continue + } + + type keyed struct { + toks []token.Token + key string + orig int + } + items := make([]keyed, len(groups)) + for gi, g := range groups { + items[gi] = keyed{toks: g, key: interfaceSortKey(g), orig: gi} + } + sort.SliceStable(items, func(a, b int) bool { + return strings.ToLower(items[a].key) < strings.ToLower(items[b].key) + }) + + reordered := false + for gi := range items { + if items[gi].orig != gi { + reordered = true + break + } + } + if !reordered { + continue + } + + var repl []token.Token + for gi, it := range items { + if gi > 0 { + repl = append(repl, token.Token{Kind: token.Punct, Value: ","}) + } + repl = append(repl, it.toks...) + } + s.ReplaceRange(start, end, repl) + changed = true + i = start + len(repl) + } + return changed +} + +// interfaceGroups splits the inclusive range [start, end] on top-level commas; +// each group keeps its own leading whitespace, mirroring getInterfaces. +func interfaceGroups(s *tokens.Stream, start, end int) [][]token.Token { + var groups [][]token.Token + cur := []token.Token{} + for i := start; i <= end && i < s.Len(); i++ { + if isPunctVal(s, i, ",") { + groups = append(groups, cur) + cur = []token.Token{} + continue + } + cur = append(cur, s.At(i)) + } + groups = append(groups, cur) + return groups +} + +// interfaceSortKey mirrors the fixer's normalized name: from the first +// meaningful token, concatenate token content (with "\" replaced by a space) +// until whitespace or a comment ends the name. +func interfaceSortKey(group []token.Token) string { + var b strings.Builder + started := false + for _, t := range group { + if !started { + if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment { + continue + } + started = true + } + if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment { + break + } + b.WriteString(strings.ReplaceAll(t.Value, `\`, " ")) + } + return b.String() +} diff --git a/blink/internal/fixer/rules/ordered_traits.go b/blink/internal/fixer/rules/ordered_traits.go new file mode 100644 index 0000000000..79877a9f93 --- /dev/null +++ b/blink/internal/fixer/rules/ordered_traits.go @@ -0,0 +1,273 @@ +package rules + +import ( + "slices" + "sort" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTraitsFixer.php +// +// OrderedTraits sorts trait `use` statements alphabetically (case-insensitive by +// default), and sorts multiple traits within one `use A, B;` statement. Only the +// name portions move; surrounding whitespace and punctuation stay in place. +type OrderedTraits struct{} + +func (OrderedTraits) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\OrderedTraitsFixer` +} + +func (OrderedTraits) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTraitsFixer.php" +} + +func (OrderedTraits) Fix(s *tokens.Stream) bool { + // gather all trait-use statements as (start, end) ranges + type stmt struct { + start, end int + } + var groups [][]stmt + var cur []stmt + + i := 0 + for i < s.Len() { + t := s.At(i) + if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment { + i++ + continue + } + if !kwIs(t, "use") || !isTraitUse(s, i) { + if len(cur) > 0 { + groups = append(groups, cur) + cur = nil + } + i++ + continue + } + // statement end: next ";" or "{...}" block + end := nextPunctOfKind(s, i, ";", "{") + if end < 0 { + break + } + if isPunctVal(s, end, "{") { + end = s.MatchForward(end) + if end < 0 { + break + } + } + cur = append(cur, stmt{start: i, end: end}) + i = end + 1 + } + if len(cur) > 0 { + groups = append(groups, cur) + } + + changed := false + // process groups in reverse so earlier indices stay valid across ReplaceRange + for _, g := range slices.Backward(groups) { + // build each statement's token slice, with its multiple traits sorted + stmtToks := make([][]token.Token, len(g)) + names := make([]string, len(g)) + for k, st := range g { + toks := make([]token.Token, 0, st.end-st.start+1) + for j := st.start; j <= st.end; j++ { + toks = append(toks, s.At(j)) + } + toks = sortTraitsInStatement(toks) + stmtToks[k] = toks + names[k] = traitName(toks) + } + // sort statement slices by trait name (case-insensitive, stable) + order := make([]int, len(g)) + for k := range order { + order[k] = k + } + sort.SliceStable(order, func(a, b int) bool { + return strings.ToLower(names[order[a]]) < strings.ToLower(names[order[b]]) + }) + // assign sorted slices to original positions; detect change + reordered := false + for k := range order { + if order[k] != k { + reordered = true + break + } + } + if !reordered { + // even if statement order is unchanged, within-statement sort may have changed a slice + within := false + for k, st := range g { + if !sameTokens(stmtToks[k], s, st.start, st.end) { + within = true + break + } + } + if !within { + continue + } + } + // apply position -> sorted slice, in reverse index order + for k, gk := range slices.Backward(g) { + s.ReplaceRange(gk.start, gk.end, stmtToks[order[k]]) + } + changed = true + } + return changed +} + +func sameTokens(toks []token.Token, s *tokens.Stream, start, end int) bool { + if len(toks) != end-start+1 { + return false + } + for k, t := range toks { + o := s.At(start + k) + if o.Kind != t.Kind || o.Value != t.Value { + return false + } + } + return true +} + +// isTraitUse reports whether the "use" keyword at i is a trait use (inside a +// class/trait/enum body), not a namespace import or a closure "use". +func isTraitUse(s *tokens.Stream, i int) bool { + n := sigNext(s, i) + if n < 0 { + return false + } + // closure use is "use (" ; imports/traits start with a name or "\" + if s.At(n).Kind != token.Ident && !isPunctVal(s, n, `\`) { + return false + } + // enclosing "{" must belong to a class/trait/enum + depth := 0 + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case "}": + depth++ + case "{": + if depth == 0 { + return braceIsClassy(s, j) + } + depth-- + } + } + return false +} + +// braceIsClassy reports whether the "{" at index opens a class/trait/enum body, +// by walking back over the class header to its keyword. +func braceIsClassy(s *tokens.Stream, brace int) bool { + for j := sigPrev(s, brace); j >= 0; j = sigPrev(s, j) { + t := s.At(j) + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "class", "trait", "enum": + return true + case "extends", "implements", "abstract", "final", "readonly": + continue + } + return false + } + switch t.Kind { + case token.Ident: + continue + case token.Punct: + if t.Value == "," || t.Value == `\` { + continue + } + return false + default: + return false + } + } + return false +} + +// traitName returns the first trait's fully-qualified name with the leading "\" +// trimmed, matching the fixer's toTraitName sort key. +func traitName(toks []token.Token) string { + var b strings.Builder + for _, t := range toks { + if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{" || t.Value == ",") { + break + } + if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == `\`) { + b.WriteString(t.Value) + } + } + return strings.TrimLeft(b.String(), `\`) +} + +// sortTraitsInStatement sorts multiple traits within a single "use A, B;" +// statement, swapping only the name token runs and keeping everything else. +func sortTraitsInStatement(toks []token.Token) []token.Token { + // find name runs (T_STRING / "\") separated by "," ; stop at ";" or "{" + type run struct{ start, end int } + var runs []run + rs := -1 + for idx, t := range toks { + isName := t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == `\`) + if isName { + if rs < 0 { + rs = idx + } + continue + } + if t.Kind == token.Punct && (t.Value == "," || t.Value == ";" || t.Value == "{") { + if rs >= 0 { + runs = append(runs, run{rs, idx - 1}) + rs = -1 + } + if t.Value == "{" { + break + } + } + } + if len(runs) <= 1 { + return toks + } + keys := make([]string, len(runs)) + for k, r := range runs { + var b strings.Builder + for j := r.start; j <= r.end; j++ { + b.WriteString(toks[j].Value) + } + keys[k] = strings.TrimLeft(b.String(), `\`) + } + order := make([]int, len(runs)) + for k := range order { + order[k] = k + } + sort.SliceStable(order, func(a, b int) bool { + return strings.ToLower(keys[order[a]]) < strings.ToLower(keys[order[b]]) + }) + same := true + for k := range order { + if order[k] != k { + same = false + break + } + } + if same { + return toks + } + // rebuild sequentially: at each run's slot, place the sorted run's tokens + // (runs may differ in length); everything between runs is kept verbatim + var res []token.Token + prev := 0 + for k, r := range runs { + res = append(res, toks[prev:r.start]...) + src := runs[order[k]] + res = append(res, toks[src.start:src.end+1]...) + prev = r.end + 1 + } + res = append(res, toks[prev:]...) + return res +} diff --git a/blink/internal/fixer/rules/ordered_types.go b/blink/internal/fixer/rules/ordered_types.go new file mode 100644 index 0000000000..c4c31b6099 --- /dev/null +++ b/blink/internal/fixer/rules/ordered_types.go @@ -0,0 +1,160 @@ +package rules + +import ( + "sort" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTypesFixer.php +// +// OrderedTypes sorts the members of a union or intersection type declaration +// using the fixer defaults: case-insensitive alphabetical order with "null" +// always first ("B|A" -> "A|B", "string|int|null" -> "null|int|string"). It acts +// only in confirmed type positions (parameter, return, typed property) on a flat +// single-operator run; nullable "?" prefixes, DNF "()" types, mixed "|"/"&" runs +// and types spanning multiple lines are left untouched. +type OrderedTypes struct{} + +func (OrderedTypes) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\OrderedTypesFixer` +} + +func (OrderedTypes) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTypesFixer.php" +} + +// typeMember is one member of a union/intersection, its tokens and its sort key. +type typeMember struct { + toks []token.Token + key string +} + +func (OrderedTypes) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != "|" && t.Value != "&") { + continue + } + if !isTypeUnionOperator(s, i) { + continue + } + start := typeRunBoundaryPrev(s, i) + end := typeRunBoundaryNext(s, i) + if end < 0 { + continue + } + // the run's first..last significant token (excludes surrounding whitespace) + runStart := start + 1 + for runStart < end && s.At(runStart).Kind == token.Whitespace { + runStart++ + } + runEnd := end - 1 + for runEnd > runStart && s.At(runEnd).Kind == token.Whitespace { + runEnd-- + } + // process each run once, at its leftmost operator + first := -1 + for j := runStart; j <= runEnd; j++ { + if v := s.At(j); v.Kind == token.Punct && (v.Value == "|" || v.Value == "&") { + first = j + break + } + } + if first != i { + continue + } + + op := t.Value + members, ok := collectTypeMembers(s, runStart, runEnd+1, op) + if !ok || len(members) < 2 { + continue + } + + sorted := make([]typeMember, len(members)) + copy(sorted, members) + sort.SliceStable(sorted, func(a, b int) bool { + an, bn := sorted[a].key == "null", sorted[b].key == "null" + if an != bn { + return an // null first + } + return sorted[a].key < sorted[b].key + }) + if sameMemberOrder(members, sorted) { + continue // already ordered; nothing to rewrite + } + + repl := make([]token.Token, 0, len(sorted)*2) + for idx, m := range sorted { + if idx > 0 { + repl = append(repl, token.Token{Kind: token.Punct, Value: op}) + } + repl = append(repl, m.toks...) + } + s.ReplaceRange(runStart, runEnd, repl) + changed = true + i = runStart + len(repl) - 1 + } + return changed +} + +// collectTypeMembers splits the significant tokens in [from, to) into members +// separated by the single operator op. It fails (ok=false) when the run mixes +// operators, contains a "?"/"("/")" or spans multiple lines - cases the fixer +// must not reorder. +func collectTypeMembers(s *tokens.Stream, from, to int, op string) (members []typeMember, ok bool) { + var cur typeMember + flush := func() { + cur.key = strings.ToLower(cur.key) + members = append(members, cur) + cur = typeMember{} + } + for j := from; j < to; j++ { + tk := s.At(j) + switch tk.Kind { + case token.Whitespace: + if hasNewline(tk.Value) { + return nil, false // multi-line type, leave alone + } + continue + case token.Comment, token.DocComment: + return nil, false + case token.Punct: + switch tk.Value { + case op: + if len(cur.toks) == 0 { + return nil, false + } + flush() + continue + case `\`: + cur.toks = append(cur.toks, tk) + cur.key += tk.Value + continue + default: + return nil, false // "?", "(", ")", "&"/"|" mismatch + } + default: + cur.toks = append(cur.toks, tk) + cur.key += tk.Value + } + } + if len(cur.toks) == 0 { + return nil, false + } + flush() + return members, true +} + +// sameMemberOrder reports whether a and b list members in the same order. +func sameMemberOrder(a, b []typeMember) bool { + for i := range a { + if a[i].key != b[i].key { + return false + } + } + return true +} diff --git a/blink/internal/fixer/rules/param_return_and_var_tag_malforms.go b/blink/internal/fixer/rules/param_return_and_var_tag_malforms.go new file mode 100644 index 0000000000..3bf9cc5be4 --- /dev/null +++ b/blink/internal/fixer/rules/param_return_and_var_tag_malforms.go @@ -0,0 +1,23 @@ +package rules + +import "blink/internal/tokens" + +// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/ParamReturnAndVarTagMalformsFixer.php +// +// ParamReturnAndVarTagMalforms is deprecated upstream and a no-op: its +// isCandidate returns false and fix does nothing (it was split into dedicated +// single-task rules). It is kept as a registered no-op so the default set +// matches ECS. +type ParamReturnAndVarTagMalforms struct{} + +func (ParamReturnAndVarTagMalforms) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\ParamReturnAndVarTagMalformsFixer` +} + +func (ParamReturnAndVarTagMalforms) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/ParamReturnAndVarTagMalformsFixer.php" +} + +func (ParamReturnAndVarTagMalforms) Fix(*tokens.Stream) bool { + return false +} diff --git a/blink/internal/fixer/rules/parity_5bugs_test.go b/blink/internal/fixer/rules/parity_5bugs_test.go new file mode 100644 index 0000000000..9dd8c06461 --- /dev/null +++ b/blink/internal/fixer/rules/parity_5bugs_test.go @@ -0,0 +1,48 @@ +package rules + +import "testing" + +func TestArrowFnSpacing(t *testing.T) { + got, changed := apply(t, FunctionDeclaration{}, " $x;") + if want := " $x;"; !changed || got != want { + t.Fatalf("changed=%v got=%q want=%q", changed, got, want) + } + if _, changed := apply(t, FunctionDeclaration{}, " $x;"); changed { + t.Fatal("already-spaced fn must not change") + } +} + +func TestPhpdocTypesGenericsAndConst(t *testing.T) { + // a pseudo-type keyword inside generics is lowercased + got, changed := apply(t, PhpdocTypes{}, "\n */\nclass A {}") + if want := "\n */\nclass A {}"; !changed || got != want { + t.Fatalf("generics: changed=%v got=%q", changed, got) + } + // a class-constant reference (Ref::STATIC) is left untouched + if _, changed := apply(t, PhpdocTypes{}, " +// "new Foo()". Anonymous classes and dynamic "new $var" are left alone. +type NewWithParentheses struct{} + +func (NewWithParentheses) Name() string { + return `PhpCsFixer\Fixer\Operator\NewWithParenthesesFixer` +} + +func (NewWithParentheses) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NewWithParenthesesFixer.php" +} + +func (NewWithParentheses) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "new" { + continue + } + j := skipWhitespace(s, i+1) + if j >= s.Len() { + continue + } + // a static class name (Ident, \-qualified, self/parent/static) or a + // dynamic class reference (new $var, new $this->prop, new $a::$b) + nt := s.At(j) + if nt.Kind == token.Keyword && strings.ToLower(nt.Value) == "class" { + // anonymous class: "new class extends X" -> "new class() extends X" + // (ECS's psr12 config sets anonymous_class => true) + if nextSignificantValue(s, j) == "(" { + continue // already "new class(...)" + } + s.InsertAt(j+1, token.Token{Kind: token.Punct, Value: "("}) + s.InsertAt(j+2, token.Token{Kind: token.Punct, Value: ")"}) + changed = true + continue + } + if nt.Kind == token.Variable { + k := consumeNewVarRef(s, j) + if nextSignificantValue(s, k-1) == "(" { + continue // already has parentheses + } + s.InsertAt(k, token.Token{Kind: token.Punct, Value: "("}) + s.InsertAt(k+1, token.Token{Kind: token.Punct, Value: ")"}) + changed = true + continue + } + isName := nt.Kind == token.Ident || + (nt.Kind == token.Punct && nt.Value == `\`) || + (nt.Kind == token.Keyword && isStaticRef(nt.Value)) + if !isName { + continue // new (expr), ... + } + // consume the class reference (Ident / \ / self-parent-static) + k := j + for k < s.Len() { + c := s.At(k) + if c.Kind == token.Ident || (c.Kind == token.Punct && c.Value == `\`) || + (c.Kind == token.Keyword && isStaticRef(c.Value)) { + k++ + continue + } + break + } + if nextSignificantValue(s, k-1) == "(" { + continue // already has parentheses + } + s.InsertAt(k, token.Token{Kind: token.Punct, Value: "("}) + s.InsertAt(k+1, token.Token{Kind: token.Punct, Value: ")"}) + changed = true + } + return changed +} + +// consumeNewVarRef returns the index just past a dynamic class reference that +// starts with a variable at `start`: "$var", "$this->prop", "$a::$b", "$a[0]". +// It stops before a "(" so an existing constructor/method call is left intact. +func consumeNewVarRef(s *tokens.Stream, start int) int { + k := start + 1 // past the initial variable + for k < s.Len() { + c := s.At(k) + if c.Kind == token.Punct && (c.Value == "->" || c.Value == "?->" || c.Value == "::") { + m := skipWhitespace(s, k+1) + if m < s.Len() && (s.At(m).Kind == token.Ident || s.At(m).Kind == token.Variable) { + k = m + 1 + continue + } + break + } + if c.Kind == token.Punct && c.Value == "[" { + cl := s.MatchForward(k) + if cl < 0 { + break + } + k = cl + 1 + continue + } + break + } + return k +} + +func isStaticRef(v string) bool { + switch strings.ToLower(v) { + case "self", "parent", "static": + return true + } + return false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/SingleLineEmptyBodyFixer.php +// +// SingleLineEmptyBody collapses an empty class or function body to "{}" on the +// declaration line ("function f()\n{\n}" -> "function f() {}"). +type SingleLineEmptyBody struct{} + +func (SingleLineEmptyBody) Name() string { + return `PhpCsFixer\Fixer\Basic\SingleLineEmptyBodyFixer` +} + +func (SingleLineEmptyBody) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/SingleLineEmptyBodyFixer.php" +} + +func (SingleLineEmptyBody) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "{" { + continue + } + switch kind, _ := classifyBrace(s, i); kind { + case braceClassLike, braceFunctionDecl: + default: + continue + } + closeIdx := s.MatchForward(i) + if closeIdx < 0 { + continue + } + empty := true + for k := i + 1; k < closeIdx; k++ { + if s.At(k).Kind != token.Whitespace { + empty = false + break + } + } + if !empty || closeIdx == i+1 { + continue + } + // remove the whitespace between { and } + for k := closeIdx - 1; k > i; k-- { + s.RemoveAt(k) + } + // pull the brace onto the declaration line + if i > 0 && s.At(i-1).Kind == token.Whitespace && hasNewline(s.At(i-1).Value) { + s.SetValue(i-1, " ") + } + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/percs_test.go b/blink/internal/fixer/rules/percs_test.go new file mode 100644 index 0000000000..f4642ed03f --- /dev/null +++ b/blink/internal/fixer/rules/percs_test.go @@ -0,0 +1,45 @@ +package rules + +import "testing" + +func TestNewWithParentheses(t *testing.T) { + got, changed := apply(t, NewWithParentheses{}, " true) + if got, changed := apply(t, NewWithParentheses{}, "job;") + if want := "job();"; !changed || got != want { + t.Fatalf("changed=%v got=%q want=%q", changed, got, want) + } + // an existing call on a dynamic reference is left intact + if _, changed := apply(t, NewWithParentheses{}, "make();"); changed { + t.Fatal("new $this->make() already has parentheses") + } +} + +func TestSingleLineEmptyBody(t *testing.T) { + got, changed := apply(t, SingleLineEmptyBody{}, "" from a file that is only PHP (no inline +// HTML), leaving a single trailing newline. +type NoClosingTag struct{} + +func (NoClosingTag) Name() string { + return `PhpCsFixer\Fixer\PhpTag\NoClosingTagFixer` +} + +func (NoClosingTag) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpTag/NoClosingTagFixer.php" +} + +func (NoClosingTag) Fix(s *tokens.Stream) bool { + last := -1 + for i := range s.Len() { + t := s.At(i) + // real inline HTML means a templating file; whitespace-only inline HTML + // (e.g. the newline the lexer emits after "?>") does not count + if t.Kind == token.InlineHTML && strings.TrimSpace(t.Value) != "" { + return false + } + if t.Kind == token.CloseTag { + last = i + } + } + if last < 0 { + return false + } + // only whitespace (or whitespace-only inline HTML) may follow the final "?>" + for j := last + 1; j < s.Len(); j++ { + k := s.At(j).Kind + if k == token.Whitespace { + continue + } + if k == token.InlineHTML && strings.TrimSpace(s.At(j).Value) == "" { + continue + } + return false + } + for k := s.Len() - 1; k >= last; k-- { + s.RemoveAt(k) + } + // normalize EOF to exactly one trailing newline (idempotent with eof rule) + if s.Len() > 0 { + li := s.Len() - 1 + if s.At(li).Kind == token.Whitespace { + s.SetValue(li, "\n") + } else { + s.InsertAt(s.Len(), token.Token{Kind: token.Whitespace, Value: "\n"}) + } + } + return true +} diff --git a/blink/internal/fixer/rules/phpdoc.go b/blink/internal/fixer/rules/phpdoc.go new file mode 100644 index 0000000000..4cfde92c80 --- /dev/null +++ b/blink/internal/fixer/rules/phpdoc.go @@ -0,0 +1,156 @@ +package rules + +import ( + "regexp" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// applyToDocblocks runs fn over each doc comment, replacing it when fn reports a +// change. +func applyToDocblocks(s *tokens.Stream, fn func(*docblock) bool) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.DocComment { + continue + } + d, ok := parseDoc(t.Value) + if !ok { + continue + } + if fn(&d) { + s.SetValue(i, d.render()) + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimFixer.php +// +// PhpdocTrim removes blank lines at the start and end of a docblock. +type PhpdocTrim struct{} + +func (PhpdocTrim) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocTrimFixer` +} + +func (PhpdocTrim) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimFixer.php" +} + +func (PhpdocTrim) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + before := len(d.inner) + for len(d.inner) > 0 && strings.TrimSpace(d.inner[0].content) == "" { + d.inner = d.inner[1:] + } + for len(d.inner) > 0 && strings.TrimSpace(d.inner[len(d.inner)-1].content) == "" { + d.inner = d.inner[:len(d.inner)-1] + } + return len(d.inner) != before + }) +} + +// only when void/null is the whole return type - not part of a union like +// "null|string", which is a real nullable type +var emptyReturnRe = regexp.MustCompile(`(?i)^@return\s+(void|null)($|\s)`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoEmptyReturnFixer.php +// +// PhpdocNoEmptyReturn removes an "@return void" or "@return null" tag. +type PhpdocNoEmptyReturn struct{} + +func (PhpdocNoEmptyReturn) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoEmptyReturnFixer` +} + +func (PhpdocNoEmptyReturn) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoEmptyReturnFixer.php" +} + +func (PhpdocNoEmptyReturn) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + kept := d.inner[:0:0] + removed := false + for _, l := range d.inner { + if emptyReturnRe.MatchString(strings.TrimSpace(l.content)) { + removed = true + continue + } + kept = append(kept, l) + } + if removed { + d.inner = kept + } + return removed + }) +} + +var phpdocScalarMap = map[string]string{ + "boolean": "bool", "integer": "int", "double": "float", + "real": "float", "str": "string", "callback": "callable", +} + +var phpdocTypeTagRe = regexp.MustCompile(`(?i)^(@(?:param|return|var|throws|property|property-read|property-write|method)\s+)(\S+)(.*)$`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocScalarFixer.php +// +// PhpdocScalar normalizes scalar type aliases in phpdoc tags (integer -> int, +// boolean -> bool, double/real -> float, str -> string, callback -> callable). +type PhpdocScalar struct{} + +func (PhpdocScalar) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocScalarFixer` +} + +func (PhpdocScalar) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocScalarFixer.php" +} + +func (PhpdocScalar) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + m := phpdocTypeTagRe.FindStringSubmatch(strings.TrimLeft(l.content, " ")) + if m == nil { + continue + } + newType := normalizeScalarType(m[2]) + if newType == m[2] { + continue + } + lead := l.content[:len(l.content)-len(strings.TrimLeft(l.content, " "))] + d.inner[i].content = lead + m[1] + newType + m[3] + changed = true + } + return changed + }) +} + +// normalizeScalarType replaces scalar aliases in a phpdoc type, handling unions, +// nullables and array suffixes ("integer[]|null" -> "int[]|null"). The lookup is +// case-sensitive so a class named like an alias (e.g. Laravel's "Str") is safe. +func normalizeScalarType(typ string) string { + nullable := strings.HasPrefix(typ, "?") + body := strings.TrimPrefix(typ, "?") + parts := strings.Split(body, "|") + for i, p := range parts { + base, suffix := p, "" + for strings.HasSuffix(base, "[]") { + base = base[:len(base)-2] + suffix = "[]" + suffix + } + if repl, ok := phpdocScalarMap[base]; ok { + parts[i] = repl + suffix + } + } + out := strings.Join(parts, "|") + if nullable { + out = "?" + out + } + return out +} diff --git a/blink/internal/fixer/rules/phpdoc_align.go b/blink/internal/fixer/rules/phpdoc_align.go new file mode 100644 index 0000000000..7812b4dade --- /dev/null +++ b/blink/internal/fixer/rules/phpdoc_align.go @@ -0,0 +1,180 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocAlignFixer.php +// +// PhpdocAlign here follows the ECS laravel config: align="left", spacing +// {param:2,_default:1}, default tags. Left alignment collapses the spacing +// between tag/hint/var/desc to the configured spacing and re-indents wrapped +// description lines under the description column. +type PhpdocAlign struct{} + +// default tags split by kind +var ( + phpAlignNameTags = []string{"param", "property", "property-read", "property-write", "phpstan-param", "phpstan-property", "phpstan-property-read", "phpstan-property-write", "phpstan-assert", "phpstan-assert-if-true", "phpstan-assert-if-false", "psalm-param", "psalm-param-out", "psalm-property", "psalm-property-read", "psalm-property-write", "psalm-assert", "psalm-assert-if-true", "psalm-assert-if-false"} + phpAlignMethodTags = []string{"method", "phpstan-method", "psalm-method"} + // DEFAULT_TAGS = method,param,property,return,throws,type,var + phpAlignNoNameTags = []string{"return", "throws", "type", "var"} +) + +func (PhpdocAlign) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocAlignFixer` +} + +func (PhpdocAlign) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocAlignFixer.php" +} + +func phpAlignSpacingFor(tag string) int { + if tag == "param" { + return 2 + } + return 1 +} + +type alignMatch struct { + indent string + tag string // "" for a comment-only continuation line + hint string + varName string + static string + desc string + isTag bool // matched the tag regex +} + +func (PhpdocAlign) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.DocComment { + continue + } + v := s.At(i).Value + rebuilt := phpAlignDocBlock(v) + if rebuilt != v { + s.SetValue(i, rebuilt) + changed = true + } + } + return changed +} + +func phpAlignDocBlock(content string) string { + lines := splitDocLines(content) + l := len(lines) + for i := 0; i < l; i++ { + m := phpAlignGetMatches(lines[i], false) + if m == nil { + continue + } + current := i + items := []alignMatch{*m} + for { + i++ + if i >= l { + // break both loops + phpAlignRewrite(lines, current, items) + return strings.Join(lines, "") + } + m2 := phpAlignGetMatches(lines[i], true) + if m2 == nil { + break + } + items = append(items, *m2) + } + phpAlignRewrite(lines, current, items) + i-- // reconsider the line that broke the group (it may start a new group) + } + return strings.Join(lines, "") +} + +// phpAlignRewrite reformats the grouped item lines (left align). +func phpAlignRewrite(lines []string, current int, items []alignMatch) { + // group-wide flag: any tag line carries a `static` keyword + hasStatic := false + for j := range items { + if items[j].isTag && items[j].static != "" { + hasStatic = true + break + } + } + var itemOpening *alignMatch + for j := range items { + item := items[j] + var line string + if !item.isTag { + if len(item.desc) > 0 && item.desc[0] == '@' { + line = item.indent + " * " + item.desc + lines[current+j] = line + "\n" + continue + } + leftIndent := phpAlignLeftDescIndent(items, j) + hintPad := "" + if itemOpening != nil && itemOpening.hint != "" { + hintPad = " " + } + line = item.indent + " * " + hintPad + strings.Repeat(" ", leftIndent) + item.desc + lines[current+j] = line + "\n" + continue + } + itemOpening = &items[j] + spacing := phpAlignSpacingFor(item.tag) + line = item.indent + " * @" + item.tag + // static: only emitted when the group has any static keyword + if hasStatic { + if item.static != "" { + line += strings.Repeat(" ", spacing) + item.static + } + // non-static line adds no padding under left align + } + // hint + if item.hint != "" { + line += strings.Repeat(" ", spacing) + item.hint + } + // var + if item.varName != "" { + line += strings.Repeat(" ", spacing) + item.varName + if item.desc != "" { + line += strings.Repeat(" ", spacing) + item.desc + } + } else if item.desc != "" { + line += strings.Repeat(" ", spacing) + item.desc + } + lines[current+j] = line + "\n" + } +} + +// phpAlignLeftDescIndent mirrors getLeftAlignedDescriptionIndent for align=left. +func phpAlignLeftDescIndent(items []alignMatch, index int) int { + var item *alignMatch + for ; index >= 0; index-- { + item = &items[index] + if item.isTag { + break + } + } + if item == nil || !item.isTag { + return 0 + } + spacing := phpAlignSpacingFor(item.tag) + sent := func(sfrag string, hasVal bool) int { + if !hasVal { + return 0 + } + n := len(sfrag) + if n == 0 { + return 0 + } + return n + spacing + } + // static, tag, hint, var + return sent(item.static, item.static != "") + + sent(item.tag, true) + + sent(item.hint, item.hint != "") + + sent(item.varName, item.varName != "") +} diff --git a/blink/internal/fixer/rules/phpdoc_align_match.go b/blink/internal/fixer/rules/phpdoc_align_match.go new file mode 100644 index 0000000000..537a2b134d --- /dev/null +++ b/blink/internal/fixer/rules/phpdoc_align_match.go @@ -0,0 +1,335 @@ +package rules + +import ( + "slices" + "strings" +) + +func phpAlignIsHSpace(c byte) bool { return c == ' ' || c == '\t' } + +// phpAlignGetMatches parses a docblock line, mirroring PhpdocAlignFixer::getMatches +// for the laravel config (default tags). Returns nil when the line is not a tag +// line (or, when matchCommentOnly, not a description continuation line). +func phpAlignGetMatches(rawLine string, matchCommentOnly bool) *alignMatch { + line := strings.TrimRight(rawLine, "\r\n") + // ^(?P(?:\ {2}|\t)*)\ ?\* + i := 0 + indentEnd := 0 + for i < len(line) { + if strings.HasPrefix(line[i:], " ") { + i += 2 + indentEnd = i + } else if line[i] == '\t' { + i++ + indentEnd = i + } else { + break + } + } + indent := line[:indentEnd] + if i < len(line) && line[i] == ' ' { + i++ + } + if i >= len(line) || line[i] != '*' { + return nil + } + i++ // past '*' + starPos := i + + // try the tag regex: \h*@tag... + j := i + for j < len(line) && phpAlignIsHSpace(line[j]) { + j++ + } + if j < len(line) && line[j] == '@' { + if m := phpAlignParseTag(indent, line, j+1); m != nil { + return m + } + } + + if !matchCommentOnly { + return nil + } + // regexCommentLine: \*(?!\h?+@)(?:\s+(?P\V+))(?= len(line) { + return nil + } + desc := line[d:] + if desc == "" { + return nil + } + // (?= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' +} + +func phpAlignInList(tag string, list []string) bool { + return slices.Contains(list, tag) +} + +// name tag: @tag \s+ (hint)? \s* (var=(?:&|\.{3})?\$\S+) (?:\s+ desc)? \h*$ +func phpAlignParseNameTag(indent, tag, line string, pos int) *alignMatch { + // require \s+ + p := pos + for p < len(line) && phpAlignIsWS(line[p]) { + p++ + } + if p == pos { + return nil // needed \s+ + } + // find the var: a '$' at bracket depth 0 whose token is separated from the + // hint by whitespace. A callable/Closure carries `$param` inside parentheses + // (depth > 0) and a union member like `Foo|$this` attaches with no space, so + // both stay in the hint; the documented variable follows a space. + dollar := -1 + depth := 0 + for k := p; k < len(line); k++ { + switch line[k] { + case '<', '[', '(', '{': + depth++ + case '>', ']', ')', '}': + if depth > 0 { + depth-- + } + case '$': + if depth == 0 { + ps := k + if k-1 >= p && line[k-1] == '&' { + ps = k - 1 + } else if k-3 >= p && line[k-3:k] == "..." { + ps = k - 3 + } + if ps == p || phpAlignIsWS(line[ps-1]) { + dollar = k + } + } + } + if dollar >= 0 { + break + } + } + if dollar < 0 { + return nil + } + varStart := dollar + if varStart-1 >= p && line[varStart-1] == '&' { + varStart-- + } else if varStart-3 >= p && line[varStart-3:varStart] == "..." { + varStart -= 3 + } + hint := strings.TrimRight(line[p:varStart], " \t") + hint = strings.TrimSpace(hint) + // var = \S+ from varStart + ve := varStart + for ve < len(line) && !phpAlignIsWS(line[ve]) { + ve++ + } + varName := line[varStart:ve] + // desc: (?:\s+ (\V*))? then \h*$ + desc := "" + q := ve + for q < len(line) && phpAlignIsWS(line[q]) { + q++ + } + if q > ve && q < len(line) { + desc = line[q:] + } + return &alignMatch{indent: indent, tag: tag, isTag: true, hint: hint, varName: varName, desc: desc} +} + +// no-name tag: @tag \s+ (hint=REGEX_TYPES)? (?:\s+ desc)? \h*$ +func phpAlignParseNoNameTag(indent, tag, line string, pos int) *alignMatch { + p := pos + for p < len(line) && phpAlignIsWS(line[p]) { + p++ + } + if p == pos { + return nil + } + hintEnd, balanced := phpAlignScanType(line, p) + if !balanced { + // REGEX_TYPES cannot match an unbalanced type, so the hint is empty and + // desc needs its own \s+; that only exists when 2+ leading spaces let the + // hint's \s+ give one back. A single space leaves no split -> no match. + if p-pos < 2 { + return nil + } + return &alignMatch{indent: indent, tag: tag, isTag: true, hint: "", varName: "", desc: line[p:]} + } + hint := strings.TrimSpace(line[p:hintEnd]) + desc := "" + q := hintEnd + for q < len(line) && phpAlignIsWS(line[q]) { + q++ + } + if q < len(line) { + desc = line[q:] + } + return &alignMatch{indent: indent, tag: tag, isTag: true, hint: hint, varName: "", desc: desc} +} + +// method tag: @tag (\s+static)? \s+ (hint)? \s+ (signature=.+\)) — best effort +func phpAlignParseMethodTag(indent, tag, line string, pos int) *alignMatch { + // Fall back: treat as no-name if it doesn't clearly have a signature "(...)" + if strings.IndexByte(line[pos:], '(') < 0 { + return phpAlignParseNoNameTag(indent, tag, line, pos) + } + p := pos + for p < len(line) && phpAlignIsWS(line[p]) { + p++ + } + if p == pos { + return nil + } + static := "" + if strings.HasPrefix(line[p:], "static") && (p+6 >= len(line) || phpAlignIsWS(line[p+6])) { + static = "static" + p += 6 + for p < len(line) && phpAlignIsWS(line[p]) { + p++ + } + } + // hint then \s+ signature (ends with ')') + sigParen := strings.LastIndexByte(line, ')') + if sigParen < 0 { + return nil + } + if p > sigParen { + return nil + } + // signature start: after hint + \s+. Find the last top-level ws before the signature name. + hintEnd, _ := phpAlignScanType(line, p) + var hint, signature string + if hintEnd > sigParen { + // no return type: the scanned run is the name+signature itself + signature = strings.TrimRight(line[p:sigParen+1], " \t") + } else { + hint = strings.TrimSpace(line[p:hintEnd]) + q := hintEnd + for q < len(line) && phpAlignIsWS(line[q]) { + q++ + } + if q > sigParen { + return nil + } + signature = strings.TrimRight(line[q:sigParen+1], " \t") + } + if !strings.HasSuffix(signature, ")") { + return nil + } + // desc: (?:\s+ \V*) after the signature's closing paren + desc := "" + r := sigParen + 1 + for r < len(line) && phpAlignIsWS(line[r]) { + r++ + } + if r < len(line) { + desc = line[r:] + } + // hint/static swap: if hint empty and static set, static becomes hint + if hint == "" && static != "" { + hint = static + static = "" + } + return &alignMatch{indent: indent, tag: tag, isTag: true, hint: hint, varName: signature, static: static, desc: desc} +} + +func phpAlignIsWS(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f' +} + +// phpAlignScanType consumes a PHPDoc type expression starting at pos, returning +// the index just past it. Approximates TypeExpression::REGEX_TYPES: it consumes +// type characters and bracketed groups, treating a top-level space as the end +// unless the next non-space is a union/intersection continuation (| or &). +// phpAlignScanType returns the index just past the type and whether it ended +// balanced. An unbalanced run (depth > 0 at end of line) means REGEX_TYPES would +// not match at all. +func phpAlignScanType(line string, pos int) (int, bool) { + depth := 0 + i := pos + last := pos // last index that is definitely part of the type + for i < len(line) { + c := line[i] + switch c { + case '<', '[', '(', '{': + depth++ + i++ + last = i + case '>', ']', ')', '}': + if depth > 0 { + depth-- + } + i++ + last = i + case ' ', '\t': + if depth > 0 { + i++ + continue + } + // top-level space: continuation only if next non-space is | or & + k := i + for k < len(line) && (line[k] == ' ' || line[k] == '\t') { + k++ + } + if k < len(line) && (line[k] == '|' || line[k] == '&') { + i = k + continue + } + return last, depth == 0 + case '\n', '\r': + return last, depth == 0 + default: + i++ + last = i + } + } + return last, depth == 0 +} diff --git a/blink/internal/fixer/rules/phpdoc_align_test.go b/blink/internal/fixer/rules/phpdoc_align_test.go new file mode 100644 index 0000000000..c5b01c6b3f --- /dev/null +++ b/blink/internal/fixer/rules/phpdoc_align_test.go @@ -0,0 +1,34 @@ +package rules + +import "testing" + +func TestPhpdocAlign(t *testing.T) { + f := PhpdocAlign{} + + // left align: param uses spacing 2, return uses spacing 1 + got, changed := apply(t, f, ", ...). +var phpdocGenericTagRe = regexp.MustCompile(`(?i)^(@(?:implements|extends|use|template-extends|template-implements)\s+)(\S+)(.*)$`) + +func (PhpdocTypes) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + trimmed := strings.TrimLeft(l.content, " ") + m := phpdocTypeTagRe.FindStringSubmatch(trimmed) + if m == nil { + m = phpdocGenericTagRe.FindStringSubmatch(trimmed) + } + if m == nil { + continue + } + newType := normalizePhpdocTypeCase(m[2]) + if newType == m[2] { + continue + } + lead := l.content[:len(l.content)-len(trimmed)] + d.inner[i].content = lead + m[1] + newType + m[3] + changed = true + } + return changed + }) +} + +var phpdocTypeWordRe = regexp.MustCompile(`[$A-Za-z_\\][A-Za-z0-9_\\]*`) + +// normalizePhpdocTypeCase lowercases keyword bases anywhere in a phpdoc type - +// unions, array suffixes, nullables and generics (Foo -> Foo). +// A word that is namespaced ("\Foo") or a class-constant/member reference +// ("Ref::STATIC") is left as-is. +func normalizePhpdocTypeCase(typ string) string { + var b strings.Builder + prev := 0 + for _, loc := range phpdocTypeWordRe.FindAllStringIndex(typ, -1) { + st, en := loc[0], loc[1] + b.WriteString(typ[prev:st]) + w := typ[st:en] + qualified := strings.Contains(w, `\`) || (st > 0 && (typ[st-1] == ':' || typ[st-1] == '\\')) + if !qualified { + if low := strings.ToLower(w); low != w && phpdocTypeKeywords[low] { + w = low + } + } + b.WriteString(w) + prev = en + } + b.WriteString(typ[prev:]) + return b.String() +} + +var aliasTagRe = regexp.MustCompile(`^@(type|link)\b`) + +var aliasTagMap = map[string]string{"type": "var", "link": "see"} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAliasTagFixer.php +// +// PhpdocNoAliasTag rewrites alias tags: @type -> @var, @link -> @see. +type PhpdocNoAliasTag struct{} + +func (PhpdocNoAliasTag) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoAliasTagFixer` +} + +func (PhpdocNoAliasTag) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAliasTagFixer.php" +} + +func (PhpdocNoAliasTag) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + changed := false + for i, l := range d.inner { + trimmed := strings.TrimLeft(l.content, " ") + m := aliasTagRe.FindStringSubmatch(trimmed) + if m == nil { + continue + } + lead := l.content[:len(l.content)-len(trimmed)] + d.inner[i].content = lead + "@" + aliasTagMap[m[1]] + trimmed[len(m[0]):] + changed = true + } + return changed + }) +} + +var noPackageRe = regexp.MustCompile(`^@(?:package|subpackage)\b`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoPackageFixer.php +// +// PhpdocNoPackage removes @package and @subpackage tag lines. +type PhpdocNoPackage struct{} + +func (PhpdocNoPackage) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoPackageFixer` +} + +func (PhpdocNoPackage) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoPackageFixer.php" +} + +func (PhpdocNoPackage) Fix(s *tokens.Stream) bool { + return removeDocLines(s, noPackageRe) +} + +var noAccessRe = regexp.MustCompile(`^@access\b`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAccessFixer.php +// +// PhpdocNoAccess removes @access tag lines. +type PhpdocNoAccess struct{} + +func (PhpdocNoAccess) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoAccessFixer` +} + +func (PhpdocNoAccess) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAccessFixer.php" +} + +func (PhpdocNoAccess) Fix(s *tokens.Stream) bool { + return removeDocLines(s, noAccessRe) +} + +// removeDocLines drops every inner line whose tag matches re (checked at the +// start of the content, after any leading spaces). +func removeDocLines(s *tokens.Stream, re *regexp.Regexp) bool { + return applyToDocblocks(s, func(d *docblock) bool { + kept := d.inner[:0:0] + removed := false + for _, l := range d.inner { + if re.MatchString(strings.TrimLeft(l.content, " ")) { + removed = true + continue + } + kept = append(kept, l) + } + if removed { + d.inner = kept + } + return removed + }) +} + +var singleLineVarRe = regexp.MustCompile(`^(@(?:var|type|param))\s+([^\s$]+)\s*(\$\S+)?\s*(.*)$`) + +var phpdocWsRe = regexp.MustCompile(`\s+`) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocSingleLineVarSpacingFixer.php +// +// PhpdocSingleLineVarSpacing normalizes spacing in single-line @var/@type/@param docblocks. +type PhpdocSingleLineVarSpacing struct{} + +func (PhpdocSingleLineVarSpacing) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocSingleLineVarSpacingFixer` +} + +func (PhpdocSingleLineVarSpacing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocSingleLineVarSpacingFixer.php" +} + +func (PhpdocSingleLineVarSpacing) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + if !d.single || len(d.inner) == 0 { + return false + } + content := d.inner[0].content + m := singleLineVarRe.FindStringSubmatch(content) + if m == nil { + return false + } + out := []string{m[1], m[2]} + if m[3] != "" { + out = append(out, m[3]) + } + normalized := strings.Join(out, " ") + if rest := strings.TrimSpace(phpdocWsRe.ReplaceAllString(m[4], " ")); rest != "" { + normalized += " " + rest + } + if normalized == content { + return false + } + d.inner[0].content = normalized + return true + }) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimConsecutiveBlankLineSeparationFixer.php +// +// PhpdocTrimConsecutiveBlankLineSeparation collapses runs of blank inner lines into one. +type PhpdocTrimConsecutiveBlankLineSeparation struct{} + +func (PhpdocTrimConsecutiveBlankLineSeparation) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\PhpdocTrimConsecutiveBlankLineSeparationFixer` +} + +func (PhpdocTrimConsecutiveBlankLineSeparation) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimConsecutiveBlankLineSeparationFixer.php" +} + +func (PhpdocTrimConsecutiveBlankLineSeparation) Fix(s *tokens.Stream) bool { + return applyToDocblocks(s, func(d *docblock) bool { + kept := d.inner[:0:0] + prevBlank := false + changed := false + for _, l := range d.inner { + blank := strings.TrimSpace(l.content) == "" + if blank && prevBlank { + changed = true + continue + } + kept = append(kept, l) + prevBlank = blank + } + if changed { + d.inner = kept + } + return changed + }) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/NoBlankLinesAfterPhpdocFixer.php +// +// NoBlankLinesAfterPhpdoc removes blank lines between a docblock and the code it documents. +type NoBlankLinesAfterPhpdoc struct{} + +func (NoBlankLinesAfterPhpdoc) Name() string { + return `PhpCsFixer\Fixer\Phpdoc\NoBlankLinesAfterPhpdocFixer` +} + +func (NoBlankLinesAfterPhpdoc) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/NoBlankLinesAfterPhpdocFixer.php" +} + +func (NoBlankLinesAfterPhpdoc) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.DocComment { + continue + } + j := i + 1 + if j >= s.Len() || s.At(j).Kind != token.Whitespace { + continue + } + v := s.At(j).Value + if strings.Count(v, "\n") < 2 { + continue + } + // a file-level docblock before "declare" keeps its blank line - ECS only + // trims the blank after a docblock attached to a structural element + if k := nextSignificantIndex(s, i); k >= 0 && + s.At(k).Kind == token.Keyword && strings.EqualFold(s.At(k).Value, "declare") { + continue + } + // Keep the last newline plus the following statement's indentation. + nv := v[strings.LastIndexByte(v, '\n'):] + if nv != v { + s.SetValue(j, nv) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/phpdoc_batch_test.go b/blink/internal/fixer/rules/phpdoc_batch_test.go new file mode 100644 index 0000000000..6189272752 --- /dev/null +++ b/blink/internal/fixer/rules/phpdoc_batch_test.go @@ -0,0 +1,185 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +// idempotent asserts that a second Fix pass over the fixer's own output is a no-op. +func idempotent(t *testing.T, r fixerRule, once string) { + t.Helper() + if _, changed := apply(t, r, once); changed { + t.Fatalf("not idempotent, second pass changed: %q", once) + } +} + +func TestNoEmptyPhpdoc(t *testing.T) { + got, changed := apply(t, NoEmptyPhpdoc{}, " 0 && s.At(i-1).Kind == token.Whitespace { + s.RemoveAt(i - 1) + i-- + } + i-- + continue + } + d.inner = kept + s.SetValue(i, d.render()) + } + return changed +} + +func allBlankLines(lines []docLine) bool { + for _, l := range lines { + if strings.TrimSpace(l.content) != "" { + return false + } + } + return true +} + +// isInheritdoc reports whether a docblock line is nothing but an inheritdoc tag. +func isInheritdoc(content string) bool { + c := strings.ToLower(strings.TrimSpace(content)) + return c == "{@inheritdoc}" || c == "@inheritdoc" +} diff --git a/blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc_test.go b/blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc_test.go new file mode 100644 index 0000000000..2033751b2f --- /dev/null +++ b/blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc_test.go @@ -0,0 +1,30 @@ +package rules + +import "testing" + +func TestPhpdocNoUselessInheritdoc(t *testing.T) { + f := PhpdocNoUselessInheritdoc{} + + // a sole {@inheritDoc}: the whole block and its indentation are removed + got, changed := apply(t, f, "= len(anns) { + break + } + first, next := anns[idx], anns[idx+1] + if separationShouldBeTogether(first.name, next.name) { + ensureAnnotationsTogether(lines, first, next) + } else { + ensureAnnotationsSeparate(lines, first, next) + } + } +} + +// ensureAnnotationsTogether removes any lines between two annotations. +func ensureAnnotationsTogether(lines []string, first, second docAnnotation) { + for pos := first.end + 1; pos < second.start; pos++ { + lines[pos] = "" + } +} + +// ensureAnnotationsSeparate leaves exactly one blank line between two annotations. +func ensureAnnotationsSeparate(lines []string, first, second docAnnotation) { + pos := first.end + final := second.start - 1 + if pos == final { + lines[pos] = lineAddBlank(lines[pos]) + return + } + for p := pos + 1; p < final; p++ { + lines[p] = "" + } +} + +// separationShouldBeTogether reports whether two adjacent tags must stay +// together (true only for the DocBlock's "true" case; null and false both mean +// separate under the default skip_unlisted_annotations=false). +func separationShouldBeTogether(first, second string) bool { + if first == "" || second == "" { + return false + } + if first == second { + return true + } + for _, group := range phpdocSeparationGroups { + firstIn := tagInGroup(first, group) + secondIn := tagInGroup(second, group) + if firstIn { + return secondIn + } + if secondIn { + return false + } + } + return false +} + +func tagInGroup(tag string, group []string) bool { + for _, g := range group { + if !strings.Contains(g, "*") { + if g == tag { + return true + } + continue + } + if wildcardTagMatch(g, tag) { + return true + } + } + return false +} + +// wildcardTagMatch matches a group entry containing "*" (any characters) against +// a tag, anchored at both ends. +func wildcardTagMatch(pattern, tag string) bool { + parts := strings.Split(pattern, "*") + pos := 0 + for i, part := range parts { + if part == "" { + continue + } + if i == 0 { + if !strings.HasPrefix(tag[pos:], part) { + return false + } + pos += len(part) + continue + } + idx := strings.Index(tag[pos:], part) + if idx < 0 { + return false + } + pos += idx + len(part) + } + if parts[len(parts)-1] != "" { + return strings.HasSuffix(tag, parts[len(parts)-1]) + } + return true +} diff --git a/blink/internal/fixer/rules/phpdoc_separation_test.go b/blink/internal/fixer/rules/phpdoc_separation_test.go new file mode 100644 index 0000000000..bb47e2dc0a --- /dev/null +++ b/blink/internal/fixer/rules/phpdoc_separation_test.go @@ -0,0 +1,33 @@ +package rules + +import "testing" + +func TestPhpdocSeparation(t *testing.T) { + f := PhpdocSeparation{} + + // different unlisted tags get a blank line; same-name tags stay together + got, changed := apply(t, f, " $v) {\n}\n") + want := " $v) {\n}\n" + if !changed || got != want { + t.Fatalf("convert: changed=%v got=%q", changed, got) + } + + // a docblock naming the loop variable is kept + if _, changed := apply(t, f, " $v) {\n}\n"); changed { + t.Fatal("docblock naming the loop var must stay a docblock") + } + + // a docblock before a class is kept + if _, changed := apply(t, f, "= 0 && s.At(name).Kind == token.Ident && endsWithTestName(s.At(name).Value, false) { + return true + } + // any name in extends/implements clause + for j := index + 1; j < s.Len(); j++ { + if isPunctVal(s, j, "{") { + break + } + if s.At(j).Kind == token.Ident && endsWithTestName(s.At(j).Value, true) { + return true + } + } + return false +} + +// endsWithTestName mirrors /(?:Test|TestCase)$/ (name) and, with iface, +// /(?:Test|TestCase)(?:Interface)?$/ (extended/implemented names). +func endsWithTestName(name string, iface bool) bool { + if strings.HasSuffix(name, "Test") || strings.HasSuffix(name, "TestCase") { + return true + } + if iface && (strings.HasSuffix(name, "TestInterface") || strings.HasSuffix(name, "TestCaseInterface")) { + return true + } + return false +} + +// puIsMethod reports whether index is a real (non-lambda) method: a `function` +// keyword directly followed by a name. +func puIsMethod(s *tokens.Stream, index int) bool { + if !kwIs(s.At(index), "function") { + return false + } + n := sigNext(s, index) + if n < 0 { + return false + } + // a name means it's a method; "(" or "&" means a closure + return s.At(n).Kind == token.Ident +} + +var puModifierKw = map[string]bool{ + "public": true, "protected": true, "private": true, + "final": true, "abstract": true, "readonly": true, "static": true, +} + +// puDocBlockIndex mirrors DocBlockAnnotationTrait::getDocBlockIndex: walk back +// over whitespace, modifiers, comments and folded attributes to the token that +// may be the method's doc comment. +func puDocBlockIndex(s *tokens.Stream, index int) int { + idx := index + for { + p := getPrevNonWhitespace(s, idx) + if p < 0 { + return idx + } + t := s.At(p) + if t.Kind == token.Comment { // includes folded #[...] attributes + idx = p + continue + } + if t.Kind == token.Keyword && puModifierKw[strings.ToLower(t.Value)] { + idx = p + continue + } + return p + } +} + +// camelCaseToUnderscore mirrors Utils::camelCaseToUnderscore for ASCII names +// (PHPUnit method names): insert "_" at case boundaries, then lowercase. +func camelCaseToUnderscore(str string) string { + b := []byte(str) + isUpper := func(c byte) bool { return c >= 'A' && c <= 'Z' } + var out []byte + for i := range b { + c := b[i] + if i > 0 && b[i-1] != '_' { + var next byte + hasNext := i+1 < len(b) + if hasNext { + next = b[i+1] + } + condA := isUpper(c) && hasNext && !isUpper(next) + condB := !isUpper(b[i-1]) && isUpper(c) + if condA || condB { + out = append(out, '_') + } + } + out = append(out, c) + } + return strings.ToLower(string(out)) +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitMethodCasingFixer.php +// +// PhpUnitMethodCasing enforces snake_case (the ECS laravel config) for PHPUnit +// test method names. +type PhpUnitMethodCasing struct{} + +func (PhpUnitMethodCasing) Name() string { + return `PhpCsFixer\Fixer\PhpUnit\PhpUnitMethodCasingFixer` +} + +func (PhpUnitMethodCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitMethodCasingFixer.php" +} + +func (PhpUnitMethodCasing) Fix(s *tokens.Stream) bool { + changed := false + for _, cls := range phpUnitTestClasses(s) { + if methodCasingClass(s, cls[0], cls[1]) { + changed = true + } + } + return changed +} + +func methodCasingClass(s *tokens.Stream, start, end int) bool { + changed := false + existing := map[string]bool{} + for i := end - 1; i > start; i-- { + if !puIsMethod(s, i) { + continue + } + if n := sigNext(s, i); n >= 0 { + existing[strings.ToLower(s.At(n).Value)] = true + } + } + for i := end - 1; i > start; i-- { + if !isTestMethod(s, i) { + continue + } + nameIdx := sigNext(s, i) + if nameIdx < 0 { + continue + } + name := s.At(nameIdx).Value + nameLower := strings.ToLower(name) + newName := camelCaseToUnderscore(name) + newLower := strings.ToLower(newName) + if existing[newLower] && nameLower != newLower { + continue + } + existing[newLower] = true + if newName != name { + s.SetValue(nameIdx, newName) + changed = true + } + docIdx := puDocBlockIndex(s, i) + if docIdx >= 0 && s.At(docIdx).Kind == token.DocComment { + if updateDependsDoc(s, docIdx) { + changed = true + } + } + } + return changed +} + +func isTestMethod(s *tokens.Stream, index int) bool { + if !puIsMethod(s, index) { + return false + } + nameIdx := sigNext(s, index) + if nameIdx < 0 { + return false + } + if strings.HasPrefix(s.At(nameIdx).Value, "test") { + return true + } + if isTestAttributePresent(s, index) { + return true + } + docIdx := puDocBlockIndex(s, index) + return docIdx >= 0 && s.At(docIdx).Kind == token.DocComment && strings.Contains(s.At(docIdx).Value, "@test") +} + +// isTestAttributePresent reports whether the method carries a #[Test] attribute, +// which the lexer folds into a single comment token starting with "#[". +func isTestAttributePresent(s *tokens.Stream, index int) bool { + p := getPrevNonWhitespace(s, index) + for p >= 0 { + t := s.At(p) + if t.Kind == token.Comment { + if strings.HasPrefix(t.Value, "#[") && phpUnitTestAttrRe(t.Value) { + return true + } + p = getPrevNonWhitespace(s, p) + continue + } + if t.Kind == token.Keyword && puModifierKw[strings.ToLower(t.Value)] { + p = getPrevNonWhitespace(s, p) + continue + } + break + } + return false +} + +// phpUnitTestAttrRe reports whether a folded attribute comment names the PHPUnit +// Test attribute (short "Test" or fully-qualified). +func phpUnitTestAttrRe(v string) bool { + inner := strings.TrimSuffix(strings.TrimPrefix(v, "#["), "]") + for part := range strings.SplitSeq(inner, ",") { + part = strings.TrimSpace(part) + if i := strings.IndexByte(part, '('); i >= 0 { + part = part[:i] + } + part = strings.TrimSpace(part) + short := part + if i := strings.LastIndex(part, `\`); i >= 0 { + short = part[i+1:] + } + if short == "Test" { + return true + } + } + return false +} + +// updateDependsDoc rewrites @depends method references to the new casing. +func updateDependsDoc(s *tokens.Stream, docIdx int) bool { + content := s.At(docIdx).Value + if !strings.Contains(content, "@depends") { + return false + } + changed := false + lines := strings.Split(content, "\n") + for i, line := range lines { + idx := strings.Index(line, "@depends") + if idx < 0 { + continue + } + rest := line[idx+len("@depends"):] + // skip whitespace + j := 0 + for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') { + j++ + } + ws := rest[:j] + // capture the reference up to whitespace/end + k := j + for k < len(rest) && rest[k] != ' ' && rest[k] != '\t' && rest[k] != '\r' && rest[k] != '\n' { + k++ + } + ref := rest[j:k] + if ref == "" { + continue + } + newRef := camelCaseToUnderscore(ref) + if newRef != ref { + lines[i] = line[:idx+len("@depends")] + ws + newRef + rest[k:] + changed = true + } + } + if changed { + s.SetValue(docIdx, strings.Join(lines, "\n")) + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitSetUpTearDownVisibilityFixer.php +// +// PhpUnitSetUpTearDownVisibility makes setUp()/tearDown() protected. +type PhpUnitSetUpTearDownVisibility struct{} + +func (PhpUnitSetUpTearDownVisibility) Name() string { + return `PhpCsFixer\Fixer\PhpUnit\PhpUnitSetUpTearDownVisibilityFixer` +} + +func (PhpUnitSetUpTearDownVisibility) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitSetUpTearDownVisibilityFixer.php" +} + +func (PhpUnitSetUpTearDownVisibility) Fix(s *tokens.Stream) bool { + changed := false + for _, cls := range phpUnitTestClasses(s) { + if setUpTearDownClass(s, cls[0], cls[1]) { + changed = true + } + } + return changed +} + +func setUpTearDownClass(s *tokens.Stream, start, end int) bool { + changed := false + counter := 0 + for i := start + 1; i < end && i < s.Len(); i++ { + if counter == 2 { + break + } + if isPunctVal(s, i, "{") { + if e := s.MatchForward(i); e > i { + i = e + } + continue + } + if !kwIs(s.At(i), "function") { + continue + } + nameIdx := sigNext(s, i) + if nameIdx < 0 { + continue + } + fn := strings.ToLower(s.At(nameIdx).Value) + if fn != "setup" && fn != "teardown" { + continue + } + counter++ + // find the visibility modifier of this method, if any + visIdx := methodVisibilityIndex(s, i) + if visIdx >= 0 { + if strings.EqualFold(s.At(visIdx).Value, "public") { + s.Set(visIdx, token.Token{Kind: token.Keyword, Value: "protected"}) + changed = true + } + continue + } + // no visibility: insert "protected " before the function keyword + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + s.InsertAt(i, token.Token{Kind: token.Keyword, Value: "protected"}) + changed = true + end += 2 + } + return changed +} + +// methodVisibilityIndex returns the index of the method's visibility keyword +// (public/protected/private) in its modifier run, or -1 if none. +func methodVisibilityIndex(s *tokens.Stream, fnIdx int) int { + p := getPrevNonWhitespace(s, fnIdx) + for p >= 0 { + t := s.At(p) + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "public", "protected", "private": + return p + case "final", "abstract", "readonly", "static": + p = getPrevNonWhitespace(s, p) + continue + } + } + if t.Kind == token.Comment { + p = getPrevNonWhitespace(s, p) + continue + } + break + } + return -1 +} diff --git a/blink/internal/fixer/rules/priorities.go b/blink/internal/fixer/rules/priorities.go new file mode 100644 index 0000000000..6eea5e01c9 --- /dev/null +++ b/blink/internal/fixer/rules/priorities.go @@ -0,0 +1,194 @@ +package rules + +// fixerPriority maps a fixer's short name to its PHP-CS-Fixer getPriority() +// value. Fixers run in descending priority, mirroring ECS's execution order. +var fixerPriority = map[string]int{ + "DoubleAsteriskInlineVarFixer": -37, + "FixTagTypoFixer": -37, + "TypeToVarTagFixer": -37, + "MergeDocBlockStartFixer": -37, + "AddMissingVarNameFixer": -37, + "SingleLineInlineVarDocBlockFixer": -37, + "RemoveSuperfluousReturnNameFixer": -37, + "RemoveSuperfluousVarNameFixer": -37, + "FixParamNameTypoFixer": -37, + "RemoveParamNameReferenceFixer": -37, + "SwitchedTypeAndNameFixer": -37, + "RemoveDeadParamFixer": -37, + "RemoveDeadVarThisFixer": -37, + "AddMissingParamNameFixer": 0, + "AlignMultilineCommentFixer": 27, + "ArrayIndentationFixer": 29, + "ArrayListItemNewlineFixer": 40, + "StandaloneLineInMultilineArrayFixer": 5, + "ArraySyntaxFixer": 37, + "AssignNullCoalescingToCoalesceEqualFixer": -1, + "AttributeBlockNoSpacesFixer": 0, + "BinaryOperatorSpacesFixer": -32, + "BlankLineAfterNamespaceFixer": -20, + "BlankLineAfterOpeningTagFixer": 1, + "DoctrineAnnotationArrayAssignmentFixer": 1, + "DoctrineAnnotationSpacesFixer": 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) + "LinebreakAfterOpeningTagFixer": 2, + "IncrementStyleFixer": 15, + "NullableTypeDeclarationFixer": 2, + "OrderedTypesFixer": 0, + "MethodChainingIndentationFixer": 0, + "BlankLineAfterStrictTypesFixer": 0, + "BlankLineBetweenImportGroupsFixer": -40, + "BlankLinesBeforeNamespaceFixer": -31, + "BracesPositionFixer": -2, + "CastSpacesFixer": -10, + "ClassAttributesSeparationFixer": 55, + "ClassDefinitionFixer": 36, + "ClassReferenceNameCasingFixer": 0, + "CleanNamespaceFixer": 10, + "CompactNullableTypeDeclarationFixer": 0, + "ConcatSpaceFixer": 0, + "ConstantCaseFixer": 0, + "ControlStructureBracesFixer": 1, + "ControlStructureContinuationPositionFixer": 0, + "DeclareEqualNormalizeFixer": 0, + "DeclareParenthesesFixer": 0, + "ElseifFixer": 40, + "NoSuperfluousElseifFixer": 39, + "EmptyLoopBodyFixer": 39, + "EmptyLoopConditionFixer": 1, + "EncodingFixer": 100, + "ExplicitIndirectVariableFixer": 0, + "ExplicitStringVariableFixer": 6, + "FullOpeningTagFixer": 98, + "FullyQualifiedStrictTypesFixer": 7, + "FunctionDeclarationFixer": 31, + "FunctionToConstantFixer": 2, + "HeredocToNowdocFixer": 0, + "IncludeFixer": 0, + "IndentationTypeFixer": 50, + "IntegerLiteralCaseFixer": 0, + "IsNullFixer": 1, + "LineEndingFixer": 0, + "ListSyntaxFixer": 2, + "LongToShorthandOperatorFixer": 17, + "LambdaNotUsedImportFixer": 31, + "LowercaseCastFixer": 0, + "LowercaseKeywordsFixer": 0, + "LowercaseStaticReferenceFixer": 0, + "MagicConstantCasingFixer": 0, + "MagicMethodCasingFixer": 0, + "MethodArgumentSpaceFixer": 30, + "MethodChainingNewlineFixer": 39, + "MultilineCommentOpeningClosingFixer": 0, + "NativeFunctionCasingFixer": 0, + "NativeFunctionTypeDeclarationCasingFixer": 0, + "NativeTypeDeclarationCasingFixer": 0, + "NewWithParenthesesFixer": 38, + "NoBinaryStringFixer": 40, + "NoBlankLinesAfterClassOpeningFixer": 0, + "NoBlankLinesAfterPhpdocFixer": -20, + "NoClosingTagFixer": 0, + "NoEmptyCommentFixer": 2, + "NoEmptyPhpdocFixer": 3, + "NoEmptyStatementFixer": 40, + "NoExtraBlankLinesFixer": -20, + "NoLeadingImportSlashFixer": -20, + "NoAliasFunctionsFixer": 40, + "NoLeadingNamespaceWhitespaceFixer": 0, + "NoAlternativeSyntaxFixer": 42, + "NoMixedEchoPrintFixer": -10, + "NoUnneededCurlyBracesFixer": 40, + "NoMultilineWhitespaceAroundDoubleArrowFixer": 31, + "NoMultipleStatementsPerLineFixer": -1, + "MultilineWhitespaceBeforeSemicolonsFixer": 0, + "SingleLineEmptyBodyFixer": -19, + "NoNullPropertyInitializationFixer": 0, + "NoShortBoolCastFixer": -9, + "NoSinglelineWhitespaceBeforeSemicolonsFixer": 0, + "NoSpaceAroundDoubleColonFixer": 1, + "NoSpacesAfterFunctionNameFixer": 3, + "NoSpacesAroundOffsetFixer": 0, + "NoSuperfluousPhpdocTagsFixer": 6, + "NoTrailingCommaInSinglelineFixer": 0, + "NoTrailingWhitespaceFixer": 0, + "NoTrailingWhitespaceInCommentFixer": 0, + "NoUnneededControlParenthesesFixer": 30, + "NoUnneededImportAliasFixer": 1, + "NoUnsetCastFixer": 0, + "NoUnusedImportsFixer": -10, + "NoUselessConcatOperatorFixer": 5, + "NoUselessReturnFixer": -18, + "NoWhitespaceBeforeCommaInArrayFixer": 0, + "NoWhitespaceInBlankLineFixer": -99, + "NoWhitespaceInEmptyArrayFixer": 0, + "NormalizeIndexBraceFixer": 0, + "NotOperatorWithSuccessorSpaceFixer": -10, + "NullableTypeDeclarationForDefaultNullValueFixer": 3, + "ObjectOperatorWithoutWhitespaceFixer": 0, + "OrderedClassElementsFixer": 65, + "OrderedImportsFixer": -30, + "PhpdocIndentFixer": 20, + "PhpdocInlineTagNormalizerFixer": 0, + "PhpdocLineSpanFixer": 5, + "PhpdocNoAccessFixer": 0, + "PhpdocNoAliasTagFixer": 0, + "PhpdocNoDuplicateTypesFixer": 0, + "PhpdocNoEmptyReturnFixer": 4, + "PhpdocNoPackageFixer": 0, + "PhpdocNoUselessInheritdocFixer": 6, + "PhpdocOrderByValueFixer": -10, + "PhpdocReturnSelfReferenceFixer": 10, + "PhpdocScalarFixer": 0, + "PhpdocSingleLineVarSpacingFixer": -10, + "PhpdocTagCasingFixer": 0, + "PhpdocTrimConsecutiveBlankLineSeparationFixer": -41, + "PhpdocTrimFixer": -5, + "PhpdocTypesFixer": 0, + "PhpdocTypesOrderFixer": 0, + "PhpdocVarAnnotationCorrectOrderFixer": 0, + "PhpdocVarWithoutNameFixer": 0, + "ProtectedToPrivateFixer": 66, + "RemoveUselessDefaultCommentFixer": 40, + "ReturnTypeDeclarationFixer": -17, + "SelfAccessorFixer": -11, + "SelfStaticAccessorFixer": -10, + "ShortScalarCastFixer": 0, + "SimplifiedNullReturnFixer": 16, + "SingleBlankLineAtEofFixer": -100, + "SingleClassElementPerStatementFixer": 56, + "SingleImportPerStatementFixer": 1, + "SingleLineAfterImportsFixer": -11, + "NoBlankLineBetweenImportsFixer": -50, + "SpaceAfterCommaHereNowDocFixer": 0, + "ArrayOpenerAndCloserNewlineFixer": 34, + "SingleLineCommentSpacingFixer": 1, + "SingleLineCommentStyleFixer": -31, + "SingleQuoteFixer": 10, + "SingleSpaceAroundConstructFixer": 36, + "SingleTraitInsertPerStatementFixer": 36, + "SpaceAfterSemicolonFixer": -1, + "SpacesInsideParenthesesFixer": 3, + "StandaloneLinePromotedPropertyFixer": 40, + "StandardizeIncrementFixer": 16, + "StandardizeNotEqualsFixer": 0, + "StatementIndentationFixer": -3, + "SwitchCaseSemicolonToColonFixer": 0, + "SwitchCaseSpaceFixer": 0, + "SwitchContinueToBreakFixer": 0, + "TernaryOperatorSpacesFixer": 1, + "TernaryToNullCoalescingFixer": 0, + "TrailingCommaInMultilineFixer": 0, + "TrimArraySpacesFixer": 0, + "TypeDeclarationSpacesFixer": 0, + "TypesSpacesFixer": -1, + "UnaryOperatorSpacesFixer": 0, + "VisibilityRequiredFixer": 0, + "WhitespaceAfterCommaInArrayFixer": 0, + "PhpdocAlignFixer": -42, + "YodaStyleFixer": 0, + "NoAliasLanguageConstructCallFixer": 0, + "BlankLineBeforeStatementFixer": -21, + "PhpdocSummaryFixer": 0, + "PhpdocTagTypeFixer": 0, + "PhpdocOrderFixer": -2, +} diff --git a/blink/internal/fixer/rules/protected_to_private.go b/blink/internal/fixer/rules/protected_to_private.go new file mode 100644 index 0000000000..71f8ade651 --- /dev/null +++ b/blink/internal/fixer/rules/protected_to_private.go @@ -0,0 +1,113 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ProtectedToPrivateFixer.php +// +// ProtectedToPrivate converts "protected" members to "private" in a final class +// (a final class cannot be extended, so protected is equivalent to private). +type ProtectedToPrivate struct{} + +func (ProtectedToPrivate) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\ProtectedToPrivateFixer` +} + +func (ProtectedToPrivate) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ProtectedToPrivateFixer.php" +} + +func (ProtectedToPrivate) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Keyword || !strings.EqualFold(s.At(i).Value, "class") { + continue + } + prev, ok := prevSignificant(s, i) + if !ok || prev.Kind != token.Keyword || !strings.EqualFold(prev.Value, "final") { + continue + } + nameIdx := nextSignificantIndex(s, i) + if nameIdx < 0 || s.At(nameIdx).Kind != token.Ident { + continue + } + open := findBodyBrace(s, nameIdx) + if open < 0 { + continue + } + // a class extending a parent may inherit/override protected members; + // ECS leaves those alone + if classExtends(s, nameIdx, open) { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + continue + } + depth := 0 + for k := open; k < closeIdx; k++ { + if s.At(k).Kind == token.Punct { + switch s.At(k).Value { + case "{": + depth++ + case "}": + depth-- + } + continue + } + // only this class's own members (depth 1), not a nested class body, + // and not a constant named "protected" (lexed as a keyword) + if depth == 1 && s.At(k).Kind == token.Keyword && strings.EqualFold(s.At(k).Value, "protected") && !isConstNamePosition(s, k) { + s.SetValue(k, "private") + changed = true + } + } + } + return changed +} + +func classExtends(s *tokens.Stream, nameIdx, open int) bool { + for k := nameIdx + 1; k < open; k++ { + if s.At(k).Kind == token.Keyword && strings.EqualFold(s.At(k).Value, "extends") { + return true + } + } + return false +} + +// isConstNamePosition reports whether the token at k is the name (or type) of a +// "const" declaration, e.g. the "PROTECTED" in "const int PROTECTED", which the +// lexer classifies as a keyword but is not a visibility modifier. +func isConstNamePosition(s *tokens.Stream, k int) bool { + j := prevSignificantIndex(s, k) + for j >= 0 { + t := s.At(j) + if t.Kind == token.Keyword && strings.EqualFold(t.Value, "const") { + return true + } + if t.Kind == token.Ident || + (t.Kind == token.Keyword && !strings.EqualFold(t.Value, "const")) || + (t.Kind == token.Punct && (t.Value == "?" || t.Value == `\`)) { + // a type token before the name; keep walking back + if t.Kind == token.Keyword && isModifierKeyword(t.Value) { + return false + } + j = prevSignificantIndex(s, j) + continue + } + return false + } + return false +} + +func isModifierKeyword(v string) bool { + switch strings.ToLower(v) { + case "public", "private", "protected", "static", "final", "abstract", "readonly", "var": + return true + } + return false +} diff --git a/blink/internal/fixer/rules/rector_regression_test.go b/blink/internal/fixer/rules/rector_regression_test.go new file mode 100644 index 0000000000..fd6ce7b284 --- /dev/null +++ b/blink/internal/fixer/rules/rector_regression_test.go @@ -0,0 +1,34 @@ +package rules + +import "testing" + +// SELF/PARENT/STATIC as constant names (member access or declaration) must not +// be lowercased by lowercase_static_reference. +func TestLowercaseStaticReferenceKeepsConstants(t *testing.T) { + for _, src := range []string{ + " fixerPriorityOf(all[b]) + }) + return all +} + +// fixerPriorityOf returns a fixer's PHP-CS-Fixer priority (0 when unknown). +func fixerPriorityOf(f fixer.Fixer) int { + name := f.Name() + short := name + if i := strings.LastIndexByte(name, '\\'); i >= 0 { + short = name[i+1:] + } + return fixerPriority[short] +} + +// ByName returns the fixer whose Name matches, if any. +func ByName(name string) (fixer.Fixer, bool) { + for _, f := range All() { + if f.Name() == name { + return f, true + } + } + return nil, false +} diff --git a/blink/internal/fixer/rules/regression_test.go b/blink/internal/fixer/rules/regression_test.go new file mode 100644 index 0000000000..b82de864af --- /dev/null +++ b/blink/internal/fixer/rules/regression_test.go @@ -0,0 +1,32 @@ +package rules + +import "testing" + +// keyword-spelled class-constant names must not be lowercased (sweep finding) +func TestLowercaseKeywordsKeepsConstantName(t *testing.T) { + src := "= 0 && s.At(n).Kind == token.Ident { + return s.At(n).Value + } + } + } + return "" +} + +func clearUselessDocContent(docContent, className string) string { + lines := strings.Split(docContent, "\n") + cleaned := make([]string, 0, len(lines)) + for _, line := range lines { + if className != "" && strings.Trim(line, "* ") == className { + continue + } + for _, re := range uselessCommentRegexes { + line = re.ReplaceAllString(line, "") + } + cleaned = append(cleaned, line) + } + // drop empty lines + kept := cleaned[:0] + for _, l := range cleaned { + if l != "" { + kept = append(kept, l) + } + } + if len(kept) == 2 && kept[0] == "/**" && strings.TrimSpace(kept[1]) == "*/" { + return "" + } + commentText := strings.Join(kept, "\n") + return doctrineGeneratedRegex.ReplaceAllString(commentText, "") +} diff --git a/blink/internal/fixer/rules/return_ternary_test.go b/blink/internal/fixer/rules/return_ternary_test.go new file mode 100644 index 0000000000..76b3302509 --- /dev/null +++ b/blink/internal/fixer/rules/return_ternary_test.go @@ -0,0 +1,110 @@ +package rules + +import "testing" + +func TestReturnTypeDeclaration(t *testing.T) { + // space before the colon is removed, one space kept after + got, changed := apply(t, ReturnTypeDeclaration{}, " $x;") + if want := " $x;"; !changed || got != want { + t.Fatalf("arrow: changed=%v got=%q want=%q", changed, got, want) + } + + // no-op on already-correct code + if _, changed := apply(t, ReturnTypeDeclaration{}, "b;", + } { + if got, changed := apply(t, TernaryOperatorSpaces{}, src); changed || got != src { + t.Fatalf("must not touch %q: changed=%v got=%q", src, changed, got) + } + } + + // multiline ternary alignment is preserved + if _, changed := apply(t, TernaryOperatorSpaces{}, " "function f(): int"). +// The colon is only a return type when it follows the ")" of a function parameter +// list, so ternary, case and alternative-syntax colons are left untouched. +type ReturnTypeDeclaration struct{} + +func (ReturnTypeDeclaration) Name() string { + return `PhpCsFixer\Fixer\FunctionNotation\ReturnTypeDeclarationFixer` +} + +func (ReturnTypeDeclaration) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/ReturnTypeDeclarationFixer.php" +} + +func (ReturnTypeDeclaration) Fix(s *tokens.Stream) bool { + changed := false + i := 0 + for i < s.Len() { + if s.At(i).Kind != token.Punct || s.At(i).Value != ":" { + i++ + continue + } + if !isReturnTypeColon(s, i) { + i++ + continue + } + + // after side: exactly one space, unless a newline follows (leave multiline) + if i+1 < s.Len() { + next := s.At(i + 1) + if next.Kind == token.Whitespace { + if !hasNewline(next.Value) && next.Value != " " { + s.SetValue(i+1, " ") + changed = true + } + } else { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + } + + // before side: no space, but keep newline-spanning whitespace intact + if i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + changed = true + i-- // colon shifted left by the removed whitespace + } + i++ + } + return changed +} + +// isReturnTypeColon reports whether the ":" at index i closes a function +// signature: its previous significant token is a ")" that matches a "(" opened +// by a function/fn declaration. +func isReturnTypeColon(s *tokens.Stream, i int) bool { + c := prevSignificantIndex(s, i) + if c < 0 || s.At(c).Kind != token.Punct || s.At(c).Value != ")" { + return false + } + open := s.MatchBackward(c) + if open < 0 { + return false + } + p := prevSignificantIndex(s, open) + if p < 0 { + return false + } + pt := s.At(p) + if pt.Kind == token.Keyword { + v := strings.ToLower(pt.Value) + return v == "function" || v == "fn" + } + // named function: an identifier name preceded (optionally via "&") by "function" + if pt.Kind == token.Ident { + q := prevSignificantIndex(s, p) + if q >= 0 && s.At(q).Kind == token.Punct && s.At(q).Value == "&" { + q = prevSignificantIndex(s, q) + } + return q >= 0 && s.At(q).Kind == token.Keyword && strings.ToLower(s.At(q).Value) == "function" + } + return false +} + +// prevSignificantIndex returns the index of the first non-whitespace token before +// i, or -1 if there is none. +func prevSignificantIndex(s *tokens.Stream, i int) int { + for j := i - 1; j >= 0; j-- { + if s.Kind(j) != token.Whitespace { + return j + } + } + return -1 +} diff --git a/blink/internal/fixer/rules/rules_test.go b/blink/internal/fixer/rules/rules_test.go new file mode 100644 index 0000000000..e48af016b6 --- /dev/null +++ b/blink/internal/fixer/rules/rules_test.go @@ -0,0 +1,372 @@ +package rules + +import ( + "testing" + + "blink/internal/lexer" + "blink/internal/tokens" +) + +type fixerRule interface { + Fix(*tokens.Stream) bool +} + +func apply(t *testing.T, r fixerRule, src string) (string, bool) { + t.Helper() + s := tokens.New(lexer.Lex(src)) + changed := r.Fix(s) + return s.Render(), changed +} + +func TestNoSinglelineWhitespaceBeforeSemicolons(t *testing.T) { + got, changed := apply(t, NoSinglelineWhitespaceBeforeSemicolons{}, "1, 'y' => 2];") + if want := " 1, 'y' => 2];"; !changed || got != want { + t.Fatalf("changed=%v got=%q want=%q", changed, got, want) + } + if _, changed := apply(t, BinaryOperatorSpaces{}, " 1];"); changed { + t.Fatal("already-normalized arrow should not change") + } + // arrow spanning a newline is left alone (alignment) + if _, changed := apply(t, BinaryOperatorSpaces{}, "\n 1,\n];"); changed { + t.Fatal("multi-line arrow should be kept") + } + // assignment "=" is normalized too + got, changed = apply(t, BinaryOperatorSpaces{}, "TRUE;"); changed { + t.Fatal("member named TRUE should not be touched") + } +} + +func TestLowercaseStaticReference(t *testing.T) { + got, changed := apply(t, LowercaseStaticReference{}, "bar ();") + if want := "bar();"; !changed || got != want { + t.Fatalf("changed=%v got=%q want=%q", changed, got, want) + } + if _, changed := apply(t, NoSpacesAfterFunctionName{}, "1,'b' => 2];\n", + "\n", + "\n", + " $b) {\n};\necho __line__;\n", + ""); changed { + t.Fatal("XML declaration must not be converted") + } +} + +func TestNoClosingTag(t *testing.T) { + got, changed := apply(t, NoClosingTag{}, "\n") + if want := "\n
\n\n"); changed { + t.Fatal("file with inline HTML must keep closing tags") + } +} + +func TestBlankLineAfterNamespace(t *testing.T) { + got, changed := apply(t, BlankLineAfterNamespace{}, " "new self()", +// "Foo::CONST" -> "self::CONST", type declarations "Foo $x" -> "self $x". +type SelfAccessor struct{} + +func (SelfAccessor) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\SelfAccessorFixer` +} + +func (SelfAccessor) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SelfAccessorFixer.php" +} + +func (SelfAccessor) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Keyword || !isClassLikeKeyword(strings.ToLower(s.At(i).Value)) { + continue + } + // skip "new class" (anonymous) and "::class" + if prev, ok := prevSignificant(s, i); ok { + if prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") { + continue + } + if prev.Kind == token.Punct && (prev.Value == "->" || prev.Value == "?->" || prev.Value == "::") { + continue + } + } + nameIdx := nextSignificantIndex(s, i) + if nameIdx < 0 || s.At(nameIdx).Kind != token.Ident { + continue + } + className := s.At(nameIdx).Value + open := findBodyBrace(s, nameIdx) + if open < 0 { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + continue + } + for k := open + 1; k < closeIdx; k++ { + if s.At(k).Kind != token.Ident || s.At(k).Value != className { + continue + } + if selfAccessorRef(s, k) { + s.SetValue(k, "self") + changed = true + } + } + } + return changed +} + +func isClassLikeKeyword(lw string) bool { + switch lw { + case "class", "interface", "trait", "enum": + return true + } + return false +} + +// findBodyBrace returns the index of the "{" opening a class body declared at +// nameIdx, scanning past any "extends"/"implements" clause. Returns -1 if none. +func findBodyBrace(s *tokens.Stream, nameIdx int) int { + for k := nameIdx + 1; k < s.Len(); k++ { + if s.At(k).Kind == token.Punct { + switch s.At(k).Value { + case "{": + return k + case ";": + return -1 + } + } + } + return -1 +} + +// selfAccessorRef reports whether the class-name token at k is a reference to the +// class (a "new"/"instanceof" operand, a "::" access, or a type declaration) +// rather than a name in another position. +func selfAccessorRef(s *tokens.Stream, k int) bool { + prev, hasPrev := prevSignificant(s, k) + nextIdx := nextSignificantIndex(s, k) + + if hasPrev { + if prev.Kind == token.Punct { + switch prev.Value { + case "->", "?->", "::", `\`: + return false + } + } + if prev.Kind == token.Keyword { + switch strings.ToLower(prev.Value) { + case "function", "const", "extends", "implements", "use", "namespace", "as": + return false + case "new", "instanceof": + return true + } + } + } + if nextIdx >= 0 && s.At(nextIdx).Kind == token.Punct && s.At(nextIdx).Value == `\` { + return false // heads a namespaced name + } + if nextIdx >= 0 && s.At(nextIdx).Kind == token.Punct && s.At(nextIdx).Value == "::" { + return true + } + // a type declaration only counts inside a parameter list or a return type; + // property types are left untouched by ECS + if enclosingFuncParamOpen(s, k) >= 0 { + return true + } + return selfAccessorInReturnType(s, k) +} + +// selfAccessorInReturnType reports whether the type token at k sits in a return +// type, walking back over "?", "|", "&", "\" to a return-type colon. +func selfAccessorInReturnType(s *tokens.Stream, k int) bool { + j := k + for { + p := prevSignificantIndex(s, j) + if p < 0 { + return false + } + if s.At(p).Kind == token.Punct && s.At(p).Value == ":" { + return isReturnTypeColon(s, p) + } + if s.At(p).Kind == token.Ident || + (s.At(p).Kind == token.Punct && (s.At(p).Value == "?" || s.At(p).Value == "|" || s.At(p).Value == "&" || s.At(p).Value == `\`)) { + j = p + continue + } + return false + } +} diff --git a/blink/internal/fixer/rules/self_static_accessor.go b/blink/internal/fixer/rules/self_static_accessor.go new file mode 100644 index 0000000000..93f5d2ccbd --- /dev/null +++ b/blink/internal/fixer/rules/self_static_accessor.go @@ -0,0 +1,118 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SelfStaticAccessorFixer.php +// +// SelfStaticAccessor replaces "static" with "self" inside a final class or an +// enum (both cannot be extended, so late static binding is redundant): a static +// accessor "static::foo()" becomes "self::foo()" and "new static" becomes +// "new self". A "static" used as a type ("): static") or a closure/modifier +// ("static function") is left alone. +type SelfStaticAccessor struct{} + +func (SelfStaticAccessor) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\SelfStaticAccessorFixer` +} + +func (SelfStaticAccessor) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SelfStaticAccessorFixer.php" +} + +type selfStaticFrame struct { + classLike bool + final bool +} + +func (SelfStaticAccessor) Fix(s *tokens.Stream) bool { + changed := false + var stack []selfStaticFrame + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind == token.Punct { + switch t.Value { + case "{": + if kind, kw := classifyBrace(s, i); kind == braceClassLike { + stack = append(stack, selfStaticFrame{classLike: true, final: isFinalClassLike(s, kw)}) + } else { + stack = append(stack, selfStaticFrame{}) + } + case "}": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + } + continue + } + if t.Kind == token.Keyword && strings.EqualFold(t.Value, "static") && + enclosingFinalClassLike(stack) && isStaticAccessor(s, i) { + s.SetValue(i, "self") + changed = true + } + } + return changed +} + +// isFinalClassLike reports whether the class-like whose keyword sits at kw cannot +// be extended: an enum, or a class carrying the "final" modifier. An anonymous +// class ("new class") is treated conservatively as non-final. +func isFinalClassLike(s *tokens.Stream, kw int) bool { + if kw < 0 { + return false + } + if strings.EqualFold(s.At(kw).Value, "enum") { + return true + } + for j := kw - 1; j >= 0; j-- { + t := s.At(j) + switch t.Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "final": + return true + case "new": + return false // anonymous class + case "abstract", "readonly": + continue + } + return false + } + if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{" || t.Value == "}") { + return false // statement boundary - no modifier seen + } + return false + } + return false +} + +// enclosingFinalClassLike reports whether the nearest class-like frame on the +// stack is final. +func enclosingFinalClassLike(stack []selfStaticFrame) bool { + for _, f := range slices.Backward(stack) { + if f.classLike { + return f.final + } + } + return false +} + +// isStaticAccessor reports whether the "static" at i is a class reference: +// "static::" (next significant is "::") or "new static" (previous is "new"). +func isStaticAccessor(s *tokens.Stream, i int) bool { + if nextSignificantValue(s, i) == "::" { + return true + } + if prev, ok := prevSignificant(s, i); ok && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") { + return true + } + return false +} diff --git a/blink/internal/fixer/rules/self_static_accessor_test.go b/blink/internal/fixer/rules/self_static_accessor_test.go new file mode 100644 index 0000000000..7e7df76f03 --- /dev/null +++ b/blink/internal/fixer/rules/self_static_accessor_test.go @@ -0,0 +1,43 @@ +package rules + +import "testing" + +func TestSelfStaticAccessor(t *testing.T) { + // final class: static accessor and "new static" become self + src := "return / ::return member access + } + // expect exactly "return null ;" + j := nextSignificantIndex(s, i) + if j < 0 || !isNullLiteral(s.At(j)) { + continue + } + k := nextSignificantIndex(s, j) + if k < 0 || s.At(k).Kind != token.Punct || s.At(k).Value != ";" { + continue + } + if enclosingReturnTypeNullable(s, i) { + continue + } + // drop everything between "return" and ";" -> "return;" + for x := k - 1; x > i; x-- { + s.RemoveAt(x) + } + changed = true + } + return changed +} + +// enclosingReturnTypeNullable reports whether the innermost function enclosing +// position `at` declares a nullable return type. +func enclosingReturnTypeNullable(s *tokens.Stream, at int) bool { + depth := 0 + for j := at - 1; j >= 0; j-- { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "}": + depth++ + case "{": + if depth > 0 { + depth-- + continue + } + kind, kw := classifyBrace(s, j) + switch kind { + case braceFunctionDecl, braceClosure: + return funcReturnTypeNullable(s, kw, j) + case braceClassLike: + return false // a return cannot sit directly in a class body + default: + // inner control/free block; the function is further out + } + } + } + return false +} + +// funcReturnTypeNullable inspects the signature of the function whose keyword is +// at kw and whose body opens at brace, reporting a nullable return type. +func funcReturnTypeNullable(s *tokens.Stream, kw, brace int) bool { + paramsOpen := -1 + for x := kw + 1; x < brace; x++ { + if s.At(x).Kind == token.Punct && s.At(x).Value == "(" { + paramsOpen = x + break + } + } + if paramsOpen < 0 { + return false + } + paramsClose := s.MatchForward(paramsOpen) + if paramsClose < 0 || paramsClose >= brace { + return false + } + c := nextSignificantIndex(s, paramsClose) + // a closure may carry a "use (...)" clause between the params and the return type + if c >= 0 && s.At(c).Kind == token.Keyword && strings.EqualFold(s.At(c).Value, "use") { + uo := nextSignificantIndex(s, c) + if uo < 0 || s.At(uo).Kind != token.Punct || s.At(uo).Value != "(" { + return false + } + uc := s.MatchForward(uo) + if uc < 0 || uc >= brace { + return false + } + c = nextSignificantIndex(s, uc) + } + if c < 0 || s.At(c).Kind != token.Punct || s.At(c).Value != ":" { + return false // no return type declared + } + var b strings.Builder + for x := c + 1; x < brace; x++ { + tk := s.At(x) + switch tk.Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + b.WriteString(tk.Value) + } + typ := strings.ToLower(b.String()) + if typ == "" { + return false + } + if strings.HasPrefix(typ, "?") { + return true + } + for _, part := range strings.FieldsFunc(typ, func(r rune) bool { return r == '|' || r == '&' }) { + part = strings.Trim(part, "()") + if part == "null" || part == "mixed" { + return true + } + } + return false +} diff --git a/blink/internal/fixer/rules/single_blank_line_at_eof.go b/blink/internal/fixer/rules/single_blank_line_at_eof.go new file mode 100644 index 0000000000..a48c0d5782 --- /dev/null +++ b/blink/internal/fixer/rules/single_blank_line_at_eof.go @@ -0,0 +1,44 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SingleBlankLineAtEofFixer.php +// +// SingleBlankLineAtEndOfFile ensures the file ends with exactly one newline. +type SingleBlankLineAtEndOfFile struct{} + +func (SingleBlankLineAtEndOfFile) Name() string { + return `PhpCsFixer\Fixer\Whitespace\SingleBlankLineAtEofFixer` +} + +func (SingleBlankLineAtEndOfFile) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SingleBlankLineAtEofFixer.php" +} + +func (SingleBlankLineAtEndOfFile) Fix(s *tokens.Stream) bool { + if s.Len() == 0 { + return false + } + last := s.Len() - 1 + t := s.At(last) + if t.Kind == token.Whitespace { + // the final whitespace token is entirely trailing; collapse to one \n + if t.Value != "\n" { + s.SetValue(last, "\n") + return true + } + return false + } + // file does not end in whitespace: append a single newline, unless the last + // token already carries a trailing newline (e.g. a comment) + if strings.HasSuffix(t.Value, "\n") { + return false + } + s.InsertAt(s.Len(), token.Token{Kind: token.Whitespace, Value: "\n"}) + return true +} diff --git a/blink/internal/fixer/rules/single_blank_line_before_namespace.go b/blink/internal/fixer/rules/single_blank_line_before_namespace.go new file mode 100644 index 0000000000..e36617b8c9 --- /dev/null +++ b/blink/internal/fixer/rules/single_blank_line_before_namespace.go @@ -0,0 +1,42 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/SingleBlankLineBeforeNamespaceFixer.php +// +// SingleBlankLineBeforeNamespace ensures exactly one blank line before a +// namespace declaration on its own line (the older sibling of +// BlankLinesBeforeNamespace, kept for parity with configs that reference it). +type SingleBlankLineBeforeNamespace struct{} + +func (SingleBlankLineBeforeNamespace) Name() string { + return `PhpCsFixer\Fixer\NamespaceNotation\SingleBlankLineBeforeNamespaceFixer` +} + +func (SingleBlankLineBeforeNamespace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/SingleBlankLineBeforeNamespaceFixer.php" +} + +func (SingleBlankLineBeforeNamespace) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "namespace" || memberPrev(s, i) { + continue + } + if i == 0 { + continue + } + prev := s.At(i - 1) + if prev.Kind == token.Whitespace && hasNewline(prev.Value) && prev.Value != "\n\n" { + s.SetValue(i-1, "\n\n") + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/source_test.go b/blink/internal/fixer/rules/source_test.go new file mode 100644 index 0000000000..14865de839 --- /dev/null +++ b/blink/internal/fixer/rules/source_test.go @@ -0,0 +1,30 @@ +package rules + +import ( + "regexp" + "testing" + + "blink/internal/fixer" +) + +var sourceURLPattern = regexp.MustCompile( + `^https://github\.com/(PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src|symplify/coding-standard/blob/main/src|ecsphp/ecs-src/blob/main/packages/coding-standard/src)/Fixer/[A-Za-z]+/[A-Za-z]+Fixer\.php$`, +) + +// TestEveryFixerHasSourceURL enforces the Fixer contract offline: every rule +// must expose a well-formed PHP-CS-Fixer source link consistent with its name. +func TestEveryFixerHasSourceURL(t *testing.T) { + for _, f := range All() { + url := f.SourceURL() + if url == "" { + t.Errorf("%s: empty SourceURL", f.Name()) + continue + } + if !sourceURLPattern.MatchString(url) { + t.Errorf("%s: SourceURL %q does not match the PHP-CS-Fixer source pattern", f.Name(), url) + } + if want := fixer.SourceURLFor(f.Name()); url != want { + t.Errorf("%s: SourceURL %q is inconsistent with the name, want %q", f.Name(), url, want) + } + } +} diff --git a/blink/internal/fixer/rules/space_after_comma_here_now_doc.go b/blink/internal/fixer/rules/space_after_comma_here_now_doc.go new file mode 100644 index 0000000000..944fd49184 --- /dev/null +++ b/blink/internal/fixer/rules/space_after_comma_here_now_doc.go @@ -0,0 +1,42 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/SpaceAfterCommaHereNowDocFixer.php +// +// SpaceAfterCommaHereNowDoc puts a newline between a heredoc/nowdoc closing +// marker and a following "," or "]" (a heredoc argument in a call/array). The +// blink lexer keeps a whole heredoc/nowdoc as a single string token, so the +// closing marker is the end of that token. +type SpaceAfterCommaHereNowDoc struct{} + +func (SpaceAfterCommaHereNowDoc) Name() string { + return `Symplify\CodingStandard\Fixer\Spacing\SpaceAfterCommaHereNowDocFixer` +} + +func (SpaceAfterCommaHereNowDoc) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/SpaceAfterCommaHereNowDocFixer.php" +} + +func (SpaceAfterCommaHereNowDoc) Fix(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i >= 0; i-- { + if s.At(i).Kind != token.String || !strings.HasPrefix(s.At(i).Value, "<<<") { + continue + } + n := i + 1 + if n >= s.Len() { + continue + } + if s.At(n).Kind == token.Punct && (s.At(n).Value == "," || s.At(n).Value == "]") { + s.InsertAt(n, token.Token{Kind: token.Whitespace, Value: "\n"}) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/space_after_comma_here_now_doc_test.go b/blink/internal/fixer/rules/space_after_comma_here_now_doc_test.go new file mode 100644 index 0000000000..c61ac10a57 --- /dev/null +++ b/blink/internal/fixer/rules/space_after_comma_here_now_doc_test.go @@ -0,0 +1,22 @@ +package rules + +import "testing" + +func TestSpaceAfterCommaHereNowDoc(t *testing.T) { + f := SpaceAfterCommaHereNowDoc{} + + got, changed := apply(t, f, "= s.Len() { + continue + } + next := s.At(i + 1) + if next.Kind == token.Whitespace || next.Kind == token.CloseTag { + continue + } + if next.Kind == token.Punct && (next.Value == ")" || next.Value == ";") { + continue + } + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + i++ // skip inserted whitespace + } + return changed +} diff --git a/blink/internal/fixer/rules/space_around.go b/blink/internal/fixer/rules/space_around.go new file mode 100644 index 0000000000..ce333d8977 --- /dev/null +++ b/blink/internal/fixer/rules/space_around.go @@ -0,0 +1,152 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +func hasNewline(s string) bool { return strings.ContainsAny(s, "\n\r") } + +// nextSignificantValue returns the value of the first non-whitespace token after +// index i, or "" if none. +func nextSignificantValue(s *tokens.Stream, i int) string { + for j := i + 1; j < s.Len(); j++ { + if s.At(j).Kind != token.Whitespace { + return s.At(j).Value + } + } + return "" +} + +// prevSignificant returns the first non-whitespace token before index i. +func prevSignificant(s *tokens.Stream, i int) (token.Token, bool) { + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind != token.Whitespace { + return s.At(j), true + } + } + return token.Token{}, false +} + +// memberPrev reports whether the token before index i is an object/static +// access operator, meaning the token at i is a property or method name. +func memberPrev(s *tokens.Stream, i int) bool { + if prev, ok := prevSignificant(s, i); ok { + return prev.Value == "->" || prev.Value == "?->" || prev.Value == "::" + } + return false +} + +// isOperand reports whether a token can be the operand of ++/-- (a variable, +// name, or a closing ) or ]). +func isOperand(t token.Token) bool { + if t.Kind == token.Variable || t.Kind == token.Ident { + return true + } + return t.Kind == token.Punct && (t.Value == ")" || t.Value == "]") +} + +// castAt detects a cast at an opening "(" and returns the type token index and +// the closing ")" index. It reuses CastSpaces' guard so grouping, calls and +// index accesses are not mistaken for casts. +func castAt(s *tokens.Stream, open int) (typeIdx, closeIdx int, ok bool) { + if s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + return 0, 0, false + } + j := open + 1 + if j < s.Len() && s.At(j).Kind == token.Whitespace { + j++ + } + if j >= s.Len() || !isCastType(s.At(j)) { + return 0, 0, false + } + typeIdx = j + j++ + if j < s.Len() && s.At(j).Kind == token.Whitespace { + j++ + } + if j >= s.Len() || s.At(j).Kind != token.Punct || s.At(j).Value != ")" { + return 0, 0, false + } + if prev, ok := prevSignificant(s, open); ok { + if prev.Kind == token.Ident || prev.Kind == token.Variable || + prev.Value == ")" || prev.Value == "]" { + return 0, 0, false + } + } + return typeIdx, j, true +} + +// insideDeclareArgs reports whether index i sits inside a declare( ... ) header, +// so binary-operator spacing leaves declare_equal_normalize to handle its "=". +func insideDeclareArgs(s *tokens.Stream, i int) bool { + depth := 0 + for j := i - 1; j >= 0; j-- { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case ")": + depth++ + case "(": + if depth == 0 { + if prev, ok := prevSignificant(s, j); ok { + return prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "declare") + } + return false + } + depth-- + case ";": + if depth == 0 { + return false + } + } + } + return false +} + +// normalizeSpaceAround forces exactly one space on both sides of every token the +// target predicate selects. Whitespace spanning a newline is left alone so +// intentional alignment survives. +func normalizeSpaceAround(s *tokens.Stream, target func(*tokens.Stream, int) bool) bool { + changed := false + i := 0 + for i < s.Len() { + if !target(s, i) { + i++ + continue + } + + if i > 0 { + prev := s.At(i - 1) + if prev.Kind == token.Whitespace { + if !hasNewline(prev.Value) && prev.Value != " " { + s.SetValue(i-1, " ") + changed = true + } + } else { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + i++ // operator shifted right by the inserted space + changed = true + } + } + + if i+1 < s.Len() { + next := s.At(i + 1) + if next.Kind == token.Whitespace { + if !hasNewline(next.Value) && next.Value != " " { + s.SetValue(i+1, " ") + changed = true + } + } else { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + } + i++ + } + return changed +} diff --git a/blink/internal/fixer/rules/standalone_line_in_multiline_array.go b/blink/internal/fixer/rules/standalone_line_in_multiline_array.go new file mode 100644 index 0000000000..415849266e --- /dev/null +++ b/blink/internal/fixer/rules/standalone_line_in_multiline_array.go @@ -0,0 +1,97 @@ +package rules + +import ( + "blink/internal/token" + "blink/internal/tokens" +) + +// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/StandaloneLineInMultilineArrayFixer.php +// +// StandaloneLineInMultilineArray puts each item of an associative array literal +// on its own line. Unlike ArrayListItemNewline it also reflows arrays that are +// already spread across lines, and it does not add a trailing comma. Matching +// the upstream fix(), the first array that must be skipped (a non-associative +// array, or a lone non-array item not introduced by "=>") stops the whole pass. +type StandaloneLineInMultilineArray struct{} + +func (StandaloneLineInMultilineArray) Name() string { + return `Symplify\CodingStandard\Fixer\ArrayNotation\StandaloneLineInMultilineArrayFixer` +} + +func (StandaloneLineInMultilineArray) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/StandaloneLineInMultilineArrayFixer.php" +} + +func (StandaloneLineInMultilineArray) Fix(s *tokens.Stream) bool { + changed := false + for open := 0; open < s.Len(); open++ { + if s.At(open).Kind != token.Punct || s.At(open).Value != "[" { + continue + } + if !isArrayLiteralOpen(s, open) { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 || sigNext(s, open) == closeIdx { + continue // empty [] + } + if standaloneArrayShouldSkip(s, open, closeIdx) { + return changed // upstream fix() returns on the first skipped array + } + if reflowParen(s, open, closeIdx) { + changed = true + } + } + return changed +} + +// standaloneArrayShouldSkip mirrors shouldSkipNestedArrayValue. +func standaloneArrayShouldSkip(s *tokens.Stream, open, closeIdx int) bool { + if !arrayHasTopLevelArrow(s, open, closeIdx) { + return true // not an associative array + } + count, firstIsArray := arrayItemInfo(s, open, closeIdx) + if count == 1 && !firstIsArray { + prev := sigPrev(s, open) + if prev < 0 { + return false + } + return s.At(prev).Value != "=>" + } + return false +} + +// arrayItemInfo returns the top-level item count and whether the first item's +// value is itself an array. +func arrayItemInfo(s *tokens.Stream, open, closeIdx int) (count int, firstIsArray bool) { + count = 1 + depth := 0 + firstArrowChecked := false + for j := open + 1; j < closeIdx; j++ { + t := s.At(j) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + continue + case ")", "]", "}": + depth-- + continue + case ",": + if depth == 0 && sigNext(s, j) != closeIdx { + count++ + } + continue + case "=>": + if depth == 0 && !firstArrowChecked { + firstArrowChecked = true + if v := sigNext(s, j); v >= 0 && s.At(v).Kind == token.Punct && s.At(v).Value == "[" { + firstIsArray = true + } + } + continue + } + } + } + return count, firstIsArray +} diff --git a/blink/internal/fixer/rules/standalone_line_in_multiline_array_test.go b/blink/internal/fixer/rules/standalone_line_in_multiline_array_test.go new file mode 100644 index 0000000000..13f4cf7e17 --- /dev/null +++ b/blink/internal/fixer/rules/standalone_line_in_multiline_array_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestStandaloneLineInMultilineArray(t *testing.T) { + f := StandaloneLineInMultilineArray{} + + // a single-line associative array is broken to one item per line + got, changed := apply(t, f, " \"x\", 2 => \"y\"];\n") + want := " \"x\",\n 2 => \"y\"\n];\n" + if !changed || got != want { + t.Fatalf("single-line: changed=%v got=%q", changed, got) + } + + // a plain (non-associative) list is left alone + if _, changed := apply(t, f, " is left alone + if _, changed := apply(t, f, " \"x\",\n];\n"); changed { + t.Fatal("single associative item must be left alone") + } + + // the first skipped array stops the whole pass (upstream fix() returns) + if _, changed := apply(t, f, " 1, \"j\" => 2]];\n"); changed { + t.Fatal("a leading non-associative array aborts the pass") + } +} diff --git a/blink/internal/fixer/rules/standalone_line_plain_constructor_param.go b/blink/internal/fixer/rules/standalone_line_plain_constructor_param.go new file mode 100644 index 0000000000..52852b1e79 --- /dev/null +++ b/blink/internal/fixer/rules/standalone_line_plain_constructor_param.go @@ -0,0 +1,78 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/StandaloneLinePlainConstructorParamFixer.php +// +// StandaloneLinePlainConstructorParam puts each parameter of a constructor +// without promoted properties on its own line, but only when it has 4 or more +// parameters. Promoted constructors are left to StandaloneLinePromotedProperty. +type StandaloneLinePlainConstructorParam struct{} + +// Short parameter lists read fine on a single line. +const plainConstructorMinParamCount = 4 + +func (StandaloneLinePlainConstructorParam) Name() string { + return `Symplify\CodingStandard\Fixer\Spacing\StandaloneLinePlainConstructorParamFixer` +} + +func (StandaloneLinePlainConstructorParam) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/StandaloneLinePlainConstructorParamFixer.php" +} + +func (StandaloneLinePlainConstructorParam) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Ident || !strings.EqualFold(s.At(i).Value, "__construct") { + continue + } + if p := sigPrev(s, i); p < 0 || s.At(p).Kind != token.Keyword || strings.ToLower(s.At(p).Value) != "function" { + continue + } + open := sigNext(s, i) + if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 || sigNext(s, open) == closeIdx { + continue // no parameters + } + if countParams(s, open, closeIdx) < plainConstructorMinParamCount { + continue + } + if hasPromotedParam(s, open, closeIdx) { + continue // promoted constructors are handled by StandaloneLinePromotedProperty + } + if reflowParen(s, open, closeIdx) { + changed = true + } + } + return changed +} + +// countParams counts top-level parameters (variables at nesting depth 0) in the paren. +func countParams(s *tokens.Stream, open, closeIdx int) int { + count := 0 + depth := 0 + for j := open + 1; j < closeIdx; j++ { + t := s.At(j) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + } + continue + } + if depth == 0 && t.Kind == token.Variable { + count++ + } + } + return count +} diff --git a/blink/internal/fixer/rules/standalone_line_plain_constructor_param_test.go b/blink/internal/fixer/rules/standalone_line_plain_constructor_param_test.go new file mode 100644 index 0000000000..138d41e823 --- /dev/null +++ b/blink/internal/fixer/rules/standalone_line_plain_constructor_param_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestStandaloneLinePlainConstructorParam(t *testing.T) { + f := StandaloneLinePlainConstructorParam{} + + // 4+ plain params: each on its own line + got, changed := apply(t, f, "= 0; j-- { + t := s.At(j) + switch t.Kind { + case token.Whitespace: + continue + case token.Comment: + if requiredAttributeRe.MatchString(t.Value) { + isRequired = true + } + continue + case token.DocComment: + if requiredAnnotationRe.MatchString(t.Value) { + isRequired = true + } + continue + case token.Keyword: + switch strings.ToLower(t.Value) { + case "public": + isPublic = true + continue + case "protected", "private", "static", "final", "abstract", "readonly": + continue + } + return isPublic && isRequired + default: + return isPublic && isRequired + } + } + return isPublic && isRequired +} diff --git a/blink/internal/fixer/rules/standalone_line_required_param_test.go b/blink/internal/fixer/rules/standalone_line_required_param_test.go new file mode 100644 index 0000000000..f62b4b65bd --- /dev/null +++ b/blink/internal/fixer/rules/standalone_line_required_param_test.go @@ -0,0 +1,35 @@ +package rules + +import "testing" + +func TestStandaloneLineRequiredParam(t *testing.T) { + f := StandaloneLineRequiredParam{} + + // #[Required] public method: each parameter on its own line + got, changed := apply(t, f, " 0 { + c := attr[start-1] + if c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + start-- + continue + } + break + } + return attr[start:end] +} + +// matchParen returns the index of the ")" matching the "(" at open, or -1. +func matchParen(attr string, open int) int { + depth := 0 + for j := open; j < len(attr); j++ { + switch attr[j] { + case '(', '[': + depth++ + case ')', ']': + depth-- + if depth == 0 { + return j + } + } + } + return -1 +} + +// splitTopLevelArgs splits the argument list on top-level commas, trimming each +// segment. A trailing empty segment (from a trailing comma) is dropped. +func splitTopLevelArgs(inner string) []string { + var args []string + depth := 0 + start := 0 + inString := byte(0) + for j := 0; j < len(inner); j++ { + c := inner[j] + if inString != 0 { + switch c { + case '\\': + j++ + case inString: + inString = 0 + } + continue + } + switch c { + case '\'', '"': + inString = c + case '(', '[', '{': + depth++ + case ')', ']', '}': + depth-- + case ',': + if depth == 0 { + args = append(args, strings.TrimSpace(inner[start:j])) + start = j + 1 + } + } + } + if last := strings.TrimSpace(inner[start:]); last != "" { + args = append(args, last) + } + return args +} diff --git a/blink/internal/fixer/rules/standalone_line_symfony_attribute_param_test.go b/blink/internal/fixer/rules/standalone_line_symfony_attribute_param_test.go new file mode 100644 index 0000000000..3902c9face --- /dev/null +++ b/blink/internal/fixer/rules/standalone_line_symfony_attribute_param_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestStandaloneLineSymfonyAttributeParam(t *testing.T) { + f := StandaloneLineSymfonyAttributeParam{} + + // AsCommand always breaks, even with a single argument set + got, changed := apply(t, f, " 0 { + brace-- + } + case "(", "[": + paren++ + case ")", "]": + if paren > 0 { + paren-- + } + } + } + if t.Kind != token.Whitespace || !hasNewline(t.Value) || paren != 0 { + continue + } + prev, ok := prevSignificant(s, i) + if !ok || (prev.Value != ";" && prev.Value != "{" && prev.Value != "}") { + continue // continuation line - leave its alignment alone + } + if nextSignificantValue(s, i) == "" { + continue + } + + level := brace + if nextSignificantValue(s, i) == "}" { + level-- // a closing brace dedents to the outer level + } + if level < 0 { + level = 0 + } + target := strings.Repeat(" ", level) + + v := t.Value + nl := strings.LastIndexByte(v, '\n') + if v[nl+1:] != target { + s.SetValue(i, v[:nl+1]+target) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/statements.go b/blink/internal/fixer/rules/statements.go new file mode 100644 index 0000000000..e5d88e4c84 --- /dev/null +++ b/blink/internal/fixer/rules/statements.go @@ -0,0 +1,80 @@ +package rules + +import ( + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoMultipleStatementsPerLineFixer.php +// +// NoMultipleStatementsPerLine puts each statement on its own line: "$a=1; $b=2;" +// becomes two lines. Semicolons inside "(...)"/"[...]" (e.g. a for header) and a +// trailing ";" before "}" or a newline are left alone. +type NoMultipleStatementsPerLine struct{} + +func (NoMultipleStatementsPerLine) Name() string { + return `PhpCsFixer\Fixer\Basic\NoMultipleStatementsPerLineFixer` +} + +func (NoMultipleStatementsPerLine) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoMultipleStatementsPerLineFixer.php" +} + +func (NoMultipleStatementsPerLine) Fix(s *tokens.Stream) bool { + changed := false + depth := 0 + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[": + depth++ + continue + case ")", "]": + depth-- + continue + case ";": + default: + continue + } + if depth != 0 || i+1 >= s.Len() { + continue + } + + // what follows the ";" on the same line? + nextIdx := i + 1 + inlineWS := false + if s.At(nextIdx).Kind == token.Whitespace { + if hasNewline(s.At(nextIdx).Value) { + continue // already end of line + } + inlineWS = true + nextIdx++ + } + if nextIdx >= s.Len() { + continue + } + nt := s.At(nextIdx) + if nt.Kind == token.Punct && (nt.Value == "}" || nt.Value == ";") { + continue + } + if nt.Kind == token.CloseTag { + continue + } + // a trailing comment after ";" is not a second statement + if nt.Kind == token.Comment || nt.Kind == token.DocComment { + continue + } + + indent := lineIndent(s, i) + if inlineWS { + s.SetValue(i+1, "\n"+indent) + } else { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: "\n" + indent}) + } + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/string_notation.go b/blink/internal/fixer/rules/string_notation.go new file mode 100644 index 0000000000..41460f2093 --- /dev/null +++ b/blink/internal/fixer/rules/string_notation.go @@ -0,0 +1,42 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/SingleQuoteFixer.php +// +// SingleQuote converts a double-quoted string to single quotes when it is safe: +// no variables ($), no escape sequences (\), and no single quote in the content. +type SingleQuote struct{} + +func (SingleQuote) Name() string { + return `PhpCsFixer\Fixer\StringNotation\SingleQuoteFixer` +} + +func (SingleQuote) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/SingleQuoteFixer.php" +} + +func (SingleQuote) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.String || len(t.Value) < 2 { + continue + } + if t.Value[0] != '"' || t.Value[len(t.Value)-1] != '"' { + continue // not a plain double-quoted string (e.g. heredoc) + } + content := t.Value[1 : len(t.Value)-1] + if strings.ContainsAny(content, "$\\'") { + continue // interpolation, escape, or a quote that would need escaping + } + s.SetValue(i, "'"+content+"'") + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/structural.go b/blink/internal/fixer/rules/structural.go new file mode 100644 index 0000000000..4213af70cc --- /dev/null +++ b/blink/internal/fixer/rules/structural.go @@ -0,0 +1,212 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLinesBeforeNamespaceFixer.php +// +// BlankLinesBeforeNamespace ensures exactly one blank line before a namespace +// declaration that sits on its own line. +type BlankLinesBeforeNamespace struct{} + +func (BlankLinesBeforeNamespace) Name() string { + return `PhpCsFixer\Fixer\NamespaceNotation\BlankLinesBeforeNamespaceFixer` +} + +func (BlankLinesBeforeNamespace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLinesBeforeNamespaceFixer.php" +} + +func (BlankLinesBeforeNamespace) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "namespace" || memberPrev(s, i) { + continue + } + if i == 0 { + continue + } + prev := s.At(i - 1) + if prev.Kind == token.Whitespace && hasNewline(prev.Value) && prev.Value != "\n\n" { + s.SetValue(i-1, "\n\n") + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLineAfterNamespaceFixer.php +// +// BlankLineAfterNamespace ensures one blank line after a "namespace X;" +// declaration. Bracketed namespaces ("namespace X { }") are left alone. +type BlankLineAfterNamespace struct{} + +func (BlankLineAfterNamespace) Name() string { + return `PhpCsFixer\Fixer\NamespaceNotation\BlankLineAfterNamespaceFixer` +} + +func (BlankLineAfterNamespace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLineAfterNamespaceFixer.php" +} + +func (BlankLineAfterNamespace) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "namespace" || memberPrev(s, i) { + continue + } + semi := -1 + for j := i + 1; j < s.Len(); j++ { + if s.At(j).Kind != token.Punct { + continue + } + if s.At(j).Value == "{" { + break // bracketed namespace + } + if s.At(j).Value == ";" { + semi = j + break + } + } + if semi < 0 || semi+1 >= s.Len() { + continue + } + if nextSignificantValue(s, semi) == "" { + continue // namespace is the last statement; nothing to separate + } + if s.At(semi+1).Kind == token.Whitespace { + if hasNewline(s.At(semi+1).Value) && s.At(semi+1).Value != "\n\n" { + s.SetValue(semi+1, "\n\n") + changed = true + } + } else { + s.InsertAt(semi+1, token.Token{Kind: token.Whitespace, Value: "\n\n"}) + changed = true + } + } + return changed +} + +var classLikeKeywords = map[string]bool{ + "class": true, "interface": true, "trait": true, "enum": true, +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoBlankLinesAfterClassOpeningFixer.php +// +// NoBlankLinesAfterClassOpening removes blank lines right after a class, +// interface, trait or enum opening brace. +type NoBlankLinesAfterClassOpening struct{} + +func (NoBlankLinesAfterClassOpening) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\NoBlankLinesAfterClassOpeningFixer` +} + +func (NoBlankLinesAfterClassOpening) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoBlankLinesAfterClassOpeningFixer.php" +} + +func (NoBlankLinesAfterClassOpening) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Keyword || !classLikeKeywords[strings.ToLower(t.Value)] || memberPrev(s, i) { + continue + } + brace := -1 + for j := i + 1; j < s.Len(); j++ { + if s.At(j).Kind != token.Punct { + continue + } + if s.At(j).Value == "{" { + brace = j + break + } + if s.At(j).Value == ";" { + break // e.g. no body + } + } + if brace < 0 || brace+1 >= s.Len() { + continue + } + ws := s.At(brace + 1) + if ws.Kind != token.Whitespace || strings.Count(ws.Value, "\n") <= 1 { + continue + } + idx := strings.LastIndexByte(ws.Value, '\n') + want := "\n" + ws.Value[idx+1:] + if ws.Value != want { + s.SetValue(brace+1, want) + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/IndentationTypeFixer.php +// +// IndentationType converts leading indentation tabs to four spaces. +type IndentationType struct{} + +func (IndentationType) Name() string { + return `PhpCsFixer\Fixer\Whitespace\IndentationTypeFixer` +} + +func (IndentationType) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/IndentationTypeFixer.php" +} + +func (IndentationType) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if !strings.Contains(t.Value, "\t") { + continue + } + switch t.Kind { + case token.Whitespace: + // segments after a newline are line indentation; the first segment is + // trailing whitespace of the previous line and is left alone + parts := strings.Split(t.Value, "\n") + touched := false + for k := 1; k < len(parts); k++ { + if strings.Contains(parts[k], "\t") { + parts[k] = strings.ReplaceAll(parts[k], "\t", " ") + touched = true + } + } + if touched { + s.SetValue(i, strings.Join(parts, "\n")) + changed = true + } + case token.Comment, token.DocComment: + if v := reindentCommentTabs(t.Value); v != t.Value { + s.SetValue(i, v) + changed = true + } + } + } + return changed +} + +// reindentCommentTabs converts leading-indentation tabs to four spaces on each +// continuation line of a multi-line comment or docblock, matching ECS. +func reindentCommentTabs(v string) string { + lines := strings.Split(v, "\n") + for k := 1; k < len(lines); k++ { + line := lines[k] + j := 0 + for j < len(line) && (line[j] == ' ' || line[j] == '\t') { + j++ + } + if strings.Contains(line[:j], "\t") { + lines[k] = strings.ReplaceAll(line[:j], "\t", " ") + line[j:] + } + } + return strings.Join(lines, "\n") +} diff --git a/blink/internal/fixer/rules/switch_case.go b/blink/internal/fixer/rules/switch_case.go new file mode 100644 index 0000000000..a25a5b8989 --- /dev/null +++ b/blink/internal/fixer/rules/switch_case.go @@ -0,0 +1,101 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +var caseKeywords = map[string]bool{"case": true, "default": true} + +// caseTerminator finds the ":" (or ";") that ends the case/default label at i, +// skipping ternary "?:" pairs and "::". It stops at a block brace for safety. +func caseTerminator(s *tokens.Stream, i int) (idx int, isSemicolon, ok bool) { + ternary := 0 + for k := i + 1; k < s.Len(); k++ { + t := s.At(k) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "?": + ternary++ + case ":": + if ternary > 0 { + ternary-- + } else { + return k, false, true + } + case ";": + if ternary == 0 { + return k, true, true + } + case "{", "}": + return 0, false, false + } + } + return 0, false, false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSemicolonToColonFixer.php +// +// SwitchCaseSemicolonToColon turns a "case 1;" terminator into "case 1:". Enum +// cases are left alone. +type SwitchCaseSemicolonToColon struct{} + +func (SwitchCaseSemicolonToColon) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\SwitchCaseSemicolonToColonFixer` +} + +func (SwitchCaseSemicolonToColon) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSemicolonToColonFixer.php" +} + +func (SwitchCaseSemicolonToColon) Fix(s *tokens.Stream) bool { + changed := false + for i := range s.Len() { + t := s.At(i) + if t.Kind != token.Keyword || !caseKeywords[strings.ToLower(t.Value)] || inClassLikeBody(s, i) { + continue + } + if idx, isSemicolon, ok := caseTerminator(s, i); ok && isSemicolon { + s.SetValue(idx, ":") + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSpaceFixer.php +// +// SwitchCaseSpace removes whitespace between a case/default label and its colon +// ("case 1 :" -> "case 1:"). +type SwitchCaseSpace struct{} + +func (SwitchCaseSpace) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\SwitchCaseSpaceFixer` +} + +func (SwitchCaseSpace) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSpaceFixer.php" +} + +func (SwitchCaseSpace) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || !caseKeywords[strings.ToLower(t.Value)] || inClassLikeBody(s, i) { + continue + } + idx, _, ok := caseTerminator(s, i) + if !ok { + continue + } + if idx > 0 && s.At(idx-1).Kind == token.Whitespace && !hasNewline(s.At(idx-1).Value) { + s.RemoveAt(idx - 1) + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/symplify_docblock.go b/blink/internal/fixer/rules/symplify_docblock.go new file mode 100644 index 0000000000..8980782c48 --- /dev/null +++ b/blink/internal/fixer/rules/symplify_docblock.go @@ -0,0 +1,215 @@ +package rules + +import ( + "regexp" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Shared base for Symplify's single-task @param/@return/@var doc-block fixers +// (AbstractDocBlockFixer): iterate every comment/doc-comment carrying a +// param/return/var tag, transform it, and set the result (or drop an emptied +// block). Priority -37 upstream. + +var ( + docBlockTypeAnnotationRe = regexp.MustCompile(`@(psalm-|phpstan-)?(param|return|var)`) + docBlockEmptyRe = regexp.MustCompile(`/\*\*|\*/|\*|\s`) +) + +// docBlockFixerCandidate mirrors AbstractDocBlockFixer::isCandidate. +func docBlockFixerCandidate(s *tokens.Stream) bool { + hasComment := false + for i := 0; i < s.Len(); i++ { + if k := s.At(i).Kind; k == token.Comment || k == token.DocComment { + hasComment = true + break + } + } + if !hasComment { + return false + } + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "callable") { + if i+3 < s.Len() && s.At(i+3).Kind == token.Punct && s.At(i+3).Value == ")" { + return false + } + } + } + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind == token.Variable || (t.Kind == token.Keyword && strings.EqualFold(t.Value, "function")) { + return true + } + } + return false +} + +func docBlockIsEmpty(content string) bool { + return docBlockEmptyRe.ReplaceAllString(content, "") == "" +} + +// docBlockFixerApply runs transform over each param/return/var comment, mirroring +// AbstractDocBlockFixer::fix (set as doc comment, or drop an emptied block). +func docBlockFixerApply(s *tokens.Stream, transform func(content string, s *tokens.Stream, i int) string) bool { + if !docBlockFixerCandidate(s) { + return false + } + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Comment && t.Kind != token.DocComment { + continue + } + if !docBlockTypeAnnotationRe.MatchString(t.Value) { + continue + } + newContent := transform(t.Value, s, i) + if newContent == t.Value { + continue + } + if docBlockIsEmpty(newContent) { + followWS := i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace + s.RemoveAt(i) + if followWS { + s.RemoveAt(i) + } + i-- + changed = true + continue + } + s.Set(i, token.Token{Kind: token.DocComment, Value: newContent}) + changed = true + } + return changed +} + +// --- DoubleAsteriskInlineVar: "/* @var" -> "/** @var" on a single-asterisk comment --- + +type DoubleAsteriskInlineVar struct{} + +var doubleAsteriskStartRe = regexp.MustCompile(`^/\*(\n?\s+@(?:psalm-|phpstan-)?var)`) + +func (DoubleAsteriskInlineVar) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\DoubleAsteriskInlineVarFixer` +} + +func (DoubleAsteriskInlineVar) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/DoubleAsteriskInlineVarFixer.php" +} + +func (DoubleAsteriskInlineVar) Fix(s *tokens.Stream) bool { + return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string { + if s.At(i).Kind != token.Comment { + return content + } + return doubleAsteriskStartRe.ReplaceAllString(content, "/**$1") + }) +} + +// --- FixTagTypo: "@returns/@params/@vars" -> singular --- + +type FixTagTypo struct{} + +var fixTagTypoRe = regexp.MustCompile(`@((?:psalm-|phpstan-)?(?:param|return|var))s\b`) + +func (FixTagTypo) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\FixTagTypoFixer` +} + +func (FixTagTypo) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/FixTagTypoFixer.php" +} + +func (FixTagTypo) Fix(s *tokens.Stream) bool { + return docBlockFixerApply(s, func(content string, _ *tokens.Stream, _ int) string { + return fixTagTypoRe.ReplaceAllString(content, "@$1") + }) +} + +// --- TypeToVarTag: "@type" -> "@var" (+ single-asterisk to double) --- + +type TypeToVarTag struct{} + +var ( + typeTagRe = regexp.MustCompile(`@type\b`) + typeToVarSingleAsteriskRe = regexp.MustCompile(`^/\*(\n?\s+@var)`) +) + +func (TypeToVarTag) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\TypeToVarTagFixer` +} + +func (TypeToVarTag) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/TypeToVarTagFixer.php" +} + +func (TypeToVarTag) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Comment && t.Kind != token.DocComment { + continue + } + if !typeTagRe.MatchString(t.Value) { + continue + } + nc := typeTagRe.ReplaceAllString(t.Value, "@var") + nc = typeToVarSingleAsteriskRe.ReplaceAllString(nc, "/**$1") + s.Set(i, token.Token{Kind: token.DocComment, Value: nc}) + changed = true + } + return changed +} + +// --- MergeDocBlockStart: single-asterisk "/*" doc block -> "/**", drop empty "*"-only lines --- + +type MergeDocBlockStart struct{} + +var ( + mergeDocEmptyAsteriskLineRe = regexp.MustCompile(`^\s*\*\s*$`) + mergeDocPhpdocTagLineRe = regexp.MustCompile(`\n\s*\*\s*@`) + mergeDocOpenerRe = regexp.MustCompile(`^/\*`) +) + +func (MergeDocBlockStart) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\MergeDocBlockStartFixer` +} + +func (MergeDocBlockStart) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/MergeDocBlockStartFixer.php" +} + +func (MergeDocBlockStart) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Comment { + continue + } + if !strings.HasPrefix(t.Value, "/*") || strings.HasPrefix(t.Value, "/**") { + continue + } + if !mergeDocPhpdocTagLineRe.MatchString(t.Value) { + continue + } + lines := strings.Split(t.Value, "\n") + kept := lines[:0:0] + removed := false + for k, line := range lines { + if k != 0 && mergeDocEmptyAsteriskLineRe.MatchString(line) { + removed = true + continue + } + kept = append(kept, line) + } + if !removed { + continue + } + kept[0] = mergeDocOpenerRe.ReplaceAllString(kept[0], "/**") + s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(kept, "\n")}) + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/symplify_docblock2.go b/blink/internal/fixer/rules/symplify_docblock2.go new file mode 100644 index 0000000000..c90b5d73ca --- /dev/null +++ b/blink/internal/fixer/rules/symplify_docblock2.go @@ -0,0 +1,176 @@ +package rules + +import ( + "regexp" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// --- AddMissingVarName: "/** @var Type */" with no name -> add the next variable's name --- + +type AddMissingVarName struct{} + +var varWithoutNameRe = regexp.MustCompile(`^(/\*\* @(?:psalm-|phpstan-)?var )([\\\w|\[\]&-]+)(\s+\*/)$`) + +func (AddMissingVarName) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\AddMissingVarNameFixer` +} + +func (AddMissingVarName) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/AddMissingVarNameFixer.php" +} + +func (AddMissingVarName) Fix(s *tokens.Stream) bool { + return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string { + m := varWithoutNameRe.FindStringSubmatch(content) + if m == nil { + return content + } + varTok := addMissingNextVariable(s, i) + if varTok == "" { + return content + } + return m[1] + m[2] + " " + varTok + m[3] + }) +} + +func addMissingNextVariable(s *tokens.Stream, i int) string { + n := sigNext(s, i) + if n < 0 || s.At(n).Kind != token.Variable { + return "" + } + if a := sigNext(s, n); a >= 0 && s.At(a).Kind == token.Punct && (s.At(a).Value == "->" || s.At(a).Value == "?->") { + return "" + } + return s.At(n).Value +} + +// --- SingleLineInlineVarDocBlock: collapse a multiline inline @var above a variable --- + +type SingleLineInlineVarDocBlock struct{} + +var ( + singleLineAsteriskStartRe = regexp.MustCompile(`^/\*\s+\*(\s+@(?:psalm-|phpstan-)?var)`) + singleLineSpaceRe = regexp.MustCompile(`\s+`) + singleLineAsteriskLeftRe = regexp.MustCompile(`(\*\*)(\s+\*)`) +) + +func (SingleLineInlineVarDocBlock) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\SingleLineInlineVarDocBlockFixer` +} + +func (SingleLineInlineVarDocBlock) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/SingleLineInlineVarDocBlockFixer.php" +} + +func (SingleLineInlineVarDocBlock) Fix(s *tokens.Stream) bool { + return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string { + if !singleLineIsVariableComment(s, i) { + return content + } + if strings.Count(content, "\n") > 2 { + return content + } + content = singleLineAsteriskStartRe.ReplaceAllString(content, "/**$1") + content = singleLineSpaceRe.ReplaceAllString(content, " ") + return singleLineAsteriskLeftRe.ReplaceAllString(content, "$1") + }) +} + +func singleLineIsVariableComment(s *tokens.Stream, i int) bool { + n := sigNext(s, i) + if n < 0 { + return false + } + nn := sigNext(s, n+2) + if nn < 0 { + return false + } + if s.At(nn).Kind == token.Keyword { + switch strings.ToLower(s.At(nn).Value) { + case "static", "function": + return false + } + } + return s.At(n).Kind == token.Variable +} + +// --- RemoveSuperfluousReturnName: "@return Type $var" -> "@return Type" --- + +type RemoveSuperfluousReturnName struct{} + +var ( + returnVarNameRe = regexp.MustCompile(`(@(?:psalm-|phpstan-)?return)(\s+[|\\\w]+)?(\s+)(\$[\w]+)`) + docVariableNameRe = regexp.MustCompile(`\$\w+`) +) + +func (RemoveSuperfluousReturnName) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousReturnNameFixer` +} + +func (RemoveSuperfluousReturnName) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/RemoveSuperfluousReturnNameFixer.php" +} + +func (RemoveSuperfluousReturnName) Fix(s *tokens.Stream) bool { + return docBlockFixerApply(s, func(content string, _ *tokens.Stream, _ int) string { + lines := splitDocLines(content) + for idx, line := range lines { + m := returnVarNameRe.FindStringSubmatch(line) + if m == nil { + continue + } + if m[4] == "$this" { + continue + } + if len(docVariableNameRe.FindAllString(line, -1)) >= 2 { + continue + } + lines[idx] = returnVarNameRe.ReplaceAllString(line, "$1$2") + } + return strings.Join(lines, "") + }) +} + +// --- RemoveSuperfluousVarName: property "@var Type $prop" -> "@var Type" ($this -> self) --- + +type RemoveSuperfluousVarName struct{} + +var varVariableNameRe = regexp.MustCompile(`(@(?:psalm-|phpstan-)?var)(\s+[|\\\w]+)?(\s+)(\$[\w]+)`) + +func (RemoveSuperfluousVarName) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousVarNameFixer` +} + +func (RemoveSuperfluousVarName) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/RemoveSuperfluousVarNameFixer.php" +} + +func (RemoveSuperfluousVarName) Fix(s *tokens.Stream) bool { + return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string { + n := sigNext(s, i) + if n < 0 || s.At(n).Kind != token.Keyword { + return content + } + switch strings.ToLower(s.At(n).Value) { + case "public", "protected", "private", "static": + default: + return content + } + lines := splitDocLines(content) + for idx, line := range lines { + m := varVariableNameRe.FindStringSubmatch(line) + if m == nil { + continue + } + if strings.HasSuffix(m[4], "$this") && m[4] == "$this" { + lines[idx] = varVariableNameRe.ReplaceAllString(line, "$1 self") + } else { + lines[idx] = varVariableNameRe.ReplaceAllString(line, "$1$2") + } + } + return strings.Join(lines, "") + }) +} diff --git a/blink/internal/fixer/rules/symplify_docblock3.go b/blink/internal/fixer/rules/symplify_docblock3.go new file mode 100644 index 0000000000..b6e24a9782 --- /dev/null +++ b/blink/internal/fixer/rules/symplify_docblock3.go @@ -0,0 +1,175 @@ +package rules + +import ( + "regexp" + "sort" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// --- FixParamNameTypo: correct a @param variable name to match the real argument --- + +type FixParamNameTypo struct{} + +var fpntParamNameRe = regexp.MustCompile(`@param(\s+)(callable)?(.*?)(\$\w+)`) + +// fpntParamAnnRe gates a line to a real doc-block @param annotation, mirroring +// PhpCsFixer DocBlock::getAnnotationsOfType (a tag line has `* ` before `@`). +// This stops mid-text "@param" inside // comments from counting as a param. +var fpntParamAnnRe = regexp.MustCompile(`\*\s*@param`) + +func (FixParamNameTypo) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\FixParamNameTypoFixer` +} + +func (FixParamNameTypo) SourceURL() string { + return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/FixParamNameTypoFixer.php" +} + +func (FixParamNameTypo) Fix(s *tokens.Stream) bool { + return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string { + argumentNames := fpntResolveArgNames(s, i) + if len(argumentNames) == 0 { + return content + } + paramNames := fpntParamNames(content) // map index->name + missArg := map[int]string{} + for k, arg := range argumentNames { + found := -1 + for _, pk := range sortedKeys(paramNames) { + if paramNames[pk] == arg { + found = pk + break + } + } + if found >= 0 { + delete(paramNames, found) + } else { + missArg[k] = arg + } + } + if len(missArg) == 0 || len(paramNames) == 0 { + return content + } + return fpntFixTypos(argumentNames, missArg, paramNames, content) + }) +} + +// fpntResolveArgNames mirrors DocblockRelatedParamNamesResolver: the parameter +// variable names of the next function/fn after the doc block. +func fpntResolveArgNames(s *tokens.Stream, docPos int) []string { + fn := -1 + for j := docPos + 1; j < s.Len(); j++ { + if s.At(j).Kind == token.Keyword { + lv := strings.ToLower(s.At(j).Value) + if lv == "function" || lv == "fn" { + fn = j + break + } + } + } + if fn < 0 { + return nil + } + open := nextPunctOfKind(s, fn, "(") + if open < 0 { + return nil + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 { + return nil + } + var names []string + depth := 0 + for i := open + 1; i < closeIdx; i++ { + t := s.At(i) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + } + continue + } + if depth == 0 && t.Kind == token.Variable { + names = append(names, t.Value) + } + } + return names +} + +func fpntParamNames(content string) map[int]string { + out := map[int]string{} + idx := 0 + for line := range strings.SplitSeq(content, "\n") { + if !fpntParamAnnRe.MatchString(line) { + continue + } + m := fpntParamNameRe.FindStringSubmatch(line) + if m == nil { + continue + } + if m[2] == "callable" { + continue + } + out[idx] = m[4] + idx++ + } + return out +} + +func fpntFixTypos(argumentNames []string, missArg, paramNames map[int]string, content string) string { + replaced := map[string]bool{} + for _, a := range argumentNames { + replaced[a] = false + } + for _, k := range sortedKeys(missArg) { + argName := missArg[k] + typoName, ok := paramNames[k] + if !ok { + continue + } + re := regexp.MustCompile(`@param(.*?)(` + regexp.QuoteMeta(typoName) + `\b)`) + content = replaceFirstFunc(content, re, func(groups []string) string { + paramName := groups[2] + if done, exists := replaced[paramName]; exists && !done { + replaced[paramName] = true + return groups[0] + } + return "@param" + groups[1] + argName + }) + } + return content +} + +// replaceFirstFunc replaces every match (like preg_replace_callback) using the +// submatch groups. +func replaceFirstFunc(content string, re *regexp.Regexp, fn func(groups []string) string) string { + var b strings.Builder + last := 0 + for _, loc := range re.FindAllStringSubmatchIndex(content, -1) { + groups := make([]string, len(loc)/2) + for g := 0; g < len(loc)/2; g++ { + if loc[2*g] >= 0 { + groups[g] = content[loc[2*g]:loc[2*g+1]] + } + } + b.WriteString(content[last:loc[0]]) + b.WriteString(fn(groups)) + last = loc[1] + } + b.WriteString(content[last:]) + return b.String() +} + +func sortedKeys(m map[int]string) []int { + ks := make([]int, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Ints(ks) + return ks +} diff --git a/blink/internal/fixer/rules/symplify_docblock_fixers.go b/blink/internal/fixer/rules/symplify_docblock_fixers.go new file mode 100644 index 0000000000..de1498bc96 --- /dev/null +++ b/blink/internal/fixer/rules/symplify_docblock_fixers.go @@ -0,0 +1,543 @@ +package rules + +import ( + "regexp" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// Shared machinery for Symplify's single-task @param/@return/@var doc block +// fixers (AbstractDocBlockFixer): iterate doc/comment tokens carrying a +// param/return/var annotation, transform the content, and drop the whole block +// when it becomes empty. + +var docTypeAnnotationRe = regexp.MustCompile(`@(psalm-|phpstan-)?(param|return|var)`) + +// applyDocBlockFixer runs process over each doc/comment token that holds a +// @param/@return/@var annotation, mirroring AbstractDocBlockFixer::fix. +func applyDocBlockFixer(s *tokens.Stream, process func(string) string) bool { + if !dbfCandidateW4(s) { + return false + } + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.DocComment && t.Kind != token.Comment { + continue + } + if !docTypeAnnotationRe.MatchString(t.Value) { + continue + } + nc := process(t.Value) + if nc == t.Value { + continue + } + if isEmptyDocBlock(nc) { + s.RemoveAt(i) + if i < s.Len() && s.At(i).Kind == token.Whitespace { + s.RemoveAt(i) + } + } else { + s.Set(i, token.Token{Kind: token.DocComment, Value: nc}) + } + changed = true + } + return changed +} + +// dbfCandidateW4 mirrors AbstractDocBlockFixer::isCandidate. +func dbfCandidateW4(s *tokens.Stream) bool { + hasComment, hasFuncOrVar := false, false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + switch t.Kind { + case token.DocComment, token.Comment: + hasComment = true + case token.Variable: + hasFuncOrVar = true + case token.Keyword: + if strings.EqualFold(t.Value, "function") { + hasFuncOrVar = true + } + // a "callable(...)" typehint disables the fixer + if strings.EqualFold(t.Value, "callable") && i+3 < s.Len() && s.At(i+3).Value == ")" { + return false + } + } + } + return hasComment && hasFuncOrVar +} + +// isEmptyDocBlock reports whether only "/**", "*/", "*" and whitespace remain. +func isEmptyDocBlock(content string) bool { + r := strings.NewReplacer("/**", "", "*/", "", "*", "") + stripped := r.Replace(content) + return strings.TrimSpace(stripped) == "" +} + +// --- RemoveDeadParam: drop a @param line that carries only a name, no type --- + +var deadParamLineRe = regexp.MustCompile(`(?m)@(?:psalm-|phpstan-)?param\s+\$\w+\s*$`) + +type RemoveDeadParam struct{} + +func (RemoveDeadParam) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\RemoveDeadParamFixer` +} + +func (RemoveDeadParam) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/RemoveDeadParamFixer.php" +} + +func (RemoveDeadParam) Fix(s *tokens.Stream) bool { + return applyDocBlockFixer(s, func(content string) string { + return removeDocLinesMatching(content, deadParamLineRe) + }) +} + +// --- RemoveDeadVarThis: drop an inline @var line typing $this --- + +var deadVarThisLineRe = regexp.MustCompile(`@(?:psalm-|phpstan-)?var\b[^\n]*\$this\b`) + +type RemoveDeadVarThis struct{} + +func (RemoveDeadVarThis) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\RemoveDeadVarThisFixer` +} + +func (RemoveDeadVarThis) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/RemoveDeadVarThisFixer.php" +} + +func (RemoveDeadVarThis) Fix(s *tokens.Stream) bool { + return applyDocBlockFixer(s, func(content string) string { + return removeDocLinesMatching(content, deadVarThisLineRe) + }) +} + +// --- RemoveParamNameReference: drop "&" from a @param variable name --- + +var paramNameRefRe = regexp.MustCompile(`(@param.*?)&(\$\w+)`) + +type RemoveParamNameReference struct{} + +func (RemoveParamNameReference) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\RemoveParamNameReferenceFixer` +} + +func (RemoveParamNameReference) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/RemoveParamNameReferenceFixer.php" +} + +func (RemoveParamNameReference) Fix(s *tokens.Stream) bool { + return applyDocBlockFixer(s, func(content string) string { + return paramNameRefRe.ReplaceAllString(content, "$1$2") + }) +} + +// --- SwitchedTypeAndName: reorder "@param $name Type" -> "@param Type $name" --- + +var switchedTypeNameRe = regexp.MustCompile(`(?m)@((?:psalm-|phpstan-)?(?:param|var))(\s+)(\$\w+)(\s+)((?:[|\\\w\[\]]|<[^<>]*>)+)(\s+\S.*)?$`) +var typeSyntaxRe = regexp.MustCompile(`[\\\[\]<>|]`) + +var switchedKnownPrimitiveTypes = map[string]bool{ + "string": true, "int": true, "integer": true, "float": true, "bool": true, + "boolean": true, "array": true, "object": true, "callable": true, "iterable": true, + "mixed": true, "void": true, "null": true, "false": true, "true": true, "self": true, + "static": true, "parent": true, "resource": true, "scalar": true, "never": true, + "number": true, "double": true, +} + +type SwitchedTypeAndName struct{} + +func (SwitchedTypeAndName) Name() string { + return `Symplify\CodingStandard\Fixer\Commenting\SwitchedTypeAndNameFixer` +} + +func (SwitchedTypeAndName) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/SwitchedTypeAndNameFixer.php" +} + +func (SwitchedTypeAndName) Fix(s *tokens.Stream) bool { + return applyDocBlockFixer(s, func(content string) string { + lines := splitDocLines(content) + changed := false + for i, l := range lines { + m := switchedTypeNameRe.FindStringSubmatch(l) + if m == nil || m[3] == "" || m[5] == "" || !switchedIsKnownType(m[5]) { + continue + } + lines[i] = switchedTypeNameRe.ReplaceAllString(l, "@${1}${2}${5}${4}${3}${6}") + changed = true + } + if !changed { + return content + } + return strings.Join(lines, "") + }) +} + +func switchedIsKnownType(t string) bool { + if typeSyntaxRe.MatchString(t) { + return true + } + return switchedKnownPrimitiveTypes[strings.ToLower(t)] +} + +// --- RemovePHPStormAnnotation: drop a "Created by PhpStorm" doc block --- + +var createdByPhpStormRe = regexp.MustCompile(`(?is)/\*\*\s+\*\s+Created by PHPStorm(.*?)\*/`) + +type RemovePHPStormAnnotation struct{} + +func (RemovePHPStormAnnotation) Name() string { + return `Symplify\CodingStandard\Fixer\Annotation\RemovePHPStormAnnotationFixer` +} + +func (RemovePHPStormAnnotation) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemovePHPStormAnnotationFixer.php" +} + +func (RemovePHPStormAnnotation) Fix(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.DocComment && t.Kind != token.Comment { + continue + } + if createdByPhpStormRe.ReplaceAllString(t.Value, "") == "" { + s.RemoveAt(i) + changed = true + } + } + return changed +} + +// --- shared name resolvers (mirror Symplify Naming resolvers) --- + +// methodNameAfter returns the name of the first named function after commentIdx. +func methodNameAfter(s *tokens.Stream, commentIdx int) string { + for i := commentIdx + 1; i < s.Len(); i++ { + if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "function") { + n := sigNext(s, i) + if n >= 0 && s.At(n).Kind == token.Ident { + return s.At(n).Value + } + } + } + return "" +} + +// propertyNameAfter returns the first variable after commentIdx. +func propertyNameAfter(s *tokens.Stream, commentIdx int) string { + for i := commentIdx + 1; i < s.Len(); i++ { + if s.At(i).Kind == token.Variable { + return s.At(i).Value + } + } + return "" +} + +var camelBoundaryRe = regexp.MustCompile(`([a-z])([A-Z])`) +var wordRe = regexp.MustCompile(`[a-zA-Z]+`) + +// resolveWords splits camelCase, lowercases and extracts word runs. +func resolveWords(value string) []string { + spaced := camelBoundaryRe.ReplaceAllString(value, "$1 $2") + return wordRe.FindAllString(strings.ToLower(spaced), -1) +} + +// --- RemovePropertyVariableNameDescription: drop trailing "$name" from @var --- + +var varTagRe = regexp.MustCompile(`@(?:psalm-|phpstan-)?var`) + +type RemovePropertyVariableNameDescription struct{} + +func (RemovePropertyVariableNameDescription) Name() string { + return `Symplify\CodingStandard\Fixer\Annotation\RemovePropertyVariableNameDescriptionFixer` +} + +func (RemovePropertyVariableNameDescription) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemovePropertyVariableNameDescriptionFixer.php" +} + +func (RemovePropertyVariableNameDescription) Fix(s *tokens.Stream) bool { + if !streamHasVariable(s) || !streamHasComment(s) { + return false + } + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.DocComment && t.Kind != token.Comment { + continue + } + propertyName := propertyNameAfter(s, i) + if propertyName == "" { + continue + } + if len(varTagRe.FindAllString(t.Value, -1)) != 1 { + continue + } + lines := strings.Split(t.Value, "\n") + lineChanged := false + suffix := " " + propertyName + for k, line := range lines { + if !strings.HasSuffix(line, suffix) || !varTagRe.MatchString(line) { + continue + } + lines[k] = strings.TrimRight(line[:len(line)-len(suffix)], " \t\n\v\f\r") + lineChanged = true + } + if lineChanged { + s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(lines, "\n")}) + changed = true + } + } + return changed +} + +// --- RemoveMethodNameDuplicateDescription --- + +var articlesRe = regexp.MustCompile(`(?i)\b(?:a|an|the)\b`) +var thirdPersonSRe = regexp.MustCompile(`^([\s*]*\w+?)s\b`) +var spaceNlRe = regexp.MustCompile(`[\s\n]+`) + +type RemoveMethodNameDuplicateDescription struct{} + +func (RemoveMethodNameDuplicateDescription) Name() string { + return `Symplify\CodingStandard\Fixer\Annotation\RemoveMethodNameDuplicateDescriptionFixer` +} + +func (RemoveMethodNameDuplicateDescription) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemoveMethodNameDuplicateDescriptionFixer.php" +} + +func (RemoveMethodNameDuplicateDescription) Fix(s *tokens.Stream) bool { + if !streamHasFunction(s) || !streamHasComment(s) { + return false + } + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.DocComment && t.Kind != token.Comment { + continue + } + methodName := methodNameAfter(s, i) + if methodName == "" { + continue + } + lines := strings.Split(t.Value, "\n") + var kept []string + lineChanged := false + for _, line := range lines { + l := articlesRe.ReplaceAllString(line, "") + l = thirdPersonSRe.ReplaceAllString(l, "$1") + spaceless := spaceNlRe.ReplaceAllString(l, "") + spaceless = strings.TrimRight(spaceless, ".!") + if isDuplicateDescription(spaceless, methodName) { + lineChanged = true + continue + } + kept = append(kept, line) + } + if lineChanged { + s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(kept, "\n")}) + changed = true + } + } + return changed +} + +func isDuplicateDescription(spaceless, methodName string) bool { + desc := strings.ToLower(strings.TrimLeft(spaceless, "*")) + mn := strings.ToLower(methodName) + if desc == mn { + return true + } + dn := getterSetterNoun(desc) + return dn != "" && dn == getterSetterNoun(mn) +} + +func getterSetterNoun(v string) string { + for _, p := range []string{"get", "set"} { + if strings.HasPrefix(v, p) && len(v) > len(p) { + return v[len(p):] + } + } + return "" +} + +// --- RemoveEventSubscriberDescription --- + +type RemoveEventSubscriberDescription struct{} + +func (RemoveEventSubscriberDescription) Name() string { + return `Symplify\CodingStandard\Fixer\Annotation\RemoveEventSubscriberDescriptionFixer` +} + +func (RemoveEventSubscriberDescription) SourceURL() string { + return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemoveEventSubscriberDescriptionFixer.php" +} + +func (RemoveEventSubscriberDescription) Fix(s *tokens.Stream) bool { + if !streamHasFunction(s) || !streamHasComment(s) { + return false + } + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.DocComment && t.Kind != token.Comment { + continue + } + fnIdx := nextFunctionIndex(s, i) + if fnIdx < 0 || !isPublicBetween(s, i, fnIdx) { + continue + } + methodName := methodNameAfter(s, i) + if methodName == "" { + continue + } + lines := strings.Split(t.Value, "\n") + var kept []string + lineChanged := false + for _, line := range lines { + if isEventDescriptionLine(line, methodName) { + lineChanged = true + continue + } + kept = append(kept, line) + } + if !lineChanged { + continue + } + if isEmptyDocblockLines(kept) { + s.RemoveAt(i) + } else { + s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(kept, "\n")}) + } + changed = true + } + return changed +} + +func nextFunctionIndex(s *tokens.Stream, commentIdx int) int { + for i := commentIdx + 1; i < s.Len(); i++ { + if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "function") { + return i + } + } + return -1 +} + +func isPublicBetween(s *tokens.Stream, commentIdx, fnIdx int) bool { + for i := commentIdx + 1; i < fnIdx; i++ { + if s.At(i).Kind == token.Keyword { + lv := strings.ToLower(s.At(i).Value) + if lv == "private" || lv == "protected" { + return false + } + } + } + return true +} + +func isEventDescriptionLine(line, methodName string) bool { + words := resolveWords(line) + hasEvent := false + var descWords []string + for _, w := range words { + if w == "event" { + hasEvent = true + continue + } + descWords = append(descWords, w) + } + if !hasEvent || len(descWords) == 0 { + return false + } + var methodWords []string + for _, w := range resolveWords(methodName) { + if w != "on" { + methodWords = append(methodWords, w) + } + } + sortStrings(descWords) + sortStrings(methodWords) + return equalStrings(descWords, methodWords) +} + +func isEmptyDocblockLines(lines []string) bool { + joined := strings.Join(lines, "") + for _, r := range joined { + if r != '/' && r != '*' && r != ' ' && r != '\t' && r != '\n' && r != '\r' && r != '\v' && r != '\f' { + return false + } + } + return true +} + +func sortStrings(a []string) { + for i := 1; i < len(a); i++ { + for j := i; j > 0 && a[j] < a[j-1]; j-- { + a[j], a[j-1] = a[j-1], a[j] + } + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func streamHasFunction(s *tokens.Stream) bool { + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "function") { + return true + } + } + return false +} + +func streamHasVariable(s *tokens.Stream) bool { + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind == token.Variable { + return true + } + } + return false +} + +func streamHasComment(s *tokens.Stream) bool { + for i := 0; i < s.Len(); i++ { + if k := s.At(i).Kind; k == token.DocComment || k == token.Comment { + return true + } + } + return false +} + +// removeDocLinesMatching removes each doc line whose content matches re, +// mirroring DocBlock getLines()->remove(). +func removeDocLinesMatching(content string, re *regexp.Regexp) string { + lines := splitDocLines(content) + changed := false + for i, l := range lines { + if re.MatchString(l) { + lines[i] = "" + changed = true + } + } + if !changed { + return content + } + return strings.Join(lines, "") +} diff --git a/blink/internal/fixer/rules/symplify_docblock_test.go b/blink/internal/fixer/rules/symplify_docblock_test.go new file mode 100644 index 0000000000..3ca776d62d --- /dev/null +++ b/blink/internal/fixer/rules/symplify_docblock_test.go @@ -0,0 +1,112 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +// TestDoubleAsteriskInlineVar promotes a single-asterisk inline /* @var */ to a docblock. +func TestDoubleAsteriskInlineVar(t *testing.T) { + got, changed := apply(t, DoubleAsteriskInlineVar{}, " "$a ? $b : $c"). The elvis "?:" keeps the "?" and +// ":" adjacent (one space before "?", one after ":"). Nullable types ("?int") +// and null-coalescing ("??", "?->") are left untouched. +type TernaryOperatorSpaces struct{} + +func (TernaryOperatorSpaces) Name() string { + return `PhpCsFixer\Fixer\Operator\TernaryOperatorSpacesFixer` +} + +func (TernaryOperatorSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/TernaryOperatorSpacesFixer.php" +} + +// sides records whether the before and after side of an operator token needs a +// single space. +type sides struct{ before, after bool } + +func (TernaryOperatorSpaces) Fix(s *tokens.Stream) bool { + mark := map[int]sides{} + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "?" { + continue + } + // a "?" in type position (return type ": ?int", param "(?int $x)", + // union "int|?string") is a nullable marker, never a ternary + if prev, ok := prevSignificant(s, i); ok { + switch prev.Value { + case ":", "(", ",", "|", "&": + continue + } + } + m := matchTernaryColon(s, i) + if m < 0 { + continue // nullable type or coalescing "?": not a ternary + } + if ternaryIsElvis(s, i, m) { + mark[i] = sides{before: true} + mark[m] = sides{after: true} + } else { + mark[i] = sides{before: true, after: true} + mark[m] = sides{before: true, after: true} + } + } + if len(mark) == 0 { + return false + } + + changed := false + // Right-to-left so insertions at higher indices never shift a token that a + // lower original index still refers to. + for i := s.Len() - 1; i >= 0; i-- { + sp, ok := mark[i] + if !ok { + continue + } + if sp.after && i+1 < s.Len() { + next := s.At(i + 1) + if next.Kind == token.Whitespace { + if !hasNewline(next.Value) && next.Value != " " { + s.SetValue(i+1, " ") + changed = true + } + } else { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + } + if sp.before && i > 0 { + prev := s.At(i - 1) + if prev.Kind == token.Whitespace { + if !hasNewline(prev.Value) && prev.Value != " " { + s.SetValue(i-1, " ") + changed = true + } + } else { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + } + } + return changed +} + +// matchTernaryColon returns the index of the ":" matching the ternary "?" at i, +// or -1 when there is none (making the "?" a nullable-type marker). Nested +// ternaries and bracketed groups are skipped; the search stops at the end of the +// statement. +func matchTernaryColon(s *tokens.Stream, i int) int { + depth := 0 // nested ternary depth + bracket := 0 // (), [], {} nesting + for j := i + 1; j < s.Len(); j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "{": + if bracket == 0 { + return -1 // a ternary cannot span a block; "?" was a type marker + } + bracket++ + case "(", "[": + bracket++ + case ")", "]", "}": + if bracket == 0 { + return -1 // closes an enclosing group before any ternary ":" + } + bracket-- + case ";": + if bracket == 0 { + return -1 // statement ended, no ternary + } + case "?": + if bracket == 0 { + depth++ + } + case ":": + if bracket == 0 { + if depth == 0 { + return j + } + depth-- + } + } + } + return -1 +} + +// ternaryIsElvis reports whether the "?" at i and its colon at m form an elvis +// operator, i.e. there is no operand between them. +func ternaryIsElvis(s *tokens.Stream, i, m int) bool { + for k := i + 1; k < m; k++ { + if s.At(k).Kind != token.Whitespace { + return false + } + } + return true +} diff --git a/blink/internal/fixer/rules/ternary_to_null_coalescing.go b/blink/internal/fixer/rules/ternary_to_null_coalescing.go new file mode 100644 index 0000000000..cdf8e637d7 --- /dev/null +++ b/blink/internal/fixer/rules/ternary_to_null_coalescing.go @@ -0,0 +1,177 @@ +package rules + +import ( + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/TernaryToNullCoalescingFixer.php +// +// TernaryToNullCoalescing rewrites "isset($a) ? $a : $b" as "$a ?? $b" when the +// single isset argument is exactly the ternary's true branch. Multi-argument +// isset, a mismatched true branch, or a span containing comments are left alone. +type TernaryToNullCoalescing struct{} + +func (TernaryToNullCoalescing) Name() string { + return `PhpCsFixer\Fixer\Operator\TernaryToNullCoalescingFixer` +} + +func (TernaryToNullCoalescing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/TernaryToNullCoalescingFixer.php" +} + +func (TernaryToNullCoalescing) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "isset" { + continue + } + b := sigNext(s, i) + if b < 0 || s.At(b).Kind != token.Punct || s.At(b).Value != "(" { + continue + } + c := s.MatchForward(b) + if c < 0 { + continue + } + d := sigNext(s, c) + if d < 0 || s.At(d).Kind != token.Punct || s.At(d).Value != "?" { + continue // not a ternary (also skips "??" and "?->", which are one token) + } + e := ternaryColon(s, d+1) + if e < 0 || sigNext(s, e) < 0 { + continue // no matching ":" or no default branch + } + if !spanClean(s, i, e) { + continue // a comment inside the span would be dropped + } + if hasTopLevelComma(s, b+1, c-1) { + continue // isset($a, $b) - not convertible + } + issetToks := sigSlice(s, b+1, c-1) + trueToks := sigSlice(s, d+1, e-1) + if len(issetToks) == 0 || !tokensEqualAt(s, issetToks, trueToks) { + continue + } + // replace "isset ( expr ) ? expr :" with "expr ?? " + repl := trimmedCopy(s, b+1, c-1) + repl = append(repl, + token.Token{Kind: token.Whitespace, Value: " "}, + token.Token{Kind: token.Punct, Value: "??"}, + token.Token{Kind: token.Whitespace, Value: " "}, + ) + end := e + if e+1 < s.Len() && s.At(e+1).Kind == token.Whitespace { + end = e + 1 // swallow the space after ":" so spacing stays single + } + s.ReplaceRange(i, end, repl) + changed = true + } + return changed +} + +// ternaryColon returns the index of the ":" that closes the ternary begun by a +// "?" before `from`, at bracket depth 0 and accounting for nested "?:". +func ternaryColon(s *tokens.Stream, from int) int { + depth := 0 + tern := 0 + for j := from; j < s.Len(); j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + if depth == 0 { + return -1 + } + depth-- + case "?": + if depth == 0 { + tern++ + } + case ":": + if depth == 0 { + if tern == 0 { + return j + } + tern-- + } + case ";": + if depth == 0 { + return -1 + } + } + } + return -1 +} + +// hasTopLevelComma reports whether [lo, hi] holds a comma at bracket depth 0. +func hasTopLevelComma(s *tokens.Stream, lo, hi int) bool { + depth := 0 + for j := lo; j <= hi && j < s.Len(); j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ",": + if depth == 0 { + return true + } + } + } + return false +} + +// sigSlice returns the indices of the significant (non-trivia) tokens in [lo, hi]. +func sigSlice(s *tokens.Stream, lo, hi int) []int { + var out []int + for j := lo; j <= hi && j < s.Len(); j++ { + switch s.At(j).Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + } + out = append(out, j) + } + return out +} + +// tokensEqualAt reports whether two significant-token index slices carry the +// same kinds and values. +func tokensEqualAt(s *tokens.Stream, a, b []int) bool { + if len(a) != len(b) { + return false + } + for k := range a { + ta, tb := s.At(a[k]), s.At(b[k]) + if ta.Kind != tb.Kind || ta.Value != tb.Value { + return false + } + } + return true +} + +// trimmedCopy deep-copies the tokens in [lo, hi], dropping edge whitespace. +func trimmedCopy(s *tokens.Stream, lo, hi int) []token.Token { + for lo <= hi && s.At(lo).Kind == token.Whitespace { + lo++ + } + for hi >= lo && s.At(hi).Kind == token.Whitespace { + hi-- + } + out := make([]token.Token, 0, hi-lo+1) + for j := lo; j <= hi; j++ { + out = append(out, s.At(j)) + } + return out +} diff --git a/blink/internal/fixer/rules/ternary_to_null_coalescing_test.go b/blink/internal/fixer/rules/ternary_to_null_coalescing_test.go new file mode 100644 index 0000000000..77db49d388 --- /dev/null +++ b/blink/internal/fixer/rules/ternary_to_null_coalescing_test.go @@ -0,0 +1,25 @@ +package rules + +import "testing" + +func TestTernaryToNullCoalescing(t *testing.T) { + f := TernaryToNullCoalescing{} + + cases := []struct { + src, want string + changed bool + }{ + {`p) ? $o->p : 5;`, `p ?? 5;`, true}, + {`sizeof($a); Foo\join($x); Bar::join($y);`); changed { + t.Fatal("method/namespaced/static must not change") + } + if _, changed := apply(t, NoAliasFunctions{}, `= 2 && (v[0] == '\'' || v[0] == '"') && v[len(v)-1] == v[0] { + return v[0] + } + return 0 +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/AttributeNotation/AttributeBlockNoSpacesFixer.php +// +// AttributeBlockNoSpaces trims spaces just inside the outer brackets of a PHP 8 +// attribute: "#[ Foo ]" -> "#[Foo]". Inner content is left intact. +type AttributeBlockNoSpaces struct{} + +func (AttributeBlockNoSpaces) Name() string { + return `PhpCsFixer\Fixer\AttributeNotation\AttributeBlockNoSpacesFixer` +} + +func (AttributeBlockNoSpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/AttributeNotation/AttributeBlockNoSpacesFixer.php" +} + +func (AttributeBlockNoSpaces) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Comment || !strings.HasPrefix(t.Value, "#[") || !strings.HasSuffix(t.Value, "]") { + continue + } + inner := t.Value[2 : len(t.Value)-1] + trimmed := strings.TrimRight(strings.TrimLeft(inner, " \t"), " \t") + if trimmed == inner { + continue + } + s.SetValue(i, "#["+trimmed+"]") + changed = true + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoSpaceAroundDoubleColonFixer.php +// +// NoSpaceAroundDoubleColon removes single-line whitespace around a "::" operator +// ("Foo :: bar" -> "Foo::bar"). Whitespace spanning a newline is kept. +type NoSpaceAroundDoubleColon struct{} + +func (NoSpaceAroundDoubleColon) Name() string { + return `PhpCsFixer\Fixer\Operator\NoSpaceAroundDoubleColonFixer` +} + +func (NoSpaceAroundDoubleColon) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoSpaceAroundDoubleColonFixer.php" +} + +func (NoSpaceAroundDoubleColon) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "::" { + continue + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } + if i-1 >= 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + i-- + changed = true + } + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/TrimArraySpacesFixer.php +// +// TrimArraySpaces removes single-line whitespace just inside an array-literal +// "[" ... "]" ("[ 1, 2 ]" -> "[1, 2]"). Offset access is left alone. Long +// "array( ... )" syntax is not handled (short "[]" literals only). +type TrimArraySpaces struct{} + +func (TrimArraySpaces) Name() string { + return `PhpCsFixer\Fixer\ArrayNotation\TrimArraySpacesFixer` +} + +func (TrimArraySpaces) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/TrimArraySpacesFixer.php" +} + +func (TrimArraySpaces) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "[" || isOffsetOpen(s, i) { + continue + } + closeIdx := s.MatchForward(i) + if closeIdx < 0 { + continue + } + if closeIdx-1 > i && s.At(closeIdx-1).Kind == token.Whitespace && !hasNewline(s.At(closeIdx-1).Value) { + s.RemoveAt(closeIdx - 1) + changed = true + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } + } + return changed +} + +// nativeTypeNames are the built-in type names lowercased in type declarations. +// Every entry is a reserved word, so none can name a user class or constant. +var nativeTypeNames = map[string]bool{ + "int": true, "string": true, "bool": true, "float": true, "void": true, + "array": true, "iterable": true, "object": true, "mixed": true, "null": true, + "false": true, "true": true, "never": true, "callable": true, "self": true, + "parent": true, "static": true, +} + +// typeContextPrev reports whether the significant token before i marks a type +// position (a signature/property/return-type boundary). +func typeContextPrev(s *tokens.Stream, i int) bool { + p := prevSignificantIndex(s, i) + if p < 0 { + return false + } + pt := s.At(p) + if pt.Kind == token.Punct { + switch pt.Value { + case "(", ",", "|", "?", "&", ":": + return true + } + return false + } + if pt.Kind == token.Keyword { + switch strings.ToLower(pt.Value) { + case "public", "private", "protected", "static", "readonly", "var": + return true + } + } + return false +} + +// typeContextNext reports whether the significant token after i is consistent +// with a type declaration (a variable, a union/intersection, or a body/end). +func typeContextNext(s *tokens.Stream, i int) bool { + n := nextSignificantIndex(s, i) + if n < 0 { + return false + } + nt := s.At(n) + if nt.Kind == token.Variable { + return true + } + if nt.Kind == token.Punct { + switch nt.Value { + case "|", "&", "{", ";": + return true + } + } + return false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeTypeDeclarationCasingFixer.php +// +// NativeTypeDeclarationCasing lowercases native type names used in type +// declarations (parameters, properties, return types). It only ever touches +// reserved words, which cannot name a class, and only in a type position. +type NativeTypeDeclarationCasing struct{} + +func (NativeTypeDeclarationCasing) Name() string { + return `PhpCsFixer\Fixer\Casing\NativeTypeDeclarationCasingFixer` +} + +func (NativeTypeDeclarationCasing) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeTypeDeclarationCasingFixer.php" +} + +func (NativeTypeDeclarationCasing) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Ident && t.Kind != token.Keyword { + continue + } + lower := strings.ToLower(t.Value) + if !nativeTypeNames[lower] || t.Value == lower { + continue + } + if !typeContextPrev(s, i) || !typeContextNext(s, i) { + continue + } + s.SetValue(i, lower) + changed = true + } + return changed +} + +// isLabelByte reports whether c is valid in a heredoc/nowdoc label. +func isLabelByte(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} + +// heredocToNowdoc converts a heredoc token value to a nowdoc by quoting the +// label, when the label is unquoted and the body has no interpolation ("$" or +// "{") and no backslash escapes. It returns the new value and whether it changed. +func heredocToNowdoc(v string) (string, bool) { + if !strings.HasPrefix(v, "<<<") { + return v, false + } + i := 3 + for i < len(v) && (v[i] == ' ' || v[i] == '\t') { + i++ + } + if i < len(v) && (v[i] == '\'' || v[i] == '"') { + return v, false // already quoted (nowdoc or explicit heredoc) + } + labelStart := i + for i < len(v) && isLabelByte(v[i]) { + i++ + } + if i == labelStart { + return v, false + } + labelEnd := i + _, after, ok := strings.Cut(v, "\n") + if !ok { + return v, false + } + body := after + if strings.ContainsAny(body, "$\\") || strings.Contains(body, "{") { + return v, false + } + return v[:labelStart] + "'" + v[labelStart:labelEnd] + "'" + v[labelEnd:], true +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/HeredocToNowdocFixer.php +// +// HeredocToNowdoc converts a heredoc with no interpolation or escapes into a +// nowdoc by quoting its label ("<< "<<<'EOT'"). +type HeredocToNowdoc struct{} + +func (HeredocToNowdoc) Name() string { + return `PhpCsFixer\Fixer\StringNotation\HeredocToNowdocFixer` +} + +func (HeredocToNowdoc) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/HeredocToNowdocFixer.php" +} + +func (HeredocToNowdoc) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.String || !strings.HasPrefix(t.Value, "<<<") { + continue + } + if out, ok := heredocToNowdoc(t.Value); ok { + s.SetValue(i, out) + changed = true + } + } + return changed +} + +// rangeHasNewline reports whether any whitespace token strictly between lo and +// hi spans a newline. +func rangeHasNewline(s *tokens.Stream, lo, hi int) bool { + for j := lo + 1; j < hi; j++ { + if s.At(j).Kind == token.Whitespace && hasNewline(s.At(j).Value) { + return true + } + } + return false +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessConcatOperatorFixer.php +// +// NoUselessConcatOperator merges two adjacent string literals joined by "." into +// one when they share a quote style and neither interpolates ("'a' . 'b'" -> +// "'ab'"). Merges across a newline are left alone. +type NoUselessConcatOperator struct{} + +func (NoUselessConcatOperator) Name() string { + return `PhpCsFixer\Fixer\Operator\NoUselessConcatOperatorFixer` +} + +func (NoUselessConcatOperator) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessConcatOperatorFixer.php" +} + +func (NoUselessConcatOperator) Fix(s *tokens.Stream) bool { + changed := false + i := 0 + for i < s.Len() { + t := s.At(i) + if t.Kind != token.String { + i++ + continue + } + q := stringQuote(t.Value) + if q == 0 || (q == '"' && strings.Contains(t.Value, "$")) { + i++ + continue + } + j := nextSignificantIndex(s, i) + if j < 0 || s.At(j).Kind != token.Punct || s.At(j).Value != "." { + i++ + continue + } + k := nextSignificantIndex(s, j) + if k < 0 || s.At(k).Kind != token.String { + i++ + continue + } + next := s.At(k).Value + if stringQuote(next) != q || (q == '"' && strings.Contains(next, "$")) { + i++ + continue + } + if rangeHasNewline(s, i, k) { + i++ + continue + } + s.SetValue(i, t.Value[:len(t.Value)-1]+next[1:]) + for idx := k; idx > i; idx-- { + s.RemoveAt(idx) + } + changed = true + // stay at i so chained literals ("'a'.'b'.'c'") collapse fully + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/NoBinaryStringFixer.php +// +// NoBinaryString drops the binary-string prefix "b"/"B" (b"x" -> "x"). The +// lexer emits the prefix as a separate identifier directly before the string. +type NoBinaryString struct{} + +func (NoBinaryString) Name() string { + return `PhpCsFixer\Fixer\StringNotation\NoBinaryStringFixer` +} + +func (NoBinaryString) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/NoBinaryStringFixer.php" +} + +func (NoBinaryString) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Ident || (t.Value != "b" && t.Value != "B") { + continue + } + if i+1 >= s.Len() || s.At(i+1).Kind != token.String { + continue + } + if prev, ok := prevSignificant(s, i); ok { + switch prev.Value { + case "->", "?->", "::", `\`: + continue + } + } + s.RemoveAt(i) + i-- + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/token_batch_test.go b/blink/internal/fixer/rules/token_batch_test.go new file mode 100644 index 0000000000..0779682054 --- /dev/null +++ b/blink/internal/fixer/rules/token_batch_test.go @@ -0,0 +1,137 @@ +package rules + +import "testing" + +func TestAttributeBlockNoSpaces(t *testing.T) { + got, changed := apply(t, AttributeBlockNoSpaces{}, " open && (s.At(p).Kind == token.Whitespace || + s.At(p).Kind == token.Comment || s.At(p).Kind == token.DocComment) { + p-- + } + if p <= open { + continue // empty + } + if s.At(p).Value == "," || s.At(p).Value == "..." { + continue // already trailing, or a spread (no trailing comma after it) + } + // multi-line only: a newline sits between the last element and the close + multiline := false + for k := p + 1; k < i; k++ { + if s.At(k).Kind == token.Whitespace && hasNewline(s.At(k).Value) { + multiline = true + break + } + } + if !multiline { + continue + } + s.InsertAt(p+1, token.Token{Kind: token.Punct, Value: ","}) + changed = true + } + return changed +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoTrailingCommaInSinglelineFixer.php +// +// NoTrailingCommaInSingleline removes a trailing comma before a single-line +// closing ")", "]" or "}". +type NoTrailingCommaInSingleline struct{} + +func (NoTrailingCommaInSingleline) Name() string { + return `PhpCsFixer\Fixer\Basic\NoTrailingCommaInSinglelineFixer` +} + +func (NoTrailingCommaInSingleline) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoTrailingCommaInSinglelineFixer.php" +} + +func (NoTrailingCommaInSingleline) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "," { + continue + } + j := i + 1 + for j < s.Len() && s.At(j).Kind == token.Whitespace { + if hasNewline(s.At(j).Value) { + j = -1 + break + } + j++ + } + if j < 0 || j >= s.Len() { + continue + } + if v := s.At(j).Value; v == ")" || v == "]" || v == "}" { + s.RemoveAt(i) + i-- + changed = true + } + } + return changed +} diff --git a/blink/internal/fixer/rules/visibility_required.go b/blink/internal/fixer/rules/visibility_required.go new file mode 100644 index 0000000000..05cd1f8908 --- /dev/null +++ b/blink/internal/fixer/rules/visibility_required.go @@ -0,0 +1,87 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +var memberModifiers = map[string]bool{ + "public": true, "private": true, "protected": true, "static": true, + "abstract": true, "final": true, "readonly": true, "var": true, +} + +var visibilityModifiers = map[string]bool{ + "public": true, "private": true, "protected": true, +} + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/VisibilityRequiredFixer.php +// +// VisibilityRequired adds an explicit "public" to class methods, properties and +// constants that declare no visibility. "var $x" becomes "public $x". Trait use +// and enum cases are left alone. +type VisibilityRequired struct{} + +func (VisibilityRequired) Name() string { + return `PhpCsFixer\Fixer\ClassNotation\VisibilityRequiredFixer` +} + +func (VisibilityRequired) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/VisibilityRequiredFixer.php" +} + +func (VisibilityRequired) Fix(s *tokens.Stream) bool { + changed := false + for i := 0; i < s.Len(); i++ { + if s.At(i).Kind != token.Punct || s.At(i).Value != "{" { + continue + } + if kind, _ := classifyBrace(s, i); kind != braceClassLike { + continue + } + // process back to front so insertions do not shift earlier starts + for _, m := range slices.Backward(classMemberStarts(s, i)) { + if addVisibility(s, m) { + changed = true + } + } + } + return changed +} + +func addVisibility(s *tokens.Stream, m int) bool { + hasVisibility := false + varIdx := -1 + for k := m; k < s.Len(); { + t := s.At(k) + if t.Kind != token.Keyword { + break // a type, "?", or "$var": this is a property declaration + } + lw := strings.ToLower(t.Value) + if lw == "use" || lw == "case" { + return false // trait use / enum case - not a visibility target + } + if !memberModifiers[lw] { + break // "function" or "const": the member declaration begins here + } + if visibilityModifiers[lw] { + hasVisibility = true + } + if lw == "var" { + varIdx = k + } + k = skipWhitespace(s, k+1) + } + if hasVisibility { + return false + } + if varIdx >= 0 { + s.SetValue(varIdx, "public") + return true + } + s.InsertAt(m, token.Token{Kind: token.Keyword, Value: "public"}) + s.InsertAt(m+1, token.Token{Kind: token.Whitespace, Value: " "}) + return true +} diff --git a/blink/internal/fixer/rules/yoda_style.go b/blink/internal/fixer/rules/yoda_style.go new file mode 100644 index 0000000000..8a3bb48513 --- /dev/null +++ b/blink/internal/fixer/rules/yoda_style.go @@ -0,0 +1,256 @@ +package rules + +import ( + "slices" + "strings" + + "blink/internal/token" + "blink/internal/tokens" +) + +// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/YodaStyleFixer.php +// +// YodaStyle here follows ECS's common-set configuration (equal=false, +// identical=false, less_and_greater=false): non-yoda. When a constant is on the +// left of an == / === / != / !== comparison and a variable expression on the +// right, they are swapped so the variable comes first ("null === $x" -> "$x === +// null"). "<"/">" comparisons are left untouched. +type YodaStyle struct{} + +func (YodaStyle) Name() string { + return `PhpCsFixer\Fixer\ControlStructure\YodaStyleFixer` +} + +func (YodaStyle) SourceURL() string { + return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/YodaStyleFixer.php" +} + +func yodaOp(v string) bool { + switch v { + case "==", "===", "!=", "!==", "<", ">", "<=", ">=": + return true + } + return false +} + +// yodaMirror flips a relational operator when its operands are swapped, so +// "5 > $z" becomes "$z < 5". Equality operators are symmetric and unchanged. +func yodaMirror(v string) string { + switch v { + case "<": + return ">" + case ">": + return "<" + case "<=": + return ">=" + case ">=": + return "<=" + } + return v +} + +func isYodaLiteral(t token.Token) bool { + switch t.Kind { + case token.Number, token.String: + return true + case token.Ident: + return true + case token.Keyword: + switch strings.ToLower(t.Value) { + case "true", "false", "null": + return true + } + } + return false +} + +func (YodaStyle) Fix(s *tokens.Stream) bool { + type swap struct{ ls, le, rs, re int } + var swaps []swap + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Punct || !yodaOp(t.Value) { + continue + } + // left must be a bounded constant + ls, le, ok := leftLiteralOperand(s, i) + if !ok || !isLeftBoundary(s, prevSignificantIndex(s, ls)) { + continue + } + // right must be a variable expression (not itself a constant), bounded + rs := nextSignificantIndex(s, i) + if rs < 0 { + continue + } + re := rightPrimaryEnd(s, rs) + if re < 0 { + continue + } + if rs == re && isYodaLiteral(s.At(rs)) { + continue // both sides constant + } + if !isRightBoundary(s, nextSignificantIndex(s, re)) { + continue + } + swaps = append(swaps, swap{ls, le, rs, re}) + } + if len(swaps) == 0 { + return false + } + for _, sw := range slices.Backward(swaps) { + left := append([]token.Token(nil), s.Tokens()[sw.ls:sw.le+1]...) + mid := append([]token.Token(nil), s.Tokens()[sw.le+1:sw.rs]...) + right := append([]token.Token(nil), s.Tokens()[sw.rs:sw.re+1]...) + for k := range mid { + if mid[k].Kind == token.Punct { + mid[k].Value = yodaMirror(mid[k].Value) + } + } + repl := append(append(append([]token.Token(nil), right...), mid...), left...) + s.ReplaceRange(sw.ls, sw.re, repl) + } + return true +} + +// leftLiteralOperand returns the span of a constant left operand ending just +// before the comparison at op: a plain literal, signed number, empty array, +// bare constant, or "Name::class". +func leftLiteralOperand(s *tokens.Stream, op int) (int, int, bool) { + le := prevSignificantIndex(s, op) + if le < 0 { + return 0, 0, false + } + t := s.At(le) + // "]" of an empty array "[]" + if t.Kind == token.Punct && t.Value == "]" { + o := s.MatchBackward(le) + if o >= 0 && nextSignificantIndex(s, o) == le { + return o, le, true + } + return 0, 0, false + } + // "class" of "Name::class" + if t.Kind == token.Ident && strings.EqualFold(t.Value, "class") { + p := prevSignificantIndex(s, le) + if p >= 0 && s.At(p).Value == "::" { + n := prevSignificantIndex(s, p) + if n >= 0 && s.At(n).Kind == token.Ident { + return n, le, true + } + } + return 0, 0, false + } + if isYodaLiteral(t) { + // signed number "-1" + if t.Kind == token.Number { + p := prevSignificantIndex(s, le) + if p >= 0 && s.At(p).Kind == token.Punct && (s.At(p).Value == "-" || s.At(p).Value == "+") { + // only treat as sign when what precedes the sign is a boundary + if isLeftBoundary(s, prevSignificantIndex(s, p)) { + return p, le, true + } + } + } + return le, le, true + } + return 0, 0, false +} + +// rightPrimaryEnd walks right from rs over a primary expression (name/variable, +// member and static access, matched call/index groups) and returns its last +// token index, or -1. +func rightPrimaryEnd(s *tokens.Stream, rs int) int { + if !isPrimaryStart(s.At(rs)) { + return -1 + } + end := rs + for { + n := nextSignificantIndex(s, end) + if n < 0 { + return end + } + nt := s.At(n) + if nt.Kind == token.Punct { + switch nt.Value { + case "->", "?->", "::", `\`: + m := nextSignificantIndex(s, n) + if m < 0 { + return end + } + end = m + continue + case "(", "[": + c := s.MatchForward(n) + if c < 0 { + return end + } + end = c + continue + } + } + return end + } +} + +// isPrimaryStart reports whether t can begin a primary (variable) expression. +func isPrimaryStart(t token.Token) bool { + switch t.Kind { + case token.Variable, token.Ident: + return true + case token.Keyword: + return isPrimaryKeyword(t.Value) + case token.Punct: + return t.Value == `\` + } + return false +} + +func isPrimaryKeyword(v string) bool { + switch strings.ToLower(v) { + case "static", "self", "parent": + return true + } + return false +} + +func isLeftBoundary(s *tokens.Stream, p int) bool { + if p < 0 { + return true + } + t := s.At(p) + if t.Kind == token.Punct { + switch t.Value { + case "(", "[", "{", ",", ";", "&&", "||", "?", "??", ":", "=", "!", "=>", ".": + return true + } + return false + } + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "return", "and", "or", "xor", "echo", "print", "case", "if", "elseif", "while": + return true + } + } + return false +} + +func isRightBoundary(s *tokens.Stream, j int) bool { + if j < 0 { + return true + } + t := s.At(j) + if t.Kind == token.Punct { + switch t.Value { + case ")", "]", "}", ";", ",", ":", "&&", "||", "?", "??", ".", "=>": + return true + } + return false + } + if t.Kind == token.Keyword { + switch strings.ToLower(t.Value) { + case "and", "or", "xor": + return true + } + } + return false +} diff --git a/blink/internal/fixer/rules/yoda_style_test.go b/blink/internal/fixer/rules/yoda_style_test.go new file mode 100644 index 0000000000..4b408e26d9 --- /dev/null +++ b/blink/internal/fixer/rules/yoda_style_test.go @@ -0,0 +1,47 @@ +package rules + +import ( + "testing" + + "blink/internal/fixer" +) + +func TestYodaStyle(t *testing.T) { + f := YodaStyle{} + + // constant on the left moves to the right (non-yoda, ECS common config) + assertFix(t, f, "pending) {}", "pending !== null) {}", true) + assertFix(t, f, "resolved($a)) {}", "resolved($a) === true) {}", true) + + // constant name, ::class, signed number, empty array on the left + assertFix(t, f, " <= >= are de-yoda'd with the operator flipped + assertFix(t, f, " $x) {}", "= $y;", " boundary) is de-yoda'd too + assertFix(t, f, " 1,\n};", " 1,\n};", true) + + // already non-yoda - no-op + assertFix(t, f, "html ?? '') === '';", "html ?? '') === '';", false) +} + +func TestYodaStyleSourceURL(t *testing.T) { + f := YodaStyle{} + if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want { + t.Fatalf("SourceURL %q, want %q", got, want) + } +} diff --git a/blink/internal/lexer/lexer.go b/blink/internal/lexer/lexer.go new file mode 100644 index 0000000000..c294b26545 --- /dev/null +++ b/blink/internal/lexer/lexer.go @@ -0,0 +1,411 @@ +// Package lexer is a small, self-contained PHP tokenizer producing a flat, +// lossless token stream. It mirrors the shape of PHP's token_get_all rather +// than building an AST, so fixers can walk and mutate tokens by index. +// +// It classifies keywords vs names, emits multi-char operators (=> -> :: === ...) +// as single Punct tokens, and captures heredoc/nowdoc as one opaque String +// token. It is a pragmatic subset, not a full PHP grammar: string interpolation +// is not split and casts are not tokenized. Enough for coding-standard fixers. +package lexer + +import ( + "slices" + "strings" + + "blink/internal/token" +) + +// Lex converts source into a flat token slice. Concatenating the Value of each +// returned token reproduces src exactly. +func Lex(src string) []token.Token { + // most tokens are a few bytes; presize to avoid repeated slice regrowth + l := &lexer{src: src, toks: make([]token.Token, 0, len(src)/3+8)} + l.run() + return l.toks +} + +type lexer struct { + src string + pos int + inPHP bool + toks []token.Token +} + +func (l *lexer) emit(k token.Kind, start int) { + l.toks = append(l.toks, token.Token{Kind: k, Value: l.src[start:l.pos], Pos: start}) +} + +func (l *lexer) run() { + for l.pos < len(l.src) { + if l.inPHP { + l.lexPHP() + } else { + l.lexHTML() + } + } +} + +func (l *lexer) lexHTML() { + start := l.pos + for { + idx := strings.Index(l.src[l.pos:], " start { + l.toks = append(l.toks, token.Token{Kind: token.InlineHTML, Value: l.src[start:l.pos], Pos: start}) + } + if l.pos < len(l.src) { + l.lexOpenTag() + l.inPHP = true + } +} + +// isOpenTagHere reports whether l.pos starts a real PHP open tag. "= 3 && strings.EqualFold(rest[:3], "xml") { + return false + } + return true +} + +func (l *lexer) lexOpenTag() { + start := l.pos + switch { + case l.hasPrefix(""): + l.pos += 2 + l.emit(token.CloseTag, start) + l.inPHP = false + case l.hasPrefix("<<<"): + if end := l.scanHeredoc(); end > l.pos { + l.pos = end + l.emit(token.String, start) + } else { + if op := l.matchOperator(); op > 0 { + l.pos += op + } else { + l.pos++ + } + l.emit(token.Punct, start) + } + case isSpace(c): + for l.pos < len(l.src) && isSpace(l.src[l.pos]) { + l.pos++ + } + l.emit(token.Whitespace, start) + case c == '$': + l.pos++ + for l.pos < len(l.src) && isIdent(l.src[l.pos]) { + l.pos++ + } + l.emit(token.Variable, start) + case l.hasPrefix("#["): + l.lexAttribute(start) + case l.hasPrefix("//") || c == '#': + l.lexLineComment(start) + case l.hasPrefix("/*"): + l.lexBlockComment(start) + case c == '\'': + l.lexString(start, '\'') + case c == '"': + l.lexString(start, '"') + case isDigit(c): + for l.pos < len(l.src) && isNumber(l.src[l.pos]) { + l.pos++ + } + l.emit(token.Number, start) + case isIdentStart(c): + for l.pos < len(l.src) && isIdent(l.src[l.pos]) { + l.pos++ + } + l.emit(l.identKind(l.src[start:l.pos]), start) + default: + if op := l.matchOperator(); op > 0 { + l.pos += op + } else { + l.pos++ + } + l.emit(token.Punct, start) + } +} + +// identKind classifies an identifier run as Keyword or Ident. A name that +// directly follows an object/static access operator (-> ?-> ::) is always a +// property or method name, never a keyword. +func (l *lexer) identKind(word string) token.Kind { + prev := l.lastSignificant() + if prev == "->" || prev == "?->" || prev == "::" || prev == `\` { + return token.Ident // property/method name or namespace segment + } + // a name right after "function" or "const" (e.g. a method named "match") is + // an identifier, not the keyword it happens to spell + if lp := strings.ToLower(prev); lp == "function" || lp == "const" { + return token.Ident + } + // a namespace segment like "Enum\Action" is an identifier, not a keyword + if l.pos < len(l.src) && l.src[l.pos] == '\\' { + return token.Ident + } + if keywords[strings.ToLower(word)] { + return token.Keyword + } + return token.Ident +} + +// lastSignificant returns the value of the last non-trivia token emitted so far. +func (l *lexer) lastSignificant() string { + for _, tk := range slices.Backward(l.toks) { + switch tk.Kind { + case token.Whitespace, token.Comment, token.DocComment: + continue + default: + return tk.Value + } + } + return "" +} + +// matchOperator returns the byte length of the longest known multi-char operator +// at the current position, or 0 when none matches (single-char punctuation). +func (l *lexer) matchOperator() int { + if l.pos >= len(l.src) { + return 0 + } + // only the operators starting with the current byte, longest first + for _, op := range operatorsByByte[l.src[l.pos]] { + if l.hasPrefix(op) { + return len(op) + } + } + return 0 +} + +func (l *lexer) lexLineComment(start int) { + for l.pos < len(l.src) && l.src[l.pos] != '\n' { + // a ?> ends a line comment in PHP; stop before it + if l.hasPrefix("?>") { + break + } + l.pos++ + } + l.emit(token.Comment, start) +} + +func (l *lexer) lexBlockComment(start int) { + l.pos += 2 // consume /* + for l.pos < len(l.src) && !l.hasPrefix("*/") { + l.pos++ + } + if l.hasPrefix("*/") { + l.pos += 2 + } + val := l.src[start:l.pos] + kind := token.Comment + // /** ... */ is a doc comment, but /**/ is not + if strings.HasPrefix(val, "/**") && val != "/**/" { + kind = token.DocComment + } + l.toks = append(l.toks, token.Token{Kind: kind, Value: val, Pos: start}) +} + +func (l *lexer) lexString(start int, quote byte) { + l.pos++ // opening quote + for l.pos < len(l.src) { + c := l.src[l.pos] + if c == '\\' && l.pos+1 < len(l.src) { + l.pos += 2 + continue + } + if c == quote { + l.pos++ + break + } + l.pos++ + } + l.emit(token.String, start) +} + +func (l *lexer) hasPrefix(s string) bool { + return strings.HasPrefix(l.src[l.pos:], s) +} + +// lexAttribute consumes a "#[ ... ]" attribute as one opaque token, matching +// brackets across lines and skipping string and heredoc/nowdoc content inside +// (so a multiline nowdoc in an attribute never leaks out as code). +func (l *lexer) lexAttribute(start int) { + l.pos += 2 // consume "#[" + depth := 1 + for l.pos < len(l.src) && depth > 0 { + switch c := l.src[l.pos]; { + case c == '[': + depth++ + l.pos++ + case c == ']': + depth-- + l.pos++ + case c == '\'' || c == '"': + l.skipString(c) + case l.hasPrefix("<<<"): + if end := l.scanHeredoc(); end > l.pos { + l.pos = end + } else { + l.pos++ + } + default: + l.pos++ + } + } + l.emit(token.Comment, start) +} + +// skipString advances past a single- or double-quoted string, honoring escapes. +func (l *lexer) skipString(quote byte) { + l.pos++ // opening quote + for l.pos < len(l.src) { + c := l.src[l.pos] + if c == '\\' && l.pos+1 < len(l.src) { + l.pos += 2 + continue + } + l.pos++ + if c == quote { + return + } + } +} + +// scanHeredoc returns the end offset of a heredoc/nowdoc starting at "<<<", or +// -1 when the "<<<" is not a valid heredoc opener. The whole construct (through +// the closing label) is treated as one opaque token so fixers never touch it. +func (l *lexer) scanHeredoc() int { + src := l.src + p := l.pos + 3 + for p < len(src) && (src[p] == ' ' || src[p] == '\t') { + p++ + } + var quote byte + if p < len(src) && (src[p] == '\'' || src[p] == '"') { + quote = src[p] + p++ + } + labelStart := p + for p < len(src) && isIdent(src[p]) { + p++ + } + if p == labelStart { + return -1 // no label -> not a heredoc + } + label := src[labelStart:p] + if quote != 0 { + if p >= len(src) || src[p] != quote { + return -1 + } + p++ + } + // opener must end the line + for p < len(src) && src[p] != '\n' { + p++ + } + if p >= len(src) { + return -1 + } + // scan body lines for the closing label (PHP 7.3+ allows it indented) + for p < len(src) { + p++ // step past the newline to the start of the next line + lineStart := p + for lineStart < len(src) && (src[lineStart] == ' ' || src[lineStart] == '\t') { + lineStart++ + } + if strings.HasPrefix(src[lineStart:], label) { + after := lineStart + len(label) + if after >= len(src) || !isIdent(src[after]) { + return after // closing marker found + } + } + for p < len(src) && src[p] != '\n' { + p++ + } + if p >= len(src) { + return len(src) // unterminated; consume the rest + } + } + return len(src) +} + +func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isNumber(c byte) bool { + return isDigit(c) || c == '.' || c == '_' || c == 'x' || c == 'X' || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} +func isIdentStart(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80 +} +func isIdent(c byte) bool { return isIdentStart(c) || isDigit(c) } + +// operators lists multi-char operators, longest first, so matchOperator emits +// each as a single Punct token (=> -> :: == === etc.) instead of splitting it. +var operators = []string{ + "<=>", "===", "!==", "**=", "...", "<<=", ">>=", "??=", "?->", + "->", "=>", "==", "!=", "<>", "<=", ">=", "&&", "||", "++", "--", + "+=", "-=", "*=", "/=", ".=", "%=", "&=", "|=", "^=", "::", "??", + "**", "<<", ">>", +} + +// operatorsByByte groups operators by their first byte, preserving the +// longest-first order of `operators`, so matchOperator only tests the few +// candidates for the current byte instead of all of them. +var operatorsByByte = func() [256][]string { + var m [256][]string + for _, op := range operators { + m[op[0]] = append(m[op[0]], op) + } + return m +}() + +// keywords are PHP reserved words (case-insensitive). Type names (int, string, +// ...) and constants (true, false, null) are T_STRING in PHP and stay Ident. +var keywords = map[string]bool{ + "abstract": true, "and": true, "array": true, "as": true, "break": true, + "callable": true, "case": true, "catch": true, "class": true, "clone": true, + "const": true, "continue": true, "declare": true, "default": true, "do": true, + "echo": true, "else": true, "elseif": true, "empty": true, "enddeclare": true, + "endfor": true, "endforeach": true, "endif": true, "endswitch": true, + "endwhile": true, "enum": true, "extends": true, "final": true, "finally": true, + "fn": true, "for": true, "foreach": true, "function": true, "global": true, + "goto": true, "if": true, "implements": true, "include": true, + "include_once": true, "instanceof": true, "insteadof": true, "interface": true, + "isset": true, "list": true, "match": true, "namespace": true, "new": true, + "or": true, "print": true, "private": true, "protected": true, "public": true, + "readonly": true, "require": true, "require_once": true, "return": true, + "static": true, "switch": true, "throw": true, "trait": true, "try": true, + "unset": true, "use": true, "var": true, "while": true, "xor": true, + "yield": true, +} diff --git a/blink/internal/lexer/lexer_test.go b/blink/internal/lexer/lexer_test.go new file mode 100644 index 0000000000..736010585a --- /dev/null +++ b/blink/internal/lexer/lexer_test.go @@ -0,0 +1,180 @@ +package lexer + +import ( + "strings" + "testing" + + "blink/internal/token" +) + +// lossless is the core invariant: rendering all token values equals the source. +func TestLexLossless(t *testing.T) { + cases := []string{ + "", + "
after
", + " more", + } + for _, src := range cases { + var got strings.Builder + for _, tk := range Lex(src) { + got.WriteString(tk.Value) + } + if got.String() != src { + t.Errorf("not lossless\n src: %q\n got: %q", src, got.String()) + } + } +} + +func TestLexClassifies(t *testing.T) { + toks := Lex(" 3 && tk.Value[:3] == "<<<" { + heredoc = tk.Value + } + } + if heredoc == "" { + t.Errorf("heredoc not captured as a single String token in %q", src) + } + // lossless + var got strings.Builder + for _, tk := range Lex(src) { + got.WriteString(tk.Value) + } + if got.String() != src { + t.Errorf("not lossless\n src: %q\n got: %q", src, got.String()) + } + } +} + +func TestLexMultilineAttributeWithNowdoc(t *testing.T) { + src := " 2 && tk.Value[:2] == "#[" { + attr = tk.Value + } + } + if attr == "" { + t.Fatal("multiline attribute not captured as one token") + } + if !strings.Contains(attr, "prose e.g. with. dots") { + t.Errorf("nowdoc body not inside the attribute token: %q", attr) + } + // lossless + var got strings.Builder + for _, tk := range Lex(src) { + got.WriteString(tk.Value) + } + if got.String() != src { + t.Errorf("not lossless:\n src: %q\n got: %q", src, got.String()) + } +} + +func TestLexKeywordsVsIdent(t *testing.T) { + toks := Lex("list;") + for _, tk := range toks { + if tk.Value == "list" && tk.Kind != token.Ident { + t.Errorf("property `list` after -> should be Ident, got %s", tk.Kind) + } + } +} + +func TestLexMultiCharOperators(t *testing.T) { + cases := map[string]string{ + "$b;": "=>", + "b;": "->", + " $b;": "<=>", + "= p.width: + bar = strings.Repeat("=", p.width) + case filled == 0: + bar = ">" + strings.Repeat(" ", p.width-1) + default: + bar = strings.Repeat("=", filled-1) + ">" + strings.Repeat(" ", p.width-filled) + } + _, _ = fmt.Fprintf(p.w, "\r %d/%d [%s] %3d%%", p.cur, p.total, bar, pct) +} diff --git a/blink/internal/reporter/reporter.go b/blink/internal/reporter/reporter.go new file mode 100644 index 0000000000..35d7753625 --- /dev/null +++ b/blink/internal/reporter/reporter.go @@ -0,0 +1,196 @@ +// Package reporter reproduces ECS's console output: numbered file-diff blocks +// with "Applied checkers" listings, wrapped diffs, and Symfony-style +// success/warning status blocks. +package reporter + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "blink/internal/runner" +) + +type formatter struct { + w io.Writer + width int + color bool +} + +// Report writes results to w and returns the process exit code (0 when clean or +// after a successful fix, 1 when a check finds fixable issues). +func Report(w io.Writer, results []runner.FileResult, isFixer bool) int { + f := newFormatter(w) + + // --fix applies changes and prints a summary; the per-file diff listing is a + // check (dry-run) concern, matching ECS. + if !isFixer { + f.reportFileDiffs(results) + } + f.newLine(1) + + if len(results) == 0 { + f.success("No errors found. Great job - your code is shiny in style!") + return 0 + } + + f.newLine(1) + + n := len(results) + if isFixer { + f.success(fmt.Sprintf( + "%d error%s successfully fixed and no other errors found!", + n, plural(n), + )) + return 0 + } + + verb := "errors are" + if n == 1 { + verb = "error is" + } + f.warning(fmt.Sprintf( + "%d %s fixable! Just add \"--fix\" to console command and rerun to apply.", + n, verb, + )) + return 1 +} + +// Footer prints an ECS-style run summary: files scanned, time and memory. +func Footer(w io.Writer, files int, d time.Duration, memBytes uint64) { + _, _ = fmt.Fprintf(w, "\n // %d files · %s · %d MB\n", files, d.Truncate(time.Millisecond), memBytes/(1024*1024)) +} + +func newFormatter(w io.Writer) formatter { + width := 80 + color := false + if file, ok := w.(*os.File); ok { + if ws, ok2 := winsize(file); ok2 { + color = true + if ws > 0 { + width = ws + } + } + } + if c := os.Getenv("COLUMNS"); c != "" { + if n, err := strconv.Atoi(c); err == nil && n > 0 { + width = n + } + } + return formatter{w: w, width: width, color: color} +} + +func (f formatter) reportFileDiffs(results []runner.FileResult) { + if len(results) == 0 { + return + } + f.newLine(1) + + for i, r := range results { + f.newLine(2) + f.writeln(f.bold(fmt.Sprintf("%d) %s", i+1, r.Path))) + f.newLine(1) + f.writeln(f.formatDiff(r.Diff)) + f.newLine(1) + f.writeln(f.underscore("Applied checkers:")) + f.newLine(1) + f.listing(r.AppliedRules) + } +} + +// formatDiff wraps a unified diff in the begin/end frame and colors +, - and @ +// lines, matching ECS's ColorConsoleDiffFormatter. +func (f formatter) formatDiff(diff string) string { + var colored []string + for line := range strings.SplitSeq(strings.TrimRight(diff, "\n"), "\n") { + switch { + case line == "--- Original", line == "+++ New": + continue + case strings.HasPrefix(line, "+"): + line = f.fg(line, 32) // green + case strings.HasPrefix(line, "-"): + line = f.fg(line, 31) // red + case strings.HasPrefix(line, "@"): + line = f.fg(line, 36) // cyan + case line == " ": + line = "" + } + colored = append(colored, line) + } + + begin := f.comment(" ---------- begin diff ----------") + end := f.comment(" ----------- end diff -----------") + return begin + "\n" + strings.Join(colored, "\n") + "\n" + end + "\n" +} + +func (f formatter) listing(items []string) { + for _, it := range items { + f.writeln(" * " + it) + } + f.newLine(1) +} + +// success / warning render Symfony-style padded, colored blocks. +func (f formatter) success(msg string) { f.block("[OK]", msg, 30, 42) } +func (f formatter) warning(msg string) { f.block("[WARNING]", msg, 30, 43) } + +func (f formatter) block(tag, msg string, fg, bg int) { + f.newLine(1) + pad := func(s string) string { + if len([]rune(s)) < f.width { + s += strings.Repeat(" ", f.width-len([]rune(s))) + } + return f.bg(s, fg, bg) + } + f.writeln(pad("")) + f.writeln(pad(" " + tag + " " + msg)) + f.writeln(pad("")) + f.newLine(1) +} + +func (f formatter) newLine(n int) { + for range n { + _, _ = io.WriteString(f.w, "\n") + } +} + +func (f formatter) writeln(s string) { _, _ = fmt.Fprintln(f.w, s) } + +// ANSI helpers, no-ops when color is disabled. +func (f formatter) fg(s string, code int) string { + if !f.color { + return s + } + return fmt.Sprintf("\x1b[%dm%s\x1b[39m", code, s) +} + +func (f formatter) bg(s string, fg, bg int) string { + if !f.color { + return s + } + return fmt.Sprintf("\x1b[%d;%dm%s\x1b[39;49m", fg, bg, s) +} + +func (f formatter) comment(s string) string { return f.fg(s, 33) } // yellow +func (f formatter) bold(s string) string { + if !f.color { + return s + } + return "\x1b[1m" + s + "\x1b[22m" +} +func (f formatter) underscore(s string) string { + if !f.color { + return s + } + return "\x1b[4m" + s + "\x1b[24m" +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/blink/internal/reporter/reporter_test.go b/blink/internal/reporter/reporter_test.go new file mode 100644 index 0000000000..085370469c --- /dev/null +++ b/blink/internal/reporter/reporter_test.go @@ -0,0 +1,57 @@ +package reporter + +import ( + "bytes" + "strings" + "testing" + + "blink/internal/runner" +) + +func TestReportClean(t *testing.T) { + var buf bytes.Buffer + code := Report(&buf, nil, false) + if code != 0 { + t.Fatalf("clean exit code = %d, want 0", code) + } + if !strings.Contains(buf.String(), "No errors found. Great job - your code is shiny in style!") { + t.Fatalf("missing success message:\n%s", buf.String()) + } +} + +func TestReportCheckFixable(t *testing.T) { + var buf bytes.Buffer + results := []runner.FileResult{{ + Path: "src/Foo.php", + AppliedRules: []string{`PhpCsFixer\Fixer\Semicolon\SpaceAfterSemicolonFixer`}, + Diff: "--- Original\n+++ New\n@@ @@\n-$a=1;$b=2;\n+$a=1; $b=2;\n", + }} + code := Report(&buf, results, false) + out := buf.String() + if code != 1 { + t.Fatalf("check exit code = %d, want 1", code) + } + for _, want := range []string{ + "1) src/Foo.php", + "begin diff", + "Applied checkers:", + `PhpCsFixer\Fixer\Semicolon\SpaceAfterSemicolonFixer`, + "1 error is fixable!", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } +} + +func TestReportFixed(t *testing.T) { + var buf bytes.Buffer + results := []runner.FileResult{{Path: "a.php", AppliedRules: []string{"X"}}, {Path: "b.php", AppliedRules: []string{"Y"}}} + code := Report(&buf, results, true) + if code != 0 { + t.Fatalf("fix exit code = %d, want 0", code) + } + if !strings.Contains(buf.String(), "2 errors successfully fixed and no other errors found!") { + t.Fatalf("missing fixed message:\n%s", buf.String()) + } +} diff --git a/blink/internal/reporter/winsize_unix.go b/blink/internal/reporter/winsize_unix.go new file mode 100644 index 0000000000..22b83e3549 --- /dev/null +++ b/blink/internal/reporter/winsize_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package reporter + +import ( + "os" + "syscall" + "unsafe" +) + +const tiocgwinsz = 0x5413 // linux TIOCGWINSZ + +func winsize(file *os.File) (int, bool) { + ws := struct{ Row, Col, X, Y uint16 }{} + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + file.Fd(), + uintptr(tiocgwinsz), + uintptr(unsafe.Pointer(&ws)), + ) + if errno != 0 { + return 0, false + } + return int(ws.Col), true +} diff --git a/blink/internal/reporter/winsize_windows.go b/blink/internal/reporter/winsize_windows.go new file mode 100644 index 0000000000..7f42f5eac7 --- /dev/null +++ b/blink/internal/reporter/winsize_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package reporter + +import ( + "os" + "syscall" + "unsafe" +) + +var procGetConsoleScreenBufferInfo = syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleScreenBufferInfo") + +// consoleScreenBufferInfo mirrors the Win32 CONSOLE_SCREEN_BUFFER_INFO struct. +type consoleScreenBufferInfo struct { + SizeX, SizeY int16 + CursorX, CursorY int16 + Attributes uint16 + WindowLeft, WindowTop, WindowRight, WindowBottom int16 + MaxWindowSizeX, MaxWindowSizeY int16 +} + +func winsize(file *os.File) (int, bool) { + var info consoleScreenBufferInfo + ok, _, _ := procGetConsoleScreenBufferInfo.Call(file.Fd(), uintptr(unsafe.Pointer(&info))) + if ok == 0 { + return 0, false + } + return int(info.WindowRight-info.WindowLeft) + 1, true +} diff --git a/blink/internal/runner/runner.go b/blink/internal/runner/runner.go new file mode 100644 index 0000000000..939a68e16b --- /dev/null +++ b/blink/internal/runner/runner.go @@ -0,0 +1,129 @@ +// Package runner ties finder, lexer, token stream and fixers together, echoing +// ECS's application layer. Files are processed by a pool of parallel workers. +package runner + +import ( + "os" + "sort" + "sync" + + "blink/internal/config" + "blink/internal/diff" + "blink/internal/finder" + "blink/internal/lexer" + "blink/internal/tokens" +) + +// FileResult records the change made to one file. +type FileResult struct { + Path string + AppliedRules []string // rules that changed this file, in application order + Diff string // unified diff (Original vs fixed) + After string // fixed content +} + +func (r FileResult) Changed() bool { return len(r.AppliedRules) > 0 } + +// Run scans the config's paths and applies rules across Jobs workers. When +// write is true, changed files are written back to disk. Results are returned +// sorted by path so output is deterministic regardless of worker scheduling. +// Progress receives per-file progress updates during a run. +type Progress interface { + Start(total int) + Advance() +} + +func Run(cfg *config.Config, write bool, prog Progress) ([]FileResult, error) { + files, err := finder.Find(cfg.Paths, cfg.Skip) + if err != nil { + return nil, err + } + if prog != nil { + prog.Start(len(files)) + } + + jobs := max(cfg.Jobs, 1) + + paths := make(chan string) + results := make(chan FileResult) + var firstErr error + var errOnce sync.Once + + var wg sync.WaitGroup + for range jobs { + wg.Go(func() { + for path := range paths { + res, err := fixFile(cfg, path, write) + if prog != nil { + prog.Advance() + } + if err != nil { + errOnce.Do(func() { firstErr = err }) + continue + } + if res.Changed() { + results <- res + } + } + }) + } + + go func() { + for _, p := range files { + paths <- p + } + close(paths) + wg.Wait() + close(results) + }() + + var collected []FileResult + for r := range results { + collected = append(collected, r) + } + if firstErr != nil { + return nil, firstErr + } + + sort.Slice(collected, func(i, j int) bool { return collected[i].Path < collected[j].Path }) + return collected, nil +} + +func fixFile(cfg *config.Config, path string, write bool) (FileResult, error) { + src, err := os.ReadFile(path) + if err != nil { + return FileResult{}, err + } + original := string(src) + + stream := tokens.New(lexer.Lex(original)) + res := FileResult{Path: path} + + for _, rule := range cfg.Rules { + if rule.Fix(stream) { + res.AppliedRules = append(res.AppliedRules, rule.Name()) + } + } + + res.After = stream.Render() + if !res.Changed() { + return res, nil + } + // The unified diff is only rendered in check (dry-run) mode; --fix reports a + // count and rewrites the file, so computing the diff there is wasted work. + if !write { + res.Diff = diff.Unified(original, res.After) + } + + if write { + info, statErr := os.Stat(path) + mode := os.FileMode(0o644) + if statErr == nil { + mode = info.Mode() + } + if err := os.WriteFile(path, []byte(res.After), mode); err != nil { + return FileResult{}, err + } + } + return res, nil +} diff --git a/blink/internal/runner/runner_test.go b/blink/internal/runner/runner_test.go new file mode 100644 index 0000000000..81bef6b679 --- /dev/null +++ b/blink/internal/runner/runner_test.go @@ -0,0 +1,51 @@ +package runner + +import ( + "os" + "path/filepath" + "testing" + + "blink/internal/config" +) + +func TestRunFixesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sample.php") + dirty := " "FooFixer"). +func shortName(name string) string { + if i := strings.LastIndexByte(name, '\\'); i >= 0 { + return name[i+1:] + } + return name +} + +// Get returns the fixers of a named set. +func Get(name string) ([]fixer.Fixer, bool) { + build, ok := byName[name] + if !ok { + return nil, false + } + return build(), true +} + +// SpacesLevel returns the first n rules of the spaces set, for gradual adoption +// (withSpacesLevel in ECS). n is clamped to the set size. +func SpacesLevel(n int) []fixer.Fixer { + all := Spaces() + if n < 0 { + n = 0 + } + if n > len(all) { + n = len(all) + } + return all[:n] +} + +// Names lists the available set names, sorted. +func Names() []string { + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// defaultMembers is psr12Members ∪ commonMembers. +var defaultMembers = func() map[string]bool { + m := map[string]bool{} + for k := range psr12Members { + m[k] = true + } + for k := range commonMembers { + m[k] = true + } + return m +}() + +// psr12Members is the @PSR-12 set (from ECS config/set/psr12.php), by short name. +var psr12Members = map[string]bool{ + "BinaryOperatorSpacesFixer": true, + "BlankLineAfterNamespaceFixer": true, + "BlankLineAfterOpeningTagFixer": true, + "BracesPositionFixer": true, + "ClassDefinitionFixer": true, + "ConcatSpaceFixer": true, + "ConstantCaseFixer": true, + "ControlStructureBracesFixer": true, + "ControlStructureContinuationPositionFixer": true, + "DeclareEqualNormalizeFixer": true, + "DeclareParenthesesFixer": true, + "ElseifFixer": true, + "EncodingFixer": true, + "FullOpeningTagFixer": true, + "FunctionDeclarationFixer": true, + "IndentationTypeFixer": true, + "LineEndingFixer": true, + "LowercaseCastFixer": true, + "LowercaseKeywordsFixer": true, + "MethodArgumentSpaceFixer": true, + "NewWithParenthesesFixer": true, + "NoBlankLinesAfterClassOpeningFixer": true, + "NoBreakCommentFixer": true, + "NoClosingTagFixer": true, + "NoExtraBlankLinesFixer": true, + "NoLeadingImportSlashFixer": true, + "NoMultipleStatementsPerLineFixer": true, + "NoSinglelineWhitespaceBeforeSemicolonsFixer": true, + "NoSpacesAfterFunctionNameFixer": true, + "NoTrailingWhitespaceFixer": true, + "NoTrailingWhitespaceInCommentFixer": true, + "NoWhitespaceBeforeCommaInArrayFixer": true, + "OrderedImportsFixer": true, + "ReturnTypeDeclarationFixer": true, + "ShortScalarCastFixer": true, + "SingleBlankLineAtEofFixer": true, + "SingleClassElementPerStatementFixer": true, + "SingleImportPerStatementFixer": true, + "SingleLineAfterImportsFixer": true, + "SingleSpaceAroundConstructFixer": true, + "SpacesInsideParenthesesFixer": true, + "StatementIndentationFixer": true, + "SwitchCaseSemicolonToColonFixer": true, + "SwitchCaseSpaceFixer": true, + "TernaryOperatorSpacesFixer": true, + "UnaryOperatorSpacesFixer": true, + "VisibilityRequiredFixer": true, + "WhitespaceAfterCommaInArrayFixer": true, +} + +// commonMembers is ECS's common set (array, casing, cleanup, comments, +// control-structures, docblock, namespaces, spaces, clean-code), by short name. +var commonMembers = map[string]bool{ + "AddMissingParamNameFixer": true, + "AddMissingVarNameFixer": true, + "AlignMultilineCommentFixer": true, + "ArrayIndentationFixer": true, + "ArrayListItemNewlineFixer": true, + "ArrayOpenerAndCloserNewlineFixer": true, + "ArraySyntaxFixer": true, + "AssignNullCoalescingToCoalesceEqualFixer": true, + "BinaryOperatorSpacesFixer": true, + "BlankLineAfterOpeningTagFixer": true, + "BlankLineAfterStrictTypesFixer": true, + "CastSpacesFixer": true, + "ClassAttributesSeparationFixer": true, + "ClassDefinitionFixer": true, + "ClassReferenceNameCasingFixer": true, + "ConcatSpaceFixer": true, + "DoubleAsteriskInlineVarFixer": true, + "ExplicitIndirectVariableFixer": true, + "ExplicitStringVariableFixer": true, + "FixParamNameTypoFixer": true, + "FixTagTypoFixer": true, + "FunctionToConstantFixer": true, + "GeneralPhpdocAnnotationRemoveFixer": true, + "IncludeFixer": true, + "IntegerLiteralCaseFixer": true, + "IsNullFixer": true, + "LambdaNotUsedImportFixer": true, + "ListSyntaxFixer": true, + "LongToShorthandOperatorFixer": true, + "MagicConstantCasingFixer": true, + "MagicMethodCasingFixer": true, + "MergeDocBlockStartFixer": true, + "MethodArgumentSpaceFixer": true, + "MethodChainingIndentationFixer": true, + "MethodChainingNewlineFixer": true, + "MultilineCommentOpeningClosingFixer": true, + "NativeFunctionCasingFixer": true, + "NativeTypeDeclarationCasingFixer": true, + "NewWithParenthesesFixer": true, + "NoAlternativeSyntaxFixer": true, + "NoBlankLineBetweenImportsFixer": true, + "NoBlankLinesAfterClassOpeningFixer": true, + "NoBlankLinesAfterPhpdocFixer": true, + "NoEmptyCommentFixer": true, + "NoEmptyPhpdocFixer": true, + "NoEmptyStatementFixer": true, + "NoExtraBlankLinesFixer": true, + "NoLeadingNamespaceWhitespaceFixer": true, + "NoMultilineWhitespaceAroundDoubleArrowFixer": true, + "NoNullPropertyInitializationFixer": true, + "NoShortBoolCastFixer": true, + "NoSinglelineWhitespaceBeforeSemicolonsFixer": true, + "NoSpacesAroundOffsetFixer": true, + "NoSuperfluousElseifFixer": true, + "NoSuperfluousPhpdocTagsFixer": true, + "NoTrailingCommaInSinglelineFixer": true, + "NoTrailingWhitespaceInCommentFixer": true, + "NoUnneededControlParenthesesFixer": true, + "NoUnneededCurlyBracesFixer": true, + "NoUnneededImportAliasFixer": true, + "NoUnusedImportsFixer": true, + "NoUselessConcatOperatorFixer": true, + "NoUselessNullsafeOperatorFixer": true, + "NoUselessReturnFixer": true, + "NoWhitespaceBeforeCommaInArrayFixer": true, + "NoWhitespaceInBlankLineFixer": true, + "NoWhitespaceInEmptyArrayFixer": true, + "NotOperatorWithSuccessorSpaceFixer": true, + "NullableTypeDeclarationForDefaultNullValueFixer": true, + "ObjectOperatorWithoutWhitespaceFixer": true, + "OrderedImportsFixer": true, + "ParamReturnAndVarTagMalformsFixer": true, + "PhpdocIndentFixer": true, + "PhpdocInlineTagNormalizerFixer": true, + "PhpdocLineSpanFixer": true, + "PhpdocNoAccessFixer": true, + "PhpdocNoAliasTagFixer": true, + "PhpdocNoDuplicateTypesFixer": true, + "PhpdocNoEmptyReturnFixer": true, + "PhpdocNoPackageFixer": true, + "PhpdocOrderByValueFixer": true, + "PhpdocReturnSelfReferenceFixer": true, + "PhpdocScalarFixer": true, + "PhpdocSingleLineVarSpacingFixer": true, + "PhpdocTagCasingFixer": true, + "PhpdocTrimConsecutiveBlankLineSeparationFixer": true, + "PhpdocTrimFixer": true, + "PhpdocTypesFixer": true, + "PhpdocVarWithoutNameFixer": true, + "ProtectedToPrivateFixer": true, + "RemoveDeadParamFixer": true, + "RemoveDeadVarThisFixer": true, + "RemoveEventSubscriberDescriptionFixer": true, + "RemoveMethodNameDuplicateDescriptionFixer": true, + "RemovePHPStormAnnotationFixer": true, + "RemoveParamNameReferenceFixer": true, + "RemovePropertyVariableNameDescriptionFixer": true, + "RemoveSuperfluousReturnNameFixer": true, + "RemoveSuperfluousVarNameFixer": true, + "RemoveUselessDefaultCommentFixer": true, + "ReturnTypeDeclarationFixer": true, + "SelfAccessorFixer": true, + "SingleBlankLineBeforeNamespaceFixer": true, + "SingleClassElementPerStatementFixer": true, + "SingleLineCommentSpacingFixer": true, + "SingleLineInlineVarDocBlockFixer": true, + "SingleQuoteFixer": true, + "SingleTraitInsertPerStatementFixer": true, + "SpaceAfterCommaHereNowDocFixer": true, + "SpaceAfterSemicolonFixer": true, + "StandaloneLinePromotedPropertyFixer": true, + "StandaloneLineRequiredParamFixer": true, + "StandardizeIncrementFixer": true, + "StandardizeNotEqualsFixer": true, + "SwitchContinueToBreakFixer": true, + "SwitchedTypeAndNameFixer": true, + "TernaryOperatorSpacesFixer": true, + "TernaryToNullCoalescingFixer": true, + "TrailingCommaInMultilineFixer": true, + "TrimArraySpacesFixer": true, + "TypeDeclarationSpacesFixer": true, + "TypeToVarTagFixer": true, + "TypesSpacesFixer": true, + "WhitespaceAfterCommaInArrayFixer": true, + "YodaStyleFixer": true, +} diff --git a/blink/internal/set/set_test.go b/blink/internal/set/set_test.go new file mode 100644 index 0000000000..afa14063e0 --- /dev/null +++ b/blink/internal/set/set_test.go @@ -0,0 +1,25 @@ +package set + +import "testing" + +func TestGet(t *testing.T) { + if _, ok := Get("spaces"); !ok { + t.Fatal("spaces set should exist") + } + if _, ok := Get("does-not-exist"); ok { + t.Fatal("unknown set should not resolve") + } +} + +func TestSpacesLevelClamps(t *testing.T) { + full := len(Spaces()) + if got := len(SpacesLevel(-5)); got != 0 { + t.Fatalf("negative level should give 0, got %d", got) + } + if got := len(SpacesLevel(1000)); got != full { + t.Fatalf("overflow level should clamp to %d, got %d", full, got) + } + if got := len(SpacesLevel(2)); got != 2 { + t.Fatalf("level 2 should give 2, got %d", got) + } +} diff --git a/blink/internal/token/token.go b/blink/internal/token/token.go new file mode 100644 index 0000000000..045518678c --- /dev/null +++ b/blink/internal/token/token.go @@ -0,0 +1,53 @@ +package token + +// Kind is the lexical category of a token. +type Kind int + +const ( + Unknown Kind = iota + OpenTag // + InlineHTML // text outside PHP tags + Whitespace // spaces, tabs, newlines + Comment // // # /* */ + DocComment // /** */ + Variable // $foo + Ident // names (T_STRING): function/class names, constants, types + Keyword // reserved words: function, class, return, namespace, ... + Number + String // '...' "..." + Punct // operators, braces, ; , etc. + EOF +) + +var kindNames = map[Kind]string{ + Unknown: "Unknown", + OpenTag: "OpenTag", + CloseTag: "CloseTag", + InlineHTML: "InlineHTML", + Whitespace: "Whitespace", + Comment: "Comment", + DocComment: "DocComment", + Variable: "Variable", + Ident: "Ident", + Keyword: "Keyword", + Number: "Number", + String: "String", + Punct: "Punct", + EOF: "EOF", +} + +func (k Kind) String() string { + if n, ok := kindNames[k]; ok { + return n + } + return "Kind(?)" +} + +// Token is a single lexical unit. The stream is lossless: concatenating every +// Value in order reproduces the original source byte for byte. +type Token struct { + Kind Kind + Value string + Pos int // byte offset in source +} diff --git a/blink/internal/tokens/stream.go b/blink/internal/tokens/stream.go new file mode 100644 index 0000000000..ab18ee8dbe --- /dev/null +++ b/blink/internal/tokens/stream.go @@ -0,0 +1,138 @@ +// Package tokens holds a flat, mutable, index-addressable token stream. This is +// the ECS/PHP-CS-Fixer working model: fixers read tokens by index and insert, +// replace or remove them in place, then Render rebuilds the source. +package tokens + +import ( + "strings" + + "blink/internal/token" +) + +type Stream struct { + toks []token.Token +} + +func New(toks []token.Token) *Stream { + return &Stream{toks: toks} +} + +func (s *Stream) Len() int { return len(s.toks) } + +func (s *Stream) At(i int) token.Token { return s.toks[i] } + +// Kind returns just the kind of the token at i, avoiding a full Token copy in +// the hot index-scanning helpers. +func (s *Stream) Kind(i int) token.Kind { return s.toks[i].Kind } + +func (s *Stream) Set(i int, t token.Token) { s.toks[i] = t } + +// SetValue replaces only the Value of the token at i, keeping its kind. +func (s *Stream) SetValue(i int, v string) { s.toks[i].Value = v } + +func (s *Stream) RemoveAt(i int) { + s.toks = append(s.toks[:i], s.toks[i+1:]...) +} + +func (s *Stream) InsertAt(i int, t token.Token) { + s.toks = append(s.toks, token.Token{}) + copy(s.toks[i+1:], s.toks[i:]) + s.toks[i] = t +} + +// MatchForward returns the index of the delimiter matching the opener at i +// ("(", "{" or "["), or -1 if there is none. Strings and comments are single +// tokens, so scanning punctuation is safe. +func (s *Stream) MatchForward(i int) int { + if i < 0 || i >= len(s.toks) || s.toks[i].Kind != token.Punct { + return -1 + } + open := s.toks[i].Value + var closer string + switch open { + case "(": + closer = ")" + case "{": + closer = "}" + case "[": + closer = "]" + default: + return -1 + } + depth := 0 + for j := i; j < len(s.toks); j++ { + if s.toks[j].Kind != token.Punct { + continue + } + switch s.toks[j].Value { + case open: + depth++ + case closer: + depth-- + if depth == 0 { + return j + } + } + } + return -1 +} + +// MatchBackward returns the index of the delimiter matching the closer at i +// (")", "}" or "]"), or -1 if there is none. +func (s *Stream) MatchBackward(i int) int { + if i < 0 || i >= len(s.toks) || s.toks[i].Kind != token.Punct { + return -1 + } + closer := s.toks[i].Value + var opener string + switch closer { + case ")": + opener = "(" + case "}": + opener = "{" + case "]": + opener = "[" + default: + return -1 + } + depth := 0 + for j := i; j >= 0; j-- { + if s.toks[j].Kind != token.Punct { + continue + } + switch s.toks[j].Value { + case closer: + depth++ + case opener: + depth-- + if depth == 0 { + return j + } + } + } + return -1 +} + +// ReplaceRange swaps tokens [start, end] (inclusive) for repl. +func (s *Stream) ReplaceRange(start, end int, repl []token.Token) { + tail := append([]token.Token(nil), s.toks[end+1:]...) + s.toks = append(s.toks[:start], repl...) + s.toks = append(s.toks, tail...) +} + +// Render concatenates every token value back into source. +func (s *Stream) Render() string { + n := 0 + for i := range s.toks { + n += len(s.toks[i].Value) + } + var b strings.Builder + b.Grow(n) + for i := range s.toks { + b.WriteString(s.toks[i].Value) + } + return b.String() +} + +// Tokens returns the underlying slice (read-only use). +func (s *Stream) Tokens() []token.Token { return s.toks } diff --git a/blink/internal/tokens/stream_test.go b/blink/internal/tokens/stream_test.go new file mode 100644 index 0000000000..2ead04eb84 --- /dev/null +++ b/blink/internal/tokens/stream_test.go @@ -0,0 +1,65 @@ +package tokens + +import ( + "testing" + + "blink/internal/token" +) + +func mk(vals ...string) *Stream { + var toks []token.Token + for _, v := range vals { + toks = append(toks, token.Token{Kind: token.Punct, Value: v}) + } + return New(toks) +} + +func TestInsertRemoveRender(t *testing.T) { + s := mk("a", "b", "d") + s.InsertAt(2, token.Token{Value: "c"}) + if got := s.Render(); got != "abcd" { + t.Fatalf("insert: got %q", got) + } + s.RemoveAt(0) + if got := s.Render(); got != "bcd" { + t.Fatalf("remove: got %q", got) + } +} + +func TestInsertAtEnd(t *testing.T) { + s := mk("a", "b") + s.InsertAt(s.Len(), token.Token{Value: "c"}) + if got := s.Render(); got != "abc" { + t.Fatalf("append: got %q", got) + } +} + +func TestSetValue(t *testing.T) { + s := mk("a", "b") + s.SetValue(1, "B") + if got := s.Render(); got != "aB" { + t.Fatalf("setvalue: got %q", got) + } +} + +func TestMatchForward(t *testing.T) { + // ( a [ b ] c ) -> outer "(" at 0 matches ")" at 6 + s := mk("(", "a", "[", "b", "]", "c", ")") + if got := s.MatchForward(0); got != 6 { + t.Fatalf("outer match: got %d, want 6", got) + } + if got := s.MatchForward(2); got != 4 { + t.Fatalf("inner match: got %d, want 4", got) + } + if got := s.MatchForward(1); got != -1 { + t.Fatalf("non-opener should be -1, got %d", got) + } +} + +func TestReplaceRange(t *testing.T) { + s := mk("a", "b", "c", "d") + s.ReplaceRange(1, 2, []token.Token{{Kind: token.Punct, Value: "X"}}) + if got := s.Render(); got != "aXd" { + t.Fatalf("replace: got %q", got) + } +} diff --git a/blink/main.go b/blink/main.go new file mode 100644 index 0000000000..ae06821820 --- /dev/null +++ b/blink/main.go @@ -0,0 +1,155 @@ +// Command blink is a token-based coding-standard checker/fixer for PHP, +// modeled on symplify/easy-coding-standard. +package main + +import ( + "fmt" + "io" + "os" + "runtime" + "runtime/debug" + "time" + + "blink/internal/config" + "blink/internal/fixer/rules" + "blink/internal/reporter" + "blink/internal/runner" +) + +func main() { + // A short-lived batch process: relax the GC so it collects far less during + // the run instead of reclaiming memory the process is about to release anyway. + debug.SetGCPercent(400) + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + fix := false + configPath := "" + ecsConfigPath := "" + var paths []string + + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--fix": + fix = true + case a == "list-checkers": + return listCheckers() + case a == "-h" || a == "--help": + usage() + return 0 + case a == "--config": + if i+1 < len(args) { + configPath = args[i+1] + i++ + } + case len(a) > 9 && a[:9] == "--config=": + configPath = a[9:] + case a == "--ecs-config": + if i+1 < len(args) { + ecsConfigPath = args[i+1] + i++ + } + case len(a) > 13 && a[:13] == "--ecs-config=": + ecsConfigPath = a[13:] + default: + paths = append(paths, a) + } + } + + var cfg *config.Config + var err error + if ecsConfigPath != "" { + var resolution *config.ECSResolution + cfg, resolution, err = config.LoadECS(ecsConfigPath) + if err == nil { + reportECSResolution(os.Stderr, resolution) + } + } else { + cfg, err = loadConfig(configPath) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 2 + } + // CLI paths override the config; runs across all CPU cores by default + cfg.WithPaths(paths...) + + start := time.Now() + progress := reporter.NewProgress(os.Stderr) + + results, err := runner.Run(cfg, fix, progress) + progress.Finish() + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 2 + } + + code := reporter.Report(os.Stdout, results, fix) + + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + reporter.Footer(os.Stdout, progress.Total(), time.Since(start), mem.Sys) + return code +} + +// loadConfig uses an explicit --config path, else a blink.json in the working +// directory, else the built-in defaults (all rules). +func loadConfig(configPath string) (*config.Config, error) { + if configPath != "" { + return config.Load(configPath) + } + if _, err := os.Stat("blink.json"); err == nil { + return config.Load("blink.json") + } + return config.Configure(), nil +} + +// reportECSResolution prints, to w, how an ECS turbo config mapped: how many +// rules resolved to blink fixers and everything that could not, so a turbo run +// is never silently narrower than the ECS config it stands in for. +func reportECSResolution(w io.Writer, resolution *config.ECSResolution) { + _, _ = fmt.Fprintf(w, "turbo: mapped %d of %d ECS rules to blink fixers\n", resolution.Mapped, resolution.Total) + if len(resolution.Unsupported) > 0 { + _, _ = fmt.Fprintf(w, " %d unsupported (no blink fixer), skipped:\n", len(resolution.Unsupported)) + for _, class := range resolution.Unsupported { + _, _ = fmt.Fprintf(w, " - %s\n", class) + } + } + if len(resolution.ConfigIgnored) > 0 { + _, _ = fmt.Fprintf(w, " %d configured rule(s) applied with blink's built-in behaviour (config not modelled):\n", len(resolution.ConfigIgnored)) + for _, class := range resolution.ConfigIgnored { + _, _ = fmt.Fprintf(w, " - %s\n", class) + } + } + if len(resolution.PerPathSkips) > 0 { + _, _ = fmt.Fprintln(w, " per-path rule skips applied project-wide (not yet honoured per path):") + for _, class := range resolution.PerPathSkips { + _, _ = fmt.Fprintf(w, " - %s\n", class) + } + } +} + +func listCheckers() int { + fmt.Println("Registered checkers:") + for _, r := range rules.All() { + fmt.Printf(" - %s\n", r.Name()) + } + return 0 +} + +func usage() { + fmt.Print(`blink - token-based PHP coding standard tool + +Usage: + blink [paths...] check paths (default: .) + blink --fix [paths...] fix paths in place + blink --config FILE ... use a blink.json config + blink --ecs-config FILE ... use an ECS dump-config JSON (turbo mode) + blink list-checkers list registered fixers + +Loads blink.json from the working directory when present. +Runs across all CPU cores by default. +`) +} diff --git a/blink/psr12-left.txt b/blink/psr12-left.txt new file mode 100644 index 0000000000..991e37ef93 --- /dev/null +++ b/blink/psr12-left.txt @@ -0,0 +1,60 @@ +PSR-12 rules left to implement +============================== + +Reference: PHP-CS-Fixer @PSR12 = @PSR2 (+ @PSR1) plus extra rules. +Below: what remains NOT implemented in blink. 47 fixers are done (see bottom). + +A statement/scope layer exists: Stream.MatchForward/MatchBackward, classifyBrace +(labels each "{"), and classMemberStarts (iterates class members). The rules +below need more of it (member reordering, type spans) or a fuller type model. + + +NEEDS FULLER AST / TYPE MODEL +----------------------------- +function_declaration "function" signature spacing, closures +compact_nullable_type_declaration "? int" -> "?int" in type positions +modifier_keywords modifier ordering (casing/presence done) +new_with_parentheses "new Foo" -> "new Foo()" (needs class-name ctx) + +Notes: +- method_argument_space is implemented for single-line comma spacing only; the + multi-line "one argument per line" reflow is not done. +- ordered_class_elements is conservative: it skips any class containing comments + or attributes (attributes lex as # comments here), reordering only simple + classes. Broadening it needs first-class attribute tokens. + + +LEFTOVERS (bounded / low value) +------------------------------- +no_break_comment add "// no break" on fallthrough (heuristic) +binary_operator_spaces arithmetic/bitwise (+ - * / % & | ^): unary- + ambiguous, need operand context + + +IMPLEMENTED (47) +---------------- +casing: lowercase_keywords, constant_case, lowercase_static_reference, + lowercase_cast, short_scalar_cast +spacing: no_leading_namespace_whitespace, no_singleline_whitespace_before_semicolons, + no_whitespace_in_blank_line, no_extra_blank_lines, space_after_semicolon, + binary_operator_spaces (=, =>, comparison, logical), concat_space, + cast_spaces, ternary_operator_spaces, blank_line_after_opening_tag, + no_trailing_whitespace, no_trailing_whitespace_in_comment, + single_blank_line_at_eof +functions: method_argument_space (single-line), return_type_declaration, + no_spaces_after_function_name +constructs: single_space_around_construct, spaces_inside_parentheses, + unary_operator_spaces, elseif, + no_leading_import_slash, declare_equal_normalize, + switch_case_semicolon_to_colon, switch_case_space, + no_multiple_statements_per_line +php tag: full_opening_tag, no_closing_tag +braces: braces_position +class: visibility_required, single_trait_insert_per_statement, + single_class_element_per_statement, class_definition, + ordered_class_elements (conservative) +imports: single_import_per_statement, ordered_imports, + blank_line_between_import_groups, single_line_after_imports +structural: blank_lines_before_namespace, blank_line_after_namespace, + no_blank_lines_after_class_opening, indentation_type, + statement_indentation diff --git a/blink/testdata/dirty.php b/blink/testdata/dirty.php new file mode 100644 index 0000000000..4c1a5f5aaa --- /dev/null +++ b/blink/testdata/dirty.php @@ -0,0 +1,12 @@ + Date: Tue, 29 Sep 2026 13:29:08 +0200 Subject: [PATCH 2/2] Match ECS lowercase-keyword const-name quirk; add blink composer bin Replicate PHP-CS-Fixer LowercaseKeywordsFixer behaviour on keyword-spelled class-constant names: lowercase the name only when the same keyword reappears after :: in its value, so blink is byte-identical to ECS on full rector-src. Expose blink as a lazy-building composer bin (bin/blink). --- bin/blink | 19 +++++ blink/internal/fixer/rules/casing.go | 69 ++++++++++++++++--- blink/internal/fixer/rules/regression_test.go | 15 ++++ composer.json | 3 +- 4 files changed, 95 insertions(+), 11 deletions(-) create mode 100755 bin/blink diff --git a/bin/blink b/bin/blink new file mode 100755 index 0000000000..a0d1da0382 --- /dev/null +++ b/bin/blink @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +# Composer bin wrapper for blink, the Go coding-standard tool under blink/. +# Builds the binary on first run (Go required) and execs it, so it works as +# vendor/bin/blink after installation without a build step at install time. +set -e + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/blink" +BINARY="$SRC/blink" + +if [ ! -x "$BINARY" ]; then + if ! command -v go >/dev/null 2>&1; then + echo "blink: Go toolchain required to build the binary (https://go.dev/dl)" >&2 + exit 1 + fi + (cd "$SRC" && go build -o "$BINARY" .) +fi + +exec "$BINARY" "$@" diff --git a/blink/internal/fixer/rules/casing.go b/blink/internal/fixer/rules/casing.go index cecf57b375..e3a57804cc 100644 --- a/blink/internal/fixer/rules/casing.go +++ b/blink/internal/fixer/rules/casing.go @@ -40,16 +40,27 @@ func (LowercaseKeywords) Fix(s *tokens.Stream) bool { if t.Kind != token.Keyword { continue } - // a keyword-spelled constant name in assignment position (e.g. - // "const string RETURN = ...") must not be lowercased - if nextSignificantValue(s, i) == "=" { - continue - } - // a keyword-spelled class/member name (e.g. "Enum" in "extends Enum", - // "Spatie\Enum\Enum", "class Enum", or a named argument "instanceOf:") is - // an identifier, not a keyword - if keywordUsedAsIdentifier(s, i) { - continue + // a keyword-spelled class-constant name is left as written, EXCEPT when the + // same keyword reappears after :: in that constant's value (e.g. + // "const int PUBLIC = Modifiers::PUBLIC" -> "public"). PHP-CS-Fixer's + // LowercaseKeywordsFixer lowercases it only in that case (a transformer + // misfire); mirroring it keeps blink 1:1 with ECS. + if isClassConstName(s, i) { + if !sameKeywordAfterDoubleColon(s, i, lowerASCII(t.Value)) { + continue + } + } else { + // a keyword-spelled constant name in assignment position (e.g. + // "const string RETURN = ...") must not be lowercased + if nextSignificantValue(s, i) == "=" { + continue + } + // a keyword-spelled class/member name (e.g. "Enum" in "extends Enum", + // "Spatie\Enum\Enum", "class Enum", or a named argument "instanceOf:") is + // an identifier, not a keyword + if keywordUsedAsIdentifier(s, i) { + continue + } } if lower := lowerASCII(t.Value); lower != t.Value { s.SetValue(i, lower) @@ -159,3 +170,41 @@ func keywordUsedAsIdentifier(s *tokens.Stream, i int) bool { } return false } + +// isClassConstName reports whether token i is the name in a class-constant +// declaration - "const NAME" or a typed "const NAME". +func isClassConstName(s *tokens.Stream, i int) bool { + j := prevSignificantIndex(s, i) + for steps := 0; j >= 0 && steps < 8; steps++ { + prev := s.At(j) + if prev.Kind == token.Keyword && strings.ToLower(prev.Value) == "const" { + return true + } + // walk back over the type between "const" and the name + if prev.Kind == token.Ident || prev.Kind == token.Keyword || + (prev.Kind == token.Punct && (prev.Value == "?" || prev.Value == "|" || prev.Value == "&" || prev.Value == `\`)) { + j = prevSignificantIndex(s, j) + continue + } + return false + } + return false +} + +// sameKeywordAfterDoubleColon reports whether the same keyword (name, already +// lowercased) reappears after :: within the constant's value, up to the ; or { +// that ends the declaration. +func sameKeywordAfterDoubleColon(s *tokens.Stream, i int, name string) bool { + for j := i + 1; j < s.Len(); j++ { + v := s.At(j) + if v.Kind == token.Punct && (v.Value == ";" || v.Value == "{") { + return false + } + if v.Kind == token.Punct && v.Value == "::" { + if n := nextSignificantIndex(s, j); n >= 0 && lowerASCII(s.At(n).Value) == name { + return true + } + } + } + return false +} diff --git a/blink/internal/fixer/rules/regression_test.go b/blink/internal/fixer/rules/regression_test.go index b82de864af..3aded384d6 100644 --- a/blink/internal/fixer/rules/regression_test.go +++ b/blink/internal/fixer/rules/regression_test.go @@ -10,6 +10,21 @@ func TestLowercaseKeywordsKeepsConstantName(t *testing.T) { } } +// ECS/PHP-CS-Fixer lowercases a keyword-spelled const name only when the same +// keyword reappears after :: in its value; blink mirrors that quirk for parity +func TestLowercaseKeywordsConstNameSelfReference(t *testing.T) { + self := "=8.4",