package internal import ( "os" "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Parser", func() { var tmpDir string BeforeEach(func() { var err error tmpDir, err = os.MkdirTemp("", "ndpgen-test-*") Expect(err).NotTo(HaveOccurred()) }) AfterEach(func() { os.RemoveAll(tmpDir) }) Describe("ParseDirectory", func() { It("should parse a simple host service interface", func() { src := `package host import "context" // SubsonicAPIService provides access to Navidrome's Subsonic API. //nd:hostservice name=SubsonicAPI permission=subsonicapi type SubsonicAPIService interface { // Call executes a Subsonic API request. //nd:hostfunc Call(ctx context.Context, uri string) (response string, err error) } ` err := os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) svc := services[0] Expect(svc.Name).To(Equal("SubsonicAPI")) Expect(svc.Permission).To(Equal("subsonicapi")) Expect(svc.Interface).To(Equal("SubsonicAPIService")) Expect(svc.Methods).To(HaveLen(1)) m := svc.Methods[0] Expect(m.Name).To(Equal("Call")) Expect(m.HasError).To(BeTrue()) Expect(m.Params).To(HaveLen(1)) Expect(m.Params[0].Name).To(Equal("uri")) Expect(m.Params[0].Type).To(Equal("string")) Expect(m.Returns).To(HaveLen(1)) Expect(m.Returns[0].Name).To(Equal("response")) Expect(m.Returns[0].Type).To(Equal("string")) }) It("should parse multiple methods", func() { src := `package host import "context" // SchedulerService provides scheduling capabilities. //nd:hostservice name=Scheduler permission=scheduler type SchedulerService interface { //nd:hostfunc ScheduleRecurring(ctx context.Context, cronExpression string) (scheduleID string, err error) //nd:hostfunc ScheduleOneTime(ctx context.Context, delaySeconds int32) (scheduleID string, err error) //nd:hostfunc CancelSchedule(ctx context.Context, scheduleID string) (canceled bool, err error) } ` err := os.WriteFile(filepath.Join(tmpDir, "scheduler.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) svc := services[0] Expect(svc.Name).To(Equal("Scheduler")) Expect(svc.Methods).To(HaveLen(3)) Expect(svc.Methods[0].Name).To(Equal("ScheduleRecurring")) Expect(svc.Methods[0].Params[0].Type).To(Equal("string")) Expect(svc.Methods[1].Name).To(Equal("ScheduleOneTime")) Expect(svc.Methods[1].Params[0].Type).To(Equal("int32")) Expect(svc.Methods[2].Name).To(Equal("CancelSchedule")) Expect(svc.Methods[2].Returns[0].Type).To(Equal("bool")) }) It("should skip methods without hostfunc annotation", func() { src := `package host import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc Exported(ctx context.Context) error // This method is not exported NotExported(ctx context.Context) error } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) Expect(services[0].Methods).To(HaveLen(1)) Expect(services[0].Methods[0].Name).To(Equal("Exported")) }) It("should parse raw=true annotation", func() { src := `package host import "context" //nd:hostservice name=Stream permission=stream type StreamService interface { //nd:hostfunc raw=true GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) } ` err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) m := services[0].Methods[0] Expect(m.Name).To(Equal("GetStream")) Expect(m.Raw).To(BeTrue()) Expect(m.HasError).To(BeTrue()) Expect(m.Returns).To(HaveLen(2)) Expect(m.Returns[0].Name).To(Equal("contentType")) Expect(m.Returns[0].Type).To(Equal("string")) Expect(m.Returns[1].Name).To(Equal("data")) Expect(m.Returns[1].Type).To(Equal("[]byte")) }) It("should set Raw=false when raw annotation is absent", func() { src := `package host import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc Call(ctx context.Context, uri string) (response string, err error) } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services[0].Methods[0].Raw).To(BeFalse()) }) It("should reject raw=true with invalid return signature", func() { src := `package host import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc raw=true BadRaw(ctx context.Context, uri string) (result string, err error) } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) _, err = ParseDirectory(tmpDir) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("raw=true")) Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)")) }) It("should reject raw=true without error return", func() { src := `package host import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc raw=true BadRaw(ctx context.Context, uri string) (contentType string, data []byte) } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) _, err = ParseDirectory(tmpDir) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("raw=true")) }) It("should parse mixed raw and non-raw methods", func() { src := `package host import "context" //nd:hostservice name=API permission=api type APIService interface { //nd:hostfunc Call(ctx context.Context, uri string) (responseJSON string, err error) //nd:hostfunc raw=true CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) } ` err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) Expect(services[0].Methods).To(HaveLen(2)) Expect(services[0].Methods[0].Raw).To(BeFalse()) Expect(services[0].Methods[1].Raw).To(BeTrue()) Expect(services[0].HasRawMethods()).To(BeTrue()) }) It("should handle custom export name", func() { src := `package host import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc name=custom_export_name MyMethod(ctx context.Context) error } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services[0].Methods[0].ExportName).To(Equal("custom_export_name")) Expect(services[0].Methods[0].FunctionName("test")).To(Equal("custom_export_name")) }) It("should skip generated files", func() { regularSrc := `package host import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc Method(ctx context.Context) error } ` genSrc := `// Code generated. DO NOT EDIT. package host //nd:hostservice name=Generated permission=gen type GeneratedService interface { //nd:hostfunc Method() error } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(regularSrc), 0600) Expect(err).NotTo(HaveOccurred()) err = os.WriteFile(filepath.Join(tmpDir, "test_gen.go"), []byte(genSrc), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) Expect(services[0].Name).To(Equal("Test")) }) It("should skip interfaces without hostservice annotation", func() { src := `package host import "context" // Regular interface without annotation type RegularInterface interface { Method(ctx context.Context) error } //nd:hostservice name=Annotated permission=annotated type AnnotatedService interface { //nd:hostfunc Method(ctx context.Context) error } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) Expect(services[0].Name).To(Equal("Annotated")) }) It("should return empty slice for directory with no host services", func() { src := `package host type RegularInterface interface { Method() error } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(BeEmpty()) }) }) Describe("parseKeyValuePairs", func() { It("should parse key=value pairs", func() { result := parseKeyValuePairs("name=Test permission=test") Expect(result).To(HaveKeyWithValue("name", "Test")) Expect(result).To(HaveKeyWithValue("permission", "test")) }) It("should return nil for empty input", func() { result := parseKeyValuePairs("") Expect(result).To(BeNil()) }) }) Describe("typeToString", func() { It("should handle basic types", func() { src := `package test type T interface { Method(s string, i int, b bool) ([]byte, error) } ` err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) // Parse and verify type conversion works // This is implicitly tested through ParseDirectory }) It("should convert interface{} to any", func() { src := `package test import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc GetMetadata(ctx context.Context) (data map[string]interface{}, err error) } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) services, err := ParseDirectory(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(services).To(HaveLen(1)) Expect(services[0].Methods[0].Returns[0].Type).To(Equal("map[string]any")) }) }) Describe("Method helpers", func() { It("should generate correct function names", func() { m := Method{Name: "Call"} Expect(m.FunctionName("subsonicapi")).To(Equal("subsonicapi_call")) m.ExportName = "custom_name" Expect(m.FunctionName("subsonicapi")).To(Equal("custom_name")) }) It("should generate correct type names", func() { m := Method{Name: "Call"} // Host-side types are public Expect(m.RequestTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallRequest")) Expect(m.ResponseTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallResponse")) // Client/PDK types are private Expect(m.ClientRequestTypeName("SubsonicAPI")).To(Equal("subsonicAPICallRequest")) Expect(m.ClientResponseTypeName("SubsonicAPI")).To(Equal("subsonicAPICallResponse")) }) }) Describe("Service helpers", func() { It("should generate correct output file name", func() { s := Service{Name: "SubsonicAPI"} Expect(s.OutputFileName()).To(Equal("subsonicapi_gen.go")) }) It("should generate correct export prefix", func() { s := Service{Name: "SubsonicAPI"} Expect(s.ExportPrefix()).To(Equal("subsonicapi")) }) }) Describe("ParseCapabilities", func() { It("should parse a simple capability interface", func() { src := `package capabilities // MetadataAgent provides metadata retrieval. //nd:capability name=metadata type MetadataAgent interface { // GetArtistBiography returns artist biography. //nd:export name=nd_get_artist_biography GetArtistBiography(ArtistInput) (ArtistBiographyOutput, error) } // ArtistInput is the input for artist-related functions. type ArtistInput struct { // ID is the artist ID. ID string ` + "`json:\"id\"`" + ` // Name is the artist name. Name string ` + "`json:\"name\"`" + ` } // ArtistBiographyOutput is the output for GetArtistBiography. type ArtistBiographyOutput struct { // Biography is the biography text. Biography string ` + "`json:\"biography\"`" + ` } ` err := os.WriteFile(filepath.Join(tmpDir, "metadata.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) capabilities, err := ParseCapabilities(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(capabilities).To(HaveLen(1)) cap := capabilities[0] Expect(cap.Name).To(Equal("metadata")) Expect(cap.Interface).To(Equal("MetadataAgent")) Expect(cap.Required).To(BeFalse()) Expect(cap.Doc).To(ContainSubstring("MetadataAgent provides metadata retrieval")) Expect(cap.Methods).To(HaveLen(1)) m := cap.Methods[0] Expect(m.Name).To(Equal("GetArtistBiography")) Expect(m.ExportName).To(Equal("nd_get_artist_biography")) Expect(m.Input.Type).To(Equal("ArtistInput")) Expect(m.Output.Type).To(Equal("ArtistBiographyOutput")) // Check structs were collected Expect(cap.Structs).To(HaveLen(2)) }) It("should parse a required capability", func() { src := `package capabilities // Scrobbler requires all methods to be implemented. //nd:capability name=scrobbler required=true type Scrobbler interface { //nd:export name=nd_scrobbler_is_authorized IsAuthorized(AuthInput) (AuthOutput, error) //nd:export name=nd_scrobbler_scrobble Scrobble(ScrobbleInput) (ScrobblerOutput, error) } type AuthInput struct { UserID string ` + "`json:\"userId\"`" + ` } type AuthOutput struct { Authorized bool ` + "`json:\"authorized\"`" + ` } type ScrobbleInput struct { UserID string ` + "`json:\"userId\"`" + ` } type ScrobblerOutput struct { Error *string ` + "`json:\"error,omitempty\"`" + ` } ` err := os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) capabilities, err := ParseCapabilities(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(capabilities).To(HaveLen(1)) cap := capabilities[0] Expect(cap.Name).To(Equal("scrobbler")) Expect(cap.Required).To(BeTrue()) Expect(cap.Methods).To(HaveLen(2)) }) It("should parse type aliases and consts", func() { src := `package capabilities //nd:capability name=scrobbler required=true type Scrobbler interface { //nd:export name=nd_scrobble Scrobble(ScrobbleInput) (ScrobblerOutput, error) } type ScrobbleInput struct { UserID string ` + "`json:\"userId\"`" + ` } // ScrobblerErrorType indicates error handling behavior. type ScrobblerErrorType string const ( // ScrobblerErrorNone indicates no error. ScrobblerErrorNone ScrobblerErrorType = "none" // ScrobblerErrorRetry indicates retry later. ScrobblerErrorRetry ScrobblerErrorType = "retry" ) type ScrobblerOutput struct { ErrorType *ScrobblerErrorType ` + "`json:\"errorType,omitempty\"`" + ` } ` err := os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) capabilities, err := ParseCapabilities(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(capabilities).To(HaveLen(1)) cap := capabilities[0] // Type alias should be collected Expect(cap.TypeAliases).To(HaveLen(1)) Expect(cap.TypeAliases[0].Name).To(Equal("ScrobblerErrorType")) Expect(cap.TypeAliases[0].Type).To(Equal("string")) // Consts should be collected Expect(cap.Consts).To(HaveLen(1)) Expect(cap.Consts[0].Type).To(Equal("ScrobblerErrorType")) Expect(cap.Consts[0].Values).To(HaveLen(2)) Expect(cap.Consts[0].Values[0].Name).To(Equal("ScrobblerErrorNone")) Expect(cap.Consts[0].Values[0].Value).To(Equal(`"none"`)) }) It("should collect nested struct dependencies", func() { src := `package capabilities //nd:capability name=metadata type MetadataAgent interface { //nd:export name=nd_get_images GetImages(ArtistInput) (ImagesOutput, error) } type ArtistInput struct { ID string ` + "`json:\"id\"`" + ` } type ImagesOutput struct { Images []ImageInfo ` + "`json:\"images\"`" + ` } type ImageInfo struct { URL string ` + "`json:\"url\"`" + ` Size int32 ` + "`json:\"size\"`" + ` } ` err := os.WriteFile(filepath.Join(tmpDir, "metadata.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) capabilities, err := ParseCapabilities(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(capabilities).To(HaveLen(1)) cap := capabilities[0] // Should collect all 3 structs: ArtistInput, ImagesOutput, and ImageInfo Expect(cap.Structs).To(HaveLen(3)) structNames := make([]string, len(cap.Structs)) for i, s := range cap.Structs { structNames[i] = s.Name } Expect(structNames).To(ContainElements("ArtistInput", "ImagesOutput", "ImageInfo")) }) It("should return empty slice for directory with no capabilities", func() { src := `package capabilities type RegularInterface interface { Method() error } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) capabilities, err := ParseCapabilities(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(capabilities).To(BeEmpty()) }) It("should ignore methods without export annotation", func() { src := `package capabilities //nd:capability name=test type TestCapability interface { //nd:export name=nd_exported ExportedMethod(Input) (Output, error) // This method has no export annotation NotExportedMethod(Input) (Output, error) } type Input struct { Value string ` + "`json:\"value\"`" + ` } type Output struct { Result string ` + "`json:\"result\"`" + ` } ` err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) Expect(err).NotTo(HaveOccurred()) capabilities, err := ParseCapabilities(tmpDir) Expect(err).NotTo(HaveOccurred()) Expect(capabilities).To(HaveLen(1)) // Only the exported method should be captured Expect(capabilities[0].Methods).To(HaveLen(1)) Expect(capabilities[0].Methods[0].Name).To(Equal("ExportedMethod")) }) }) Describe("Export helpers", func() { It("should generate correct provider interface name", func() { e := Export{Name: "GetArtistBiography"} Expect(e.ProviderInterfaceName()).To(Equal("ArtistBiographyProvider")) e = Export{Name: "OnInit"} Expect(e.ProviderInterfaceName()).To(Equal("InitProvider")) }) It("should generate correct impl variable name", func() { e := Export{Name: "GetArtistBiography"} Expect(e.ImplVarName()).To(Equal("artistBiographyImpl")) e = Export{Name: "OnInit"} Expect(e.ImplVarName()).To(Equal("initImpl")) }) It("should generate correct export function name", func() { e := Export{Name: "GetArtistBiography", ExportName: "nd_get_artist_biography"} Expect(e.ExportFuncName()).To(Equal("_NdGetArtistBiography")) }) }) })