This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/packages/parser.go
Torjus Håkestad ea4c69bc23 feat: add nixpkgs-search binary with package search support
Add a new nixpkgs-search CLI that combines NixOS options search with
Nix package search functionality. This provides two MCP servers from
a single binary:
- `nixpkgs-search options serve` for NixOS options
- `nixpkgs-search packages serve` for Nix packages

Key changes:
- Add packages table to database schema (version 3)
- Add Package type and search methods to database layer
- Create internal/packages/ with indexer and parser for nix-env JSON
- Add MCP server mode (options/packages) with separate tool sets
- Add package handlers: search_packages, get_package
- Create cmd/nixpkgs-search with combined indexing support
- Update flake.nix with nixpkgs-search package (now default)
- Bump version to 0.2.0

The index command can index both options and packages together, or
use --no-packages/--no-options flags for partial indexing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:12:41 +01:00

200 lines
5.1 KiB
Go

package packages
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// ParsePackages reads and parses a nix-env JSON output file.
func ParsePackages(r io.Reader) (map[string]*ParsedPackage, error) {
var raw PackagesFile
if err := json.NewDecoder(r).Decode(&raw); err != nil {
return nil, fmt.Errorf("failed to decode packages JSON: %w", err)
}
packages := make(map[string]*ParsedPackage, len(raw))
for attrPath, pkg := range raw {
parsed := &ParsedPackage{
AttrPath: attrPath,
Pname: pkg.Pname,
Version: pkg.Version,
Description: pkg.Meta.Description,
LongDescription: pkg.Meta.LongDescription,
Homepage: normalizeHomepage(pkg.Meta.Homepage),
License: normalizeLicense(pkg.Meta.License),
Platforms: normalizePlatforms(pkg.Meta.Platforms),
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
Broken: pkg.Meta.Broken,
Unfree: pkg.Meta.Unfree,
Insecure: pkg.Meta.Insecure,
}
packages[attrPath] = parsed
}
return packages, nil
}
// normalizeHomepage converts homepage to a string.
func normalizeHomepage(v interface{}) string {
if v == nil {
return ""
}
switch hp := v.(type) {
case string:
return hp
case []interface{}:
if len(hp) > 0 {
if s, ok := hp[0].(string); ok {
return s
}
}
}
return ""
}
// normalizeLicense converts license to a JSON array string.
func normalizeLicense(v interface{}) string {
if v == nil {
return "[]"
}
licenses := make([]string, 0)
switch l := v.(type) {
case string:
licenses = append(licenses, l)
case map[string]interface{}:
// Single license object
if spdxID, ok := l["spdxId"].(string); ok {
licenses = append(licenses, spdxID)
} else if fullName, ok := l["fullName"].(string); ok {
licenses = append(licenses, fullName)
} else if shortName, ok := l["shortName"].(string); ok {
licenses = append(licenses, shortName)
}
case []interface{}:
for _, item := range l {
switch li := item.(type) {
case string:
licenses = append(licenses, li)
case map[string]interface{}:
if spdxID, ok := li["spdxId"].(string); ok {
licenses = append(licenses, spdxID)
} else if fullName, ok := li["fullName"].(string); ok {
licenses = append(licenses, fullName)
} else if shortName, ok := li["shortName"].(string); ok {
licenses = append(licenses, shortName)
}
}
}
}
data, _ := json.Marshal(licenses)
return string(data)
}
// normalizePlatforms converts platforms to a JSON array string.
func normalizePlatforms(v []interface{}) string {
if v == nil {
return "[]"
}
platforms := make([]string, 0, len(v))
for _, p := range v {
switch pv := p.(type) {
case string:
platforms = append(platforms, pv)
// Skip complex platform specs (objects)
}
}
data, _ := json.Marshal(platforms)
return string(data)
}
// normalizeMaintainers converts maintainers to a JSON array string.
func normalizeMaintainers(maintainers []Maintainer) string {
if len(maintainers) == 0 {
return "[]"
}
names := make([]string, 0, len(maintainers))
for _, m := range maintainers {
name := m.Name
if name == "" && m.Github != "" {
name = "@" + m.Github
}
if name != "" {
names = append(names, name)
}
}
data, _ := json.Marshal(names)
return string(data)
}
// ParsePackagesStream parses packages from a reader using streaming to reduce memory usage.
// It yields parsed packages through a callback function.
func ParsePackagesStream(r io.Reader, callback func(*ParsedPackage) error) (int, error) {
dec := json.NewDecoder(r)
// Read the opening brace
t, err := dec.Token()
if err != nil {
return 0, fmt.Errorf("failed to read opening token: %w", err)
}
if delim, ok := t.(json.Delim); !ok || delim != '{' {
return 0, fmt.Errorf("expected opening brace, got %v", t)
}
count := 0
for dec.More() {
// Read the key (attr path)
t, err := dec.Token()
if err != nil {
return count, fmt.Errorf("failed to read attr path: %w", err)
}
attrPath, ok := t.(string)
if !ok {
return count, fmt.Errorf("expected string key, got %T", t)
}
// Read the value (package)
var pkg RawPackage
if err := dec.Decode(&pkg); err != nil {
// Skip malformed packages
continue
}
parsed := &ParsedPackage{
AttrPath: attrPath,
Pname: pkg.Pname,
Version: pkg.Version,
Description: pkg.Meta.Description,
LongDescription: pkg.Meta.LongDescription,
Homepage: normalizeHomepage(pkg.Meta.Homepage),
License: normalizeLicense(pkg.Meta.License),
Platforms: normalizePlatforms(pkg.Meta.Platforms),
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
Broken: pkg.Meta.Broken,
Unfree: pkg.Meta.Unfree,
Insecure: pkg.Meta.Insecure,
}
if err := callback(parsed); err != nil {
return count, fmt.Errorf("callback error for %s: %w", attrPath, err)
}
count++
}
return count, nil
}
// SplitAttrPath splits an attribute path into its components.
// For example, "python312Packages.requests" returns ["python312Packages", "requests"].
func SplitAttrPath(attrPath string) []string {
return strings.Split(attrPath, ".")
}