2 Commits 1e84624447 ... a814c11afe

Autor SHA1 Mensagem Data
  Shalvah a814c11afe Rework to parse in 3 passes 2 meses atrás
  Shalvah d65bb0d54d Update Flysystem usage 2 meses atrás

+ 7 - 11
camel/Camel.php

@@ -64,12 +64,10 @@ class Camel
         $contents = Utils::listDirectoryContents($folder);
 
         foreach ($contents as $object) {
-            // todo Flysystem v1 had items as arrays; v2 has objects.
-            // v2 allows ArrayAccess, but when we drop v1 support (Laravel <9), we should switch to methods
             if (
-                $object['type'] == 'file'
-                && Str::endsWith(basename($object['path']), '.yaml')
-                && !Str::startsWith(basename($object['path']), 'custom.')
+                $object->isFile()
+                && Str::endsWith(basename($object->path()), '.yaml')
+                && !Str::startsWith(basename($object->path()), 'custom.')
             ) {
                 $group = Yaml::parseFile($object['path']);
                 $callback($group);
@@ -83,14 +81,12 @@ class Camel
 
         $userDefinedEndpoints = [];
         foreach ($contents as $object) {
-            // todo Flysystem v1 had items as arrays; v2 has objects.
-            // v2 allows ArrayAccess, but when we drop v1 support (Laravel <9), we should switch to methods
             if (
-                $object['type'] == 'file'
-                && Str::endsWith(basename($object['path']), '.yaml')
-                && Str::startsWith(basename($object['path']), 'custom.')
+                $object->isFile()
+                && Str::endsWith(basename($object->path()), '.yaml')
+                && Str::startsWith(basename($object->path()), 'custom.')
             ) {
-                $endpoints = Yaml::parseFile($object['path']);
+                $endpoints = Yaml::parseFile($object->path());
                 foreach (($endpoints ?: []) as $endpoint) {
                     $userDefinedEndpoints[] = $endpoint;
                 }

+ 186 - 170
src/Extracting/ParsesValidationRules.php

@@ -2,6 +2,7 @@
 
 namespace Knuckles\Scribe\Extracting;
 
+use Closure;
 use Illuminate\Contracts\Validation\Rule;
 use Illuminate\Contracts\Validation\ValidationRule;
 use Illuminate\Support\Arr;
@@ -14,6 +15,7 @@ use Knuckles\Scribe\Exceptions\ScribeException;
 use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
 use Knuckles\Scribe\Tools\WritingUtils as w;
 use ReflectionClass;
+use ReflectionFunction;
 use Throwable;
 
 trait ParsesValidationRules
@@ -22,22 +24,20 @@ trait ParsesValidationRules
 
     public static \stdClass $MISSING_VALUE;
 
-    public function getParametersFromValidationRules(array $validationRules, array $customParameterData = []): array
+    public function getParametersFromValidationRules(array $validationRulesByParameters, array $customParameterData = []): array
     {
         self::$MISSING_VALUE = new \stdClass();
-        $validationRules = $this->normaliseRules($validationRules);
+        $validationRulesByParameters = $this->normaliseRules($validationRulesByParameters);
 
         $parameters = [];
-        $dependentRules = [];
-        foreach ($validationRules as $parameter => $ruleset) {
+        $rulesWhichDependOnType = ['between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte', 'before', 'after', 'before_or_equal', 'after_or_equal'];
+        foreach ($validationRulesByParameters as $parameter => $ruleset) {
+            $userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
+            $stringRules = array_filter($ruleset, fn($rule) => is_string($rule));
+            $rulesWhichRefineAType = ['before', 'after', 'before_or_equal', 'after_or_equal'];
+
             try {
-                $parameterPlusDot = $parameter . '.';
-                if (count($customParameterData) && !isset($customParameterData[$parameter])
-                    && ! Arr::first(array_keys($customParameterData), fn ($key) => str_starts_with($key, $parameterPlusDot))
-                ) {
-                    c::debug($this->getMissingCustomDataMessage($parameter));
-                }
-                $userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
+                $this->warnAboutMissingCustomParameterData($parameter, $customParameterData);
 
                 // Make sure the user-specified description comes first (and add full stops where needed).
                 $description = $userSpecifiedParameterInfo['description'] ?? '';
@@ -52,51 +52,65 @@ trait ParsesValidationRules
                     'description' => $description,
                     'nullable' => false,
                 ];
-                $dependentRules[$parameter] = [];
 
-                // First, parse only "independent" rules
-                foreach ($ruleset as $rule) {
-                    $parsed = $this->parseRule($rule, $parameterData, true);
-                    if (!$parsed) {
-                        $dependentRules[$parameter][] = $rule;
-                    }
+                $closureRules = array_filter($ruleset, fn($rule) => ($rule instanceof ClosureValidationRule || $rule instanceof Closure));
+                foreach ($closureRules as $rule) {
+                    $this->processClosureRule($rule, $parameterData);
                 }
 
-                $parameterData['description'] = trim($parameterData['description']);
+                $enumValidationRules = array_filter($ruleset, fn($rule) => $rule instanceof \Illuminate\Validation\Rules\Enum);
+                foreach ($enumValidationRules as $rule) {
+                    $this->processEnumValidationRule($rule, $parameterData);
+                }
 
-                // Set a default type
-                if (is_null($parameterData['type'])) {
-                    $parameterData['type'] = 'string';
+                $ruleObjects = array_filter($ruleset, fn($rule) => ($rule instanceof Rule || $rule instanceof ValidationRule));
+                foreach ($ruleObjects as $rule) {
+                    $this->processRuleObject($rule, $parameterData);
                 }
 
-                $parameterData['name'] = $parameter;
+                // TODO support more rules
+                // Process in 3 passes
+                // 1. Rules which provide no info about type or example
+                // (required, required_*, same, different, nullable, exists, and others in the "utilities" group)
+                // 2. Rules which set a type.
+                // 3. Rules whose processing depends on the type. ('between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte')
+                // - Note: 'in' does not provide type info (does it?), but is enough to generate an example
+
+                // First pass: process rules which provide no type or example info
+                $firstPassRuleNames = [
+                    "required",
+                    "required_*",
+                    "same",
+                    "different",
+                    "nullable",
+                ];
 
-                if ($parameterData['required'] === true){
-                    $parameterData['nullable'] = false;
+                $firstPassRules = array_filter($stringRules, fn($rule) => Str::is($firstPassRuleNames, $rule));
+                foreach ($firstPassRules as $rule) {
+                    $this->processRule($rule, $parameterData);
                 }
 
-                $parameters[$parameter] = $parameterData;
-            } catch (Throwable $e) {
-                if ($e instanceof ScribeException) {
-                    // This is a lower-level error that we've encountered and wrapped;
-                    // Pass it on to the user.
-                    throw $e;
+                $secondPassRules = array_filter($stringRules, fn($rule) => !Str::is($firstPassRuleNames, $rule) && !in_array($rule, $rulesWhichDependOnType));
+                foreach ($secondPassRules as $rule) {
+                    $this->processRule($rule, $parameterData);
                 }
-                throw ProblemParsingValidationRules::forParam($parameter, $e);
-            }
-        }
 
-        // Now parse any "dependent" rules and set examples. At this point, we should know all field's types.
-        foreach ($dependentRules as $parameter => $ruleset) {
-            try {
-                $parameterData = $parameters[$parameter];
-                $userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
+                // The second pass should have set a type. If not, set a default type
+                if (is_null($parameterData['type'])) {
+                    $parameterData['type'] = 'string';
+                }
+
+                if ($parameterData['required'] === true) {
+                    $parameterData['nullable'] = false;
+                }
 
-                foreach ($ruleset as $rule) {
-                    $this->parseRule($rule, $parameterData, false, $parameters);
+                // Now parse any "dependent" rules and set examples. At this point, we should know all field's types.
+                $thirdPassRules = array_filter($stringRules, fn($rule) => in_array($rule, $rulesWhichDependOnType));
+                foreach ($thirdPassRules as $rule) {
+                    $this->processRule($rule, $parameterData);
                 }
 
-                // Make sure the user-specified example overwrites others.
+                // Make sure the user-specified example overwrites ours.
                 if (array_key_exists('example', $userSpecifiedParameterInfo)) {
                     if ($userSpecifiedParameterInfo['example'] != null && $this->shouldCastUserExample()) {
                         // Examples in comments are strings, we need to cast them properly
@@ -107,7 +121,6 @@ trait ParsesValidationRules
                 }
 
                 // End descriptions with a full stop
-                $parameterData['description'] = trim($parameterData['description']);
                 if (!empty($parameterData['description']) && !Str::endsWith($parameterData['description'], '.')) {
                     $parameterData['description'] .= '.';
                 }
@@ -172,98 +185,87 @@ trait ParsesValidationRules
         })->toArray();
     }
 
-    /**
-     * Parse a validation rule and extract a parameter type, description and setter (used to generate an example).
-     *
-     * If $independentOnly is true, only independent rules will be parsed.
-     * If a rule depends on another parameter (eg gt:field) or needs to know the type of the parameter first (eg:
-     * size:34), it will return false.
-     */
-    protected function parseRule($rule, array &$parameterData, bool $independentOnly, array $allParameters = []): bool
+    // For inline Closure rules, turn any comment above it into a description
+    //
+    // $request->validate([
+    // 'my_param' => [
+    // 'required',
+    //     /** Must be a hexadecimal number. */
+    //     function ($attribute, $value, $fail) {
+    //         if (!preg_match('/^[0-9a-f]+$/', $value)) {
+    //             $fail('Must be in hex format');
+    //         }
+    //     },
+    // ],
+    // ]);
+    protected function processClosureRule($rule, array &$parameterData): void
     {
-        // Reminders:
-        // 1. Append to the description (with a leading space); don't overwrite.
-        // 2. Avoid testing on the value of $parameterData['type'],
-        // as that may not have been set yet, since the rules can be in any order.
-        // For this reason, only deterministic rules are supported
-        // 3. All rules supported must be rules that we can generate a valid dummy value for.
-
-        if ($rule instanceof ClosureValidationRule || $rule instanceof \Closure) {
-            $reflection = new \ReflectionFunction($rule instanceof ClosureValidationRule ? $rule->callback : $rule);
-
-            if (is_string($description = $reflection->getDocComment())) {
-                $finalDescription = '';
-                // Cleanup comment block and extract just the description
-                foreach (explode("\n", $description) as $line) {
-                    $cleaned = preg_replace(['/\*+\/$/', '/^\/\*+\s*/', '/^\*+\s*/'], '', trim($line));
-                    if ($cleaned != '') $finalDescription .= ' ' . $cleaned;
-                }
-
-                $parameterData['description'] .= $finalDescription;
+        $docComment = (new ReflectionFunction($rule instanceof ClosureValidationRule ? $rule->callback : $rule))
+            ->getDocComment();
+
+        if (is_string($docComment)) {
+            $description = '';
+            foreach (explode("\n", $docComment) as $line) {
+                $cleaned = preg_replace(['/\*+\/$/', '/^\/\*+\s*/', '/^\*+\s*/'], '', trim($line));
+                if ($cleaned != '') $description .= ' ' . $cleaned;
             }
 
-            return true;
+            $parameterData['description'] .= $description;
         }
+    }
 
-        if ($rule instanceof \Illuminate\Validation\Rules\Enum) {
-            $reflection = new \ReflectionClass($rule);
-            $property = $reflection->getProperty('type');
-            $property->setAccessible(true);
-            $type = $property->getValue($rule);
-
-            if (enum_exists($type) && method_exists($type, 'tryFrom')) {
-                // $case->value only exists on BackedEnums, not UnitEnums
-                // method_exists($enum, 'tryFrom') implies the enum is a BackedEnum
-                // @phpstan-ignore-next-line
-                $cases = array_map(fn ($case) => $case->value, $type::cases());
-                $parameterData['type'] = gettype($cases[0]);
-                $parameterData['enumValues'] = $cases;
-                $parameterData['setter'] = fn () => Arr::random($cases);
-            }
-
-            return true;
+    protected function processEnumValidationRule($rule, array &$parameterData, array $allParameters = []): void
+    {
+        $property = (new ReflectionClass($rule))->getProperty('type');
+        $property->setAccessible(true);
+        $enumClass = $property->getValue($rule);
+
+        if (enum_exists($enumClass) && method_exists($enumClass, 'tryFrom')) {
+            // $case->value only exists on BackedEnums, not UnitEnums
+            // method_exists($enum, 'tryFrom') implies the enum is a BackedEnum
+            // @phpstan-ignore-next-line
+            $cases = array_map(fn($case) => $case->value, $enumClass::cases());
+            $parameterData['type'] = gettype($cases[0]);
+            $parameterData['enumValues'] = $cases;
+            $parameterData['setter'] = fn() => Arr::random($cases);
         }
+    }
 
-        if ($rule instanceof Rule || $rule instanceof ValidationRule) {
-            if (method_exists($rule, 'invokable')) {
-                // Laravel wraps InvokableRule instances in an InvokableValidationRule class,
-                // so we must retrieve the original rule
-                $rule = $rule->invokable();
-            }
-
-            if (method_exists($rule, 'docs')) {
-                $customData = call_user_func_array([$rule, 'docs'], []) ?: [];
+    protected function processRuleObject($rule, array &$parameterData): void
+    {
+        if (method_exists($rule, 'invokable')) {
+            // Laravel wraps InvokableRule instances in an InvokableValidationRule class,
+            // so we must retrieve the original rule
+            $rule = $rule->invokable();
+        }
 
-                if (isset($customData['description'])) {
-                    $parameterData['description'] .= ' ' . $customData['description'];
-                }
-                if (isset($customData['example'])) {
-                    $parameterData['setter'] = fn() => $customData['example'];
-                } elseif (isset($customData['setter'])) {
-                    $parameterData['setter'] = $customData['setter'];
-                }
+        // Users can define a custom "docs" method on a rule to give Scribe more info.
+        if (method_exists($rule, 'docs')) {
+            $customData = call_user_func_array([$rule, 'docs'], []) ?: [];
 
-                $parameterData = array_merge($parameterData, Arr::except($customData, [
-                    'description', 'example', 'setter',
-                ]));
+            if (isset($customData['description'])) {
+                $parameterData['description'] .= ' ' . $customData['description'];
+            }
+            if (isset($customData['example'])) {
+                $parameterData['setter'] = fn() => $customData['example'];
+            } elseif (isset($customData['setter'])) {
+                $parameterData['setter'] = $customData['setter'];
             }
 
-            return true;
-        }
-
-        if (!is_string($rule)) {
-            return false;
+            $parameterData = array_merge($parameterData, Arr::except($customData, [
+                'description', 'example', 'setter',
+            ]));
         }
+    }
 
+    /**
+     * Parse a validation rule and extract a parameter type, description and setter (used to generate an example).
+     */
+    protected function processRule($rule, array &$parameterData, array $allParameters = []): bool
+    {
+        // Reminder: Always append to the description (with a leading space); don't overwrite.
         try {
-            // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]])
-            $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule);
-            [$rule, $arguments] = $parsedRule;
-
-            $dependentRules = ['between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte', 'before', 'after', 'before_or_equal', 'after_or_equal'];
-            if ($independentOnly && in_array($rule, $dependentRules)) {
-                return false;
-            }
+            [$rule, $ruleArguments] = $this->parseStringRuleIntoRuleAndArguments($rule);
 
             switch ($rule) {
                 case 'required':
@@ -275,6 +277,11 @@ trait ParsesValidationRules
                     $parameterData['description'] .= ' Must be accepted.';
                     $parameterData['setter'] = fn() => true;
                     break;
+                case 'accepted_if':
+                    $parameterData['type'] = 'boolean';
+                    $parameterData['description'] .= " Must be accepted when <code>$ruleArguments[0]</code> is " . w::getListOfValuesAsFriendlyHtmlString(array_slice($ruleArguments, 1));
+                    $parameterData['setter'] = fn() => true;
+                    break;
 
                 /*
                 * Primitive types. No description should be added
@@ -378,18 +385,18 @@ trait ParsesValidationRules
                 case 'date_format':
                     $parameterData['type'] = 'string';
                     // Laravel description here is "The value must match the format Y-m-d". Not descriptive enough.
-                    $parameterData['description'] .= " Must be a valid date in the format <code>{$arguments[0]}</code>.";
-                    $parameterData['setter'] = function () use ($arguments) {
-                        return date($arguments[0], time());
+                    $parameterData['description'] .= " Must be a valid date in the format <code>{$ruleArguments[0]}</code>.";
+                    $parameterData['setter'] = function () use ($ruleArguments) {
+                        return date($ruleArguments[0], time());
                     };
                     break;
                 case 'after':
                 case 'after_or_equal':
                     $parameterData['type'] = 'string';
-                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "<code>{$arguments[0]}</code>"]);
+                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "<code>{$ruleArguments[0]}</code>"]);
                     // TODO introduce the concept of "modifiers", like date_format
                     // The startDate may refer to another field, in which case, we just ignore it for now.
-                    $startDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0];
+                    $startDate = isset($allParameters[$ruleArguments[0]]) ? 'today' : $ruleArguments[0];
                     $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween($startDate, '+100 years')->format('Y-m-d');
                     break;
                 case 'before':
@@ -397,38 +404,38 @@ trait ParsesValidationRules
                     $parameterData['type'] = 'string';
                     // The argument can be either another field or a date
                     // The endDate may refer to another field, in which case, we just ignore it for now.
-                    $endDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0];
-                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "<code>{$arguments[0]}</code>"]);
+                    $endDate = isset($allParameters[$ruleArguments[0]]) ? 'today' : $ruleArguments[0];
+                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "<code>{$ruleArguments[0]}</code>"]);
                     $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween('-30 years', $endDate)->format('Y-m-d');
                     break;
                 case 'starts_with':
-                    $parameterData['description'] .= ' Must start with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments);
-                    $parameterData['setter'] = fn() => $this->getFaker()->lexify("{$arguments[0]}????");;
+                    $parameterData['description'] .= ' Must start with one of ' . w::getListOfValuesAsFriendlyHtmlString($ruleArguments);
+                    $parameterData['setter'] = fn() => $this->getFaker()->lexify("{$ruleArguments[0]}????");;
                     break;
                 case 'ends_with':
-                    $parameterData['description'] .= ' Must end with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments);
-                    $parameterData['setter'] = fn() => $this->getFaker()->lexify("????{$arguments[0]}");;
+                    $parameterData['description'] .= ' Must end with one of ' . w::getListOfValuesAsFriendlyHtmlString($ruleArguments);
+                    $parameterData['setter'] = fn() => $this->getFaker()->lexify("????{$ruleArguments[0]}");;
                     break;
                 case 'uuid':
                     $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' ';
                     $parameterData['setter'] = $this->getFakeFactoryByName('uuid');
                     break;
                 case 'regex':
-                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':regex' => $arguments[0]]);
-                    $parameterData['setter'] = fn() => $this->getFaker()->regexify($arguments[0]);;
+                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':regex' => $ruleArguments[0]]);
+                    $parameterData['setter'] = fn() => $this->getFaker()->regexify($ruleArguments[0]);;
                     break;
 
                 /**
                  * Special number types.
                  */
                 case 'digits':
-                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':digits' => $arguments[0]]);
-                    $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", $arguments[0]));
+                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':digits' => $ruleArguments[0]]);
+                    $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", $ruleArguments[0]));
                     $parameterData['type'] = 'string';
                     break;
                 case 'digits_between':
-                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]]);
-                    $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", rand($arguments[0], $arguments[1])));
+                    $parameterData['description'] .= ' ' . $this->getDescription($rule, [':min' => $ruleArguments[0], ':max' => $ruleArguments[1]]);
+                    $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", rand($ruleArguments[0], $ruleArguments[1])));
                     $parameterData['type'] = 'string';
                     break;
 
@@ -437,29 +444,29 @@ trait ParsesValidationRules
                  */
                 case 'size':
                     $parameterData['description'] .= ' ' . $this->getDescription(
-                            $rule, [':size' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
+                            $rule, [':size' => $ruleArguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
                         );
-                    $parameterData['setter'] = $this->getDummyValueGenerator($parameterData['type'], ['size' => $arguments[0]]);
+                    $parameterData['setter'] = $this->getDummyValueGenerator($parameterData['type'], ['size' => $ruleArguments[0]]);
                     break;
                 case 'min':
                     $parameterData['description'] .= ' ' . $this->getDescription(
-                            $rule, [':min' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
+                            $rule, [':min' => $ruleArguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
                         );
-                    $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), fieldName: $parameterData['name']);
+                    $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($ruleArguments[0]), fieldName: $parameterData['name']);
                     break;
                 case 'max':
                     $parameterData['description'] .= ' ' . $this->getDescription(
-                            $rule, [':max' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
+                            $rule, [':max' => $ruleArguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
                         );
-                    $max = min($arguments[0], 25);
+                    $max = min($ruleArguments[0], 25);
                     $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], 1, $max, $parameterData['name']);
                     break;
                 case 'between':
                     $parameterData['description'] .= ' ' . $this->getDescription(
-                            $rule, [':min' => $arguments[0], ':max' => $arguments[1]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
+                            $rule, [':min' => $ruleArguments[0], ':max' => $ruleArguments[1]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
                         );
                     // Avoid exponentially complex operations by using the minimum length
-                    $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), floatval($arguments[0]) + 1, $parameterData['name']);
+                    $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($ruleArguments[0]), floatval($ruleArguments[0]) + 1, $parameterData['name']);
                     break;
 
                 /**
@@ -478,9 +485,9 @@ trait ParsesValidationRules
                  * Other rules.
                  */
                 case 'in':
-                    $parameterData['enumValues'] = $arguments;
-                    $parameterData['setter'] = function () use ($arguments) {
-                        return Arr::random($arguments);
+                    $parameterData['enumValues'] = $ruleArguments;
+                    $parameterData['setter'] = function () use ($ruleArguments) {
+                        return Arr::random($ruleArguments);
                     };
                     break;
 
@@ -488,60 +495,55 @@ trait ParsesValidationRules
                  * These rules only add a description. Generating valid examples is too complex.
                  */
                 case 'not_in':
-                    $parameterData['description'] .= ' Must not be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' ';
+                    $parameterData['description'] .= ' Must not be one of ' . w::getListOfValuesAsFriendlyHtmlString($ruleArguments) . ' ';
                     break;
                 case 'required_if':
                     $parameterData['description'] .= sprintf(
-                        " This field is required when <code>{$arguments[0]}</code> is %s. ",
-                        w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1))
+                        " This field is required when <code>{$ruleArguments[0]}</code> is %s. ",
+                        w::getListOfValuesAsFriendlyHtmlString(array_slice($ruleArguments, 1))
                     );
                     break;
                 case 'required_unless':
                     $parameterData['description'] .= sprintf(
-                        " This field is required unless <code>{$arguments[0]}</code> is in %s. ",
-                        w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1))
+                        " This field is required unless <code>{$ruleArguments[0]}</code> is in %s. ",
+                        w::getListOfValuesAsFriendlyHtmlString(array_slice($ruleArguments, 1))
                     );
                     break;
                 case 'required_with':
                     $parameterData['description'] .= sprintf(
                         " This field is required when %s is present. ",
-                        w::getListOfValuesAsFriendlyHtmlString($arguments)
+                        w::getListOfValuesAsFriendlyHtmlString($ruleArguments)
                     );
                     break;
                 case 'required_without':
                     $parameterData['description'] .= sprintf(
                         " This field is required when %s is not present. ",
-                        w::getListOfValuesAsFriendlyHtmlString($arguments)
+                        w::getListOfValuesAsFriendlyHtmlString($ruleArguments)
                     );
                     break;
                 case 'required_with_all':
                     $parameterData['description'] .= sprintf(
                         " This field is required when %s are present. ",
-                        w::getListOfValuesAsFriendlyHtmlString($arguments, "and")
+                        w::getListOfValuesAsFriendlyHtmlString($ruleArguments, "and")
                     );
                     break;
                 case 'required_without_all':
                     $parameterData['description'] .= sprintf(
-                    " This field is required when none of %s are present. ",
-                    w::getListOfValuesAsFriendlyHtmlString($arguments, "and")
+                        " This field is required when none of %s are present. ",
+                        w::getListOfValuesAsFriendlyHtmlString($ruleArguments, "and")
                     );
                     break;
-                case 'accepted_if':
-                    $parameterData['type'] = 'boolean';
-                    $parameterData['description'] .= " Must be accepted when <code>$arguments[0]</code> is " . w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1));
-                    $parameterData['setter'] = fn() => true;
-                    break;
                 case 'same':
-                    $parameterData['description'] .= " The value and <code>{$arguments[0]}</code> must match.";
+                    $parameterData['description'] .= " The value and <code>{$ruleArguments[0]}</code> must match.";
                     break;
                 case 'different':
-                    $parameterData['description'] .= " The value and <code>{$arguments[0]}</code> must be different.";
+                    $parameterData['description'] .= " The value and <code>{$ruleArguments[0]}</code> must be different.";
                     break;
                 case 'nullable':
                     $parameterData['nullable'] = true;
                     break;
                 case 'exists':
-                    $parameterData['description'] .= " The <code>{$arguments[1]}</code> of an existing record in the {$arguments[0]} table.";
+                    $parameterData['description'] .= " The <code>{$ruleArguments[1]}</code> of an existing record in the {$ruleArguments[0]} table.";
                     break;
                 default:
                     // Other rules not supported
@@ -551,11 +553,13 @@ trait ParsesValidationRules
             throw CouldntProcessValidationRule::forParam($parameterData['name'], $rule, $e);
         }
 
+        $parameterData['description'] = trim($parameterData['description']);
         return true;
     }
 
     /**
      * Parse a string rule into the base rule and arguments.
+     * eg "in:1,2" becomes ["in", ["1", "2"]]
      * Laravel validation rules are specified in the format {rule}:{arguments}
      * Arguments are separated by commas.
      * For instance the rule "max:3" states that the value may only be three letters.
@@ -568,7 +572,7 @@ trait ParsesValidationRules
     {
         $ruleArguments = [];
 
-        if (strpos($rule, ':') !== false) {
+        if (str_contains($rule, ':')) {
             [$rule, $argumentsString] = explode(':', $rule, 2);
 
             // These rules can have commas in their arguments, so we don't split on commas
@@ -595,7 +599,7 @@ trait ParsesValidationRules
                     : null;
             }
         } else if (!is_null($parameterData['example']) && $parameterData['example'] !== self::$MISSING_VALUE) {
-            if($parameterData['example'] === 'No-example' && !$parameterData['required']){
+            if ($parameterData['example'] === 'No-example' && !$parameterData['required']) {
                 return null;
             }
             // Casting again is important since values may have been cast to string in the validator
@@ -648,7 +652,7 @@ trait ParsesValidationRules
                 // 3. Otherwise, default to `object`
                 // Important: We're iterating in reverse, to ensure we set child items before parent items
                 // (assuming the user specified parents first, which is the more common thing)y
-                if(Arr::first($allKeys, fn($key) => Str::startsWith($key, "$name.*."))) {
+                if (Arr::first($allKeys, fn($key) => Str::startsWith($key, "$name.*."))) {
                     $details['type'] = 'object[]';
                     unset($details['setter']);
                 } else if ($childKey = Arr::first($allKeys, fn($key) => Str::startsWith($key, "$name.*"))) {
@@ -856,4 +860,16 @@ trait ParsesValidationRules
     {
         return false;
     }
+
+    protected function warnAboutMissingCustomParameterData(string $parameter, array $customParameterData): array
+    {
+        $parameterPlusDot = $parameter . '.';
+        if (count($customParameterData) && !isset($customParameterData[$parameter])
+            && !Arr::first(array_keys($customParameterData), fn($key) => str_starts_with($key, $parameterPlusDot))
+        ) {
+            c::debug($this->getMissingCustomDataMessage($parameter));
+        }
+
+        return $customParameterData;
+    }
 }

+ 7 - 0
src/Tools/Utils.php

@@ -15,7 +15,9 @@ use Knuckles\Scribe\Exceptions\CouldntFindFactory;
 use Knuckles\Scribe\Exceptions\CouldntGetRouteDetails;
 use Knuckles\Scribe\ScribeServiceProvider;
 use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
+use League\Flysystem\DirectoryListing;
 use League\Flysystem\Filesystem;
+use League\Flysystem\FilesystemException;
 use League\Flysystem\Local\LocalFilesystemAdapter;
 use Mpociot\Reflection\DocBlock\Tag;
 use ReflectionClass;
@@ -125,6 +127,11 @@ class Utils
         $fs->deleteDirectory($dir);
     }
 
+    /**
+     * @param string $dir
+     * @return DirectoryListing<\League\Flysystem\StorageAttributes>
+     * @throws FilesystemException
+     */
     public static function listDirectoryContents(string $dir)
     {
         $adapter = new LocalFilesystemAdapter(getcwd());

+ 2 - 4
tests/Unit/ValidationRuleParsingTest.php

@@ -553,9 +553,7 @@ class ValidationRuleParsingTest extends BaseLaravelTest
         $results = $this->strategy->parse([
             'enum' => [
                 'required',
-                new \Illuminate\Validation\Rules\Enum(Fixtures\TestStringBackedEnum::class),
-                // Not supported in Laravel 8
-                // Rule::enum(Fixtures\TestStringBackedEnum::class)
+                Rule::enum(Fixtures\TestStringBackedEnum::class)
             ],
         ]);
         $this->assertEquals('string', $results['enum']['type']);
@@ -741,7 +739,7 @@ if ($invokableRulesSupported) {
 }
 
 if ($laravel10Rules) {
-// Laravel 10 deprecated the previous Rule and InvokableRule classes for a single interface
+    // Laravel 10 deprecated the previous Rule and InvokableRule classes for a single interface
     // (https://github.com/laravel/framework/pull/45954)
     class DummyL10ValidationRule implements \Illuminate\Contracts\Validation\ValidationRule
     {