├── src ├── ConventionalCommits │ ├── Exception │ │ ├── ConventionalException.php │ │ ├── InvalidValue.php │ │ ├── ComposerNotFound.php │ │ ├── InvalidCommitMessage.php │ │ ├── InvalidConsoleInput.php │ │ └── InvalidArgument.php │ ├── Message │ │ ├── Unit.php │ │ ├── Scope.php │ │ ├── Body.php │ │ ├── Text.php │ │ ├── Type.php │ │ ├── Description.php │ │ ├── Noun.php │ │ └── Footer.php │ ├── Converter │ │ ├── Convertible.php │ │ └── LetterCaseConverter.php │ ├── Console │ │ ├── Question │ │ │ ├── HasBreakingChangesQuestion.php │ │ │ ├── AffectsOpenIssuesQuestion.php │ │ │ ├── AddFootersQuestion.php │ │ │ ├── DescribeBreakingChangesQuestion.php │ │ │ ├── FooterValueQuestion.php │ │ │ ├── MessageQuestion.php │ │ │ ├── IssueIdentifierQuestion.php │ │ │ ├── IssueTypeQuestion.php │ │ │ ├── DescriptionQuestion.php │ │ │ ├── TypeQuestion.php │ │ │ ├── BodyQuestion.php │ │ │ ├── FooterTokenQuestion.php │ │ │ └── ScopeQuestion.php │ │ ├── SymfonyStyleFactory.php │ │ └── Command │ │ │ ├── BaseCommand.php │ │ │ ├── ConfigCommand.php │ │ │ ├── ValidateCommand.php │ │ │ └── PrepareCommand.php │ ├── Configuration │ │ ├── Configurable.php │ │ ├── ConfigurableTool.php │ │ ├── Configuration.php │ │ ├── DefaultConfiguration.php │ │ └── FinderTool.php │ ├── Validator │ │ ├── Validator.php │ │ ├── Validatable.php │ │ ├── ValidatableTool.php │ │ ├── LetterCaseValidator.php │ │ ├── MessageValidator.php │ │ ├── EndMarkValidator.php │ │ ├── ScopeValidator.php │ │ ├── TypeValidator.php │ │ ├── RequiredFootersValidator.php │ │ └── DefaultMessageValidator.php │ ├── String │ │ └── LetterCase.php │ ├── Parser.php │ └── Message.php └── CaptainHook │ ├── Output.php │ ├── Input.php │ ├── PrepareConventionalCommit.php │ └── ValidateConventionalCommit.php ├── LICENSE ├── bin └── conventional-commits ├── schema.json ├── composer.json └── README.md /src/ConventionalCommits/Exception/ConventionalException.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Exception; 23 | 24 | use Throwable as PhpThrowable; 25 | 26 | interface ConventionalException extends PhpThrowable 27 | { 28 | } 29 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Exception/InvalidValue.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Exception; 23 | 24 | use RuntimeException; 25 | 26 | class InvalidValue extends RuntimeException implements ConventionalException 27 | { 28 | } 29 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Exception/ComposerNotFound.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Exception; 23 | 24 | use RuntimeException; 25 | 26 | class ComposerNotFound extends RuntimeException implements ConventionalException 27 | { 28 | } 29 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Exception/InvalidCommitMessage.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Exception; 23 | 24 | use RuntimeException; 25 | 26 | class InvalidCommitMessage extends RuntimeException implements ConventionalException 27 | { 28 | } 29 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Exception/InvalidConsoleInput.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Exception; 23 | 24 | use RuntimeException; 25 | 26 | class InvalidConsoleInput extends RuntimeException implements ConventionalException 27 | { 28 | } 29 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Exception/InvalidArgument.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Exception; 23 | 24 | use InvalidArgumentException; 25 | 26 | class InvalidArgument extends InvalidArgumentException implements ConventionalException 27 | { 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2023 Ben Ramsey 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Message/Unit.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Message; 23 | 24 | use Ramsey\ConventionalCommits\Validator\Validatable; 25 | use Stringable; 26 | 27 | /** 28 | * A Conventional Commits unit of information 29 | */ 30 | interface Unit extends Stringable, Validatable 31 | { 32 | public function toString(): string; 33 | } 34 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Converter/Convertible.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Converter; 23 | 24 | /** 25 | * A convertible class provides functionality to convert values from one form 26 | * into another, depending on the type of conversion 27 | */ 28 | interface Convertible 29 | { 30 | /** 31 | * @param mixed $value 32 | * 33 | * @return mixed 34 | */ 35 | public function convert($value); 36 | } 37 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/HasBreakingChangesQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Symfony\Component\Console\Question\ConfirmationQuestion; 25 | 26 | /** 27 | * A question asking the user whether the commit introduces any breaking changes 28 | */ 29 | class HasBreakingChangesQuestion extends ConfirmationQuestion 30 | { 31 | public function __construct() 32 | { 33 | parent::__construct('Are there any breaking changes?', false); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Configuration/Configurable.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Configuration; 23 | 24 | /** 25 | * Classes that are configurable provide methods to set and get configuration 26 | */ 27 | interface Configurable 28 | { 29 | /** 30 | * Set the configuration for this instance 31 | */ 32 | public function setConfiguration(Configuration $configuration): void; 33 | 34 | /** 35 | * Returns the configuration for this instance 36 | */ 37 | public function getConfiguration(): Configuration; 38 | } 39 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Message/Scope.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Message; 23 | 24 | /** 25 | * A Conventional Commits scope 26 | * 27 | * From the Conventional Commits 1.0.0 specification: 28 | * 29 | * > 4. A scope MAY be provided after a type. A scope MUST consist of a noun 30 | * > describing a section of the codebase surrounded by parenthesis, e.g., 31 | * > `fix(parser):`. 32 | * 33 | * @link https://www.conventionalcommits.org/en/v1.0.0/#specification Conventional Commits 34 | */ 35 | class Scope extends Noun 36 | { 37 | } 38 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/AffectsOpenIssuesQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Symfony\Component\Console\Question\ConfirmationQuestion; 25 | 26 | /** 27 | * A question asking the user whether the commit affects any open issues or 28 | * tickets in an issue-tracker system 29 | */ 30 | class AffectsOpenIssuesQuestion extends ConfirmationQuestion 31 | { 32 | public function __construct() 33 | { 34 | parent::__construct('Does this change affect any open issues?', false); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/SymfonyStyleFactory.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console; 23 | 24 | use Symfony\Component\Console\Input\InputInterface; 25 | use Symfony\Component\Console\Output\OutputInterface; 26 | use Symfony\Component\Console\Style\SymfonyStyle; 27 | 28 | /** 29 | * A factory useful for creating a SymfonyStyle instance 30 | */ 31 | class SymfonyStyleFactory 32 | { 33 | public function factory(InputInterface $input, OutputInterface $output): SymfonyStyle 34 | { 35 | return new SymfonyStyle($input, $output); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/AddFootersQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Symfony\Component\Console\Question\ConfirmationQuestion; 25 | 26 | /** 27 | * A question asking the user whether they would like to add any footers to the 28 | * commit message 29 | */ 30 | class AddFootersQuestion extends ConfirmationQuestion 31 | { 32 | public function __construct() 33 | { 34 | parent::__construct( 35 | 'Would you like to add any footers? (e.g., Signed-off-by, See-also)', 36 | false, 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Message/Body.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Message; 23 | 24 | /** 25 | * A Conventional Commits body 26 | * 27 | * From the Conventional Commits 1.0.0 specification: 28 | * 29 | * > 6. A longer commit body MAY be provided after the short description, 30 | * > providing additional contextual information about the code changes. The 31 | * > body MUST begin one blank line after the description. 32 | * > 33 | * > 7. A commit body is free-form and MAY consist of any number of newline 34 | * > separated paragraphs. 35 | * 36 | * @link https://www.conventionalcommits.org/en/v1.0.0/#specification Conventional Commits 37 | */ 38 | class Body extends Text 39 | { 40 | } 41 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Message/Text.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Message; 23 | 24 | use Ramsey\ConventionalCommits\Validator\ValidatableTool; 25 | 26 | use function trim; 27 | 28 | /** 29 | * A text field used for Conventional Commits 30 | */ 31 | abstract class Text implements Unit 32 | { 33 | use ValidatableTool; 34 | 35 | private string $text; 36 | 37 | public function __construct(string $text) 38 | { 39 | $this->text = trim($text); 40 | } 41 | 42 | public function __toString(): string 43 | { 44 | return $this->toString(); 45 | } 46 | 47 | public function toString(): string 48 | { 49 | return $this->text; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Configuration/ConfigurableTool.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Configuration; 23 | 24 | /** 25 | * This tool provides standard functionality for instances implementing Configurable 26 | */ 27 | trait ConfigurableTool 28 | { 29 | private ?Configuration $configuration = null; 30 | 31 | public function setConfiguration(Configuration $configuration): void 32 | { 33 | $this->configuration = $configuration; 34 | } 35 | 36 | public function getConfiguration(): Configuration 37 | { 38 | if ($this->configuration === null) { 39 | $this->configuration = new DefaultConfiguration(); 40 | } 41 | 42 | return $this->configuration; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/Validator.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 25 | 26 | /** 27 | * A validator analyzes a value and determines whether it is valid according to 28 | * the validator's rules 29 | */ 30 | interface Validator 31 | { 32 | /** 33 | * Returns boolean true if $value is valid 34 | * 35 | * @param mixed $value 36 | */ 37 | public function isValid($value): bool; 38 | 39 | /** 40 | * Returns boolean true if $value is valid, otherwise throws exception 41 | * 42 | * @param mixed $value 43 | * 44 | * @throws InvalidValue 45 | */ 46 | public function isValidOrException($value): bool; 47 | } 48 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/Validatable.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 25 | 26 | /** 27 | * Classes that are validatable provide methods to add and retrieve validators 28 | */ 29 | interface Validatable 30 | { 31 | /** 32 | * Adds a validator to this instance's stack of validators 33 | */ 34 | public function addValidator(Validator $validator): void; 35 | 36 | /** 37 | * Returns an array of validators set on this instance 38 | * 39 | * @return Validator[] 40 | */ 41 | public function getValidators(): array; 42 | 43 | /** 44 | * Returns true if instance is valid, otherwise throws an exception 45 | * 46 | * @throws InvalidValue 47 | */ 48 | public function validate(): bool; 49 | } 50 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/ValidatableTool.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | /** 25 | * This tool provides standard functionality for instances implementing Validatable 26 | */ 27 | trait ValidatableTool 28 | { 29 | /** 30 | * @var Validator[] 31 | */ 32 | private array $validator = []; 33 | 34 | public function addValidator(Validator $validator): void 35 | { 36 | $this->validator[] = $validator; 37 | } 38 | 39 | /** 40 | * @return Validator[] 41 | */ 42 | public function getValidators(): array 43 | { 44 | return $this->validator; 45 | } 46 | 47 | public function validate(): bool 48 | { 49 | foreach ($this->getValidators() as $validator) { 50 | $validator->isValidOrException($this->toString()); 51 | } 52 | 53 | return true; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Message/Type.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Message; 23 | 24 | /** 25 | * A Conventional Commits type 26 | * 27 | * From the Conventional Commits 1.0.0 specification: 28 | * 29 | * > 1. Commits MUST be prefixed with a type, which consists of a noun, `feat`, 30 | * > `fix`, etc., followed by the OPTIONAL scope, OPTIONAL `!`, and REQUIRED 31 | * > terminal colon and space. 32 | * > 33 | * > 2. The type `feat` MUST be used when a commit adds a new feature to your 34 | * > application or library. 35 | * > 36 | * > 3. The type `fix` MUST be used when a commit represents a bug fix for your 37 | * > application. 38 | * > 39 | * > 14. Types other than `feat` and `fix` MAY be used in your commit messages, 40 | * > e.g., *docs: updated ref docs*. 41 | * 42 | * @link https://www.conventionalcommits.org/en/v1.0.0/#specification Conventional Commits 43 | */ 44 | class Type extends Noun 45 | { 46 | } 47 | -------------------------------------------------------------------------------- /src/CaptainHook/Output.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\CaptainHook; 23 | 24 | use CaptainHook\App\Console\IO; 25 | use Symfony\Component\Console\Output\ConsoleOutput; 26 | 27 | /** 28 | * Uses symfony/console with CaptainHook IO verbosity 29 | */ 30 | class Output extends ConsoleOutput 31 | { 32 | public function __construct(IO $captainHookIO) 33 | { 34 | parent::__construct($this->translateCaptainHookVerbosity($captainHookIO)); 35 | } 36 | 37 | private function translateCaptainHookVerbosity(IO $captainHookIO): int 38 | { 39 | if ($captainHookIO->isDebug()) { 40 | return self::VERBOSITY_DEBUG; 41 | } 42 | 43 | if ($captainHookIO->isVeryVerbose()) { 44 | return self::VERBOSITY_VERY_VERBOSE; 45 | } 46 | 47 | if ($captainHookIO->isVerbose()) { 48 | return self::VERBOSITY_VERBOSE; 49 | } 50 | 51 | return self::VERBOSITY_NORMAL; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/CaptainHook/Input.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\CaptainHook; 23 | 24 | use CaptainHook\App\Console\IO; 25 | use Symfony\Component\Console\Input\ArrayInput; 26 | use Symfony\Component\Console\Input\InputArgument; 27 | use Symfony\Component\Console\Input\InputDefinition; 28 | 29 | use function array_keys; 30 | 31 | /** 32 | * @deprecated This class is no longer used and will be removed in the 33 | * next major release. 34 | */ 35 | class Input extends ArrayInput 36 | { 37 | public function __construct(IO $captainHookIO) 38 | { 39 | $definition = new InputDefinition(); 40 | 41 | /** 42 | * @var string $key 43 | */ 44 | foreach (array_keys($captainHookIO->getArguments()) as $key) { 45 | $definition->addArgument(new InputArgument($key)); 46 | } 47 | 48 | parent::__construct($captainHookIO->getArguments(), $definition); 49 | $this->setInteractive($captainHookIO->isInteractive()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Message/Description.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Message; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 25 | 26 | use function preg_match; 27 | use function trim; 28 | 29 | /** 30 | * A Conventional Commits body 31 | * 32 | * From the Conventional Commits 1.0.0 specification: 33 | * 34 | * > 5. A description MUST immediately follow the colon and space after the 35 | * > type/scope prefix. The description is a short summary of the code changes, 36 | * > e.g., *fix: array parsing issue when multiple spaces were contained in 37 | * > string*. 38 | * 39 | * @link https://www.conventionalcommits.org/en/v1.0.0/#specification Conventional Commits 40 | */ 41 | class Description extends Text 42 | { 43 | private const DESCRIPTION_PATTERN = '/^[[:print:]]+$/u'; 44 | 45 | public function __construct(string $text) 46 | { 47 | if (!preg_match(self::DESCRIPTION_PATTERN, $text)) { 48 | throw new InvalidArgument( 49 | 'Description may not contain any control characters.', 50 | ); 51 | } 52 | 53 | parent::__construct(trim($text)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/DescribeBreakingChangesQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 25 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 26 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 27 | use Ramsey\ConventionalCommits\Message\Footer; 28 | use Symfony\Component\Console\Question\Question; 29 | 30 | /** 31 | * A prompt asking the user to describe the breaking changes introduced by 32 | * the commit 33 | */ 34 | class DescribeBreakingChangesQuestion extends Question 35 | { 36 | public function __construct() 37 | { 38 | parent::__construct('Describe the breaking changes'); 39 | } 40 | 41 | public function getValidator(): callable 42 | { 43 | return function (?string $answer): Footer { 44 | try { 45 | return new Footer(Footer::TOKEN_BREAKING_CHANGE, (string) $answer); 46 | } catch (InvalidArgument | InvalidValue $exception) { 47 | throw new InvalidConsoleInput('Invalid breaking changes value. ' . $exception->getMessage()); 48 | } 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ConventionalCommits/String/LetterCase.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\String; 23 | 24 | /** 25 | * Provides constants for letter case identifiers 26 | */ 27 | final class LetterCase 28 | { 29 | public const CASE_ADA = 'ada'; 30 | public const CASE_CAMEL = 'camel'; 31 | public const CASE_COBOL = 'cobol'; 32 | public const CASE_DOT = 'dot'; 33 | public const CASE_KEBAB = 'kebab'; 34 | public const CASE_LOWER = 'lower'; 35 | public const CASE_MACRO = 'macro'; 36 | public const CASE_PASCAL = 'pascal'; 37 | public const CASE_SENTENCE = 'sentence'; 38 | public const CASE_SNAKE = 'snake'; 39 | public const CASE_TITLE = 'title'; 40 | public const CASE_TRAIN = 'train'; 41 | public const CASE_UPPER = 'upper'; 42 | 43 | public const CASES = [ 44 | self::CASE_ADA, 45 | self::CASE_CAMEL, 46 | self::CASE_COBOL, 47 | self::CASE_DOT, 48 | self::CASE_KEBAB, 49 | self::CASE_LOWER, 50 | self::CASE_MACRO, 51 | self::CASE_PASCAL, 52 | self::CASE_SENTENCE, 53 | self::CASE_SNAKE, 54 | self::CASE_TITLE, 55 | self::CASE_TRAIN, 56 | self::CASE_UPPER, 57 | ]; 58 | } 59 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/FooterValueQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 25 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 26 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 27 | use Ramsey\ConventionalCommits\Message\Footer; 28 | use Symfony\Component\Console\Question\Question; 29 | 30 | /** 31 | * A prompt asking the user to enter a value for the given footer token 32 | */ 33 | class FooterValueQuestion extends Question 34 | { 35 | private string $token; 36 | 37 | public function __construct(string $token) 38 | { 39 | parent::__construct('Provide a value for the footer ' . $token); 40 | 41 | $this->token = $token; 42 | } 43 | 44 | public function getValidator(): callable 45 | { 46 | return function (?string $answer): Footer { 47 | try { 48 | return new Footer($this->token, (string) $answer); 49 | } catch (InvalidArgument | InvalidValue $exception) { 50 | throw new InvalidConsoleInput('Invalid footer value. ' . $exception->getMessage()); 51 | } 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Message/Noun.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Message; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 25 | use Ramsey\ConventionalCommits\Validator\ValidatableTool; 26 | 27 | use function array_pop; 28 | use function explode; 29 | use function preg_match; 30 | use function trim; 31 | 32 | /** 33 | * A noun for use with Conventional Commits 34 | */ 35 | abstract class Noun implements Unit 36 | { 37 | use ValidatableTool; 38 | 39 | private const NOUN_PATTERN = '/^[a-zA-Z0-9][\w-]+$/u'; 40 | 41 | private string $noun; 42 | 43 | public function __construct(string $noun) 44 | { 45 | if (!preg_match(self::NOUN_PATTERN, $noun)) { 46 | $nameSegments = explode('\\', static::class); 47 | $entity = array_pop($nameSegments); 48 | 49 | throw new InvalidArgument( 50 | $entity . 's must contain only alphanumeric characters, underscores, and dashes.', 51 | ); 52 | } 53 | 54 | $this->noun = trim($noun); 55 | } 56 | 57 | public function __toString(): string 58 | { 59 | return $this->toString(); 60 | } 61 | 62 | public function toString(): string 63 | { 64 | return $this->noun; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/MessageQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\Configuration; 27 | use Symfony\Component\Console\Question\Question; 28 | 29 | use function method_exists; 30 | use function trim; 31 | 32 | /** 33 | * A prompt that accepts long-form body content for the commit message 34 | */ 35 | class MessageQuestion extends Question implements Configurable 36 | { 37 | use ConfigurableTool; 38 | 39 | public function __construct(?Configuration $configuration = null) 40 | { 41 | if (method_exists($this, 'setMultiline')) { 42 | $this->setMultiline(true); // @codeCoverageIgnore 43 | } 44 | 45 | $this->configuration = $configuration; 46 | 47 | parent::__construct( 48 | 'Enter the commit message to be validated', 49 | ); 50 | } 51 | 52 | public function getValidator(): callable 53 | { 54 | return function (?string $answer): ?string { 55 | if (trim((string) $answer) === '') { 56 | return null; 57 | } 58 | 59 | return $answer; 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/IssueIdentifierQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 25 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 26 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 27 | use Ramsey\ConventionalCommits\Message\Footer; 28 | use Symfony\Component\Console\Question\Question; 29 | 30 | /** 31 | * A prompt asking the user to enter an issue tracker identifier 32 | */ 33 | class IssueIdentifierQuestion extends Question 34 | { 35 | private string $type; 36 | 37 | public function __construct(string $type) 38 | { 39 | parent::__construct( 40 | 'Enter the issue identifier ' 41 | . '(do not include a preceding #-symbol)', 42 | ); 43 | 44 | $this->type = $type; 45 | } 46 | 47 | public function getValidator(): callable 48 | { 49 | return function (?string $answer): Footer { 50 | try { 51 | return new Footer($this->type, (string) $answer, Footer::SEPARATOR_HASH); 52 | } catch (InvalidArgument | InvalidValue $exception) { 53 | throw new InvalidConsoleInput('Invalid identifier value. ' . $exception->getMessage()); 54 | } 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/LetterCaseValidator.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 27 | 28 | use function sprintf; 29 | 30 | /** 31 | * Validates whether a string conforms to the specified letter case rules 32 | */ 33 | class LetterCaseValidator implements Configurable, Validator 34 | { 35 | use ConfigurableTool; 36 | 37 | private ?string $case; 38 | 39 | public function __construct(?string $case) 40 | { 41 | $this->case = $case; 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public function isValid($value): bool 48 | { 49 | $converter = $this->getConfiguration()->getLetterCaseConverter($this->case); 50 | 51 | return $value === $converter->convert($value); 52 | } 53 | 54 | /** 55 | * @inheritDoc 56 | */ 57 | public function isValidOrException($value): bool 58 | { 59 | if ($this->isValid($value)) { 60 | return true; 61 | } 62 | 63 | /** @var string $guaranteedStringValue */ 64 | $guaranteedStringValue = $value; 65 | 66 | throw new InvalidValue(sprintf( 67 | "'%s' is not formatted in %s case.", 68 | $guaranteedStringValue, 69 | (string) $this->case, 70 | )); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/IssueTypeQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 25 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 26 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 27 | use Ramsey\ConventionalCommits\Message\Footer; 28 | use Symfony\Component\Console\Question\Question; 29 | 30 | use function strlen; 31 | use function trim; 32 | 33 | /** 34 | * A prompt asking the user to enter the relationship of this commit to 35 | * the issue tracker issue/ticket (i.e., "fix", "re", etc.) 36 | */ 37 | class IssueTypeQuestion extends Question 38 | { 39 | public function __construct() 40 | { 41 | parent::__construct( 42 | 'What is the issue reference type? (e.g., fix, re)', 43 | ); 44 | } 45 | 46 | public function getValidator(): callable 47 | { 48 | return function (?string $answer): ?string { 49 | if ($answer === null || strlen(trim($answer)) === 0) { 50 | return null; 51 | } 52 | 53 | try { 54 | $validFooter = new Footer($answer, 'validation'); 55 | } catch (InvalidArgument | InvalidValue $exception) { 56 | throw new InvalidConsoleInput('Invalid issue reference type. ' . $exception->getMessage()); 57 | } 58 | 59 | return $validFooter->getToken(); 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Command/BaseCommand.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Command; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\FinderTool; 27 | use Symfony\Component\Console\Command\Command; 28 | use Symfony\Component\Console\Input\InputInterface; 29 | use Symfony\Component\Console\Output\OutputInterface; 30 | 31 | /** 32 | * Provides common functionality to ramsey/conventional-commits commands 33 | */ 34 | abstract class BaseCommand extends Command implements Configurable 35 | { 36 | use ConfigurableTool; 37 | use FinderTool; 38 | 39 | public const SUCCESS = 0; 40 | public const FAILURE = 1; 41 | 42 | /** 43 | * Children should implement doExecute() to provide command functionality 44 | */ 45 | abstract protected function doExecute(InputInterface $input, OutputInterface $output): int; 46 | 47 | final protected function execute(InputInterface $input, OutputInterface $output): int 48 | { 49 | $config = null; 50 | 51 | /** @var string|null $configFile */ 52 | $configFile = $input->getOption('config'); 53 | 54 | if ($configFile !== null) { 55 | $config = ['configFile' => $configFile]; 56 | } 57 | 58 | if ($config !== null || $this->configuration === null) { 59 | $this->setConfiguration($this->findConfiguration($input, $output, $config)); 60 | } 61 | 62 | return $this->doExecute($input, $output); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/MessageValidator.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 25 | use Ramsey\ConventionalCommits\Message\Body; 26 | use Ramsey\ConventionalCommits\Message\Description; 27 | use Ramsey\ConventionalCommits\Message\Footer; 28 | use Ramsey\ConventionalCommits\Message\Scope; 29 | use Ramsey\ConventionalCommits\Message\Type; 30 | 31 | /** 32 | * A message validator provides validation for all the parts of a commit message 33 | */ 34 | interface MessageValidator extends Validator 35 | { 36 | /** 37 | * Returns true if type is valid, otherwise throws an exception 38 | * 39 | * @throws InvalidValue 40 | */ 41 | public function validateType(Type $type): bool; 42 | 43 | /** 44 | * Returns true if scope is valid, otherwise throws an exception 45 | * 46 | * @throws InvalidValue 47 | */ 48 | public function validateScope(?Scope $scope): bool; 49 | 50 | /** 51 | * Returns true if description is valid, otherwise throws an exception 52 | * 53 | * @throws InvalidValue 54 | */ 55 | public function validateDescription(Description $description): bool; 56 | 57 | /** 58 | * Returns true if body is valid, otherwise throws an exception 59 | * 60 | * @throws InvalidValue 61 | */ 62 | public function validateBody(?Body $body): bool; 63 | 64 | /** 65 | * Returns true if footers are valid, otherwise throws an exception 66 | * 67 | * @param Footer[] $footers 68 | * 69 | * @throws InvalidValue 70 | */ 71 | public function validateFooters(array $footers): bool; 72 | } 73 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Converter/LetterCaseConverter.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Converter; 23 | 24 | use Jawira\CaseConverter\CaseConverterInterface; 25 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 26 | use Ramsey\ConventionalCommits\String\LetterCase; 27 | 28 | use function gettype; 29 | use function in_array; 30 | use function is_string; 31 | use function sprintf; 32 | use function ucfirst; 33 | 34 | /** 35 | * A converter that converts a string value to a given letter case 36 | */ 37 | class LetterCaseConverter implements Convertible 38 | { 39 | private CaseConverterInterface $caseConverter; 40 | private ?string $case; 41 | 42 | public function __construct(CaseConverterInterface $caseConverter, ?string $case) 43 | { 44 | if ($case !== null && !in_array($case, LetterCase::CASES)) { 45 | throw new InvalidArgument("'$case' is not a valid letter case."); 46 | } 47 | 48 | $this->case = $case; 49 | $this->caseConverter = $caseConverter; 50 | } 51 | 52 | public function getCase(): ?string 53 | { 54 | return $this->case; 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function convert($value) 61 | { 62 | if ($this->case === null) { 63 | return $value; 64 | } 65 | 66 | if (!is_string($value)) { 67 | throw new InvalidArgument(sprintf( 68 | "The value must be a string; received '%s'", 69 | gettype($value), 70 | )); 71 | } 72 | 73 | $convertToMethod = 'to' . ucfirst($this->case); 74 | 75 | return $this->caseConverter->convert($value)->{$convertToMethod}(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /bin/conventional-commits: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 18 | * @license https://opensource.org/licenses/MIT MIT License 19 | */ 20 | 21 | declare(strict_types=1); 22 | 23 | use Ramsey\ConventionalCommits\Console\Command\ConfigCommand; 24 | use Ramsey\ConventionalCommits\Console\Command\PrepareCommand; 25 | use Ramsey\ConventionalCommits\Console\Command\ValidateCommand; 26 | use Symfony\Component\Console\Application; 27 | use Symfony\Component\Console\Input\ArgvInput; 28 | use Symfony\Component\Console\Input\InputOption; 29 | 30 | (static function (array $argv): void { 31 | $composerAutoloadLocations = [ 32 | __DIR__ . '/../autoload.php', 33 | __DIR__ . '/../vendor/autoload.php', 34 | __DIR__ . '/../../../autoload.php', 35 | ]; 36 | 37 | foreach ($composerAutoloadLocations as $file) { 38 | if (file_exists($file)) { 39 | $composerAutoloader = $file; 40 | 41 | break; 42 | } 43 | } 44 | unset($file); 45 | 46 | if (!isset($composerAutoloader)) { 47 | fwrite( 48 | STDERR, 49 | 'To execute this command, please install Composer and run \'composer install\'.' . PHP_EOL 50 | . 'For more information, go to https://getcomposer.org' . PHP_EOL, 51 | ); 52 | 53 | exit(1); 54 | } 55 | 56 | require $composerAutoloader; 57 | 58 | $application = new Application('Conventional Commits'); 59 | 60 | $inputDefinition = $application->getDefinition(); 61 | $inputDefinition->addOption(new InputOption( 62 | 'config', 63 | null, 64 | InputOption::VALUE_REQUIRED, 65 | 'Path to a file containing Conventional Commits configuration', 66 | )); 67 | 68 | $application->add(new ConfigCommand()); 69 | $application->add(new PrepareCommand()); 70 | $application->add(new ValidateCommand()); 71 | $application->run(new ArgvInput($argv)); 72 | })($argv); 73 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/DescriptionQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\Configuration; 27 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 28 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 29 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 30 | use Ramsey\ConventionalCommits\Message\Description; 31 | use Symfony\Component\Console\Question\Question; 32 | 33 | use function trim; 34 | 35 | /** 36 | * A prompt asking the user to provide a short description (or subject) 37 | * for the commit message 38 | */ 39 | class DescriptionQuestion extends Question implements Configurable 40 | { 41 | use ConfigurableTool; 42 | 43 | public function __construct(?Configuration $configuration = null) 44 | { 45 | parent::__construct('Write a short description of the change'); 46 | $this->configuration = $configuration; 47 | } 48 | 49 | public function getValidator(): callable 50 | { 51 | return function (?string $answer): Description { 52 | if (trim((string) $answer) === '') { 53 | throw new InvalidConsoleInput('You must provide a short description.'); 54 | } 55 | 56 | try { 57 | $description = new Description((string) $answer); 58 | $this->getConfiguration()->getMessageValidator()->validateDescription($description); 59 | } catch (InvalidArgument | InvalidValue $exception) { 60 | throw new InvalidConsoleInput('Invalid description. ' . $exception->getMessage()); 61 | } 62 | 63 | return $description; 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/TypeQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\Configuration; 27 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 28 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 29 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 30 | use Ramsey\ConventionalCommits\Message\Type; 31 | use Symfony\Component\Console\Question\Question; 32 | 33 | use function array_merge; 34 | 35 | /** 36 | * A prompt asking the user to identify the type of change they are committing 37 | */ 38 | class TypeQuestion extends Question implements Configurable 39 | { 40 | use ConfigurableTool; 41 | 42 | public function __construct(?Configuration $configuration = null) 43 | { 44 | parent::__construct( 45 | 'What is the type of change you\'re committing? (e.g., feat, fix, etc.)', 46 | 'feat', 47 | ); 48 | 49 | $this->configuration = $configuration; 50 | } 51 | 52 | public function getValidator(): callable 53 | { 54 | return function (?string $answer): Type { 55 | try { 56 | $type = new Type((string) $answer); 57 | $this->getConfiguration()->getMessageValidator()->validateType($type); 58 | } catch (InvalidArgument | InvalidValue $exception) { 59 | throw new InvalidConsoleInput('Invalid type. ' . $exception->getMessage()); 60 | } 61 | 62 | return $type; 63 | }; 64 | } 65 | 66 | public function getAutocompleterCallback(): callable 67 | { 68 | return fn (): iterable => array_merge( 69 | ['feat', 'fix'], 70 | $this->getConfiguration()->getTypes(), 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/BodyQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\Configuration; 27 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 28 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 29 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 30 | use Ramsey\ConventionalCommits\Message\Body; 31 | use Symfony\Component\Console\Question\Question; 32 | 33 | use function method_exists; 34 | use function trim; 35 | 36 | /** 37 | * A prompt that accepts long-form body content for the commit message 38 | */ 39 | class BodyQuestion extends Question implements Configurable 40 | { 41 | use ConfigurableTool; 42 | 43 | public function __construct(?Configuration $configuration = null) 44 | { 45 | if (method_exists($this, 'setMultiline')) { 46 | $this->setMultiline(true); // @codeCoverageIgnore 47 | } 48 | 49 | $this->configuration = $configuration; 50 | $mayOrMust = $this->getConfiguration()->isBodyRequired() ? 'must' : 'may'; 51 | 52 | parent::__construct( 53 | "You {$mayOrMust} provide a longer description of the change", 54 | ); 55 | } 56 | 57 | public function getValidator(): callable 58 | { 59 | return function (?string $answer): ?Body { 60 | try { 61 | $body = new Body(trim((string) $answer)); 62 | $this->getConfiguration()->getMessageValidator()->validateBody($body); 63 | } catch (InvalidArgument | InvalidValue $exception) { 64 | throw new InvalidConsoleInput('Invalid body. ' . $exception->getMessage()); 65 | } 66 | 67 | if ($body->toString() === '') { 68 | return null; 69 | } 70 | 71 | return $body; 72 | }; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/EndMarkValidator.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 25 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 26 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 27 | 28 | use function gettype; 29 | use function is_string; 30 | use function mb_strlen; 31 | use function mb_substr; 32 | use function preg_match; 33 | use function sprintf; 34 | 35 | /** 36 | * Validates whether the value contains the expected end mark (i.e. full stop) 37 | */ 38 | class EndMarkValidator implements Validator 39 | { 40 | use ConfigurableTool; 41 | 42 | private ?string $endMark; 43 | 44 | public function __construct(?string $endMark) 45 | { 46 | $this->endMark = $endMark; 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function isValid($value): bool 53 | { 54 | if (!is_string($value)) { 55 | throw new InvalidArgument(sprintf( 56 | "The value must be a string; received '%s'", 57 | gettype($value), 58 | )); 59 | } 60 | 61 | if ($this->endMark === null) { 62 | return true; 63 | } 64 | 65 | if ($this->endMark === '') { 66 | return (bool) preg_match('/^[^[:punct:]]$/u', mb_substr($value, -1)); 67 | } 68 | 69 | $length = mb_strlen($this->endMark) * -1; 70 | 71 | return mb_substr($value, $length) === $this->endMark; 72 | } 73 | 74 | /** 75 | * @inheritDoc 76 | */ 77 | public function isValidOrException($value): bool 78 | { 79 | if ($this->isValid($value)) { 80 | return true; 81 | } 82 | 83 | /** @var string $guaranteedStringValue */ 84 | $guaranteedStringValue = $value; 85 | 86 | throw new InvalidValue(sprintf( 87 | "'%s' does not end with the expected end mark '%s'.", 88 | $guaranteedStringValue, 89 | (string) $this->endMark, 90 | )); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/FooterTokenQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\Configuration; 27 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 28 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 29 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 30 | use Ramsey\ConventionalCommits\Message\Footer; 31 | use Symfony\Component\Console\Question\Question; 32 | 33 | use function count; 34 | use function strlen; 35 | use function trim; 36 | 37 | /** 38 | * A prompt asking the user to enter a footer token 39 | */ 40 | class FooterTokenQuestion extends Question implements Configurable 41 | { 42 | use ConfigurableTool; 43 | 44 | public function __construct(?Configuration $configuration = null) 45 | { 46 | parent::__construct( 47 | 'To add a footer, provide a footer name, or press ENTER to skip (e.g., Signed-off-by)', 48 | ); 49 | 50 | $this->configuration = $configuration; 51 | } 52 | 53 | public function getValidator(): callable 54 | { 55 | return function (?string $answer): ?string { 56 | if ($answer === null || strlen(trim($answer)) === 0) { 57 | return null; 58 | } 59 | 60 | try { 61 | $validFooter = new Footer($answer, 'validation'); 62 | } catch (InvalidArgument | InvalidValue $exception) { 63 | throw new InvalidConsoleInput('Invalid footer name. ' . $exception->getMessage()); 64 | } 65 | 66 | return $validFooter->getToken(); 67 | }; 68 | } 69 | 70 | public function getAutocompleterCallback(): ?callable 71 | { 72 | if (count($this->getConfiguration()->getRequiredFooters()) === 0) { 73 | return null; 74 | } 75 | 76 | return fn (): iterable => $this->getConfiguration()->getRequiredFooters(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Question/ScopeQuestion.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Question; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\Configuration; 27 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 28 | use Ramsey\ConventionalCommits\Exception\InvalidConsoleInput; 29 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 30 | use Ramsey\ConventionalCommits\Message\Scope; 31 | use Symfony\Component\Console\Question\Question; 32 | 33 | use function count; 34 | use function trim; 35 | 36 | /** 37 | * A prompt asking the user the scope of this change within the overall project 38 | */ 39 | class ScopeQuestion extends Question implements Configurable 40 | { 41 | use ConfigurableTool; 42 | 43 | public function __construct(?Configuration $configuration = null) 44 | { 45 | parent::__construct( 46 | 'What is the scope of this change (e.g., component or file name)?', 47 | ); 48 | 49 | $this->configuration = $configuration; 50 | } 51 | 52 | public function getValidator(): callable 53 | { 54 | return function (?string $answer): ?Scope { 55 | if (trim((string) $answer) === '') { 56 | $answer = null; 57 | } 58 | 59 | try { 60 | $scope = $answer === null ? null : new Scope($answer); 61 | $this->getConfiguration()->getMessageValidator()->validateScope($scope); 62 | } catch (InvalidArgument | InvalidValue $exception) { 63 | throw new InvalidConsoleInput('Invalid scope. ' . $exception->getMessage()); 64 | } 65 | 66 | return $scope; 67 | }; 68 | } 69 | 70 | public function getAutocompleterCallback(): ?callable 71 | { 72 | if (count($this->getConfiguration()->getScopes()) === 0) { 73 | return null; 74 | } 75 | 76 | return fn (): iterable => $this->getConfiguration()->getScopes(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Command/ConfigCommand.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Command; 23 | 24 | use Ramsey\ConventionalCommits\Console\SymfonyStyleFactory; 25 | use Symfony\Component\Console\Input\InputInterface; 26 | use Symfony\Component\Console\Input\InputOption; 27 | use Symfony\Component\Console\Output\OutputInterface; 28 | 29 | use function json_encode; 30 | 31 | use const JSON_PRETTY_PRINT; 32 | use const JSON_UNESCAPED_SLASHES; 33 | use const JSON_UNESCAPED_UNICODE; 34 | 35 | /** 36 | * A console command allowing for configuration of ramsey/conventional-commits 37 | */ 38 | class ConfigCommand extends BaseCommand 39 | { 40 | private SymfonyStyleFactory $styleFactory; 41 | 42 | public function __construct(?SymfonyStyleFactory $styleFactory = null) 43 | { 44 | parent::__construct('config'); 45 | 46 | $this->styleFactory = $styleFactory ?? new SymfonyStyleFactory(); 47 | } 48 | 49 | protected function configure(): void 50 | { 51 | $this 52 | ->setDescription( 53 | 'Configures options for creating Conventional Commits', 54 | ) 55 | ->setHelp( 56 | 'Currently, this command provides only the --dump option to ' 57 | . 'print the current configuration', 58 | ) 59 | ->addOption( 60 | 'config', 61 | null, 62 | InputOption::VALUE_REQUIRED, 63 | 'Path to a file containing Conventional Commits configuration', 64 | ) 65 | ->addOption( 66 | 'dump', 67 | null, 68 | InputOption::VALUE_NONE, 69 | 'Print the current configuration to STDOUT as JSON', 70 | null, 71 | ); 72 | } 73 | 74 | protected function doExecute(InputInterface $input, OutputInterface $output): int 75 | { 76 | $console = $this->styleFactory->factory($input, $output); 77 | 78 | if ($input->getOption('dump') !== false) { 79 | $console->writeln((string) json_encode( 80 | $this->getConfiguration(), 81 | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, 82 | )); 83 | } 84 | 85 | return self::SUCCESS; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/CaptainHook/PrepareConventionalCommit.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\CaptainHook; 23 | 24 | use CaptainHook\App\Config; 25 | use CaptainHook\App\Config\Action as ActionConfig; 26 | use CaptainHook\App\Console\IO; 27 | use CaptainHook\App\Hook\Action; 28 | use CaptainHook\App\Hook\Constrained; 29 | use CaptainHook\App\Hook\Restriction; 30 | use CaptainHook\App\Hooks; 31 | use Ramsey\ConventionalCommits\Configuration\Configuration; 32 | use Ramsey\ConventionalCommits\Configuration\FinderTool; 33 | use Ramsey\ConventionalCommits\Console\Command\PrepareCommand; 34 | use SebastianFeldmann\Git\CommitMessage; 35 | use SebastianFeldmann\Git\Repository; 36 | use Symfony\Component\Console\Input\ArrayInput; 37 | 38 | use function trim; 39 | 40 | /** 41 | * During the prepare-commit-msg Git hook, this prompts the user for input and 42 | * builds a valid Conventional Commits commit message 43 | * 44 | * @phpstan-import-type ConfigurationOptionsType from Configuration 45 | */ 46 | class PrepareConventionalCommit implements Action, Constrained 47 | { 48 | use FinderTool; 49 | 50 | private PrepareCommand $prepareCommand; 51 | 52 | public function __construct(?PrepareCommand $prepareCommand = null) 53 | { 54 | $this->prepareCommand = $prepareCommand ?? new PrepareCommand(); 55 | } 56 | 57 | public static function getRestriction(): Restriction 58 | { 59 | return Restriction::fromArray([Hooks::PREPARE_COMMIT_MSG]); 60 | } 61 | 62 | public function execute( 63 | Config $config, 64 | IO $io, 65 | Repository $repository, 66 | ActionConfig $action, 67 | ): void { 68 | if (!$io->isInteractive()) { 69 | return; 70 | } 71 | 72 | $commitMessage = $repository->getCommitMsg(); 73 | if (trim($commitMessage->getContent()) !== '') { 74 | // If we already have a commit message (maybe we used -m), 75 | // do not proceed with prompting the user for input. 76 | return; 77 | } 78 | 79 | $input = new ArrayInput([]); 80 | $output = new Output($io); 81 | 82 | /** @var array{config?: ConfigurationOptionsType, configFile?: string} | null $options */ 83 | $options = $action->getOptions()->getAll(); 84 | 85 | $this->prepareCommand->setConfiguration($this->findConfiguration($input, $output, $options)); 86 | $this->prepareCommand->run($input, $output); 87 | 88 | $message = $this->prepareCommand->getMessage(); 89 | 90 | if ($message === null) { 91 | return; 92 | } 93 | 94 | $commitMessage = new CommitMessage($message->toString()); 95 | $repository->setCommitMsg($commitMessage); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://ramsey.dev/schema/conventional-commits.json#", 4 | "title": "ramsey/conventional-commits Configuration Schema", 5 | "type": "object", 6 | "properties": { 7 | "typeCase": { 8 | "description": "The letter case required when formatting the type. If this is `null`, no format is enforced.", 9 | "$ref": "#/$defs/case" 10 | }, 11 | "types": { 12 | "description": "A list of acceptable types. If this is an empty array, any type is acceptable.", 13 | "$ref": "#/$defs/noun" 14 | }, 15 | "scopeCase": { 16 | "description": "The letter case required when formatting the scope. If this is `null`, no format is enforced.", 17 | "$ref": "#/$defs/case" 18 | }, 19 | "scopeRequired": { 20 | "description": "Whether the scope property is required.", 21 | "type": "boolean", 22 | "default": false 23 | }, 24 | "scopes": { 25 | "description": "A list of acceptable scopes. If this is an empty array, any scope is acceptable.", 26 | "$ref": "#/$defs/noun" 27 | }, 28 | "descriptionCase": { 29 | "description": "The letter case required when formatting the description. If this is `null`, no format is enforced.", 30 | "$ref": "#/$defs/case" 31 | }, 32 | "descriptionEndMark": { 33 | "description": "The character(s) required at the end of the description (i.e., the full stop character). If `null`, any or no end mark is acceptable. An empty string indicates the description must not have an end mark.", 34 | "type": ["string", "null"], 35 | "default": null 36 | }, 37 | "bodyRequired": { 38 | "description": "Whether the body property is required.", 39 | "type": "boolean", 40 | "default": false 41 | }, 42 | "bodyWrapWidth": { 43 | "description": "The number of characters at which the body should automatically wrap (e.g. 72, 80). If `null`, the body will not wrap automatically.", 44 | "type": ["integer", "null"], 45 | "default": null 46 | }, 47 | "requiredFooters": { 48 | "description": "The names of footers that must be required as part of the commit message.", 49 | "type": "array", 50 | "items": { 51 | "type": "string", 52 | "pattern": "^(BREAKING CHANGE|[a-zA-Z0-9][a-zA-Z0-9-]+)$" 53 | }, 54 | "uniqueItems": true, 55 | "default": [] 56 | } 57 | }, 58 | "$defs": { 59 | "case": { 60 | "type": ["string", "null"], 61 | "enum": [ 62 | "ada", 63 | "camel", 64 | "cobol", 65 | "dot", 66 | "kebab", 67 | "lower", 68 | "macro", 69 | "pascal", 70 | "sentence", 71 | "snake", 72 | "title", 73 | "train", 74 | "upper", 75 | null 76 | ], 77 | "default": null 78 | }, 79 | "noun": { 80 | "type": "array", 81 | "items": { 82 | "type": "string", 83 | "pattern": "^[a-zA-Z0-9][a-zA-Z0-9-]+$" 84 | }, 85 | "uniqueItems": true, 86 | "default": [] 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/CaptainHook/ValidateConventionalCommit.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\CaptainHook; 23 | 24 | use CaptainHook\App\Config; 25 | use CaptainHook\App\Config\Action as ActionConfig; 26 | use CaptainHook\App\Console\IO; 27 | use CaptainHook\App\Exception\ActionFailed; 28 | use CaptainHook\App\Hook\Action; 29 | use CaptainHook\App\Hook\Constrained; 30 | use CaptainHook\App\Hook\Restriction; 31 | use CaptainHook\App\Hooks; 32 | use Ramsey\ConventionalCommits\Configuration\Configuration; 33 | use Ramsey\ConventionalCommits\Configuration\FinderTool; 34 | use Ramsey\ConventionalCommits\Console\SymfonyStyleFactory; 35 | use Ramsey\ConventionalCommits\Exception\ConventionalException; 36 | use Ramsey\ConventionalCommits\Parser; 37 | use SebastianFeldmann\Git\Repository; 38 | use Symfony\Component\Console\Input\ArrayInput; 39 | 40 | /** 41 | * During the commit-msg Git hook, this validates the commit message according 42 | * to the Conventional Commits specification 43 | * 44 | * @phpstan-import-type ConfigurationOptionsType from Configuration 45 | */ 46 | class ValidateConventionalCommit implements Action, Constrained 47 | { 48 | use FinderTool; 49 | 50 | private SymfonyStyleFactory $styleFactory; 51 | 52 | public function __construct(?SymfonyStyleFactory $styleFactory = null) 53 | { 54 | $this->styleFactory = $styleFactory ?? new SymfonyStyleFactory(); 55 | } 56 | 57 | public static function getRestriction(): Restriction 58 | { 59 | return Restriction::fromArray([Hooks::COMMIT_MSG]); 60 | } 61 | 62 | public function execute( 63 | Config $config, 64 | IO $io, 65 | Repository $repository, 66 | ActionConfig $action, 67 | ): void { 68 | /** @var array{config?: ConfigurationOptionsType, configFile?: string} | null $options */ 69 | $options = $action->getOptions()->getAll(); 70 | 71 | $message = $repository->getCommitMsg(); 72 | 73 | try { 74 | $parser = new Parser($this->findConfiguration(new ArrayInput([]), new Output($io), $options)); 75 | $parser->parse($message->getContent()); 76 | } catch (ConventionalException $exception) { 77 | $this->writeErrorMessage($io, $exception); 78 | 79 | throw new ActionFailed('Validation failed.'); 80 | } 81 | } 82 | 83 | private function writeErrorMessage(IO $io, ConventionalException $exception): void 84 | { 85 | $console = $this->styleFactory->factory(new ArrayInput([]), new Output($io)); 86 | 87 | $console->error([ 88 | 'Invalid Commit Message: ' . $exception->getMessage(), 89 | 'The commit message is not properly formatted according to the ' 90 | . 'Conventional Commits specification. For more details, see ' 91 | . 'https://www.conventionalcommits.org/en/v1.0.0/', 92 | ]); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/ScopeValidator.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 27 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 28 | use Ramsey\ConventionalCommits\Message\Scope; 29 | 30 | use function array_map; 31 | use function count; 32 | use function gettype; 33 | use function implode; 34 | use function in_array; 35 | use function is_string; 36 | use function sprintf; 37 | use function strtolower; 38 | 39 | /** 40 | * Validates whether the scope is in the list of configured scopes 41 | */ 42 | class ScopeValidator implements Configurable, Validator 43 | { 44 | use ConfigurableTool; 45 | 46 | /** 47 | * @inheritDoc 48 | */ 49 | public function isValid($value): bool 50 | { 51 | if (!is_string($value)) { 52 | throw new InvalidArgument(sprintf( 53 | "The value must be a string; received '%s'", 54 | gettype($value), 55 | )); 56 | } 57 | 58 | if (!$this->isInConfiguredScopes($value)) { 59 | return false; 60 | } 61 | 62 | try { 63 | new Scope($value); 64 | } catch (InvalidArgument $exception) { 65 | return false; 66 | } 67 | 68 | return true; 69 | } 70 | 71 | /** 72 | * @inheritDoc 73 | */ 74 | public function isValidOrException($value): bool 75 | { 76 | if ($this->isValid($value)) { 77 | return true; 78 | } 79 | 80 | /** @var string $guaranteedStringValue */ 81 | $guaranteedStringValue = $value; 82 | 83 | if ($this->getConfiguredScopes()) { 84 | throw new InvalidValue(sprintf( 85 | "'%s' is not one of the valid scopes '%s'.", 86 | $guaranteedStringValue, 87 | implode(', ', $this->getConfiguredScopes()), 88 | )); 89 | } 90 | 91 | throw new InvalidValue(sprintf( 92 | "'%s' is not a valid scope value.", 93 | $guaranteedStringValue, 94 | )); 95 | } 96 | 97 | private function isInConfiguredScopes(string $value): bool 98 | { 99 | if (count($this->getConfiguredScopes()) === 0) { 100 | return true; 101 | } 102 | 103 | return in_array(strtolower($value), $this->getConfiguredScopes()); 104 | } 105 | 106 | /** 107 | * @return string[] 108 | */ 109 | private function getConfiguredScopes(): array 110 | { 111 | if (count($this->getConfiguration()->getScopes()) === 0) { 112 | return []; 113 | } 114 | 115 | return array_map( 116 | fn (string $v): string => strtolower($v), 117 | $this->getConfiguration()->getScopes(), 118 | ); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Console/Command/ValidateCommand.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Console\Command; 23 | 24 | use Ramsey\ConventionalCommits\Console\Question\MessageQuestion; 25 | use Ramsey\ConventionalCommits\Console\SymfonyStyleFactory; 26 | use Ramsey\ConventionalCommits\Exception\ConventionalException; 27 | use Ramsey\ConventionalCommits\Parser; 28 | use Symfony\Component\Console\Input\InputArgument; 29 | use Symfony\Component\Console\Input\InputInterface; 30 | use Symfony\Component\Console\Input\InputOption; 31 | use Symfony\Component\Console\Output\OutputInterface; 32 | 33 | /** 34 | * A console command that validates a commit message as per the 35 | * Conventional Commits specification 36 | */ 37 | class ValidateCommand extends BaseCommand 38 | { 39 | private SymfonyStyleFactory $styleFactory; 40 | 41 | public function __construct(?SymfonyStyleFactory $styleFactory = null) 42 | { 43 | parent::__construct('validate'); 44 | 45 | $this->styleFactory = $styleFactory ?? new SymfonyStyleFactory(); 46 | } 47 | 48 | protected function configure(): void 49 | { 50 | $this 51 | ->setDescription('Validate a commit message to ensure it conforms to Conventional Commits') 52 | ->setHelp( 53 | 'This command validates a commit message according to the ' 54 | . 'Conventional Commits specification. For more information, ' 55 | . 'see https://www.conventionalcommits.org.', 56 | ) 57 | ->addArgument( 58 | 'message', 59 | InputArgument::OPTIONAL, 60 | 'The commit message to be validated', 61 | ) 62 | ->addOption( 63 | 'config', 64 | null, 65 | InputOption::VALUE_REQUIRED, 66 | 'Path to a file containing Conventional Commits configuration', 67 | ); 68 | } 69 | 70 | protected function doExecute(InputInterface $input, OutputInterface $output): int 71 | { 72 | $console = $this->styleFactory->factory($input, $output); 73 | 74 | /** @var string|null $message */ 75 | $message = $input->getArgument('message'); 76 | if ($message === null) { 77 | $console->title('Validate Commit Message'); 78 | /** 79 | * @var string|null $message 80 | */ 81 | $message = $console->askQuestion(new MessageQuestion($this->getConfiguration())); 82 | } 83 | 84 | if ($message === null) { 85 | $console->error('No commit message was provided'); 86 | 87 | return self::FAILURE; 88 | } 89 | 90 | try { 91 | $parser = new Parser($this->getConfiguration()); 92 | $parser->parse($message); 93 | } catch (ConventionalException $exception) { 94 | $console->error($exception->getMessage()); 95 | 96 | return self::FAILURE; 97 | } 98 | 99 | $console->section('Commit Message'); 100 | $console->block($message); 101 | 102 | return self::SUCCESS; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/TypeValidator.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 27 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 28 | use Ramsey\ConventionalCommits\Message\Type; 29 | 30 | use function array_map; 31 | use function array_merge; 32 | use function count; 33 | use function gettype; 34 | use function implode; 35 | use function in_array; 36 | use function is_string; 37 | use function sprintf; 38 | use function strtolower; 39 | 40 | /** 41 | * Validates whether the type is in the list of configured types 42 | */ 43 | class TypeValidator implements Configurable, Validator 44 | { 45 | use ConfigurableTool; 46 | 47 | /** 48 | * @inheritDoc 49 | */ 50 | public function isValid($value): bool 51 | { 52 | if (!is_string($value)) { 53 | throw new InvalidArgument(sprintf( 54 | "The value must be a string; received '%s'", 55 | gettype($value), 56 | )); 57 | } 58 | 59 | if (!$this->isInConfiguredTypes($value)) { 60 | return false; 61 | } 62 | 63 | try { 64 | new Type($value); 65 | } catch (InvalidArgument $exception) { 66 | return false; 67 | } 68 | 69 | return true; 70 | } 71 | 72 | /** 73 | * @inheritDoc 74 | */ 75 | public function isValidOrException($value): bool 76 | { 77 | if ($this->isValid($value)) { 78 | return true; 79 | } 80 | 81 | /** @var string $guaranteedStringValue */ 82 | $guaranteedStringValue = $value; 83 | 84 | if ($this->getConfiguredTypes()) { 85 | throw new InvalidValue(sprintf( 86 | "'%s' is not one of the valid types '%s'.", 87 | $guaranteedStringValue, 88 | implode(', ', $this->getConfiguredTypes()), 89 | )); 90 | } 91 | 92 | throw new InvalidValue(sprintf( 93 | "'%s' is not a valid type value.", 94 | $guaranteedStringValue, 95 | )); 96 | } 97 | 98 | private function isInConfiguredTypes(string $value): bool 99 | { 100 | if (count($this->getConfiguredTypes()) === 0) { 101 | return true; 102 | } 103 | 104 | return in_array(strtolower($value), $this->getConfiguredTypes()); 105 | } 106 | 107 | /** 108 | * @return string[] 109 | */ 110 | private function getConfiguredTypes(): array 111 | { 112 | if (count($this->getConfiguration()->getTypes()) === 0) { 113 | return []; 114 | } 115 | 116 | // "feat" and "fix" will always be valid types, according to the spec. 117 | return array_merge( 118 | ['feat', 'fix'], 119 | array_map( 120 | fn (string $v): string => strtolower($v), 121 | $this->getConfiguration()->getTypes(), 122 | ), 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Validator/RequiredFootersValidator.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits\Validator; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Exception\InvalidArgument; 27 | use Ramsey\ConventionalCommits\Exception\InvalidValue; 28 | use Ramsey\ConventionalCommits\Message\Footer; 29 | 30 | use function array_diff; 31 | use function array_filter; 32 | use function array_intersect; 33 | use function array_map; 34 | use function implode; 35 | use function is_array; 36 | use function sprintf; 37 | use function strtolower; 38 | 39 | /** 40 | * Validates whether all required footers are present, if applicable 41 | */ 42 | class RequiredFootersValidator implements Configurable, Validator 43 | { 44 | use ConfigurableTool; 45 | 46 | /** 47 | * @var string[]|null 48 | */ 49 | private ?array $requiredFooters = null; 50 | 51 | /** 52 | * @inheritDoc 53 | */ 54 | public function isValid($value): bool 55 | { 56 | $this->checkValue($value); 57 | 58 | if ($this->getRequiredFooters() === []) { 59 | return true; 60 | } 61 | 62 | /** @var Footer[] $footers */ 63 | $footers = $value; 64 | 65 | return $this->getPresentRequiredFooters($footers) === $this->getRequiredFooters(); 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function isValidOrException($value): bool 72 | { 73 | if ($this->isValid($value)) { 74 | return true; 75 | } 76 | 77 | /** @var Footer[] $footers */ 78 | $footers = $value; 79 | 80 | $missingFooters = array_diff( 81 | $this->getRequiredFooters(), 82 | $this->getPresentRequiredFooters($footers), 83 | ); 84 | 85 | throw new InvalidValue(sprintf( 86 | 'Please provide the following required footers: %s.', 87 | implode(', ', $missingFooters), 88 | )); 89 | } 90 | 91 | /** 92 | * @param Footer[] $footers 93 | * 94 | * @return string[] 95 | */ 96 | private function getPresentRequiredFooters(array $footers): array 97 | { 98 | $presentFooters = array_map(fn (Footer $v): string => strtolower($v->getToken()), $footers); 99 | 100 | return array_intersect($this->getRequiredFooters(), $presentFooters); 101 | } 102 | 103 | /** 104 | * @return string[] 105 | */ 106 | private function getRequiredFooters(): array 107 | { 108 | if ($this->requiredFooters === null) { 109 | $requiredFooters = array_map( 110 | fn (string $v): string => strtolower($v), 111 | $this->getConfiguration()->getRequiredFooters(), 112 | ); 113 | 114 | $this->requiredFooters = $requiredFooters; 115 | } 116 | 117 | return $this->requiredFooters; 118 | } 119 | 120 | /** 121 | * @param mixed $value 122 | */ 123 | private function checkValue($value): void 124 | { 125 | $isFooter = /** @param mixed $v */ fn ($v): bool => $v instanceof Footer; 126 | 127 | if (is_array($value) && array_filter($value, $isFooter) === $value) { 128 | return; 129 | } 130 | 131 | throw new InvalidArgument(sprintf( 132 | '\$value must be an array of %s.', 133 | Footer::class, 134 | )); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/ConventionalCommits/Parser.php: -------------------------------------------------------------------------------- 1 | 17 | * @license https://opensource.org/licenses/MIT MIT License 18 | */ 19 | 20 | declare(strict_types=1); 21 | 22 | namespace Ramsey\ConventionalCommits; 23 | 24 | use Ramsey\ConventionalCommits\Configuration\Configurable; 25 | use Ramsey\ConventionalCommits\Configuration\ConfigurableTool; 26 | use Ramsey\ConventionalCommits\Configuration\Configuration; 27 | use Ramsey\ConventionalCommits\Exception\InvalidCommitMessage; 28 | use Ramsey\ConventionalCommits\Message\Body; 29 | use Ramsey\ConventionalCommits\Message\Description; 30 | use Ramsey\ConventionalCommits\Message\Footer; 31 | use Ramsey\ConventionalCommits\Message\Scope; 32 | use Ramsey\ConventionalCommits\Message\Type; 33 | 34 | use function count; 35 | use function preg_match; 36 | use function preg_match_all; 37 | use function trim; 38 | 39 | /** 40 | * Validates and parses a Conventional Commits message 41 | */ 42 | class Parser implements Configurable 43 | { 44 | use ConfigurableTool; 45 | 46 | private const COMMIT_PATTERN = "/^(?(DEFINE)(?'noun'[A-Z0-9][\w-]+)" 47 | . "(?'tokenPrefix'(?:BREAKING CHANGE|(?P>noun))\ *(?:\:\ |\ \#(?=\w))))" 48 | . "(?'type'(?P>noun))" 49 | . "(?:\((?'scope'(?P>noun))\))?(?'bc'!)?: " 50 | . "(?'desc'[[:print:]]+)" 51 | . "(?:(?:\n{2}|\r{2}|(?:\r\n){2})" 52 | . "(?'body'.*?(?=(?P>tokenPrefix)|\$))?" 53 | . "(?:(?=(?P>tokenPrefix))(?'footer'.*))?)?\$/ius"; 54 | 55 | private const FOOTER_PATTERN = "/^(?(DEFINE)(?'noun'[A-Z0-9][\w-]+)" 56 | . "(?'tokenName'BREAKING CHANGE|(?P>noun))(?'tokenSeparator'\:\ |\ \#(?=\w))" 57 | . "(?'tokenPrefix'(?P>tokenName)\ *(?P>tokenSeparator)))" 58 | . "(?'footer'(?'token'(?P>tokenName))\ *(?'separator'(?P>tokenSeparator))\ *" 59 | . "(?'value'(?:.*?)(?=(?P>tokenPrefix))|(?:.*)))/iusm"; 60 | 61 | public function __construct(?Configuration $configuration = null) 62 | { 63 | $this->configuration = $configuration; 64 | } 65 | 66 | /** 67 | * Parses a commit message, returning a Message instance or throwing an 68 | * exception on failure 69 | */ 70 | public function parse(string $commitMessage): Message 71 | { 72 | $commitMessage = trim($commitMessage); 73 | 74 | if (!preg_match(self::COMMIT_PATTERN, $commitMessage, $matches)) { 75 | throw new InvalidCommitMessage( 76 | 'Could not find a valid Conventional Commits message.', 77 | ); 78 | } 79 | 80 | $type = new Type($matches['type']); 81 | $description = new Description($matches['desc']); 82 | $hasBreakingChanges = trim($matches['bc'] ?? '') === '!'; 83 | 84 | $commit = new Message($type, $description, $hasBreakingChanges); 85 | $commit->setConfiguration($this->getConfiguration()); 86 | 87 | if (trim($matches['scope'] ?? '') !== '') { 88 | $commit->setScope(new Scope($matches['scope'])); 89 | } 90 | 91 | if (trim($matches['body'] ?? '') !== '') { 92 | $commit->setBody(new Body($matches['body'])); 93 | } 94 | 95 | foreach ($this->parseFooter($matches['footer'] ?? '') as $footer) { 96 | $commit->addFooter($footer); 97 | } 98 | 99 | $commit->validate(); 100 | 101 | return $commit; 102 | } 103 | 104 | /** 105 | * @return array