best matches first */ public function find(string $query, int $limit = 10): array { $terms = $this->tokenize($query); if ($terms === []) { return []; } $scored = []; foreach (service('finder')->all() as $tool) { $score = $this->scoreTool($tool, $terms); if ($score > 0) { $tool['_score'] = $score; $scored[] = $tool; } } usort($scored, static fn (array $a, array $b): int => [$b['_score'], (int) $b['popularity']] <=> [$a['_score'], (int) $a['popularity']]); return array_slice(array_map(static function (array $t): array { unset($t['_score']); return $t; }, $scored), 0, $limit); } public function best(string $query): ?array { return $this->find($query, 1)[0] ?? null; } private function scoreTool(array $tool, array $terms): float { $name = mb_strtolower($tool['name'] . ' ' . str_replace('-', ' ', $tool['slug'])); $aliases = implode(' ', array_map('mb_strtolower', (array) ($tool['aliases'] ?? []))); $haystacks = [ 'aliases' => $aliases, 'name' => $name, 'formats' => mb_strtolower(implode(' ', [...($tool['input_formats'] ?? []), ...($tool['output_formats'] ?? [])])), 'desc' => mb_strtolower($tool['short_description'] ?? ''), ]; $weight = ['aliases' => 40, 'name' => 30, 'formats' => 20, 'desc' => 8]; $score = 0.0; foreach ($terms as $term) { $matched = false; foreach ($haystacks as $key => $haystack) { if ($haystack === '') { continue; } if (str_starts_with($haystack . ' ', $term . ' ') || $haystack === $term) { $score += $weight[$key] * 1.5; // exact word start $matched = true; } elseif (str_contains(' ' . $haystack, ' ' . $term)) { $score += $weight[$key]; $matched = true; } elseif ($key !== 'desc' && str_contains($term, ' ') === false && str_contains($haystack, $term)) { $score += $weight[$key] * 0.5; // fuzzy substring $matched = true; } } if (! $matched) { return 0; // every term must match somewhere — keeps results relevant } } // popularity tiebreak folded into score for stable ordering return $score + min(5, ((int) ($tool['popularity'] ?? 0)) / 2000); } /** @return list */ private function tokenize(string $query): array { $normalized = mb_strtolower(preg_replace('/[^a-z0-9\s]+/iu', ' ', $query) ?? ''); $tokens = preg_split('/\s+/', trim($normalized)) ?: []; return array_values(array_unique(array_filter($tokens, static fn ($t) => mb_strlen($t) >= 2))); } }