DocumentationTools.php•34.4 kB
<?php
declare(strict_types=1);
namespace OpenFGA\MCP\Tools;
use OpenFGA\ClientInterface;
use OpenFGA\MCP\Documentation\{DocumentationIndex, DocumentationIndexSingleton};
use PhpMcp\Server\Attributes\McpTool;
use RuntimeException;
use function array_slice;
use function count;
use function in_array;
use function is_array;
use function is_scalar;
use function sprintf;
use function strlen;
final readonly class DocumentationTools extends AbstractTools
{
public function __construct(
/**
* @phpstan-ignore property.onlyWritten
*/
private ClientInterface $client,
) {
}
/**
* Find documentation similar to provided content.
*
* @param string $content Reference content to find similar documentation
* @param string|null $sdk Limit search to specific SDK
* @param float $similarity_threshold Minimum similarity score (0.0-1.0, default: 0.5)
* @param int $limit Maximum number of results (default: 5)
*
* @throws RuntimeException If documentation index initialization fails
*
* @return string Markdown-formatted related documentation
*/
#[McpTool(name: 'find_similar_documentation')]
public function findSimilarDocumentation(
string $content,
?string $sdk = null,
float $similarity_threshold = 0.5,
int $limit = 5,
): string {
if ('' === trim($content)) {
return '❌ Content cannot be empty';
}
if (0.0 > $similarity_threshold || 1.0 < $similarity_threshold) {
return '❌ Similarity threshold must be between 0.0 and 1.0';
}
if (1 > $limit || 20 < $limit) {
return '❌ Limit must be between 1 and 20';
}
$validSdks = ['php', 'go', 'python', 'java', 'dotnet', 'js', 'laravel', null];
if (! in_array($sdk, $validSdks, true)) {
return '❌ Invalid SDK. Must be one of: php, go, python, java, dotnet, js, laravel';
}
$index = DocumentationIndexSingleton::getInstance();
if (! $index->isInitialized()) {
$index->initialize();
}
// Extract key terms from content for similarity matching
$keyTerms = $this->extractKeyTerms($content);
if ([] === $keyTerms) {
return '❌ Could not extract meaningful terms from the provided content';
}
// Search for similar content using key terms
/** @var array<string, array{chunk_id: string, sdk: string, score: float, preview: string, metadata: array<mixed>, similarity?: float, content?: string}> $similarChunks */
$similarChunks = [];
foreach ($keyTerms as $keyTerm) {
$chunks = $index->searchChunks($keyTerm, $sdk, $limit * 2);
foreach ($chunks as $chunk) {
// Get content from the chunk to calculate similarity
// We need to get the actual content - the preview is just a snippet
$chunkContent = $this->getChunkContent($chunk['chunk_id'], $index);
$similarity = $this->calculateSimilarity($content, $chunkContent);
if ($similarity >= $similarity_threshold) {
// Add similarity and content to the chunk
$chunkWithSimilarity = $chunk;
$chunkWithSimilarity['similarity'] = $similarity;
$chunkWithSimilarity['content'] = $chunkContent;
$chunkKey = $chunk['sdk'] . '::' . $chunk['chunk_id'];
if (! isset($similarChunks[$chunkKey])) {
$similarChunks[$chunkKey] = $chunkWithSimilarity;
} elseif (isset($similarChunks[$chunkKey]['similarity'])) {
$existingSim = $similarChunks[$chunkKey]['similarity'];
if ($existingSim < $similarity) {
$similarChunks[$chunkKey] = $chunkWithSimilarity;
}
}
}
}
}
// Sort by similarity score
usort($similarChunks, static function (array $a, array $b): int {
// Since we know similarity is added above, we can safely access it
$aSimilarity = isset($a['similarity']) && is_numeric($a['similarity']) ? $a['similarity'] : 0.0;
$bSimilarity = isset($b['similarity']) && is_numeric($b['similarity']) ? $b['similarity'] : 0.0;
return $bSimilarity <=> $aSimilarity;
});
// Apply limit
$similarChunks = array_slice($similarChunks, 0, $limit);
if ([] === $similarChunks) {
return "## Similar Documentation\n\nNo similar documentation found (threshold: {$similarity_threshold})" .
(null !== $sdk ? ' in SDK: ' . $sdk : '') .
"\n\nTry:\n- Lowering the similarity threshold\n- Providing more specific content\n- Removing SDK filter for broader results";
}
// Build markdown response
$markdown = "## Similar Documentation\n\n";
$markdown .= sprintf('**Similarity Threshold:** %s%s', $similarity_threshold, PHP_EOL);
if (null !== $sdk) {
$markdown .= sprintf('**SDK Filter:** %s%s', $sdk, PHP_EOL);
}
$markdown .= '**Found:** ' . count($similarChunks) . " similar document(s)\n\n";
$markdown .= "---\n\n";
foreach ($similarChunks as $chunkIndex => $chunk) {
$markdown .= $this->formatSimilarResult($chunk, $chunkIndex + 1);
}
return $markdown;
}
/**
* Search for code examples in documentation.
*
* @param string $query Code pattern or concept to find
* @param string|null $language Programming language filter (php, go, python, java, csharp, javascript, typescript)
* @param bool $include_context Include surrounding explanatory context
* @param int $limit Maximum number of examples to return (default: 5)
* @param int $offset Pagination offset for results (default: 0)
*
* @throws RuntimeException If documentation index initialization fails
*
* @return string Markdown-formatted code examples with descriptions
*/
#[McpTool(name: 'search_code_examples')]
public function searchCodeExamples(
string $query,
?string $language = null,
bool $include_context = true,
int $limit = 5,
int $offset = 0,
): string {
if ('' === trim($query)) {
return '❌ Search query cannot be empty';
}
if (1 > $limit || 20 < $limit) {
return '❌ Limit must be between 1 and 20';
}
if (0 > $offset) {
return '❌ Offset cannot be negative';
}
$validLanguages = ['php', 'go', 'python', 'java', 'csharp', 'javascript', 'typescript', null];
if (! in_array($language, $validLanguages, true)) {
return '❌ Invalid language. Must be one of: php, go, python, java, csharp, javascript, typescript';
}
$index = DocumentationIndexSingleton::getInstance();
if (! $index->isInitialized()) {
$index->initialize();
}
// Map language to SDK if applicable
$sdk = $this->mapLanguageToSdk($language);
// Search for code-related chunks
$allChunks = $index->searchChunks($query, $sdk);
$codeExamples = [];
foreach ($allChunks as $allChunk) {
// Get the full content for this chunk
$chunkContent = $this->getChunkContent($allChunk['chunk_id'], $index);
$chunkWithContent = $allChunk;
$chunkWithContent['content'] = $chunkContent;
$examples = $this->extractCodeFromChunk($chunkWithContent, $language);
foreach ($examples as $example) {
$example['chunk'] = $allChunk;
$codeExamples[] = $example;
}
}
$totalExamples = count($codeExamples);
if (0 === $totalExamples) {
return "## Code Examples
No code examples found for: **{$query}**" .
(null !== $language ? sprintf(' (language: %s)', $language) : '') .
"\n\nTry:\n- Searching for specific method or class names\n- Using OpenFGA terminology (e.g., 'check', 'expand', 'tuples')\n- Removing language filter for broader results";
}
// Apply pagination
$paginatedExamples = array_slice($codeExamples, $offset, $limit);
$currentPage = (int) floor($offset / $limit) + 1;
$totalPages = (int) ceil($totalExamples / $limit);
// Build markdown response
$markdown = "## Code Examples\n\n";
$markdown .= "**Search:** `{$query}`\n";
if (null !== $language) {
$markdown .= sprintf('**Language:** %s%s', $language, PHP_EOL);
}
$markdown .= '**Results:** Showing ' . ($offset + 1) . '-' . min($offset + $limit, $totalExamples) . " of {$totalExamples} examples\n\n";
$markdown .= "---\n\n";
foreach ($paginatedExamples as $exampleIndex => $example) {
$exampleNumber = $offset + $exampleIndex + 1;
$markdown .= $this->formatCodeExample($example, $exampleNumber, $include_context);
}
// Add pagination info
if (1 < $totalPages) {
$markdown .= "\n---\n\n### Pagination\n\n";
if (1 < $currentPage) {
$prevOffset = max(0, $offset - $limit);
$markdown .= sprintf('- **Previous page:** Use offset=%d%s', $prevOffset, PHP_EOL);
}
if ($currentPage < $totalPages) {
$nextOffset = $offset + $limit;
$markdown .= sprintf('- **Next page:** Use offset=%d%s', $nextOffset, PHP_EOL);
}
}
return $markdown;
}
/**
* Advanced documentation search with filtering and pagination.
*
* @param string $query Search query to find in documentation
* @param string|null $sdk Filter by specific SDK (php, go, python, java, dotnet, js, laravel)
* @param string $search_type Type of search: content (default), class, method, or section
* @param int $limit Maximum number of results to return (default: 10)
* @param int $offset Pagination offset for results (default: 0)
*
* @throws RuntimeException If documentation index initialization fails
*
* @return string Markdown-formatted search results with pagination metadata
*/
#[McpTool(name: 'search_documentation')]
public function searchDocumentation(
string $query,
?string $sdk = null,
string $search_type = 'content',
int $limit = 10,
int $offset = 0,
): string {
if ('' === trim($query)) {
return '❌ Search query cannot be empty';
}
if (1 > $limit || 50 < $limit) {
return '❌ Limit must be between 1 and 50';
}
if (0 > $offset) {
return '❌ Offset cannot be negative';
}
$validSearchTypes = ['content', 'class', 'method', 'section'];
if (! in_array($search_type, $validSearchTypes, true)) {
return '❌ Invalid search_type. Must be one of: ' . implode(', ', $validSearchTypes);
}
$validSdks = ['php', 'go', 'python', 'java', 'dotnet', 'js', 'laravel', null];
if (! in_array($sdk, $validSdks, true)) {
return '❌ Invalid SDK. Must be one of: php, go, python, java, dotnet, js, laravel';
}
$index = DocumentationIndexSingleton::getInstance();
if (! $index->isInitialized()) {
$index->initialize();
}
// Get all results first for total count
$allResults = $this->performSearch($index, $query, $sdk, $search_type);
$totalResults = count($allResults);
if (0 === $totalResults) {
$markdown = '## Documentation Search Results
';
$markdown .= "**Query:** `{$query}`
";
if (null !== $sdk) {
$markdown .= sprintf('**SDK Filter:** %s%s', $sdk, PHP_EOL);
}
$markdown .= sprintf('**Search Type:** %s%s', $search_type, PHP_EOL);
$markdown .= "\nNo results found for query: **{$query}**";
if (null !== $sdk) {
$markdown .= sprintf(' (filtered by SDK: %s)', $sdk);
}
return $markdown . "\n\nTry:\n- Using different keywords\n- Checking spelling\n- Using broader search terms";
}
// Apply pagination
$paginatedResults = array_slice($allResults, $offset, $limit);
$currentPage = (int) floor($offset / $limit) + 1;
$totalPages = (int) ceil($totalResults / $limit);
// Build markdown response
$markdown = "## Documentation Search Results\n\n";
$markdown .= "**Query:** `{$query}`\n";
if (null !== $sdk) {
$markdown .= sprintf('**SDK Filter:** %s%s', $sdk, PHP_EOL);
}
$markdown .= sprintf('**Search Type:** %s%s', $search_type, PHP_EOL);
$markdown .= '**Results:** Showing ' . ($offset + 1) . '-' . min($offset + $limit, $totalResults) . " of {$totalResults} total results\n";
$markdown .= "**Page:** {$currentPage} of {$totalPages}\n\n";
$markdown .= "---\n\n";
foreach ($paginatedResults as $resultIndex => $result) {
$resultNumber = $offset + (int) $resultIndex + 1;
$markdown .= $this->formatSearchResult($result, $resultNumber);
}
// Add pagination info
if (1 < $totalPages) {
$markdown .= "\n---\n\n### Pagination\n\n";
if (1 < $currentPage) {
$prevOffset = max(0, $offset - $limit);
$markdown .= sprintf('- **Previous page:** Use offset=%d%s', $prevOffset, PHP_EOL);
}
if ($currentPage < $totalPages) {
$nextOffset = $offset + $limit;
$markdown .= sprintf('- **Next page:** Use offset=%d%s', $nextOffset, PHP_EOL);
}
}
return $markdown;
}
/**
* Calculate similarity between two pieces of content.
*
* @param string $content1
* @param string $content2
* @return float Similarity score between 0 and 1
*/
private function calculateSimilarity(string $content1, string $content2): float
{
if ('' === $content1 || '' === $content2) {
return 0.0;
}
// Extract terms from both contents
$terms1 = $this->extractKeyTerms($content1);
$terms2 = $this->extractKeyTerms($content2);
if ([] === $terms1 || [] === $terms2) {
return 0.0;
}
// Calculate Jaccard similarity
$intersection = count(array_intersect($terms1, $terms2));
$union = count(array_unique(array_merge($terms1, $terms2)));
// Union will always be at least 1 since we checked both term arrays are non-empty
$jaccard = (float) ($intersection / $union);
// Also check for exact phrase matches for higher similarity
$phrases = [
'authorization model',
'permission check',
'tuple creation',
'relationship tuples',
'access control',
'openfga',
];
$phraseBonus = 0.0;
foreach ($phrases as $phrase) {
if (false !== stripos($content1, $phrase) && false !== stripos($content2, $phrase)) {
$phraseBonus += 0.1;
}
}
// Combine scores (cap at 1.0)
return min(1.0, $jaccard + $phraseBonus);
}
/**
* Extract code examples from a chunk.
*
* @param array<string, mixed> $chunk
* @param string|null $language
* @return array<array<string, mixed>>
*/
private function extractCodeFromChunk(array $chunk, ?string $language): array
{
$content = isset($chunk['content']) && is_scalar($chunk['content']) ? (string) $chunk['content'] : '';
$examples = [];
// Match code blocks with optional language specification
$pattern = '/```(\w+)?\n(.*?)\n```/s';
$result = preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
if (false !== $result && 0 < $result) {
foreach ($matches as $match) {
// When preg_match_all succeeds with PREG_SET_ORDER, capture groups exist
// Optional groups will be empty strings if they don't match
/** @var array{0: string, 1: string, 2: string} $match */
$codeLang = $match[1];
$codeContent = $match[2];
// Filter by language if specified
if (null !== $language) {
$langMatch = false;
switch ($language) {
case 'php':
$langMatch = 'php' === $codeLang || false !== stripos($codeContent, '<?php');
break;
case 'go':
$langMatch = 'go' === $codeLang || false !== stripos($codeContent, 'func ');
break;
case 'python':
$langMatch = 'python' === $codeLang || 'py' === $codeLang || false !== stripos($codeContent, 'def ');
break;
case 'java':
$langMatch = 'java' === $codeLang || false !== stripos($codeContent, 'public class');
break;
case 'csharp':
$langMatch = 'csharp' === $codeLang || 'cs' === $codeLang || false !== stripos($codeContent, 'using ');
break;
case 'javascript':
case 'typescript':
$langMatch = in_array($codeLang, ['javascript', 'js', 'typescript', 'ts'], true);
break;
}
if (! $langMatch) {
continue;
}
}
$examples[] = [
'language' => '' !== $codeLang ? $codeLang : 'unknown',
'code' => $codeContent,
'context' => $this->extractContext($content, $match[0]),
];
}
}
return $examples;
}
/**
* Extract context around code.
*
* @param string $content
* @param string $codeBlock
*/
private function extractContext(string $content, string $codeBlock): string
{
$position = strpos($content, $codeBlock);
if (false === $position) {
return '';
}
// Get text before the code block (up to 200 chars)
$beforeStart = max(0, $position - 200);
$beforeText = substr($content, $beforeStart, $position - $beforeStart);
// Get text after the code block (up to 200 chars)
$afterStart = $position + strlen($codeBlock);
$afterText = substr($content, $afterStart, 200);
// Clean up and combine
$beforeText = trim(preg_replace('/\s+/', ' ', $beforeText) ?? '');
$afterText = trim(preg_replace('/\s+/', ' ', $afterText) ?? '');
$context = '';
if ('' !== $beforeText) {
$context .= '...' . $beforeText;
}
$context .= ' [CODE] ';
if ('' !== $afterText) {
$context .= $afterText . '...';
}
return trim($context);
}
/**
* Extract key terms from content for similarity matching.
*
* @param string $content
* @return array<string>
*/
private function extractKeyTerms(string $content): array
{
// Remove code blocks and special characters
$cleanContent = preg_replace('/```[\s\S]*?```/', '', $content);
$cleanContent ??= '';
$cleanContent = preg_replace('/[^a-zA-Z0-9\s]/', ' ', $cleanContent);
$cleanContent ??= '';
// Extract words
$words = preg_split('/\s+/', strtolower($cleanContent));
$words = false !== $words ? $words : [];
// Filter out common words and short words
$stopWords = ['the', 'is', 'at', 'which', 'on', 'and', 'a', 'an', 'as', 'are', 'was', 'were', 'been', 'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'what', 'which', 'who', 'when', 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'in', 'of', 'to', 'for', 'with', 'from', 'up', 'out', 'if', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'under', 'again', 'further', 'then', 'once'];
$termCounts = [];
foreach ($words as $word) {
if (2 < strlen($word) && ! in_array($word, $stopWords, true)) {
if (! isset($termCounts[$word])) {
$termCounts[$word] = 0;
}
++$termCounts[$word];
}
}
// Sort by frequency and take top terms
arsort($termCounts);
$keyTerms = array_keys(array_slice($termCounts, 0, 10));
// Add OpenFGA-specific terms if present
$openfgaTerms = ['openfga', 'authorization', 'permission', 'tuple', 'relation', 'check', 'expand', 'store', 'model', 'user', 'object'];
foreach ($openfgaTerms as $openfgaTerm) {
if (false !== stripos($content, $openfgaTerm) && ! in_array($openfgaTerm, $keyTerms, true)) {
$keyTerms[] = $openfgaTerm;
}
}
return array_slice($keyTerms, 0, 15);
}
/**
* Format a code example as markdown.
*
* @param array<string, mixed> $example
* @param int $number
* @param bool $includeContext
*/
private function formatCodeExample(array $example, int $number, bool $includeContext): string
{
$markdown = "### Example {$number}\n\n";
$chunk = isset($example['chunk']) && is_array($example['chunk']) ? $example['chunk'] : [];
// Add metadata
if (isset($chunk['sdk']) && '' !== $chunk['sdk']) {
$chunkSdk = is_scalar($chunk['sdk']) ? (string) $chunk['sdk'] : '';
$markdown .= "**SDK:** `{$chunkSdk}` \n";
}
// Access class and method from metadata
$metadata = isset($chunk['metadata']) && is_array($chunk['metadata']) ? $chunk['metadata'] : [];
if (isset($metadata['class']) && '' !== $metadata['class']) {
$chunkClass = is_scalar($metadata['class']) ? (string) $metadata['class'] : '';
$markdown .= sprintf('**Class:** `%s`', $chunkClass);
if (isset($metadata['method']) && '' !== $metadata['method']) {
$chunkMethod = is_scalar($metadata['method']) ? (string) $metadata['method'] : '';
$markdown .= sprintf(' **Method:** `%s`', $chunkMethod);
}
$markdown .= " \n";
}
if (isset($example['language']) && '' !== $example['language'] && 'unknown' !== $example['language']) {
$exampleLang = is_scalar($example['language']) ? (string) $example['language'] : '';
$markdown .= "**Language:** `{$exampleLang}` \n";
}
$markdown .= "\n";
// Add context if requested
if ($includeContext && isset($example['context']) && '' !== $example['context']) {
$context = is_scalar($example['context']) ? (string) $example['context'] : '';
$markdown .= "**Context:**\n> " . str_replace('[CODE]', '*(see code below)*', $context) . "\n\n";
}
// Add code
$lang = isset($example['language']) && is_scalar($example['language']) ? (string) $example['language'] : '';
$code = isset($example['code']) && is_scalar($example['code']) ? (string) $example['code'] : '';
$markdown .= "```{$lang}\n{$code}\n```\n";
return $markdown . "\n---\n\n";
}
/**
* Format a search result as markdown.
*
* @param array<string, mixed> $result
* @param int $number
*/
private function formatSearchResult(array $result, int $number): string
{
$markdown = sprintf('### %d. ', $number);
// Build title based on available metadata
$title = '';
$metadata = isset($result['metadata']) && is_array($result['metadata']) ? $result['metadata'] : [];
if (isset($metadata['class']) && '' !== $metadata['class']) {
$title .= is_scalar($metadata['class']) ? (string) $metadata['class'] : '';
if (isset($metadata['method']) && '' !== $metadata['method']) {
$title .= '::' . (is_scalar($metadata['method']) ? (string) $metadata['method'] : '');
}
} elseif (isset($metadata['section']) && '' !== $metadata['section']) {
$title .= is_scalar($metadata['section']) ? (string) $metadata['section'] : '';
} else {
$title .= 'Documentation Chunk';
}
$markdown .= $title . "\n\n";
// Add metadata
if (isset($result['sdk']) && '' !== $result['sdk']) {
$sdkName = is_scalar($result['sdk']) ? (string) $result['sdk'] : '';
$markdown .= "**SDK:** `{$sdkName}` \n";
}
if (isset($result['source']) && '' !== $result['source']) {
$sourceName = is_scalar($result['source']) ? (string) $result['source'] : '';
$markdown .= "**Source:** `{$sourceName}` \n";
}
if (isset($result['score']) && 0.0 !== $result['score']) {
$scoreValue = is_numeric($result['score']) ? (float) $result['score'] : 0.0;
$markdown .= '**Relevance:** ' . (string) round($scoreValue * 100.0) . "% \n";
}
$markdown .= "\n";
// Add preview
if (isset($result['preview']) && '' !== $result['preview']) {
$preview = is_scalar($result['preview']) ? (string) $result['preview'] : '';
// Limit preview length
if (500 < strlen($preview)) {
$preview = substr($preview, 0, 497) . '...';
}
$markdown .= "**Preview:**\n```\n{$preview}\n```\n";
}
// Add navigation
if (isset($result['chunk_id']) && '' !== $result['chunk_id']) {
$sdk = isset($result['sdk']) && is_scalar($result['sdk']) ? (string) $result['sdk'] : 'unknown';
$id = isset($result['chunk_id']) && is_scalar($result['chunk_id']) ? (string) $result['chunk_id'] : 'unknown';
$markdown .= "\n**Reference:** `{$sdk}::{$id}`\n";
}
return $markdown . "\n---\n\n";
}
/**
* Format a similar documentation result as markdown.
*
* @param array<string, mixed> $chunk
* @param int $number
*/
private function formatSimilarResult(array $chunk, int $number): string
{
$markdown = sprintf('### %d. ', $number);
// Build title
$title = '';
$metadata = isset($chunk['metadata']) && is_array($chunk['metadata']) ? $chunk['metadata'] : [];
if (isset($metadata['class']) && '' !== $metadata['class']) {
$title .= is_scalar($metadata['class']) ? (string) $metadata['class'] : '';
if (isset($metadata['method']) && '' !== $metadata['method']) {
$title .= '::' . (is_scalar($metadata['method']) ? (string) $metadata['method'] : '');
}
} elseif (isset($metadata['section']) && '' !== $metadata['section']) {
$title .= is_scalar($metadata['section']) ? (string) $metadata['section'] : '';
} else {
$title .= 'Related Documentation';
}
$markdown .= $title . "\n\n";
// Add metadata
if (isset($chunk['sdk']) && '' !== $chunk['sdk']) {
$chunkSdkValue = is_scalar($chunk['sdk']) ? (string) $chunk['sdk'] : '';
$markdown .= "**SDK:** `{$chunkSdkValue}` \n";
}
if (isset($chunk['similarity']) && 0.0 !== $chunk['similarity']) {
$simScore = is_numeric($chunk['similarity']) ? (float) $chunk['similarity'] : 0.0;
$markdown .= '**Similarity Score:** ' . (string) round($simScore * 100.0) . "% \n";
}
if (isset($chunk['source']) && '' !== $chunk['source']) {
$sourceValue = is_scalar($chunk['source']) ? (string) $chunk['source'] : '';
$markdown .= "**Source:** `{$sourceValue}` \n";
}
$markdown .= "\n";
// Add content preview
if (isset($chunk['content']) && '' !== $chunk['content']) {
$preview = is_scalar($chunk['content']) ? (string) $chunk['content'] : '';
// Limit preview length
if (800 < strlen($preview)) {
$preview = substr($preview, 0, 797) . '...';
}
$markdown .= "**Content:**\n\n" . $preview . "\n";
}
// Add reference
if (isset($chunk['chunk_id']) && '' !== $chunk['chunk_id']) {
$sdk = isset($chunk['sdk']) && is_scalar($chunk['sdk']) ? (string) $chunk['sdk'] : 'unknown';
$id = isset($chunk['chunk_id']) && is_scalar($chunk['chunk_id']) ? (string) $chunk['chunk_id'] : 'unknown';
$markdown .= "\n**Reference:** `{$sdk}::{$id}`\n";
}
return $markdown . "\n---\n\n";
}
/**
* Get the full content of a chunk by its ID.
*
* @param string $chunkId
* @param DocumentationIndex $index
*/
private function getChunkContent(string $chunkId, DocumentationIndex $index): string
{
// For now, we'll use the chunk ID to get content
// In a real implementation, we'd fetch this from the index
$chunk = $index->getChunkById($chunkId);
if (null === $chunk) {
return '';
}
return $chunk['content'] ?? '';
}
/**
* Map programming language to SDK.
*
* @param string|null $language
*/
private function mapLanguageToSdk(?string $language): ?string
{
if (null === $language) {
return null;
}
$mapping = [
'php' => 'php',
'go' => 'go',
'python' => 'python',
'java' => 'java',
'csharp' => 'dotnet',
'javascript' => 'js',
'typescript' => 'js',
];
return $mapping[$language] ?? null;
}
/**
* Perform search based on search type.
*
* @param DocumentationIndex $index
* @param string $query
* @param string|null $sdk
* @param string $searchType
*
* @throws RuntimeException If search fails
*
* @return array<array<string, mixed>>
*/
private function performSearch(DocumentationIndex $index, string $query, ?string $sdk, string $searchType): array
{
return match ($searchType) {
'class' => $this->searchForClasses($index, $query, $sdk),
'method' => $this->searchForMethods($index, $query, $sdk),
'section' => $this->searchForSections($index, $query, $sdk),
default => $index->searchChunks($query, $sdk),
};
}
/**
* Search specifically for classes.
*
* @param DocumentationIndex $index
* @param string $query
* @param string|null $sdk
*
* @throws RuntimeException If search fails
*
* @return array<array<string, mixed>>
*/
private function searchForClasses(DocumentationIndex $index, string $query, ?string $sdk): array
{
$allChunks = $index->searchChunks($query, $sdk);
$classResults = [];
foreach ($allChunks as $allChunk) {
$metadata = $allChunk['metadata'];
if (isset($metadata['class']) && '' !== $metadata['class'] && is_scalar($metadata['class']) && false !== stripos((string) $metadata['class'], $query)) {
$classResults[] = $allChunk;
}
}
return $classResults;
}
/**
* Search specifically for methods.
*
* @param DocumentationIndex $index
* @param string $query
* @param string|null $sdk
*
* @throws RuntimeException If search fails
*
* @return array<array<string, mixed>>
*/
private function searchForMethods(DocumentationIndex $index, string $query, ?string $sdk): array
{
$allChunks = $index->searchChunks($query, $sdk);
$methodResults = [];
foreach ($allChunks as $allChunk) {
$metadata = $allChunk['metadata'];
if (isset($metadata['method']) && '' !== $metadata['method'] && is_scalar($metadata['method']) && false !== stripos((string) $metadata['method'], $query)) {
$methodResults[] = $allChunk;
}
}
return $methodResults;
}
/**
* Search specifically for sections.
*
* @param DocumentationIndex $index
* @param string $query
* @param string|null $sdk
*
* @throws RuntimeException If search fails
*
* @return array<array<string, mixed>>
*/
private function searchForSections(DocumentationIndex $index, string $query, ?string $sdk): array
{
$allChunks = $index->searchChunks($query, $sdk);
$sectionResults = [];
foreach ($allChunks as $allChunk) {
$metadata = $allChunk['metadata'];
if (isset($metadata['section']) && '' !== $metadata['section'] && is_scalar($metadata['section']) && false !== stripos((string) $metadata['section'], $query)) {
$sectionResults[] = $allChunk;
}
}
return $sectionResults;
}
}