Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/ASTral/ASTral.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="0.*" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.*" />
<PackageReference Include="TreeSitter.DotNet" Version="*" />
<PackageReference Include="MAB.DotIgnore" Version="*" />
<PackageReference Include="Anthropic" Version="*" />
<PackageReference Include="ModelContextProtocol" Version="1.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="TreeSitter.DotNet" Version="1.3.0" />
<PackageReference Include="MAB.DotIgnore" Version="3.0.2" />
<PackageReference Include="Anthropic" Version="12.8.0" />
</ItemGroup>

</Project>
110 changes: 30 additions & 80 deletions src/ASTral/Models/CodeIndex.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Text.Json;

using static ASTral.Models.JsonElementHelpers;
using ASTral.Utils;

namespace ASTral.Models;

Expand All @@ -26,8 +24,8 @@ public sealed class CodeIndex
/// <summary>Language -> file count</summary>
public required Dictionary<string, int> Languages { get; init; }

/// <summary>Serialized Symbol dicts</summary>
public required List<Dictionary<string, JsonElement>> Symbols { get; init; }
/// <summary>Typed symbol records</summary>
public required List<Symbol> Symbols { get; init; }

public int IndexVersion { get; init; } = CurrentIndexVersion;

Expand All @@ -41,35 +39,32 @@ public sealed class CodeIndex
public Dictionary<string, string> FileSummaries { get; init; } = new();

/// <summary>Find a symbol by ID.</summary>
public Dictionary<string, JsonElement>? GetSymbol(string symbolId)
public Symbol? GetSymbol(string symbolId)
{
foreach (var sym in Symbols)
{
if (sym.TryGetValue("id", out var idElem) && idElem.GetString() == symbolId)
{
if (sym.Id == symbolId)
return sym;
}
}

return null;
}

