diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59bf406 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,118 @@ +root=true +[*] +charset = utf-8 +end_of_line = lf +ij_continuation_indent_size = 4 +indent_size = 4 +indent_style = space +insert_final_newline = true +tab_width = 4 +trim_trailing_whitespace = true + +[{*.php}] +ij_php_align_key_value_pairs = true +ij_php_align_multiline_array_initializer_expression = true +ij_php_align_multiline_chained_methods = true +ij_php_align_multiline_parameters = true +ij_php_align_multiline_parameters_in_calls = true +ij_php_align_phpdoc_comments = true +ij_php_align_phpdoc_param_names = true +ij_php_anonymous_brace_style = end_of_line +ij_php_blank_lines_after_imports = 1 +ij_php_blank_lines_after_opening_tag = 0 +ij_php_blank_lines_after_package = 1 +ij_php_blank_lines_around_method = 1 +ij_php_blank_lines_before_method_body = 0 +ij_php_blank_lines_before_package = 1 +ij_php_blank_lines_before_return_statement = 1 +ij_php_blank_lines_between_imports = 1 +ij_php_block_brace_style = end_of_line +ij_php_catch_on_new_line = false +ij_php_class_brace_style = next_line +ij_php_comma_after_last_argument = true +ij_php_comma_after_last_argument_style = when_multiline +ij_php_comma_after_last_array_element = true +ij_php_comma_after_last_closure_use_var = true +ij_php_comma_after_last_closure_use_var_style = when_multiline +ij_php_comma_after_last_match_arm = true +ij_php_comma_after_last_parameter = true +ij_php_comma_after_last_parameter_style = when_multiline +ij_php_concat_spaces = true +ij_php_do_while_brace_force = always +ij_php_else_if_style = combine +ij_php_else_on_new_line = false +ij_php_extends_keyword_wrap = off +ij_php_extends_list_wrap = off +ij_php_finally_on_new_line = false +ij_php_for_brace_force = always +ij_php_force_empty_methods_in_one_line = true +ij_php_force_short_declaration_array_style = true +ij_php_if_brace_force = always +ij_php_keep_blank_lines_in_code = 1 +ij_php_keep_blank_lines_in_declarations = 1 +ij_php_lower_case_boolean_const = true +ij_php_lower_case_keywords = true +ij_php_lower_case_null_const = true +ij_php_method_brace_style = next_line +ij_php_new_line_after_php_opening_tag = true +ij_php_phpdoc_blank_line_before_tags = true +ij_php_phpdoc_blank_lines_around_parameters = true +ij_php_phpdoc_param_spaces_between_name_and_description = 1 +ij_php_phpdoc_param_spaces_between_tag_and_type = 1 +ij_php_phpdoc_param_spaces_between_type_and_name = 1 +ij_php_sort_phpdoc_elements = true +ij_php_space_after_colon = true +ij_php_space_after_colon_in_enum_backed_type = true +ij_php_space_after_colon_in_named_argument = true +ij_php_space_after_colon_in_return_type = true +ij_php_space_after_comma = true +ij_php_space_after_for_semicolon = true +ij_php_space_after_quest = true +ij_php_space_after_type_cast = true +ij_php_space_after_unary_not = false +ij_php_space_before_catch_keyword = true +ij_php_space_before_catch_parentheses = true +ij_php_space_before_colon = true +ij_php_space_before_colon_in_enum_backed_type = false +ij_php_space_before_colon_in_named_argument = false +ij_php_space_before_colon_in_return_type = false +ij_php_space_before_comma = false +ij_php_space_before_else_keyword = true +ij_php_space_before_finally_keyword = true +ij_php_space_before_for_parentheses = true +ij_php_space_before_if_parentheses = true +ij_php_space_before_method_call_parentheses = false +ij_php_space_before_method_parentheses = false +ij_php_space_before_quest = true +ij_php_space_before_switch_parentheses = true +ij_php_space_before_while_keyword = true +ij_php_space_before_while_parentheses = true +ij_php_space_between_ternary_quest_and_colon = false +ij_php_spaces_around_additive_operators = true +ij_php_spaces_around_arrow = false +ij_php_spaces_around_assignment_in_declare = false +ij_php_spaces_around_assignment_operators = true +ij_php_spaces_around_bitwise_operators = true +ij_php_spaces_around_equality_operators = true +ij_php_spaces_around_logical_operators = true +ij_php_spaces_around_multiplicative_operators = true +ij_php_spaces_around_null_coalesce_operator = true +ij_php_spaces_around_pipe_in_union_type = false +ij_php_spaces_around_relational_operators = true +ij_php_spaces_around_shift_operators = true +ij_php_spaces_around_unary_operator = false +ij_php_spaces_around_var_within_brackets = false +ij_php_spaces_within_array_initializer_braces = false +ij_php_spaces_within_brackets = false +ij_php_spaces_within_catch_parentheses = false +ij_php_spaces_within_for_parentheses = false +ij_php_spaces_within_if_parentheses = false +ij_php_spaces_within_method_call_parentheses = false +ij_php_spaces_within_method_parentheses = false +ij_php_spaces_within_parentheses = false +ij_php_spaces_within_switch_parentheses = false +ij_php_spaces_within_while_parentheses = false +ij_php_upper_case_boolean_const = false +ij_php_upper_case_null_const = false +ij_php_while_brace_force = always +ij_php_while_on_new_line = false diff --git a/Kununu/CsFixer/Command/CsFixerCommand.php b/Kununu/CsFixer/Command/CsFixerCommand.php new file mode 100644 index 0000000..7eb9f62 --- /dev/null +++ b/Kununu/CsFixer/Command/CsFixerCommand.php @@ -0,0 +1,144 @@ +setName('kununu:cs-fixer') + ->setAliases(['cs-fixer']) + ->setDescription('Applies PHP CS Fixer on specified files or directories.') + ->addArgument( + self::ARGUMENT_FILES, + InputArgument::IS_ARRAY, + 'Files or directories to fix' + ) + ->addOption( + self::OPTION_CONFIG, + 'c', + InputOption::VALUE_OPTIONAL, + 'Path to a PHP CS Fixer config file' + ) + ->addOption( + self::OPTION_EXTRA_ARGS, + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Additional arguments to pass to PHP CS Fixer' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $files = $input->getArgument(self::ARGUMENT_FILES); + + if (empty($files)) { + $io->error('No files or directories were provided.'); + + return self::FAILURE; + } + + $vendorDir = $this->getVendorDir(); + if ($vendorDir === null) { + $io->error('Could not resolve the vendor directory.'); + + return self::FAILURE; + } + + $phpCsFixerBinary = $vendorDir . '/bin/php-cs-fixer'; + + if (!is_file($phpCsFixerBinary) || !is_executable($phpCsFixerBinary)) { + $io->error(sprintf( + 'PHP CS Fixer binary not found or not executable at "%s".', + $phpCsFixerBinary + )); + + return self::FAILURE; + } + + $configSource = $input->getOption(self::OPTION_CONFIG) ?: __DIR__ . '/../../../php-cs-fixer.php'; + $configPath = realpath($configSource); + + if ($configPath === false || !is_file($configPath)) { + $io->error(sprintf('Config file "%s" not found.', $configSource)); + + return self::FAILURE; + } + + $io->note(sprintf('Using config file: %s', $configPath)); + + $fixerArgs = $input->getOption(self::OPTION_EXTRA_ARGS) ?: []; + + if (!empty($fixerArgs)) { + $io->note(sprintf( + 'Passing additional fixer arguments: %s', + implode(' ', $fixerArgs) + )); + } + + $process = new Process( + array_merge( + [$phpCsFixerBinary, 'fix', '--config=' . $configPath], + $fixerArgs, + $files + ) + ); + + $process->setTimeout(null); + + $io->section('Running PHP CS Fixer...'); + + $process->run(static fn($type, $buffer) => $io->write($buffer)); + + if (!$process->isSuccessful()) { + $io->error('PHP CS Fixer encountered errors.'); + + return self::FAILURE; + } + + $io->success('PHP CS Fixer completed successfully.'); + + return self::SUCCESS; + } + + private function getVendorDir(): ?string + { + try { + $vendorDir = $this->requireComposer()->getConfig()->get('vendor-dir'); + + if (is_string($vendorDir) && is_dir($vendorDir)) { + $realPath = realpath($vendorDir); + + return $realPath !== false ? $realPath : $vendorDir; + } + } catch (Throwable) { + } + + $fallback = realpath(__DIR__ . '/../../../../../'); + if ($fallback !== false && is_dir($fallback)) { + return $fallback; + } + + return null; + } +} diff --git a/Kununu/CsFixer/Command/CsFixerGitHookCommand.php b/Kununu/CsFixer/Command/CsFixerGitHookCommand.php new file mode 100644 index 0000000..6edb13c --- /dev/null +++ b/Kununu/CsFixer/Command/CsFixerGitHookCommand.php @@ -0,0 +1,195 @@ +setName('kununu:cs-fixer-git-hook') + ->setAliases(['cs-fixer-git-hook']) + ->setDescription('Installs PHP CS Fixer as a Git pre-commit hook.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Installing PHP CS Fixer Git Pre‑Commit Hook'); + + try { + $rootPath = $this->getGitRootPath(); + $gitPath = $rootPath . '/.git'; + + if (!is_dir($gitPath)) { + throw new RuntimeException(sprintf( + '.git directory not found at "%s".', + $gitPath + )); + } + + $this->installHook($gitPath); + $this->linkConfigAndBinary($gitPath); + + $io->success('PHP CS Fixer Git pre‑commit hook installed successfully.'); + + return self::SUCCESS; + } catch (Throwable $e) { + $io->error('Installation failed: ' . $e->getMessage()); + + return self::FAILURE; + } + } + + private function getGitRootPath(): string + { + $cwd = getcwd(); + if ($cwd === false) { + throw new RuntimeException('Could not determine current working directory.'); + } + + // Mark the directory as safe + $process = new Process(['git', 'config', '--global', '--add', 'safe.directory', $cwd]); + $process->run(); + + $process = new Process(['git', 'rev-parse', '--show-toplevel']); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException('Not a Git repository or Git not available.'); + } + + return trim($process->getOutput()); + } + + private function installHook(string $gitPath): void + { + $hooksDir = $gitPath . '/hooks'; + $sourceHook = __DIR__ . '/../Hooks/git-pre-commit'; + $destHook = $hooksDir . '/pre-commit'; + + if (!is_dir($hooksDir) && !mkdir($hooksDir, 0777, true) && !is_dir($hooksDir)) { + throw new RuntimeException(sprintf( + 'Could not create hooks directory: "%s".', + $hooksDir + )); + } + + if (file_exists($destHook) && !unlink($destHook)) { + throw new RuntimeException(sprintf( + 'Could not remove existing hook at "%s".', + $destHook + )); + } + + if (!copy($sourceHook, $destHook)) { + throw new RuntimeException(sprintf( + 'Failed to copy hook from "%s" to "%s".', + $sourceHook, + $destHook + )); + } + + if (!chmod($destHook, 0755)) { + throw new RuntimeException(sprintf( + 'Failed to make hook executable at "%s".', + $destHook + )); + } + } + + private function linkConfigAndBinary(string $gitPath): void + { + $vendorDir = $this->resolveVendorDir($gitPath); + + $this->ensureSymlinkRelative( + $vendorDir . '/kununu/code-tools/php-cs-fixer.php', + $gitPath . '/kununu/.php-cs-fixer.php' + ); + + $this->ensureSymlinkRelative( + $vendorDir . '/bin/php-cs-fixer', + $gitPath . '/kununu/php-cs-fixer' + ); + } + + private function resolveVendorDir(string $rootGitPath): string + { + $repoRoot = basename($rootGitPath) === '.git' ? dirname($rootGitPath) : $rootGitPath; + + // Candidates where vendor/ might live: repo root, repo root/services, parent of repo root, parent/services + $parentRoot = dirname($repoRoot); + $candidates = [ + $repoRoot . '/vendor', + $repoRoot . '/services/vendor', + $parentRoot . '/vendor', + $parentRoot . '/services/vendor', + ]; + + foreach ($candidates as $candidate) { + if (is_dir($candidate)) { + return realpath($candidate) ?: $candidate; + } + } + + throw new RuntimeException('Could not find vendor directory in project root or its parent.'); + } + + private function ensureSymlinkRelative(string $target, string $linkPath): void + { + $linkDir = dirname($linkPath); + + if (!is_dir($linkDir) && !mkdir($linkDir, 0777, true) && !is_dir($linkDir)) { + throw new RuntimeException(sprintf( + 'Could not create directory for symlink: "%s".', + $linkDir + )); + } + + if (is_link($linkPath) || file_exists($linkPath)) { + unlink($linkPath); + } + + $relativeTarget = $this->makeRelativePath($linkDir, $target); + + if (!symlink($relativeTarget, $linkPath)) { + throw new RuntimeException(sprintf( + 'Failed to create symlink from "%s" to "%s".', + $linkPath, + $relativeTarget + )); + } + } + + private function makeRelativePath(string $from, string $to): string + { + $fromReal = realpath($from); + $toReal = realpath($to); + + if ($fromReal === false || $toReal === false) { + throw new RuntimeException('Invalid path(s) provided.'); + } + + $from = explode(DIRECTORY_SEPARATOR, $fromReal); + $to = explode(DIRECTORY_SEPARATOR, $toReal); + + while (count($from) && count($to) && ($from[0] === $to[0])) { + array_shift($from); + array_shift($to); + } + + return str_repeat('../', count($from)) . implode('/', $to); + } +} diff --git a/Kununu/CsFixer/CsFixerPlugin.php b/Kununu/CsFixer/CsFixerPlugin.php new file mode 100644 index 0000000..5678c95 --- /dev/null +++ b/Kununu/CsFixer/CsFixerPlugin.php @@ -0,0 +1,68 @@ + ['addCsFixerGitHooks'], + ScriptEvents::POST_UPDATE_CMD => ['addCsFixerGitHooks'], + ]; + } + + public function activate(Composer $composer, IOInterface $io): void + { + $this->composer = $composer; + $this->io = $io; + } + + public function deactivate(Composer $composer, IOInterface $io): void + { + } + + public function uninstall(Composer $composer, IOInterface $io): void + { + } + + public function getCapabilities(): array + { + return [ + CommandProvider::class => CsFixerCommandProvider::class, + ]; + } + + /** @throws ExceptionInterface */ + public function addCsFixerGitHooks(): void + { + $command = new CsFixerGitHookCommand(); + $command->setComposer($this->composer); + $command->setIO($this->io); + + $stdout = fopen('php://stdout', 'w'); + if ($stdout === false) { + throw new RuntimeException('Unable to open stdout stream.'); + } + + $command->run(new StringInput(''), new StreamOutput($stdout)); + } +} diff --git a/Kununu/CsFixer/Hooks/git-pre-commit b/Kununu/CsFixer/Hooks/git-pre-commit new file mode 100755 index 0000000..ced019a --- /dev/null +++ b/Kununu/CsFixer/Hooks/git-pre-commit @@ -0,0 +1,67 @@ +#!/bin/sh + +if [ ! -t 1 ]; then + RED='' + YELLOW='' + BLUE='' + GREEN='' + NC='' +else + RED='\e[0;31m' + YELLOW='\e[1;33m' + BLUE='\e[0;34m' + GREEN='\e[1;32m' + NC='\e[0m' +fi + +IS_MERGE=$(git rev-parse -q --verify MERGE_HEAD) +if [ -n "$IS_MERGE" ] ; then + exit 0 +fi + +printf "${BLUE}Pre commit hook starting${NC}\n" + +CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.php') + +if [ -n "$CHANGED_FILES" ]; then + KUNUNU_BIN_PATH="$(git rev-parse --show-toplevel)/.git/kununu" + PHP_CS_CONFIG="$KUNUNU_BIN_PATH/.php-cs-fixer.php" + PHP_CS_FIXER="$KUNUNU_BIN_PATH/php-cs-fixer" + + if ! [ -f "$PHP_CS_CONFIG" ]; then + printf "${RED}Missing php-cs-fixer rules file${NC}\n" + printf "${YELLOW}$PHP_CS_CONFIG${NC}\n" + + exit 1 + fi + + if ! [ -x "$(command -v $PHP_CS_FIXER)" ]; then + printf "${RED}Missing php-cs-fixer or php executable${NC}\n" + + exit 1 + fi + + OUTPUT="$($PHP_CS_FIXER fix --config "$PHP_CS_CONFIG" $CHANGED_FILES)" + case $OUTPUT in + *"1) "*) + printf "${GREEN}[POLICY]${NC}\n" + printf "${YELLOW}${OUTPUT}${NC}\n" + printf '%*s\n' 130 '' | tr ' ' '-' + printf "${YELLOW}The files you are committing do not follow the code standard, fixes where applied please review/add them to your commit${NC}\n" + + exit 1 + esac + + # Check if files that are staged follow the standard + for changed in $CHANGED_FILES; do + if git show :"$changed" | $PHP_CS_FIXER fix -n --config "$PHP_CS_CONFIG" - 2>/dev/null | grep -q "1) "; then + printf "${GREEN}[POLICY]${NC}\n" + printf '%*s\n' 130 '' | tr ' ' '-' + printf "${YELLOW}File ${BLUE}$changed${NC} ${YELLOW}doesn't follow the code standard, fixes where applied please review/add them to your commit${NC}\n" + + exit 1 + fi + done +fi + +printf "${BLUE}Pre commit hook finish${NC}\n" diff --git a/Kununu/CsFixer/Provider/CsFixerCommandProvider.php b/Kununu/CsFixer/Provider/CsFixerCommandProvider.php new file mode 100644 index 0000000..bd71477 --- /dev/null +++ b/Kununu/CsFixer/Provider/CsFixerCommandProvider.php @@ -0,0 +1,19 @@ +getTokens(); diff --git a/Kununu/Sniffs/Formatting/MethodSignatureArgumentsSniff.php b/Kununu/Sniffs/Formatting/MethodSignatureArgumentsSniff.php index 550d6aa..b030e81 100644 --- a/Kununu/Sniffs/Formatting/MethodSignatureArgumentsSniff.php +++ b/Kununu/Sniffs/Formatting/MethodSignatureArgumentsSniff.php @@ -252,7 +252,7 @@ protected function getMethodSignatureMultilineLength( array $tokens, int $stackPtr, array $methodProperties, - array $methodParameters + array $methodParameters, ): int { $totalLength = $this->getMethodSingleLineSignatureLength($tokens, $stackPtr); $firstLineEndPosition = $this->getLineEndingPosition($tokens, $stackPtr); diff --git a/README.md b/README.md index db770b7..c19bdc8 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,17 @@ # code-tools -- This repository contains code tools you can use in your project. +- This repository contains code tools you can use in your project. - It is a collection of tools and scripts that help us to maintain our codebase. ## Tools +### `.editorconfig` +- EditorConfig helps maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The EditorConfig project consists of a file format for defining coding styles and a collection of text editor plugins that enable editors to read the file format and adhere to defined styles. EditorConfig files are easily readable and they work nicely with version control systems. +- Learn more about `.editorconfig` at official page [here](https://editorconfig.org/). + +### `PHP-CS-Fixer` +- This project uses **PHP-CS-Fixer** to automatically format and fix PHP code according to defined coding standards. It helps maintain clean, consistent, and readable code across the codebase. + ### `PHP_CodeSniffer` - PHP_CodeSniffer is a set of two PHP scripts; the main `phpcs` script that tokenizes PHP, JavaScript and CSS files to detect violations of a defined coding standard, and a second `phpcbf` script to automatically correct coding standard violations. PHP_CodeSniffer is an essential development tool that ensures your code remains clean and consistent. - Though the usage of this tool is not mandatory, it is highly recommended to use it to ensure the quality of the codebase. @@ -49,7 +56,9 @@ composer require --dev kununu/code-tools --no-plugins - The `--no-plugins` is used to avoid the composer plugins to be executed and prevent generating unwanted configuration files, specially in projects with `symfony/flex` installed. ## Usage +- [.editorconfig](docs/EditorConfig/README.md) instructions. +- [PHP-CS-Fixer](docs/CsFixer/README.md) instructions. - [PHP_CodeSniffer](docs/CodeSniffer/README.md) instructions. - [Rector](docs/Rector/README.md) instructions. - [bin/code-tools](docs/CodeTools/README.md) instructions. -- [bin/php-in-k8s](docs/PhpInK8s/README.md) instructions. \ No newline at end of file +- [bin/php-in-k8s](docs/PhpInK8s/README.md) instructions. diff --git a/bin/code-tools b/bin/code-tools index f9dbe44..178cf37 100755 --- a/bin/code-tools +++ b/bin/code-tools @@ -1,64 +1,93 @@ -#!/bin/bash +#!/usr/bin/env bash + +set -euo pipefail + +# Resolve project root +PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")" + +# Constants +if [ -d "$PROJECT_ROOT/services/vendor/kununu/code-tools" ]; then + readonly CONFIG_DIR="$PROJECT_ROOT/services/vendor/kununu/code-tools" +elif [ -d "$PROJECT_ROOT/vendor/kununu/code-tools" ]; then + readonly CONFIG_DIR="$PROJECT_ROOT/vendor/kununu/code-tools" +elif [ -d "$PROJECT_ROOT" ]; then + readonly CONFIG_DIR="$PROJECT_ROOT" +else + echo "❌ Could not find 'vendor/kununu/code-tools'." + exit 1 +fi + +readonly CONFIG_FILES=( + "dist/php-cs-fixer.php.dist" + "dist/phpcs.xml.dist" + "dist/rector.php.dist" + "dist/.editorconfig.dist" +) # Function to display help instructions show_help() { - echo "Usage: vendor/bin/code-tools publish:config [tool-name]" - echo "Valid tool names: code-sniffer or rector" - echo "If no tool name is passed, then all the available config files for each tool will be copied." - echo "Options:" - echo " --help Show help message." + cat < copies dist/php-cs-fixer.php.dist + code-sniffer => copies dist/phpcs.xml.dist + rector => copies dist/rector.php.dist + editorconfig => copies dist/.editorconfig.dist +EOF } -# Array of config files -CONFIG_FILES=("dist/phpcs.xml.dist" "dist/rector.php.dist") +# Function to copy a config file +copy_config_file() { + local source_file="$CONFIG_DIR/$1" + local dest_file="${1#dist/}" + dest_file="${dest_file%.dist}" + local dest_path="$PROJECT_ROOT/$dest_file" -# Check if help is requested -if [ "$1" == "--help" ]; then + if [ -f "$dest_path" ]; then + echo "⚠️ Config file '$dest_file' already exists at project level." + elif [ -f "$source_file" ]; then + cp "$source_file" "$dest_path" + echo "✅ Config file '$dest_file' copied to project level." + else + echo "❌ Config file '$source_file' does not exist." + fi +} + +# Parse arguments +if [ "${1:-}" == "--help" ]; then show_help exit 0 fi -# Check if at least one argument is provided -if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then +if [ "$#" -ne 2 ]; then show_help exit 1 fi -# Validate the first argument if [ "$1" != "publish:config" ]; then - echo "Invalid command. Valid command: publish:config" + echo "❌ Invalid command. Valid command: publish:config" exit 1 fi -# Function to copy a config file -copy_config_file() { - local SOURCE_FILE="vendor/kununu/code-tools/$1" - local DEST_FILE="${1#dist/}" - DEST_FILE="${DEST_FILE%.dist}" +tool_name="$2" - if [ -f "$DEST_FILE" ]; then - echo "Config file '$DEST_FILE' already exists at project level." - elif [ -f "$SOURCE_FILE" ]; then - cp "$SOURCE_FILE" "$DEST_FILE" - echo "Config file '$DEST_FILE' copied to project level, customize it as you like." - else - echo "Config file '$SOURCE_FILE' does not exist." - fi -} - -# If a second argument is provided, validate and copy the specific config file -if [ "$#" -eq 2 ]; then - if [ "$2" == "code-sniffer" ]; then +case "$tool_name" in + cs-fixer) + copy_config_file "dist/php-cs-fixer.php.dist" + ;; + code-sniffer) copy_config_file "dist/phpcs.xml.dist" - elif [ "$2" == "rector" ]; then + ;; + rector) copy_config_file "dist/rector.php.dist" - else - echo "Invalid config file name. Valid config file name: code-sniffer or rector" + ;; + editorconfig) + copy_config_file "dist/.editorconfig.dist" + ;; + *) + echo "❌ Invalid tool name: '$tool_name'" + show_help exit 1 - fi -else - # If no second argument, copy all config files - for config_file in "${CONFIG_FILES[@]}"; do - copy_config_file "$config_file" - done -fi + ;; +esac diff --git a/composer.json b/composer.json index 2796edc..e44a044 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "kununu/code-tools", "description": "Code Tools", - "type": "library", + "type": "composer-plugin", "license": "MIT", "authors": [ { @@ -11,11 +11,13 @@ ], "require": { "php": ">=8.1", + "composer-plugin-api": "^2.0", + "composer/composer": "^2.8", + "friendsofphp/php-cs-fixer": "^3.75", "rector/rector": "^2.0", "squizlabs/php_codesniffer": "^3.10" }, "require-dev": { - "kununu/scripts": "^5.1", "phpunit/phpunit": "^11.5" }, "autoload": { @@ -34,18 +36,21 @@ ], "config": { "sort-packages": true, - "process-timeout": 900, - "allow-plugins": { - "kununu/scripts": true - } + "process-timeout": 900 + }, + "extra": { + "class": "Kununu\\CsFixer\\CsFixerPlugin" }, "scripts": { "test-unit": "phpunit", + "cs-fixer-check": "php-cs-fixer check --config=php-cs-fixer.php Kununu/ tests/", + "cs-fixer-fix": "php-cs-fixer fix --config=php-cs-fixer.php Kununu/ tests/", "cs-check": "phpcs --standard=phpcs.xml Kununu/ tests/", "cs-fix": "phpcbf --standard=phpcs.xml Kununu/ tests/", "stan": "phpstan analyze", "rector": "rector process --dry-run Kununu/ tests/", "ci-checks": [ + "@cs-fixer-check", "@cs-check", "@stan", "@test-unit" diff --git a/dist/.editorconfig.dist b/dist/.editorconfig.dist new file mode 100644 index 0000000..59bf406 --- /dev/null +++ b/dist/.editorconfig.dist @@ -0,0 +1,118 @@ +root=true +[*] +charset = utf-8 +end_of_line = lf +ij_continuation_indent_size = 4 +indent_size = 4 +indent_style = space +insert_final_newline = true +tab_width = 4 +trim_trailing_whitespace = true + +[{*.php}] +ij_php_align_key_value_pairs = true +ij_php_align_multiline_array_initializer_expression = true +ij_php_align_multiline_chained_methods = true +ij_php_align_multiline_parameters = true +ij_php_align_multiline_parameters_in_calls = true +ij_php_align_phpdoc_comments = true +ij_php_align_phpdoc_param_names = true +ij_php_anonymous_brace_style = end_of_line +ij_php_blank_lines_after_imports = 1 +ij_php_blank_lines_after_opening_tag = 0 +ij_php_blank_lines_after_package = 1 +ij_php_blank_lines_around_method = 1 +ij_php_blank_lines_before_method_body = 0 +ij_php_blank_lines_before_package = 1 +ij_php_blank_lines_before_return_statement = 1 +ij_php_blank_lines_between_imports = 1 +ij_php_block_brace_style = end_of_line +ij_php_catch_on_new_line = false +ij_php_class_brace_style = next_line +ij_php_comma_after_last_argument = true +ij_php_comma_after_last_argument_style = when_multiline +ij_php_comma_after_last_array_element = true +ij_php_comma_after_last_closure_use_var = true +ij_php_comma_after_last_closure_use_var_style = when_multiline +ij_php_comma_after_last_match_arm = true +ij_php_comma_after_last_parameter = true +ij_php_comma_after_last_parameter_style = when_multiline +ij_php_concat_spaces = true +ij_php_do_while_brace_force = always +ij_php_else_if_style = combine +ij_php_else_on_new_line = false +ij_php_extends_keyword_wrap = off +ij_php_extends_list_wrap = off +ij_php_finally_on_new_line = false +ij_php_for_brace_force = always +ij_php_force_empty_methods_in_one_line = true +ij_php_force_short_declaration_array_style = true +ij_php_if_brace_force = always +ij_php_keep_blank_lines_in_code = 1 +ij_php_keep_blank_lines_in_declarations = 1 +ij_php_lower_case_boolean_const = true +ij_php_lower_case_keywords = true +ij_php_lower_case_null_const = true +ij_php_method_brace_style = next_line +ij_php_new_line_after_php_opening_tag = true +ij_php_phpdoc_blank_line_before_tags = true +ij_php_phpdoc_blank_lines_around_parameters = true +ij_php_phpdoc_param_spaces_between_name_and_description = 1 +ij_php_phpdoc_param_spaces_between_tag_and_type = 1 +ij_php_phpdoc_param_spaces_between_type_and_name = 1 +ij_php_sort_phpdoc_elements = true +ij_php_space_after_colon = true +ij_php_space_after_colon_in_enum_backed_type = true +ij_php_space_after_colon_in_named_argument = true +ij_php_space_after_colon_in_return_type = true +ij_php_space_after_comma = true +ij_php_space_after_for_semicolon = true +ij_php_space_after_quest = true +ij_php_space_after_type_cast = true +ij_php_space_after_unary_not = false +ij_php_space_before_catch_keyword = true +ij_php_space_before_catch_parentheses = true +ij_php_space_before_colon = true +ij_php_space_before_colon_in_enum_backed_type = false +ij_php_space_before_colon_in_named_argument = false +ij_php_space_before_colon_in_return_type = false +ij_php_space_before_comma = false +ij_php_space_before_else_keyword = true +ij_php_space_before_finally_keyword = true +ij_php_space_before_for_parentheses = true +ij_php_space_before_if_parentheses = true +ij_php_space_before_method_call_parentheses = false +ij_php_space_before_method_parentheses = false +ij_php_space_before_quest = true +ij_php_space_before_switch_parentheses = true +ij_php_space_before_while_keyword = true +ij_php_space_before_while_parentheses = true +ij_php_space_between_ternary_quest_and_colon = false +ij_php_spaces_around_additive_operators = true +ij_php_spaces_around_arrow = false +ij_php_spaces_around_assignment_in_declare = false +ij_php_spaces_around_assignment_operators = true +ij_php_spaces_around_bitwise_operators = true +ij_php_spaces_around_equality_operators = true +ij_php_spaces_around_logical_operators = true +ij_php_spaces_around_multiplicative_operators = true +ij_php_spaces_around_null_coalesce_operator = true +ij_php_spaces_around_pipe_in_union_type = false +ij_php_spaces_around_relational_operators = true +ij_php_spaces_around_shift_operators = true +ij_php_spaces_around_unary_operator = false +ij_php_spaces_around_var_within_brackets = false +ij_php_spaces_within_array_initializer_braces = false +ij_php_spaces_within_brackets = false +ij_php_spaces_within_catch_parentheses = false +ij_php_spaces_within_for_parentheses = false +ij_php_spaces_within_if_parentheses = false +ij_php_spaces_within_method_call_parentheses = false +ij_php_spaces_within_method_parentheses = false +ij_php_spaces_within_parentheses = false +ij_php_spaces_within_switch_parentheses = false +ij_php_spaces_within_while_parentheses = false +ij_php_upper_case_boolean_const = false +ij_php_upper_case_null_const = false +ij_php_while_brace_force = always +ij_php_while_on_new_line = false diff --git a/dist/php-cs-fixer.php.dist b/dist/php-cs-fixer.php.dist new file mode 100644 index 0000000..bedda2c --- /dev/null +++ b/dist/php-cs-fixer.php.dist @@ -0,0 +1,85 @@ + https://cs.symfony.com/doc/rules/index.html +return (new Config()) + ->setParallelConfig(ParallelConfigFactory::detect()) + ->setCacheFile('/tmp/.php-cs-fixer.php.cache') + ->setRiskyAllowed(true) + ->setRules([ + // Import rule sets + '@Symfony' => true, + '@Symfony:risky' => true, + // Custom rules + 'binary_operator_spaces' => [ + 'operators' => [ + '=>' => 'align', + ], + ], + 'blank_line_after_opening_tag' => false, + 'combine_consecutive_unsets' => true, + 'concat_space' => [ + 'spacing' => 'one', + ], + 'declare_strict_types' => true, + 'explicit_indirect_variable' => true, + 'function_declaration' => [ + 'closure_function_spacing' => 'none', + 'closure_fn_spacing' => 'none', + ], + 'global_namespace_import' => [ + 'import_classes' => true, + ], + 'heredoc_to_nowdoc' => true, + 'native_constant_invocation' => [ + 'fix_built_in' => false, + 'strict' => true, + ], + 'native_function_invocation' => [ + 'include' => [], + ], + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'attribute', + 'break', + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'switch', + 'throw', + 'use', + ], + ], + 'no_superfluous_phpdoc_tags' => false, + 'no_trailing_whitespace_in_string' => false, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_summary' => false, + 'return_assignment' => true, + 'self_accessor' => false, + 'self_static_accessor' => true, + 'single_line_throw' => false, + 'ternary_to_null_coalescing' => true, + 'void_return' => true, + 'yoda_style' => [ + 'always_move_variable' => false, + 'equal' => false, + 'identical' => false, + 'less_and_greater' => false, + ], + ]) + ->setFinder( + Finder::create() + ->exclude('vendor') + ->exclude('var') + ->in(__DIR__) + ); diff --git a/docs/CsFixer/README.md b/docs/CsFixer/README.md new file mode 100644 index 0000000..922930a --- /dev/null +++ b/docs/CsFixer/README.md @@ -0,0 +1,22 @@ +# PHP CS Fixer + +## Commands +### `kununu:cs-fixer` +- Runs PHP CS Fixer on the specified directories or files using the default config. + - Example: + - `composer kununu:cs-fixer src/ tests/` +- Runs PHP CS Fixer with a **custom config** file. + - Example: + - `composer kununu:cs-fixer --config=/var/www/html/services/vendor/kununu/code-tools/php-cs-fixer.php src/ tests/` + +### `kununu:cs-fixer-git-hook` +- Installs the Kununu pre-commit Git hook for coding standards enforcement + - Run: + - `composer kununu:cs-fixer-git-hook` + +## Pre-commit hook +Since this project is a _composer-plugin_, the composer `kununu:cs-fixer-git-hook` command is automatically applied during install or update. + +## Rules and Configuration +- PHP CS Fixer configuration rules can be found [here](https://cs.symfony.com/doc/rules/index.html). +- Kununu coding standards rules are located [here](../../php-cs-fixer.php). diff --git a/docs/EditorConfig/README.md b/docs/EditorConfig/README.md new file mode 100644 index 0000000..61902ab --- /dev/null +++ b/docs/EditorConfig/README.md @@ -0,0 +1,30 @@ +# .editorconfig + +This repository includes a `.editorconfig` file to help maintain consistent coding styles across different editors and IDEs. EditorConfig is supported by many popular code editors and plugins, and ensures uniform formatting rules for all contributors to this project. + +Learn more about `.editorconfig` at official page [here](https://editorconfig.org/). + +## 📄 What is .editorconfig? + +The `.editorconfig` file defines basic coding style rules (like indentation, line endings, character encoding, etc.) that are automatically applied by supported text editors. + +This helps: +- Prevent accidental style changes in commits +- Maintain clean and readable code +- Standardize formatting across different team members' environments + +## ⚙️ Sample .editorconfig + +```ini +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/php-cs-fixer.php b/php-cs-fixer.php new file mode 100644 index 0000000..bedda2c --- /dev/null +++ b/php-cs-fixer.php @@ -0,0 +1,85 @@ + https://cs.symfony.com/doc/rules/index.html +return (new Config()) + ->setParallelConfig(ParallelConfigFactory::detect()) + ->setCacheFile('/tmp/.php-cs-fixer.php.cache') + ->setRiskyAllowed(true) + ->setRules([ + // Import rule sets + '@Symfony' => true, + '@Symfony:risky' => true, + // Custom rules + 'binary_operator_spaces' => [ + 'operators' => [ + '=>' => 'align', + ], + ], + 'blank_line_after_opening_tag' => false, + 'combine_consecutive_unsets' => true, + 'concat_space' => [ + 'spacing' => 'one', + ], + 'declare_strict_types' => true, + 'explicit_indirect_variable' => true, + 'function_declaration' => [ + 'closure_function_spacing' => 'none', + 'closure_fn_spacing' => 'none', + ], + 'global_namespace_import' => [ + 'import_classes' => true, + ], + 'heredoc_to_nowdoc' => true, + 'native_constant_invocation' => [ + 'fix_built_in' => false, + 'strict' => true, + ], + 'native_function_invocation' => [ + 'include' => [], + ], + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'attribute', + 'break', + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'switch', + 'throw', + 'use', + ], + ], + 'no_superfluous_phpdoc_tags' => false, + 'no_trailing_whitespace_in_string' => false, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_summary' => false, + 'return_assignment' => true, + 'self_accessor' => false, + 'self_static_accessor' => true, + 'single_line_throw' => false, + 'ternary_to_null_coalescing' => true, + 'void_return' => true, + 'yoda_style' => [ + 'always_move_variable' => false, + 'equal' => false, + 'identical' => false, + 'less_and_greater' => false, + ], + ]) + ->setFinder( + Finder::create() + ->exclude('vendor') + ->exclude('var') + ->in(__DIR__) + ); diff --git a/tests/SniffTestCase.php b/tests/SniffTestCase.php index 67c9666..7f86144 100644 --- a/tests/SniffTestCase.php +++ b/tests/SniffTestCase.php @@ -40,7 +40,7 @@ private function runFixer( Sniff $sniffer, ?int $errorCount = null, ?int $fixableErrorCount = null, - bool $fix = false + bool $fix = false, ): array { $codeSniffer = new Runner(); $codeSniffer->config = new Config(['-s']); diff --git a/tests/Unit/Kununu/CsFixer/Command/CsFixerCommandTest.php b/tests/Unit/Kununu/CsFixer/Command/CsFixerCommandTest.php new file mode 100644 index 0000000..8bc6263 --- /dev/null +++ b/tests/Unit/Kununu/CsFixer/Command/CsFixerCommandTest.php @@ -0,0 +1,69 @@ +tempFile = sys_get_temp_dir() . '/csfixer_' . uniqid('', true) . '.php'; + if (file_put_contents($this->tempFile, $before) === false) { + $this->fail('Failed to write temporary file for test: ' . $this->tempFile); + } + + $application = new Application(); + $application->add(new CsFixerCommand()); + + $command = $application->find('kununu:cs-fixer'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + 'files' => [$this->tempFile], + ]); + + self::assertSame(0, $exitCode); + self::assertSame($after, $this->contents($this->tempFile)); + } + + public static function fixerCasesProvider(): array + { + $casesFile = __DIR__ . '/../_data/fixer_test_cases.php'; + if (!is_file($casesFile)) { + self::fail(sprintf('Fixture file not found: %s', $casesFile)); + } + + $cases = require $casesFile; + + return array_map(fn ($case) => [$case['before'], $case['after']], $cases); + } + + private function contents(string $file): string + { + $contents = file_get_contents($file); + + if ($contents === false) { + $this->fail(sprintf('Failed to read file: %s', $file)); + } + + return $contents; + } + + protected function tearDown(): void + { + if ($this->tempFile !== null && is_file($this->tempFile)) { + unlink($this->tempFile); + } + + parent::tearDown(); + } +} diff --git a/tests/Unit/Kununu/CsFixer/Command/CsFixerGitHookCommandTest.php b/tests/Unit/Kununu/CsFixer/Command/CsFixerGitHookCommandTest.php new file mode 100644 index 0000000..38a58b7 --- /dev/null +++ b/tests/Unit/Kununu/CsFixer/Command/CsFixerGitHookCommandTest.php @@ -0,0 +1,96 @@ +add(new CsFixerGitHookCommand()); + + $command = $app->find('kununu:cs-fixer-git-hook'); + $tester = new CommandTester($command); + + chdir($this->repoDir); + + $exitCode = $tester->execute([]); + + self::assertSame(CsFixerGitHookCommand::FAILURE, $exitCode); + self::assertStringContainsString('Not a Git repository or Git not available.', $tester->getDisplay()); + } + + public function testInstallsHookSuccessfully(): void + { + // 1) Make this dir a real git repo so `git rev-parse` succeeds + chdir($this->repoDir); + exec('git init 2>/dev/null'); + + // 2) Create the vendor tree where *your current code expects it*: + // resolveVendorDir(dirname($rootGitPath)) + // If rootGitPath is $this->repoDir, it will look under $this->baseDir/vendor + $vendorBase = $this->baseDir . '/vendor'; + $codeToolsDir = $vendorBase . '/kununu/code-tools'; + $binDir = $vendorBase . '/bin'; + mkdir($codeToolsDir, 0777, true); + mkdir($binDir, 0777, true); + + // Files the command symlinks to: + file_put_contents($codeToolsDir . '/php-cs-fixer.php', "add(new CsFixerGitHookCommand()); + + $command = $app->find('kununu:cs-fixer-git-hook'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([]); + + self::assertSame(CsFixerGitHookCommand::SUCCESS, $exitCode); + + $gitPath = $this->repoDir . '/.git'; + + $hook = $gitPath . '/hooks/pre-commit'; + self::assertFileExists($hook); + self::assertTrue(is_executable($hook), 'pre-commit should be executable'); + + $symlinkConfig = $gitPath . '/kununu/.php-cs-fixer.php'; + $symlinkBin = $gitPath . '/kununu/php-cs-fixer'; + + self::assertTrue(is_link($symlinkConfig), '.php-cs-fixer.php symlink should exist'); + self::assertTrue(is_link($symlinkBin), 'php-cs-fixer symlink should exist'); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->baseDir = sys_get_temp_dir() . '/csfixer_git_' . uniqid('', true); + $this->repoDir = $this->baseDir . '/project'; + + mkdir($this->repoDir, 0777, true); + + $this->oldCwd = (string) getcwd(); + } + + protected function tearDown(): void + { + chdir($this->oldCwd); + if (is_dir($this->baseDir)) { + exec('rm -rf ' . escapeshellarg($this->baseDir)); + } + parent::tearDown(); + } +} diff --git a/tests/Unit/Kununu/CsFixer/_data/fixer_test_cases.php b/tests/Unit/Kununu/CsFixer/_data/fixer_test_cases.php new file mode 100644 index 0000000..7da2f84 --- /dev/null +++ b/tests/Unit/Kununu/CsFixer/_data/fixer_test_cases.php @@ -0,0 +1,588 @@ + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' +1, + 'two' =>2, + 'three'=> 3 +]; +PHP + , + 'after' => <<<'PHP' + 1, + 'two' => 2, + 'three'=> 3, +]; + +PHP + ], + + 'explicit_indirect_variable' => [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' +format('Y'); + } +} +PHP + , + 'after' => <<<'PHP' +format('Y'); + } +} + +PHP + ], + + 'function_declaration_spacing' => [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' +isReady()) { + return true; + } else { + return false; + } + + $foo = 42; + return $foo; + + return; + } + + private function isReady(): bool + { + return static::$count > 0; + } +} +PHP + , + 'after' => <<<'PHP' +isReady()) { + return true; + } + + return false; + + return 42; + } + + private function isReady(): bool + { + return static::$count > 0; + } +} + +PHP + ], + + 'ternary_to_null_coalescing' => [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + 0; + } +} +PHP + , + 'after' => <<<'PHP' + 0; + } +} + +PHP + ], + + 'void_return' => [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' + [ + 'before' => <<<'PHP' + <<<'PHP' +