diff --git a/docs/templates/component.md b/docs/templates/component.md new file mode 100644 index 00000000..7cfcff70 --- /dev/null +++ b/docs/templates/component.md @@ -0,0 +1,48 @@ +--- +name: DataTable +kind: component +lang: typescript +domain: core +version: "1.0.0" +purity: impure +signature: "DataTable(props: { data: T[]; columns: ColumnDef[]; onRowClick?: (row: T) => void }): JSX.Element" +description: "Tabla de datos generica con soporte para columnas configurables y click en fila." +tags: [table, component, generic, ui] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/components/DataTable.tsx" +props: + - name: data + type: "T[]" + required: true + description: "Array de datos a renderizar" + - name: columns + type: "ColumnDef[]" + required: true + description: "Definicion de columnas" + - name: onRowClick + type: "(row: T) => void" + required: false + description: "Callback al hacer click en una fila" +emits: [onRowClick] +has_state: true +framework: react +variant: [default, compact, striped] +--- + +## Ejemplo + +```tsx + +``` + +## Notas + +Componente con estado interno para manejar seleccion y scroll. diff --git a/docs/templates/function.md b/docs/templates/function.md new file mode 100644 index 00000000..6380fc50 --- /dev/null +++ b/docs/templates/function.md @@ -0,0 +1,32 @@ +--- +name: filter_slice +kind: function +lang: go +domain: core +version: "1.0.0" +purity: pure +signature: "func FilterSlice[T any](xs []T, pred func(T) bool) []T" +description: "Filtra un slice aplicando un predicado sin mutar el original." +tags: [slice, functional, generic] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/core/filter_slice.go" +--- + +## Ejemplo + +```go +evens := FilterSlice([]int{1, 2, 3, 4}, func(n int) bool { return n%2 == 0 }) +// evens = [2, 4] +``` + +## Notas + +Funcion pura generica. No muta el slice original — crea uno nuevo. diff --git a/docs/templates/pipeline.md b/docs/templates/pipeline.md new file mode 100644 index 00000000..3a2f689a --- /dev/null +++ b/docs/templates/pipeline.md @@ -0,0 +1,31 @@ +--- +name: tick_to_ohlcv +kind: pipeline +lang: go +domain: finance +version: "1.0.0" +purity: impure +signature: "func TickToOHLCV(ctx context.Context, symbol string, interval time.Duration) ([]OHLCV, error)" +description: "Pipeline que obtiene ticks de un exchange y los agrega en velas OHLCV." +tags: [pipeline, finance, ohlcv, ticks] +uses_functions: [fetch_ticks_go_io, aggregate_ohlcv_go_finance] +uses_types: [ohlcv_go_finance, tick_go_finance] +returns: [ohlcv_go_finance] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/pipelines/tick_to_ohlcv.go" +--- + +## Ejemplo + +```go +candles, err := TickToOHLCV(ctx, "BTCUSDT", 5*time.Minute) +``` + +## Notas + +Pipeline impuro: orquesta fetch_ticks (IO) y aggregate_ohlcv (pura). diff --git a/docs/templates/type.md b/docs/templates/type.md new file mode 100644 index 00000000..26ef42df --- /dev/null +++ b/docs/templates/type.md @@ -0,0 +1,23 @@ +--- +name: ohlcv +lang: go +domain: finance +version: "1.0.0" +algebraic: product +definition: | + type OHLCV struct { + Open float64 + High float64 + Low float64 + Close float64 + Volume float64 + } +description: "Vela de mercado con precios de apertura, maximo, minimo, cierre y volumen." +tags: [finance, market, candle] +uses_types: [] +file_path: "types/finance/ohlcv.go" +--- + +## Notas + +Tipo producto — todos los campos siempre presentes. Modela una vela de mercado. diff --git a/go.mod b/go.mod index e2be7856..e15c4702 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module fn-registry go 1.22.2 -require github.com/mattn/go-sqlite3 v1.14.37 +require ( + github.com/mattn/go-sqlite3 v1.14.37 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum index 9c79a75d..710b3523 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/registry/indexer.go b/registry/indexer.go new file mode 100644 index 00000000..b2602d1b --- /dev/null +++ b/registry/indexer.go @@ -0,0 +1,87 @@ +package registry + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// IndexResult holds stats from an indexing run. +type IndexResult struct { + Functions int + Types int + Errors []string +} + +// Index walks the registry root, parses all .md files, and populates the database. +// It purges existing data first to ensure a clean rebuild. +func Index(db *DB, root string) (*IndexResult, error) { + if err := db.Purge(); err != nil { + return nil, fmt.Errorf("purging database: %w", err) + } + + result := &IndexResult{} + + // Index functions + functionsDir := filepath.Join(root, "functions") + if _, err := os.Stat(functionsDir); err == nil { + err := filepath.Walk(functionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + + f, err := ParseFunctionMD(path) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", path, err)) + return nil + } + + if err := db.InsertFunction(f); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", f.ID, err)) + return nil + } + + result.Functions++ + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking functions: %w", err) + } + } + + // Index types + typesDir := filepath.Join(root, "types") + if _, err := os.Stat(typesDir); err == nil { + err := filepath.Walk(typesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + + t, err := ParseTypeMD(path) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", path, err)) + return nil + } + + if err := db.InsertType(t); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", t.ID, err)) + return nil + } + + result.Types++ + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking types: %w", err) + } + } + + return result, nil +} diff --git a/registry/parser.go b/registry/parser.go new file mode 100644 index 00000000..e4212667 --- /dev/null +++ b/registry/parser.go @@ -0,0 +1,208 @@ +package registry + +import ( + "bytes" + "fmt" + "os" + "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"` + + // Component fields + Props []PropDef `yaml:"props"` + Emits []string `yaml:"emits"` + HasState *bool `yaml:"has_state"` + Framework string `yaml:"framework"` + Variant []string `yaml:"variant"` +} + +// 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"` +} + +// 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. +func ParseFunctionMD(path 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) + } + + example := extractExample(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, + 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, + } + + if example != "" && f.Example == "" { + f.Example = example + } + + return f, nil +} + +// ParseTypeMD parses a type .md file into a Type. +func ParseTypeMD(path string) (*Type, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + fm, _, 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) + } + + 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, + FilePath: raw.FilePath, + } + + return t, nil +} + +// extractExample pulls the first code block after an "## Ejemplo" heading. +func extractExample(body []byte) string { + lines := strings.Split(string(body), "\n") + inExample := false + inCode := false + var code []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "## Ejemplo") { + inExample = true + continue + } + if inExample && !inCode && strings.HasPrefix(trimmed, "```") { + inCode = true + continue + } + if inCode { + if strings.HasPrefix(trimmed, "```") { + return strings.Join(code, "\n") + } + code = append(code, line) + } + if inExample && !inCode && strings.HasPrefix(trimmed, "##") { + break + } + } + return "" +} diff --git a/registry/parser_test.go b/registry/parser_test.go new file mode 100644 index 00000000..2cbbbaf8 --- /dev/null +++ b/registry/parser_test.go @@ -0,0 +1,238 @@ +package registry + +import ( + "os" + "path/filepath" + "testing" +) + +const functionMD = `--- +name: filter_slice +kind: function +lang: go +domain: core +version: "1.0.0" +purity: pure +signature: "func FilterSlice[T any](xs []T, pred func(T) bool) []T" +description: "Filtra un slice aplicando un predicado sin mutar el original." +tags: [slice, functional, generic] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/core/filter_slice.go" +--- + +## Ejemplo + +` + "```go" + ` +evens := FilterSlice([]int{1, 2, 3, 4}, func(n int) bool { return n%2 == 0 }) +` + "```" + ` +` + +const typeMD = `--- +name: ohlcv +lang: go +domain: finance +version: "1.0.0" +algebraic: product +definition: | + type OHLCV struct { + Open float64 + High float64 + Low float64 + Close float64 + Volume float64 + } +description: "Vela de mercado con precios OHLCV." +tags: [finance, market, candle] +uses_types: [] +file_path: "types/finance/ohlcv.go" +--- + +## Notas + +Tipo producto. +` + +const componentMD = `--- +name: DataTable +kind: component +lang: typescript +domain: core +version: "1.0.0" +purity: impure +signature: "DataTable(props: { data: T[] }): JSX.Element" +description: "Tabla de datos generica." +tags: [table, ui] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/components/DataTable.tsx" +props: + - name: data + type: "T[]" + required: true + description: "Array de datos" +emits: [onRowClick] +has_state: true +framework: react +variant: [default, compact] +--- +` + +func writeTempFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + os.MkdirAll(filepath.Dir(path), 0o755) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestParseFunctionMD(t *testing.T) { + path := writeTempFile(t, t.TempDir(), "filter_slice.md", functionMD) + + f, err := ParseFunctionMD(path) + if err != nil { + t.Fatal(err) + } + + if f.ID != "filter_slice_go_core" { + t.Errorf("id: got %q", f.ID) + } + if f.Kind != KindFunction { + t.Errorf("kind: got %q", f.Kind) + } + if f.Purity != PurityPure { + t.Errorf("purity: got %q", f.Purity) + } + if len(f.Tags) != 3 { + t.Errorf("tags: got %d, want 3", len(f.Tags)) + } + if f.Example == "" { + t.Error("example should be extracted from body") + } +} + +func TestParseTypeMD(t *testing.T) { + path := writeTempFile(t, t.TempDir(), "ohlcv.md", typeMD) + + typ, err := ParseTypeMD(path) + if err != nil { + t.Fatal(err) + } + + if typ.ID != "ohlcv_go_finance" { + t.Errorf("id: got %q", typ.ID) + } + if typ.Algebraic != AlgebraicProduct { + t.Errorf("algebraic: got %q", typ.Algebraic) + } + if typ.Definition == "" { + t.Error("definition should not be empty") + } +} + +func TestParseComponentMD(t *testing.T) { + path := writeTempFile(t, t.TempDir(), "DataTable.md", componentMD) + + f, err := ParseFunctionMD(path) + if err != nil { + t.Fatal(err) + } + + if f.Kind != KindComponent { + t.Errorf("kind: got %q", f.Kind) + } + if f.Framework != "react" { + t.Errorf("framework: got %q", f.Framework) + } + if len(f.Props) != 1 { + t.Errorf("props: got %d, want 1", len(f.Props)) + } + if f.HasState == nil || !*f.HasState { + t.Error("has_state should be true") + } + if len(f.Emits) != 1 || f.Emits[0] != "onRowClick" { + t.Errorf("emits: got %v", f.Emits) + } +} + +func TestParseMissingFrontmatter(t *testing.T) { + path := writeTempFile(t, t.TempDir(), "bad.md", "# No frontmatter here\n") + + _, err := ParseFunctionMD(path) + if err == nil { + t.Error("expected error for missing frontmatter") + } +} + +func TestIndexFullCycle(t *testing.T) { + root := t.TempDir() + + // Create function .md + writeTempFile(t, root, "functions/core/filter_slice.md", functionMD) + // Create type .md + writeTempFile(t, root, "types/finance/ohlcv.md", typeMD) + + dbPath := filepath.Join(root, "registry.db") + db, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + result, err := Index(db, root) + if err != nil { + t.Fatal(err) + } + + if result.Functions != 1 { + t.Errorf("functions indexed: got %d, want 1", result.Functions) + } + if result.Types != 1 { + t.Errorf("types indexed: got %d, want 1", result.Types) + } + if len(result.Errors) != 0 { + t.Errorf("unexpected errors: %v", result.Errors) + } + + // Verify searchable + fns, err := db.SearchFunctions("filter", "", "", "", "") + if err != nil { + t.Fatal(err) + } + if len(fns) != 1 { + t.Errorf("search 'filter': got %d, want 1", len(fns)) + } + + ts, err := db.SearchTypes("ohlcv", "", "") + if err != nil { + t.Fatal(err) + } + if len(ts) != 1 { + t.Errorf("search 'ohlcv': got %d, want 1", len(ts)) + } + + // Re-index should be idempotent + result2, err := Index(db, root) + if err != nil { + t.Fatal(err) + } + if result2.Functions != 1 || result2.Types != 1 { + t.Error("re-index should produce same counts") + } +}