/// <summary>Search symbols with weighted scoring.</summary>
public List<Dictionary<string, JsonElement>> Search(
/// <summary>Search symbols with weighted scoring, returning scores.</summary>
public List<(int Score, Symbol Sym)> SearchWithScores(
string query,
string? kind = null,
string? filePattern = null)
{
var queryLower = query.ToLowerInvariant();
var queryWords = new HashSet<string>(queryLower.Split(' ', StringSplitOptions.RemoveEmptyEntries));

var scored = new List<(int Score, Dictionary<string, JsonElement> Sym)>();
var scored = new List<(int Score, Symbol Sym)>();
foreach (var sym in Symbols)
{
// Apply filters
if (kind is not null && GetString(sym, "kind") != kind)
if (kind is not null && sym.Kind != kind)
continue;
if (filePattern is not null && !MatchPattern(GetString(sym, "file"), filePattern))
if (filePattern is not null && !MatchPattern(sym.File, filePattern))
continue;

var score = ScoreSymbol(sym, queryLower, queryWords);
Expand All @@ -80,25 +75,35 @@ public List<Dictionary<string, JsonElement>> Search(
}

scored.Sort((a, b) => b.Score.CompareTo(a.Score));
return scored.Select(s => s.Sym).ToList();
return scored;
}

/// <summary>Search symbols with weighted scoring.</summary>
public List<Symbol> Search(
string query,
string? kind = null,
string? filePattern = null)
{
return SearchWithScores(query, kind, filePattern)
.Select(s => s.Sym)
.ToList();
}

private static bool MatchPattern(string filePath, string pattern)
{
// Simple glob matching using FileSystemName
return FileSystemName.MatchesSimpleExpression(pattern, filePath, ignoreCase: true)
|| FileSystemName.MatchesSimpleExpression($"*/{pattern}", filePath, ignoreCase: true);
return GlobMatcher.MatchesSimpleExpression(pattern, filePath, ignoreCase: true)
|| GlobMatcher.MatchesSimpleExpression($"*/{pattern}", filePath, ignoreCase: true);
}

internal static int ScoreSymbol(
Dictionary<string, JsonElement> sym,
Symbol sym,
string queryLower,
HashSet<string> queryWords)
{
var score = 0;

// 1. Exact name match (highest weight)
var nameLower = GetString(sym, "name").ToLowerInvariant();
var nameLower = sym.Name.ToLowerInvariant();
if (queryLower == nameLower)
score += 20;
else if (nameLower.Contains(queryLower, StringComparison.Ordinal))
Expand All @@ -112,7 +117,7 @@ internal static int ScoreSymbol(
}

// 3. Signature match
var sigLower = GetString(sym, "signature").ToLowerInvariant();
var sigLower = sym.Signature.ToLowerInvariant();
if (sigLower.Contains(queryLower, StringComparison.Ordinal))
score += 8;
foreach (var word in queryWords)
Expand All @@ -122,7 +127,7 @@ internal static int ScoreSymbol(
}

// 4. Summary match
var summaryLower = GetString(sym, "summary").ToLowerInvariant();
var summaryLower = sym.Summary.ToLowerInvariant();
if (summaryLower.Contains(queryLower, StringComparison.Ordinal))
score += 5;
foreach (var word in queryWords)
Expand All @@ -132,16 +137,15 @@ internal static int ScoreSymbol(
}

// 5. Keyword match
var keywords = GetStringList(sym, "keywords");
var keywordSet = new HashSet<string>(keywords, StringComparer.OrdinalIgnoreCase);
var keywordSet = new HashSet<string>(sym.Keywords, StringComparer.OrdinalIgnoreCase);
foreach (var word in queryWords)
{
if (keywordSet.Contains(word))
score += 3;
}

// 6. Docstring match
var docLower = GetString(sym, "docstring").ToLowerInvariant();
var docLower = sym.Docstring.ToLowerInvariant();
foreach (var word in queryWords)
{
if (docLower.Contains(word, StringComparison.Ordinal))
Expand All @@ -152,57 +156,3 @@ internal static int ScoreSymbol(
}

}

/// <summary>
/// Provides IO.FileSystemName.MatchesSimpleExpression for glob matching.
/// </summary>
internal static class FileSystemName
{
/// <summary>
/// Simple glob match supporting * and ? wildcards.
/// </summary>
public static bool MatchesSimpleExpression(string pattern, string name, bool ignoreCase = false)
{
var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return MatchWildcard(pattern, name, comparison);
}

private static bool MatchWildcard(string pattern, string text, StringComparison comparison)
{
var pIdx = 0;
var tIdx = 0;
var starPIdx = -1;
var starTIdx = -1;

while (tIdx < text.Length)
{
if (pIdx < pattern.Length && (pattern[pIdx] == '?' ||
string.Compare(pattern, pIdx, text, tIdx, 1, comparison) == 0))
{
pIdx++;
tIdx++;
}
else if (pIdx < pattern.Length && pattern[pIdx] == '*')
{
starPIdx = pIdx;
starTIdx = tIdx;
pIdx++;
}
else if (starPIdx >= 0)
{
pIdx = starPIdx + 1;
starTIdx++;
tIdx = starTIdx;
}
else
{
return false;
}
}

while (pIdx < pattern.Length && pattern[pIdx] == '*')
pIdx++;

return pIdx == pattern.Length;
}
}
51 changes: 0 additions & 51 deletions src/ASTral/Models/JsonElementHelpers.cs

This file was deleted.

18 changes: 18 additions & 0 deletions src/ASTral/Models/Symbol.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.Text.Json.Serialization;

namespace ASTral.Models;

Expand All @@ -8,54 +9,71 @@ namespace ASTral.Models;
public sealed record Symbol
{
/// <summary>Unique ID: "file_path::QualifiedName#kind"</summary>
[JsonPropertyName("id")]
public required string Id { get; init; }

/// <summary>Source file path (e.g., "src/main.py")</summary>
[JsonPropertyName("file")]
public required string File { get; init; }

/// <summary>Symbol name (e.g., "login")</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }

/// <summary>Fully qualified name (e.g., "MyClass.login")</summary>
[JsonPropertyName("qualified_name")]
public required string QualifiedName { get; init; }

/// <summary>"function" | "class" | "method" | "constant" | "type"</summary>
[JsonPropertyName("kind")]
public required string Kind { get; init; }

/// <summary>Language identifier (e.g., "python", "csharp")</summary>
[JsonPropertyName("language")]
public required string Language { get; init; }

/// <summary>Full signature line(s)</summary>
[JsonPropertyName("signature")]
public required string Signature { get; init; }

/// <summary>Extracted docstring (language-specific)</summary>
[JsonPropertyName("docstring")]
public string Docstring { get; init; } = "";

/// <summary>One-line summary</summary>
[JsonPropertyName("summary")]
public string Summary { get; set; } = "";

/// <summary>Decorators/attributes</summary>
[JsonPropertyName("decorators")]
public List<string> Decorators { get; init; } = [];

/// <summary>Extracted search keywords</summary>
[JsonPropertyName("keywords")]
public List<string> Keywords { get; init; } = [];

/// <summary>Parent symbol ID (for methods -> class)</summary>
[JsonPropertyName("parent")]
public string? Parent { get; init; }

/// <summary>Start line number (1-indexed)</summary>
[JsonPropertyName("line")]
public int Line { get; init; }

/// <summary>End line number (1-indexed)</summary>
[JsonPropertyName("end_line")]
public int EndLine { get; init; }

/// <summary>Start byte in raw file</summary>
[JsonPropertyName("byte_offset")]
public int ByteOffset { get; init; }

/// <summary>Byte length of full source</summary>
[JsonPropertyName("byte_length")]
public int ByteLength { get; init; }

/// <summary>SHA-256 of symbol source bytes (for drift detection)</summary>
[JsonPropertyName("content_hash")]
public string ContentHash { get; init; } = "";

/// <summary>
Expand Down
4 changes: 1 addition & 3 deletions src/ASTral/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@

var config = AstralConfig.Load();

if (!string.IsNullOrEmpty(config.ExtraExtensions))
LanguageRegistry.ApplyExtraExtensions(config.ExtraExtensions);
LanguageRegistry.ApplyExtraExtensions();
LanguageRegistry.ApplyExtraExtensions(config.ExtraExtensions ?? "");

var builder = Host.CreateApplicationBuilder(args);

Expand Down
Loading