Files
fn_registry/registry/parser.go
T
egutierrez 5992d78941 feat: soporte projects y vaults en registry
Añade tablas projects y vaults a registry.db con FTS5, modelos Go,
parser de project.md y vault.yaml, CRUD completo en store, hashing
determinista, validación, y soporte en el indexer para escanear
projects/{name}/ con sus apps, analysis y vaults anidados.
Migration 010 crea las tablas, triggers FTS5, y columna project_id
en apps/analysis. El indexer preserva records remotos (repo_url) al
reindexar, igual que apps/analysis.
2026-04-12 17:29:41 +02:00

537 lines
15 KiB
Go

package registry
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// rawFunction mirrors the YAML frontmatter of a function .md file.
type rawFunction struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
Lang string `yaml:"lang"`
Domain string `yaml:"domain"`
Version string `yaml:"version"`
Purity string `yaml:"purity"`
Signature string `yaml:"signature"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
UsesFunctions []string `yaml:"uses_functions"`
UsesTypes []string `yaml:"uses_types"`
Returns []string `yaml:"returns"`
ReturnsOptional bool `yaml:"returns_optional"`
ErrorType string `yaml:"error_type"`
Imports []string `yaml:"imports"`
Tested bool `yaml:"tested"`
Tests []string `yaml:"tests"`
TestFilePath string `yaml:"test_file_path"`
FilePath string `yaml:"file_path"`
// Params schema
Params []rawParam `yaml:"params"`
Output string `yaml:"output"`
// Source attribution
SourceRepo string `yaml:"source_repo"`
SourceLicense string `yaml:"source_license"`
SourceFile string `yaml:"source_file"`
// Component fields
Props []PropDef `yaml:"props"`
Emits []string `yaml:"emits"`
HasState *bool `yaml:"has_state"`
Framework string `yaml:"framework"`
Variant []string `yaml:"variant"`
}
// rawParam describes a function parameter with semantic meaning.
type rawParam struct {
Name string `yaml:"name" json:"name"`
Desc string `yaml:"desc" json:"desc"`
}
// rawType mirrors the YAML frontmatter of a type .md file.
type rawType struct {
Name string `yaml:"name"`
Lang string `yaml:"lang"`
Domain string `yaml:"domain"`
Version string `yaml:"version"`
Algebraic string `yaml:"algebraic"`
Definition string `yaml:"definition"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
UsesTypes []string `yaml:"uses_types"`
FilePath string `yaml:"file_path"`
SourceRepo string `yaml:"source_repo"`
SourceLicense string `yaml:"source_license"`
SourceFile string `yaml:"source_file"`
}
// rawApp mirrors the YAML frontmatter of an app .md file.
type rawApp struct {
Name string `yaml:"name"`
Lang string `yaml:"lang"`
Domain string `yaml:"domain"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
UsesFunctions []string `yaml:"uses_functions"`
UsesTypes []string `yaml:"uses_types"`
Framework string `yaml:"framework"`
EntryPoint string `yaml:"entry_point"`
DirPath string `yaml:"dir_path"`
RepoURL string `yaml:"repo_url"`
}
// rawAnalysis mirrors the YAML frontmatter of an analysis .md file.
type rawAnalysis struct {
Name string `yaml:"name"`
Lang string `yaml:"lang"`
Domain string `yaml:"domain"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
UsesFunctions []string `yaml:"uses_functions"`
UsesTypes []string `yaml:"uses_types"`
Framework string `yaml:"framework"`
EntryPoint string `yaml:"entry_point"`
DirPath string `yaml:"dir_path"`
RepoURL string `yaml:"repo_url"`
}
// rawProject mirrors the YAML frontmatter of a project .md file.
type rawProject struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
RepoURL string `yaml:"repo_url"`
}
// rawVaultFile mirrors the YAML of a vault.yaml manifest file.
type rawVaultFile struct {
Vaults []rawVaultEntry `yaml:"vaults"`
}
// rawVaultEntry describes a single vault in vault.yaml.
type rawVaultEntry struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Path string `yaml:"path"`
Tags []string `yaml:"tags"`
}
// extractFrontmatter splits a .md file into YAML frontmatter and body.
func extractFrontmatter(data []byte) ([]byte, []byte, error) {
content := data
if !bytes.HasPrefix(content, []byte("---\n")) && !bytes.HasPrefix(content, []byte("---\r\n")) {
return nil, nil, fmt.Errorf("missing opening --- in frontmatter")
}
// Skip opening ---
rest := content[4:]
idx := bytes.Index(rest, []byte("\n---"))
if idx < 0 {
return nil, nil, fmt.Errorf("missing closing --- in frontmatter")
}
fm := rest[:idx]
body := rest[idx+4:] // skip \n---
return fm, body, nil
}
// ParseFunctionMD parses a function .md file into a Function.
// root is the registry root directory, used to resolve file_path for code reading.
func ParseFunctionMD(path string, root string) (*Function, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
var raw rawFunction
if err := yaml.Unmarshal(fm, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
if raw.Name == "" {
return nil, fmt.Errorf("%s: name is required", path)
}
if raw.Kind == "" {
return nil, fmt.Errorf("%s: kind is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
f := &Function{
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
Name: raw.Name,
Kind: Kind(raw.Kind),
Lang: raw.Lang,
Domain: raw.Domain,
Version: raw.Version,
Purity: Purity(raw.Purity),
Signature: raw.Signature,
Description: raw.Description,
Tags: raw.Tags,
UsesFunctions: raw.UsesFunctions,
UsesTypes: raw.UsesTypes,
Returns: raw.Returns,
ReturnsOptional: raw.ReturnsOptional,
ErrorType: raw.ErrorType,
Imports: raw.Imports,
Example: sections.example,
Notes: sections.notes,
Documentation: sections.documentation,
Tested: raw.Tested,
Tests: raw.Tests,
TestFilePath: raw.TestFilePath,
FilePath: raw.FilePath,
Props: raw.Props,
Emits: raw.Emits,
HasState: raw.HasState,
Framework: raw.Framework,
Variant: raw.Variant,
SourceRepo: raw.SourceRepo,
SourceLicense: raw.SourceLicense,
SourceFile: raw.SourceFile,
}
// Serialize params + output to JSON for params_schema column
if len(raw.Params) > 0 || raw.Output != "" {
schema := struct {
Params []rawParam `json:"params,omitempty"`
Output string `json:"output,omitempty"`
}{Params: raw.Params, Output: raw.Output}
if b, err := json.Marshal(schema); err == nil {
f.ParamsSchema = string(b)
}
}
if root != "" && raw.FilePath != "" {
codePath := filepath.Join(root, raw.FilePath)
if codeData, err := os.ReadFile(codePath); err == nil {
f.Code = string(codeData)
}
}
return f, nil
}
// ParseTypeMD parses a type .md file into a Type.
// root is the registry root directory, used to resolve file_path for code reading.
func ParseTypeMD(path string, root string) (*Type, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
var raw rawType
if err := yaml.Unmarshal(fm, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
if raw.Name == "" {
return nil, fmt.Errorf("%s: name is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
t := &Type{
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
Name: raw.Name,
Lang: raw.Lang,
Domain: raw.Domain,
Version: raw.Version,
Algebraic: Algebraic(raw.Algebraic),
Definition: strings.TrimSpace(raw.Definition),
Description: raw.Description,
Tags: raw.Tags,
UsesTypes: raw.UsesTypes,
SourceRepo: raw.SourceRepo,
SourceLicense: raw.SourceLicense,
SourceFile: raw.SourceFile,
Examples: sections.example,
Notes: sections.notes,
Documentation: sections.documentation,
FilePath: raw.FilePath,
}
if root != "" && raw.FilePath != "" {
codePath := filepath.Join(root, raw.FilePath)
if codeData, err := os.ReadFile(codePath); err == nil {
t.Code = string(codeData)
}
}
return t, nil
}
// ParseAppMD parses an app .md file into an App.
func ParseAppMD(path string, root string) (*App, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
var raw rawApp
if err := yaml.Unmarshal(fm, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
if raw.Name == "" {
return nil, fmt.Errorf("%s: name is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
a := &App{
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
Name: raw.Name,
Lang: raw.Lang,
Domain: raw.Domain,
Description: raw.Description,
Tags: raw.Tags,
UsesFunctions: raw.UsesFunctions,
UsesTypes: raw.UsesTypes,
Framework: raw.Framework,
EntryPoint: raw.EntryPoint,
Documentation: sections.documentation,
Notes: sections.notes,
DirPath: raw.DirPath,
RepoURL: raw.RepoURL,
}
return a, nil
}
// ParseAnalysisMD parses an analysis .md file into an Analysis.
func ParseAnalysisMD(path string, root string) (*Analysis, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
var raw rawAnalysis
if err := yaml.Unmarshal(fm, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
if raw.Name == "" {
return nil, fmt.Errorf("%s: name is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
an := &Analysis{
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
Name: raw.Name,
Lang: raw.Lang,
Domain: raw.Domain,
Description: raw.Description,
Tags: raw.Tags,
UsesFunctions: raw.UsesFunctions,
UsesTypes: raw.UsesTypes,
Framework: raw.Framework,
EntryPoint: raw.EntryPoint,
Documentation: sections.documentation,
Notes: sections.notes,
DirPath: raw.DirPath,
RepoURL: raw.RepoURL,
}
return an, nil
}
// ParseProjectMD parses a project .md file into a Project.
func ParseProjectMD(path string, root string) (*Project, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
var raw rawProject
if err := yaml.Unmarshal(fm, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
if raw.Name == "" {
return nil, fmt.Errorf("%s: name is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
p := &Project{
ID: raw.Name,
Name: raw.Name,
Description: raw.Description,
Tags: raw.Tags,
RepoURL: raw.RepoURL,
DirPath: filepath.Join("projects", raw.Name),
Documentation: sections.documentation,
Notes: sections.notes,
}
return p, nil
}
// ParseVaultYAML parses a vault.yaml manifest into a slice of Vaults.
// projectID is the owning project ID, or "" for registry-level vaults.
// vaultsDir is the directory containing vault.yaml (used to detect symlinks).
func ParseVaultYAML(path string, projectID string, vaultsDir string) ([]*Vault, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var raw rawVaultFile
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
var vaults []*Vault
for _, rv := range raw.Vaults {
if rv.Name == "" {
continue
}
suffix := projectID
if suffix == "" {
suffix = "registry"
}
id := rv.Name + "_" + suffix
// Detect if the vault entry on disk is a symlink
isSymlink := false
vaultPath := rv.Path
entryPath := filepath.Join(vaultsDir, rv.Name)
if fi, err := os.Lstat(entryPath); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
isSymlink = true
if target, err := os.Readlink(entryPath); err == nil && vaultPath == "" {
vaultPath = target
}
}
}
vaults = append(vaults, &Vault{
ID: id,
Name: rv.Name,
ProjectID: projectID,
Description: rv.Description,
Path: vaultPath,
Symlink: isSymlink,
Tags: rv.Tags,
})
}
return vaults, nil
}
// bodySections holds the extracted sections from a .md body.
type bodySections struct {
example string // content under ## Ejemplo
notes string // content under ## Notas
documentation string // everything else
}
// extractSections splits the markdown body into named sections.
// Known sections (## Ejemplo, ## Notas) are extracted separately.
// All other content (including unknown ## headings) goes into documentation.
func extractSections(body []byte) bodySections {
lines := strings.Split(string(body), "\n")
var s bodySections
type section struct {
name string
lines []string
}
var current *section
var sections []section
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## ") {
if current != nil {
sections = append(sections, *current)
}
current = &section{name: trimmed}
continue
}
if current != nil {
current.lines = append(current.lines, line)
} else {
// Content before any ## heading goes to documentation
sections = append(sections, section{name: "_preamble", lines: []string{line}})
}
}
if current != nil {
sections = append(sections, *current)
}
var docParts []string
for _, sec := range sections {
content := strings.TrimSpace(strings.Join(sec.lines, "\n"))
if content == "" && sec.name == "_preamble" {
continue
}
switch {
case strings.HasPrefix(sec.name, "## Ejemplo"):
s.example = content
case strings.HasPrefix(sec.name, "## Notas"):
s.notes = content
case sec.name == "_preamble":
docParts = append(docParts, content)
default:
// Unknown sections go to documentation with their heading
docParts = append(docParts, sec.name+"\n\n"+content)
}
}
s.documentation = strings.TrimSpace(strings.Join(docParts, "\n\n"))
return s
}