Merge pull request 'feature/file-metadata-and-range' (#3) from feature/file-metadata-and-range into master
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -20,7 +20,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDatabase = "sqlite://hm-options.db"
|
defaultDatabase = "sqlite://hm-options.db"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDatabase = "sqlite://nixos-options.db"
|
defaultDatabase = "sqlite://nixos-options.db"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ func runStoreTests(t *testing.T, newStore func(t *testing.T) Store) {
|
|||||||
{"OptionChildren", testOptionChildren},
|
{"OptionChildren", testOptionChildren},
|
||||||
{"Declarations", testDeclarations},
|
{"Declarations", testDeclarations},
|
||||||
{"Files", testFiles},
|
{"Files", testFiles},
|
||||||
|
{"FileRange", testFileRange},
|
||||||
|
{"DeclarationsWithMetadata", testDeclarationsWithMetadata},
|
||||||
{"SchemaVersion", testSchemaVersion},
|
{"SchemaVersion", testSchemaVersion},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +453,14 @@ func testFiles(t *testing.T, store Store) {
|
|||||||
t.Errorf("Extension = %q, want .nix", got.Extension)
|
t.Errorf("Extension = %q, want .nix", got.Extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify file metadata was computed
|
||||||
|
if got.ByteSize != len(file.Content) {
|
||||||
|
t.Errorf("ByteSize = %d, want %d", got.ByteSize, len(file.Content))
|
||||||
|
}
|
||||||
|
if got.LineCount != 3 {
|
||||||
|
t.Errorf("LineCount = %d, want 3", got.LineCount)
|
||||||
|
}
|
||||||
|
|
||||||
// Get non-existent file
|
// Get non-existent file
|
||||||
got, err = store.GetFile(ctx, rev.ID, "nonexistent.nix")
|
got, err = store.GetFile(ctx, rev.ID, "nonexistent.nix")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -476,6 +486,169 @@ func testFiles(t *testing.T, store Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFileRange(t *testing.T, store Store) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.Initialize(ctx); err != nil {
|
||||||
|
t.Fatalf("Initialize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rev := &Revision{GitHash: "range123", ChannelName: "test"}
|
||||||
|
if err := store.CreateRevision(ctx, rev); err != nil {
|
||||||
|
t.Fatalf("CreateRevision failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a multi-line file
|
||||||
|
content := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10"
|
||||||
|
file := &File{
|
||||||
|
RevisionID: rev.ID,
|
||||||
|
FilePath: "multiline.nix",
|
||||||
|
Extension: ".nix",
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
if err := store.CreateFile(ctx, file); err != nil {
|
||||||
|
t.Fatalf("CreateFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default range (first 250 lines, but we have less)
|
||||||
|
result, err := store.GetFileWithRange(ctx, rev.ID, "multiline.nix", FileRange{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileWithRange default failed: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected result, got nil")
|
||||||
|
}
|
||||||
|
if result.TotalLines != 10 {
|
||||||
|
t.Errorf("TotalLines = %d, want 10", result.TotalLines)
|
||||||
|
}
|
||||||
|
if result.StartLine != 1 {
|
||||||
|
t.Errorf("StartLine = %d, want 1", result.StartLine)
|
||||||
|
}
|
||||||
|
if result.EndLine != 10 {
|
||||||
|
t.Errorf("EndLine = %d, want 10", result.EndLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with offset
|
||||||
|
result, err = store.GetFileWithRange(ctx, rev.ID, "multiline.nix", FileRange{Offset: 2, Limit: 3})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileWithRange with offset failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.StartLine != 3 {
|
||||||
|
t.Errorf("StartLine = %d, want 3", result.StartLine)
|
||||||
|
}
|
||||||
|
if result.EndLine != 5 {
|
||||||
|
t.Errorf("EndLine = %d, want 5", result.EndLine)
|
||||||
|
}
|
||||||
|
if result.Content != "line 3\nline 4\nline 5" {
|
||||||
|
t.Errorf("Content = %q, want lines 3-5", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test offset beyond file
|
||||||
|
result, err = store.GetFileWithRange(ctx, rev.ID, "multiline.nix", FileRange{Offset: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileWithRange beyond end failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.StartLine != 0 {
|
||||||
|
t.Errorf("StartLine = %d, want 0 for beyond end", result.StartLine)
|
||||||
|
}
|
||||||
|
if result.Content != "" {
|
||||||
|
t.Errorf("Content = %q, want empty for beyond end", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test non-existent file
|
||||||
|
result, err = store.GetFileWithRange(ctx, rev.ID, "nonexistent.nix", FileRange{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileWithRange for nonexistent failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Error("Expected nil for nonexistent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeclarationsWithMetadata(t *testing.T, store Store) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.Initialize(ctx); err != nil {
|
||||||
|
t.Fatalf("Initialize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rev := &Revision{GitHash: "metadata123", ChannelName: "test"}
|
||||||
|
if err := store.CreateRevision(ctx, rev); err != nil {
|
||||||
|
t.Fatalf("CreateRevision failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a file
|
||||||
|
file := &File{
|
||||||
|
RevisionID: rev.ID,
|
||||||
|
FilePath: "modules/nginx.nix",
|
||||||
|
Extension: ".nix",
|
||||||
|
Content: "line 1\nline 2\nline 3",
|
||||||
|
}
|
||||||
|
if err := store.CreateFile(ctx, file); err != nil {
|
||||||
|
t.Fatalf("CreateFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an option with declarations
|
||||||
|
opt := &Option{
|
||||||
|
RevisionID: rev.ID,
|
||||||
|
Name: "services.nginx.enable",
|
||||||
|
ParentPath: "services.nginx",
|
||||||
|
Type: "boolean",
|
||||||
|
}
|
||||||
|
if err := store.CreateOption(ctx, opt); err != nil {
|
||||||
|
t.Fatalf("CreateOption failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create declarations - one pointing to indexed file, one to non-indexed
|
||||||
|
decls := []*Declaration{
|
||||||
|
{OptionID: opt.ID, FilePath: "modules/nginx.nix", Line: 10},
|
||||||
|
{OptionID: opt.ID, FilePath: "modules/other.nix", Line: 20},
|
||||||
|
}
|
||||||
|
if err := store.CreateDeclarationsBatch(ctx, decls); err != nil {
|
||||||
|
t.Fatalf("CreateDeclarationsBatch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get declarations with metadata
|
||||||
|
declMetas, err := store.GetDeclarationsWithMetadata(ctx, rev.ID, opt.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDeclarationsWithMetadata failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(declMetas) != 2 {
|
||||||
|
t.Fatalf("Expected 2 declarations, got %d", len(declMetas))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the declaration for the indexed file
|
||||||
|
var indexed, notIndexed *DeclarationWithMetadata
|
||||||
|
for _, d := range declMetas {
|
||||||
|
if d.FilePath == "modules/nginx.nix" {
|
||||||
|
indexed = d
|
||||||
|
} else {
|
||||||
|
notIndexed = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if indexed == nil {
|
||||||
|
t.Fatal("Expected indexed declaration")
|
||||||
|
}
|
||||||
|
if !indexed.HasFile {
|
||||||
|
t.Error("Expected HasFile=true for indexed file")
|
||||||
|
}
|
||||||
|
if indexed.ByteSize != len(file.Content) {
|
||||||
|
t.Errorf("ByteSize = %d, want %d", indexed.ByteSize, len(file.Content))
|
||||||
|
}
|
||||||
|
if indexed.LineCount != 3 {
|
||||||
|
t.Errorf("LineCount = %d, want 3", indexed.LineCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if notIndexed == nil {
|
||||||
|
t.Fatal("Expected not-indexed declaration")
|
||||||
|
}
|
||||||
|
if notIndexed.HasFile {
|
||||||
|
t.Error("Expected HasFile=false for non-indexed file")
|
||||||
|
}
|
||||||
|
if notIndexed.ByteSize != 0 {
|
||||||
|
t.Errorf("ByteSize = %d, want 0 for non-indexed", notIndexed.ByteSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testSchemaVersion(t *testing.T, store Store) {
|
func testSchemaVersion(t *testing.T, store Store) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,30 @@ type File struct {
|
|||||||
FilePath string
|
FilePath string
|
||||||
Extension string
|
Extension string
|
||||||
Content string
|
Content string
|
||||||
|
ByteSize int
|
||||||
|
LineCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclarationWithMetadata includes declaration info plus file metadata.
|
||||||
|
type DeclarationWithMetadata struct {
|
||||||
|
Declaration
|
||||||
|
ByteSize int // File size in bytes, 0 if file not indexed
|
||||||
|
LineCount int // Number of lines, 0 if file not indexed
|
||||||
|
HasFile bool // True if file is indexed
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileRange specifies a range of lines to return from a file.
|
||||||
|
type FileRange struct {
|
||||||
|
Offset int // Line offset (0-based)
|
||||||
|
Limit int // Maximum lines to return (0 = default 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileResult contains a file with range metadata.
|
||||||
|
type FileResult struct {
|
||||||
|
*File
|
||||||
|
TotalLines int // Total lines in the file
|
||||||
|
StartLine int // First line returned (1-based)
|
||||||
|
EndLine int // Last line returned (1-based)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchFilters contains optional filters for option search.
|
// SearchFilters contains optional filters for option search.
|
||||||
@@ -80,9 +104,11 @@ type Store interface {
|
|||||||
CreateDeclaration(ctx context.Context, decl *Declaration) error
|
CreateDeclaration(ctx context.Context, decl *Declaration) error
|
||||||
CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error
|
CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error
|
||||||
GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error)
|
GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error)
|
||||||
|
GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error)
|
||||||
|
|
||||||
// File operations
|
// File operations
|
||||||
CreateFile(ctx context.Context, file *File) error
|
CreateFile(ctx context.Context, file *File) error
|
||||||
CreateFilesBatch(ctx context.Context, files []*File) error
|
CreateFilesBatch(ctx context.Context, files []*File) error
|
||||||
GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
|
GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
|
||||||
|
GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
|||||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
extension TEXT,
|
extension TEXT,
|
||||||
content TEXT NOT NULL
|
content TEXT NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
line_count INTEGER NOT NULL DEFAULT 0
|
||||||
)`,
|
)`,
|
||||||
IndexOptionsRevisionName,
|
IndexOptionsRevisionName,
|
||||||
IndexOptionsRevisionParent,
|
IndexOptionsRevisionParent,
|
||||||
@@ -432,11 +434,19 @@ func (s *PostgresStore) GetDeclarations(ctx context.Context, optionID int64) ([]
|
|||||||
|
|
||||||
// CreateFile creates a new file record.
|
// CreateFile creates a new file record.
|
||||||
func (s *PostgresStore) CreateFile(ctx context.Context, file *File) error {
|
func (s *PostgresStore) CreateFile(ctx context.Context, file *File) error {
|
||||||
|
// Compute metadata if not already set
|
||||||
|
if file.ByteSize == 0 {
|
||||||
|
file.ByteSize = len(file.Content)
|
||||||
|
}
|
||||||
|
if file.LineCount == 0 {
|
||||||
|
file.LineCount = countLines(file.Content)
|
||||||
|
}
|
||||||
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO files (revision_id, file_path, extension, content)
|
INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
file.RevisionID, file.FilePath, file.Extension, file.Content,
|
file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount,
|
||||||
).Scan(&file.ID)
|
).Scan(&file.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
@@ -453,8 +463,8 @@ func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) err
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
INSERT INTO files (revision_id, file_path, extension, content)
|
INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id`)
|
RETURNING id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
@@ -462,7 +472,15 @@ func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) err
|
|||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content).Scan(&file.ID)
|
// Compute metadata if not already set
|
||||||
|
if file.ByteSize == 0 {
|
||||||
|
file.ByteSize = len(file.Content)
|
||||||
|
}
|
||||||
|
if file.LineCount == 0 {
|
||||||
|
file.LineCount = countLines(file.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount).Scan(&file.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert file: %w", err)
|
return fmt.Errorf("failed to insert file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -475,9 +493,9 @@ func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) err
|
|||||||
func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
|
func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
|
||||||
file := &File{}
|
file := &File{}
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT id, revision_id, file_path, extension, content
|
SELECT id, revision_id, file_path, extension, content, byte_size, line_count
|
||||||
FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path,
|
FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path,
|
||||||
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
|
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content, &file.ByteSize, &file.LineCount)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -486,3 +504,41 @@ func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path stri
|
|||||||
}
|
}
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDeclarationsWithMetadata retrieves declarations with file metadata for an option.
|
||||||
|
func (s *PostgresStore) GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT d.id, d.option_id, d.file_path, d.line,
|
||||||
|
COALESCE(f.byte_size, 0), COALESCE(f.line_count, 0), (f.id IS NOT NULL)
|
||||||
|
FROM declarations d
|
||||||
|
LEFT JOIN files f ON f.revision_id = $1 AND f.file_path = d.file_path
|
||||||
|
WHERE d.option_id = $2`, revisionID, optionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get declarations with metadata: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var decls []*DeclarationWithMetadata
|
||||||
|
for rows.Next() {
|
||||||
|
decl := &DeclarationWithMetadata{}
|
||||||
|
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line,
|
||||||
|
&decl.ByteSize, &decl.LineCount, &decl.HasFile); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan declaration: %w", err)
|
||||||
|
}
|
||||||
|
decls = append(decls, decl)
|
||||||
|
}
|
||||||
|
return decls, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileWithRange retrieves a file with a specified line range.
|
||||||
|
func (s *PostgresStore) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) {
|
||||||
|
file, err := s.GetFile(ctx, revisionID, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyLineRange(file, r), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package database
|
|||||||
|
|
||||||
// SchemaVersion is the current database schema version.
|
// SchemaVersion is the current database schema version.
|
||||||
// When this changes, the database will be dropped and recreated.
|
// When this changes, the database will be dropped and recreated.
|
||||||
const SchemaVersion = 1
|
const SchemaVersion = 2
|
||||||
|
|
||||||
// Common SQL statements shared between implementations.
|
// Common SQL statements shared between implementations.
|
||||||
const (
|
const (
|
||||||
@@ -53,7 +53,9 @@ const (
|
|||||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
extension TEXT,
|
extension TEXT,
|
||||||
content TEXT NOT NULL
|
content TEXT NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
line_count INTEGER NOT NULL DEFAULT 0
|
||||||
)`
|
)`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -470,10 +470,18 @@ func (s *SQLiteStore) GetDeclarations(ctx context.Context, optionID int64) ([]*D
|
|||||||
|
|
||||||
// CreateFile creates a new file record.
|
// CreateFile creates a new file record.
|
||||||
func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error {
|
func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error {
|
||||||
|
// Compute metadata if not already set
|
||||||
|
if file.ByteSize == 0 {
|
||||||
|
file.ByteSize = len(file.Content)
|
||||||
|
}
|
||||||
|
if file.LineCount == 0 {
|
||||||
|
file.LineCount = countLines(file.Content)
|
||||||
|
}
|
||||||
|
|
||||||
result, err := s.db.ExecContext(ctx, `
|
result, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO files (revision_id, file_path, extension, content)
|
INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count)
|
||||||
VALUES (?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
file.RevisionID, file.FilePath, file.Extension, file.Content,
|
file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
@@ -496,15 +504,23 @@ func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
INSERT INTO files (revision_id, file_path, extension, content)
|
INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count)
|
||||||
VALUES (?, ?, ?, ?)`)
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content)
|
// Compute metadata if not already set
|
||||||
|
if file.ByteSize == 0 {
|
||||||
|
file.ByteSize = len(file.Content)
|
||||||
|
}
|
||||||
|
if file.LineCount == 0 {
|
||||||
|
file.LineCount = countLines(file.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert file: %w", err)
|
return fmt.Errorf("failed to insert file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -522,9 +538,9 @@ func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error
|
|||||||
func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
|
func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
|
||||||
file := &File{}
|
file := &File{}
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT id, revision_id, file_path, extension, content
|
SELECT id, revision_id, file_path, extension, content, byte_size, line_count
|
||||||
FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path,
|
FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path,
|
||||||
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
|
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content, &file.ByteSize, &file.LineCount)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -533,3 +549,110 @@ func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string
|
|||||||
}
|
}
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDeclarationsWithMetadata retrieves declarations with file metadata for an option.
|
||||||
|
func (s *SQLiteStore) GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT d.id, d.option_id, d.file_path, d.line,
|
||||||
|
COALESCE(f.byte_size, 0), COALESCE(f.line_count, 0), (f.id IS NOT NULL)
|
||||||
|
FROM declarations d
|
||||||
|
LEFT JOIN files f ON f.revision_id = ? AND f.file_path = d.file_path
|
||||||
|
WHERE d.option_id = ?`, revisionID, optionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get declarations with metadata: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var decls []*DeclarationWithMetadata
|
||||||
|
for rows.Next() {
|
||||||
|
decl := &DeclarationWithMetadata{}
|
||||||
|
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line,
|
||||||
|
&decl.ByteSize, &decl.LineCount, &decl.HasFile); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan declaration: %w", err)
|
||||||
|
}
|
||||||
|
decls = append(decls, decl)
|
||||||
|
}
|
||||||
|
return decls, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileWithRange retrieves a file with a specified line range.
|
||||||
|
func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) {
|
||||||
|
file, err := s.GetFile(ctx, revisionID, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyLineRange(file, r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countLines counts the number of lines in content.
|
||||||
|
func countLines(content string) int {
|
||||||
|
if content == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 1
|
||||||
|
for _, c := range content {
|
||||||
|
if c == '\n' {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't count trailing newline as extra line
|
||||||
|
if len(content) > 0 && content[len(content)-1] == '\n' {
|
||||||
|
count--
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLineRange extracts a range of lines from a file.
|
||||||
|
func applyLineRange(file *File, r FileRange) *FileResult {
|
||||||
|
lines := strings.Split(file.Content, "\n")
|
||||||
|
totalLines := len(lines)
|
||||||
|
|
||||||
|
// Handle trailing newline
|
||||||
|
if totalLines > 0 && lines[totalLines-1] == "" {
|
||||||
|
totalLines--
|
||||||
|
lines = lines[:totalLines]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults
|
||||||
|
offset := r.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
limit := r.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 250 // Default limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate range
|
||||||
|
startLine := offset + 1 // 1-based
|
||||||
|
if offset >= totalLines {
|
||||||
|
// Beyond end of file
|
||||||
|
return &FileResult{
|
||||||
|
File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: "", ByteSize: file.ByteSize, LineCount: file.LineCount},
|
||||||
|
TotalLines: totalLines,
|
||||||
|
StartLine: 0,
|
||||||
|
EndLine: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endIdx := offset + limit
|
||||||
|
if endIdx > totalLines {
|
||||||
|
endIdx = totalLines
|
||||||
|
}
|
||||||
|
endLine := endIdx // 1-based (last line included)
|
||||||
|
|
||||||
|
// Extract lines
|
||||||
|
selectedLines := lines[offset:endIdx]
|
||||||
|
content := strings.Join(selectedLines, "\n")
|
||||||
|
|
||||||
|
return &FileResult{
|
||||||
|
File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: content, ByteSize: file.ByteSize, LineCount: file.LineCount},
|
||||||
|
TotalLines: totalLines,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ func (s *Server) handleGetOption(ctx context.Context, args map[string]interface{
|
|||||||
return ErrorContent(fmt.Errorf("option '%s' not found", name)), nil
|
return ErrorContent(fmt.Errorf("option '%s' not found", name)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get declarations
|
// Get declarations with file metadata
|
||||||
declarations, err := s.store.GetDeclarations(ctx, option.ID)
|
declarations, err := s.store.GetDeclarationsWithMetadata(ctx, rev.ID, option.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Printf("Failed to get declarations: %v", err)
|
s.logger.Printf("Failed to get declarations: %v", err)
|
||||||
}
|
}
|
||||||
@@ -134,10 +134,15 @@ func (s *Server) handleGetOption(ctx context.Context, args map[string]interface{
|
|||||||
sb.WriteString("\n**Declared in:**\n")
|
sb.WriteString("\n**Declared in:**\n")
|
||||||
for _, decl := range declarations {
|
for _, decl := range declarations {
|
||||||
if decl.Line > 0 {
|
if decl.Line > 0 {
|
||||||
sb.WriteString(fmt.Sprintf("- %s:%d\n", decl.FilePath, decl.Line))
|
sb.WriteString(fmt.Sprintf("- %s:%d", decl.FilePath, decl.Line))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(fmt.Sprintf("- %s\n", decl.FilePath))
|
sb.WriteString(fmt.Sprintf("- %s", decl.FilePath))
|
||||||
}
|
}
|
||||||
|
// Add file metadata if available
|
||||||
|
if decl.HasFile && decl.ByteSize > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" (%d bytes, %d lines)", decl.ByteSize, decl.LineCount))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,16 +204,34 @@ func (s *Server) handleGetFile(ctx context.Context, args map[string]interface{})
|
|||||||
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := s.store.GetFile(ctx, rev.ID, path)
|
// Parse range parameters
|
||||||
|
var offset, limit int
|
||||||
|
if o, ok := args["offset"].(float64); ok {
|
||||||
|
offset = int(o)
|
||||||
|
}
|
||||||
|
if l, ok := args["limit"].(float64); ok {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use GetFileWithRange
|
||||||
|
fileRange := database.FileRange{Offset: offset, Limit: limit}
|
||||||
|
result, err := s.store.GetFileWithRange(ctx, rev.ID, path, fileRange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorContent(fmt.Errorf("failed to get file: %w", err)), nil
|
return ErrorContent(fmt.Errorf("failed to get file: %w", err)), nil
|
||||||
}
|
}
|
||||||
if file == nil {
|
if result == nil {
|
||||||
return ErrorContent(fmt.Errorf("file '%s' not found (files may not be indexed for this revision)", path)), nil
|
return ErrorContent(fmt.Errorf("file '%s' not found (files may not be indexed for this revision)", path)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format output with range metadata
|
||||||
|
var sb strings.Builder
|
||||||
|
if result.TotalLines > 0 && (result.StartLine > 1 || result.EndLine < result.TotalLines) {
|
||||||
|
sb.WriteString(fmt.Sprintf("Showing lines %d-%d of %d total\n\n", result.StartLine, result.EndLine, result.TotalLines))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("```%s\n%s\n```", strings.TrimPrefix(result.Extension, "."), result.Content))
|
||||||
|
|
||||||
return CallToolResult{
|
return CallToolResult{
|
||||||
Content: []Content{TextContent(fmt.Sprintf("```%s\n%s\n```", strings.TrimPrefix(file.Extension, "."), file.Content))},
|
Content: []Content{TextContent(sb.String())},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type ServerConfig struct {
|
|||||||
func DefaultNixOSConfig() ServerConfig {
|
func DefaultNixOSConfig() ServerConfig {
|
||||||
return ServerConfig{
|
return ServerConfig{
|
||||||
Name: "nixos-options",
|
Name: "nixos-options",
|
||||||
Version: "0.1.1",
|
Version: "0.1.2",
|
||||||
DefaultChannel: "nixos-stable",
|
DefaultChannel: "nixos-stable",
|
||||||
SourceName: "nixpkgs",
|
SourceName: "nixpkgs",
|
||||||
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options.
|
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options.
|
||||||
@@ -47,7 +47,7 @@ This ensures option documentation matches the nixpkgs version the project actual
|
|||||||
func DefaultHomeManagerConfig() ServerConfig {
|
func DefaultHomeManagerConfig() ServerConfig {
|
||||||
return ServerConfig{
|
return ServerConfig{
|
||||||
Name: "hm-options",
|
Name: "hm-options",
|
||||||
Version: "0.1.1",
|
Version: "0.1.2",
|
||||||
DefaultChannel: "hm-stable",
|
DefaultChannel: "hm-stable",
|
||||||
SourceName: "home-manager",
|
SourceName: "home-manager",
|
||||||
Instructions: `Home Manager Options MCP Server - Search and query Home Manager configuration options.
|
Instructions: `Home Manager Options MCP Server - Search and query Home Manager configuration options.
|
||||||
@@ -291,6 +291,16 @@ func (s *Server) getToolDefinitions() []Tool {
|
|||||||
Type: "string",
|
Type: "string",
|
||||||
Description: "Git hash or channel name. Uses default if not specified.",
|
Description: "Git hash or channel name. Uses default if not specified.",
|
||||||
},
|
},
|
||||||
|
"offset": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: "Line offset (0-based). Default: 0",
|
||||||
|
Default: 0,
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: "Maximum lines to return. Default: 250, use 0 for all lines",
|
||||||
|
Default: 250,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Required: []string{"path"},
|
Required: []string{"path"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
buildGoModule {
|
buildGoModule {
|
||||||
inherit pname src;
|
inherit pname src;
|
||||||
version = "0.1.1";
|
version = "0.1.2";
|
||||||
|
|
||||||
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user