diff --git a/.idea/copilotDiffState.xml b/.idea/copilotDiffState.xml new file mode 100644 index 0000000..cf5d926 --- /dev/null +++ b/.idea/copilotDiffState.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/debug.go b/debug.go new file mode 100644 index 0000000..6c48e5f --- /dev/null +++ b/debug.go @@ -0,0 +1,157 @@ +package main + +import ( + "fmt" + "io/ioutil" + "masonry/lang" +) + +func main() { + // Read the example.masonry file + content, err := ioutil.ReadFile("example.masonry") + if err != nil { + fmt.Printf("Error reading example.masonry: %v\n", err) + return + } + + input := string(content) + + ast, err := lang.ParseInput(input) + if err != nil { + fmt.Printf("Error: %v\n", err) + } else { + fmt.Printf("🎉 Successfully parsed complete DSL with pages!\n\n") + + for _, def := range ast.Definitions { + if def.Server != nil { + fmt.Printf("📡 Server: %s\n", def.Server.Name) + for _, setting := range def.Server.Settings { + if setting.Host != nil { + fmt.Printf(" host: %s\n", *setting.Host) + } + if setting.Port != nil { + fmt.Printf(" port: %d\n", *setting.Port) + } + } + fmt.Printf("\n") + } + + if def.Entity != nil { + entity := def.Entity + fmt.Printf("🏗️ Entity: %s", entity.Name) + if entity.Description != nil { + fmt.Printf(" - %s", *entity.Description) + } + fmt.Printf("\n") + + for _, field := range entity.Fields { + fmt.Printf(" %s: %s", field.Name, field.Type) + if field.Required { + fmt.Printf(" (required)") + } + if field.Unique { + fmt.Printf(" (unique)") + } + if field.Default != nil { + fmt.Printf(" default=%s", *field.Default) + } + fmt.Printf("\n") + } + fmt.Printf("\n") + } + + if def.Endpoint != nil { + endpoint := def.Endpoint + fmt.Printf("🚀 Endpoint: %s %s", endpoint.Method, endpoint.Path) + if endpoint.Entity != nil { + fmt.Printf(" (for %s)", *endpoint.Entity) + } + if endpoint.Description != nil { + fmt.Printf(" - %s", *endpoint.Description) + } + if endpoint.Auth { + fmt.Printf(" [AUTH]") + } + fmt.Printf("\n\n") + } + + if def.Page != nil { + page := def.Page + fmt.Printf("🎨 Page: %s at %s", page.Name, page.Path) + if page.Title != nil { + fmt.Printf(" - %s", *page.Title) + } + if page.Auth { + fmt.Printf(" [AUTH]") + } + fmt.Printf("\n") + fmt.Printf(" Layout: %s\n", page.Layout) + + for _, meta := range page.Meta { + fmt.Printf(" Meta %s: %s\n", meta.Name, meta.Content) + } + + for _, comp := range page.Components { + fmt.Printf(" 📦 Component: %s", comp.Type) + if comp.Entity != nil { + fmt.Printf(" for %s", *comp.Entity) + } + fmt.Printf("\n") + + for _, attr := range comp.Config { + if attr.Fields != nil { + fmt.Printf(" fields: %v\n", attr.Fields.Fields) + } + if attr.Actions != nil { + fmt.Printf(" actions: ") + for i, action := range attr.Actions.Actions { + if i > 0 { + fmt.Printf(", ") + } + fmt.Printf("%s", action.Name) + if action.Endpoint != nil { + fmt.Printf(" via %s", *action.Endpoint) + } + } + fmt.Printf("\n") + } + if attr.DataSource != nil { + fmt.Printf(" data from: %s\n", attr.DataSource.Endpoint) + } + if attr.Style != nil { + fmt.Printf(" style: %s", *attr.Style.Theme) + if len(attr.Style.Classes) > 0 { + fmt.Printf(" classes: %v", attr.Style.Classes) + } + fmt.Printf("\n") + } + if attr.Pagination != nil { + fmt.Printf(" pagination: enabled") + if attr.Pagination.PageSize != nil { + fmt.Printf(" size %d", *attr.Pagination.PageSize) + } + fmt.Printf("\n") + } + if attr.Filters != nil { + fmt.Printf(" filters: ") + for i, filter := range attr.Filters.Filters { + if i > 0 { + fmt.Printf(", ") + } + fmt.Printf("%s as %s", filter.Field, filter.Type) + if filter.Label != nil { + fmt.Printf(" (%s)", *filter.Label) + } + } + fmt.Printf("\n") + } + if attr.Validation { + fmt.Printf(" validation: enabled\n") + } + } + } + fmt.Printf("\n") + } + } + } +} diff --git a/example.masonry b/example.masonry new file mode 100644 index 0000000..53a8c76 --- /dev/null +++ b/example.masonry @@ -0,0 +1,125 @@ +// Example Masonry DSL definition +// This demonstrates the comprehensive language structure + +// Server configuration +server MyApp host "localhost" port 8080 + +// Entity definitions with various field types and relationships +entity User desc "User account management" + id: uuid required unique + email: string required validate email validate min_length "5" + name: string default "Anonymous" + created_at: timestamp default "now()" + profile_id: uuid relates to Profile as one via "user_id" + +entity Profile desc "User profile information" + id: uuid required unique + user_id: uuid required relates to User as one + bio: text validate max_length "500" + avatar_url: string validate url + updated_at: timestamp + posts: uuid relates to Post as many + +entity Post desc "Blog posts" + id: uuid required unique + title: string required validate min_length "1" validate max_length "200" + content: text required + author_id: uuid required relates to User as one + published: boolean default "false" + created_at: timestamp default "now()" + tags: uuid relates to Tag as many through "post_tags" + +entity Tag desc "Content tags" + id: uuid required unique + name: string required unique validate min_length "1" validate max_length "50" + slug: string required unique indexed + created_at: timestamp default "now()" + +// API Endpoints with different HTTP methods and parameter sources +endpoint GET "/users" for User desc "List users" auth + param page: int from query + param limit: int required from query + returns list as "json" fields [id, email, name] + +endpoint POST "/users" for User desc "Create user" + param user_data: object required from body + returns object as "json" fields [id, email, name] + +endpoint PUT "/users/{id}" for User desc "Update user" + param id: uuid required from path + param user_data: object required from body + returns object + custom "update_user_logic" + +endpoint DELETE "/users/{id}" for User desc "Delete user" auth + param id: uuid required from path + returns object + +endpoint GET "/posts" for Post desc "List posts" + param author_id: uuid from query + param published: boolean from query + param page: int from query + returns list as "json" fields [id, title, author_id, published] + +endpoint POST "/posts" for Post desc "Create post" auth + param post_data: object required from body + returns object fields [id, title, content, author_id] + +// Frontend pages with components +page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth + meta description "Manage system users" + meta keywords "users, admin, management" + + component Table for User + fields [email, name, id] + actions [edit via "/users/{id}", delete via "/users/{id}", create via "/users"] + data from "/users" + style modern classes ["table-striped", "table-hover"] + pagination size 20 + filters [email as text label "Search email", name as text label "Search name"] + validate + + component Form for User + fields [email, name] + actions [save via "/users", cancel] + style clean + validate + +page UserList at "/users" layout MainLayout title "Users" + meta description "Browse all users" + + component Table for User + fields [email, name] + data from "/users" + pagination size 10 + filters [name as text label "Search by name"] + +page PostManagement at "/admin/posts" layout AdminLayout title "Post Management" auth + meta description "Manage blog posts" + meta keywords "posts, blog, content" + + component Table for Post + fields [title, author_id, published, created_at] + actions [edit via "/posts/{id}", delete via "/posts/{id}", create via "/posts"] + data from "/posts" + style modern + pagination size 15 + filters [title as text label "Search title", published as select label "Published status"] + validate + +page CreatePost at "/posts/new" layout MainLayout title "Create Post" auth + component Form for Post + fields [title, content] + actions [save via "/posts", cancel] + style clean + validate + +page BlogList at "/blog" layout PublicLayout title "Blog Posts" + meta description "Read our latest blog posts" + meta keywords "blog, articles, content" + + component Table for Post + fields [title, created_at] + data from "/posts" + pagination size 5 + filters [title as text label "Search posts"] diff --git a/go.mod b/go.mod index 130a2e4..d90a1cf 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/alecthomas/participle/v2 v2.1.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect diff --git a/go.sum b/go.sum index 3f981b0..55ed6ea 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= +github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= diff --git a/lang/lang.go b/lang/lang.go new file mode 100644 index 0000000..295847a --- /dev/null +++ b/lang/lang.go @@ -0,0 +1,181 @@ +package lang + +import ( + "github.com/alecthomas/participle/v2" +) + +// Root AST node containing all definitions +type AST struct { + Definitions []Definition `parser:"@@*"` +} + +// Union type for top-level definitions +type Definition struct { + Server *Server `parser:"@@"` + Entity *Entity `parser:"| @@"` + Endpoint *Endpoint `parser:"| @@"` + Page *Page `parser:"| @@"` +} + +// Clean server syntax +type Server struct { + Name string `parser:"'server' @Ident"` + Settings []ServerSetting `parser:"@@*"` +} + +type ServerSetting struct { + Host *string `parser:"('host' @String)"` + Port *int `parser:"| ('port' @Int)"` +} + +// Clean entity syntax with better readability +type Entity struct { + Name string `parser:"'entity' @Ident"` + Description *string `parser:"('desc' @String)?"` + Fields []Field `parser:"@@*"` +} + +// Much cleaner field syntax +type Field struct { + Name string `parser:"@Ident ':'"` + Type string `parser:"@Ident"` + Required bool `parser:"@'required'?"` + Unique bool `parser:"@'unique'?"` + Index bool `parser:"@'indexed'?"` + Default *string `parser:"('default' @String)?"` + Validations []Validation `parser:"@@*"` + Relationship *Relationship `parser:"@@?"` +} + +// Simple validation syntax +type Validation struct { + Type string `parser:"'validate' @Ident"` + Value *string `parser:"@String?"` +} + +// Clear relationship syntax +type Relationship struct { + Type string `parser:"'relates' 'to' @Ident"` + Cardinality string `parser:"'as' @('one' | 'many')"` + ForeignKey *string `parser:"('via' @String)?"` + Through *string `parser:"('through' @String)?"` +} + +// Endpoint definitions with clean, readable syntax +type Endpoint struct { + Method string `parser:"'endpoint' @('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH')"` + Path string `parser:"@String"` + Entity *string `parser:"('for' @Ident)?"` + Description *string `parser:"('desc' @String)?"` + Auth bool `parser:"@'auth'?"` + Params []EndpointParam `parser:"@@*"` + Response *ResponseSpec `parser:"@@?"` + CustomLogic *string `parser:"('custom' @String)?"` +} + +// Clean parameter syntax +type EndpointParam struct { + Name string `parser:"'param' @Ident ':'"` + Type string `parser:"@Ident"` + Required bool `parser:"@'required'?"` + Source string `parser:"'from' @('path' | 'query' | 'body')"` +} + +// Response specification +type ResponseSpec struct { + Type string `parser:"'returns' @Ident"` + Format *string `parser:"('as' @String)?"` + Fields []string `parser:"('fields' '[' @Ident (',' @Ident)* ']')?"` +} + +// Page definitions for frontend with clean syntax +type Page struct { + Name string `parser:"'page' @Ident"` + Path string `parser:"'at' @String"` + Layout string `parser:"'layout' @Ident"` + Title *string `parser:"('title' @String)?"` + Description *string `parser:"('desc' @String)?"` + Auth bool `parser:"@'auth'?"` + Meta []MetaTag `parser:"@@*"` + Components []Component `parser:"@@*"` +} + +// Meta tags for SEO +type MetaTag struct { + Name string `parser:"'meta' @Ident"` + Content string `parser:"@String"` +} + +// Component definitions with endpoint references +type Component struct { + Type string `parser:"'component' @Ident"` + Entity *string `parser:"('for' @Ident)?"` + Config []ComponentAttr `parser:"@@*"` +} + +// Component attributes and configurations +type ComponentAttr struct { + Fields *ComponentFields `parser:"@@"` + Actions *ComponentActions `parser:"| @@"` + DataSource *ComponentDataSource `parser:"| @@"` + Style *ComponentStyle `parser:"| @@"` + Pagination *ComponentPagination `parser:"| @@"` + Filters *ComponentFilters `parser:"| @@"` + Validation bool `parser:"| @'validate'"` +} + +// Component field specification +type ComponentFields struct { + Fields []string `parser:"'fields' '[' @Ident (',' @Ident)* ']'"` +} + +// Component actions (can reference endpoints) +type ComponentActions struct { + Actions []ComponentAction `parser:"'actions' '[' @@ (',' @@)* ']'"` +} + +type ComponentAction struct { + Name string `parser:"@Ident"` + Endpoint *string `parser:"('via' @String)?"` +} + +// Data source configuration (can reference endpoints) +type ComponentDataSource struct { + Endpoint string `parser:"'data' 'from' @String"` +} + +// Component styling +type ComponentStyle struct { + Theme *string `parser:"'style' @Ident"` + Classes []string `parser:"('classes' '[' @String (',' @String)* ']')?"` +} + +// Pagination configuration +type ComponentPagination struct { + PageSize *int `parser:"'pagination' ('size' @Int)?"` +} + +// Filter specifications +type ComponentFilters struct { + Filters []ComponentFilter `parser:"'filters' '[' @@ (',' @@)* ']'"` +} + +type ComponentFilter struct { + Field string `parser:"@Ident"` + Type string `parser:"'as' @('text' | 'select' | 'date' | 'number')"` + Label *string `parser:"('label' @String)?"` +} + +func ParseInput(input string) (AST, error) { + parser, err := participle.Build[AST]( + participle.Unquote("String"), + ) + if err != nil { + return AST{}, err + } + ast, err := parser.ParseString("", input) + if err != nil { + return AST{}, err + } + return *ast, nil +} diff --git a/lang/lang_test.go b/lang/lang_test.go new file mode 100644 index 0000000..7629186 --- /dev/null +++ b/lang/lang_test.go @@ -0,0 +1,1114 @@ +package lang + +import ( + "fmt" + "testing" +) + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + input string + want AST + wantErr bool + }{ + { + name: "simple server definition", + input: `server MyApp host "localhost" port 8080`, + want: AST{ + Definitions: []Definition{ + { + Server: &Server{ + Name: "MyApp", + Settings: []ServerSetting{ + {Host: stringPtr("localhost")}, + {Port: intPtr(8080)}, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "server with just name", + input: `server MyApp`, + want: AST{ + Definitions: []Definition{ + { + Server: &Server{ + Name: "MyApp", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "entity with basic fields", + input: `entity User desc "User management" + id: uuid required unique + email: string required validate email validate min_length "5" + name: string default "Anonymous"`, + want: AST{ + Definitions: []Definition{ + { + Entity: &Entity{ + Name: "User", + Description: stringPtr("User management"), + Fields: []Field{ + { + Name: "id", + Type: "uuid", + Required: true, + Unique: true, + }, + { + Name: "email", + Type: "string", + Required: true, + Validations: []Validation{ + {Type: "email"}, + {Type: "min_length", Value: stringPtr("5")}, + }, + }, + { + Name: "name", + Type: "string", + Default: stringPtr("Anonymous"), + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "entity with relationship", + input: `entity User + profile: uuid relates to Profile as one via "user_id"`, + want: AST{ + Definitions: []Definition{ + { + Entity: &Entity{ + Name: "User", + Fields: []Field{ + { + Name: "profile", + Type: "uuid", + Relationship: &Relationship{ + Type: "Profile", + Cardinality: "one", + ForeignKey: stringPtr("user_id"), + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "endpoint with parameters and auth", + input: `endpoint GET "/users" for User desc "List users" auth + param page: int from query + param limit: int required from query + returns list as "json" fields [id, email, name]`, + want: AST{ + Definitions: []Definition{ + { + Endpoint: &Endpoint{ + Method: "GET", + Path: "/users", + Entity: stringPtr("User"), + Description: stringPtr("List users"), + Auth: true, + Params: []EndpointParam{ + { + Name: "page", + Type: "int", + Source: "query", + }, + { + Name: "limit", + Type: "int", + Required: true, + Source: "query", + }, + }, + Response: &ResponseSpec{ + Type: "list", + Format: stringPtr("json"), + Fields: []string{"id", "email", "name"}, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "endpoint with custom logic", + input: `endpoint PUT "/users/{id}" for User + param id: uuid required from path + param user_data: object required from body + returns object + custom "update_user_logic"`, + want: AST{ + Definitions: []Definition{ + { + Endpoint: &Endpoint{ + Method: "PUT", + Path: "/users/{id}", + Entity: stringPtr("User"), + Params: []EndpointParam{ + { + Name: "id", + Type: "uuid", + Required: true, + Source: "path", + }, + { + Name: "user_data", + Type: "object", + Required: true, + Source: "body", + }, + }, + Response: &ResponseSpec{ + Type: "object", + }, + CustomLogic: stringPtr("update_user_logic"), + }, + }, + }, + }, + wantErr: false, + }, + { + name: "page with meta tags and auth", + input: `page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth + meta description "Manage system users" + meta keywords "users, admin, management"`, + want: AST{ + Definitions: []Definition{ + { + Page: &Page{ + Name: "UserManagement", + Path: "/admin/users", + Layout: "AdminLayout", + Title: stringPtr("User Management"), + Auth: true, + Meta: []MetaTag{ + {Name: "description", Content: "Manage system users"}, + {Name: "keywords", Content: "users, admin, management"}, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "page with table component full configuration", + input: `page UserList at "/users" layout MainLayout + component Table for User + fields [email, name, id] + actions [edit via "/users/{id}", delete via "/users/{id}", create via "/users"] + data from "/users" + style modern classes ["table-striped", "table-hover"] + pagination size 20 + filters [email as text label "Search email", name as text label "Search name"] + validate`, + want: AST{ + Definitions: []Definition{ + { + Page: &Page{ + Name: "UserList", + Path: "/users", + Layout: "MainLayout", + Components: []Component{ + { + Type: "Table", + Entity: stringPtr("User"), + Config: []ComponentAttr{ + { + Fields: &ComponentFields{ + Fields: []string{"email", "name", "id"}, + }, + }, + { + Actions: &ComponentActions{ + Actions: []ComponentAction{ + {Name: "edit", Endpoint: stringPtr("/users/{id}")}, + {Name: "delete", Endpoint: stringPtr("/users/{id}")}, + {Name: "create", Endpoint: stringPtr("/users")}, + }, + }, + }, + { + DataSource: &ComponentDataSource{ + Endpoint: "/users", + }, + }, + { + Style: &ComponentStyle{ + Theme: stringPtr("modern"), + Classes: []string{"table-striped", "table-hover"}, + }, + }, + { + Pagination: &ComponentPagination{ + PageSize: intPtr(20), + }, + }, + { + Filters: &ComponentFilters{ + Filters: []ComponentFilter{ + {Field: "email", Type: "text", Label: stringPtr("Search email")}, + {Field: "name", Type: "text", Label: stringPtr("Search name")}, + }, + }, + }, + { + Validation: true, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "page with form component", + input: `page UserForm at "/users/new" layout MainLayout + component Form for User + fields [email, name] + actions [save via "/users", cancel] + style clean + validate`, + want: AST{ + Definitions: []Definition{ + { + Page: &Page{ + Name: "UserForm", + Path: "/users/new", + Layout: "MainLayout", + Components: []Component{ + { + Type: "Form", + Entity: stringPtr("User"), + Config: []ComponentAttr{ + { + Fields: &ComponentFields{ + Fields: []string{"email", "name"}, + }, + }, + { + Actions: &ComponentActions{ + Actions: []ComponentAction{ + {Name: "save", Endpoint: stringPtr("/users")}, + {Name: "cancel"}, + }, + }, + }, + { + Style: &ComponentStyle{ + Theme: stringPtr("clean"), + }, + }, + { + Validation: true, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "mixed definitions - complete application", + input: `server MyApp host "localhost" port 8080 + + entity User desc "User management" + id: uuid required unique + email: string required validate email + name: string default "Anonymous" + + endpoint GET "/users" for User desc "List users" auth + param page: int from query + returns list as "json" fields [id, email, name] + + page UserList at "/users" layout MainLayout title "Users" + component Table for User + fields [email, name] + data from "/users" + pagination size 10`, + want: AST{ + Definitions: []Definition{ + { + Server: &Server{ + Name: "MyApp", + Settings: []ServerSetting{ + {Host: stringPtr("localhost")}, + {Port: intPtr(8080)}, + }, + }, + }, + { + Entity: &Entity{ + Name: "User", + Description: stringPtr("User management"), + Fields: []Field{ + { + Name: "id", + Type: "uuid", + Required: true, + Unique: true, + }, + { + Name: "email", + Type: "string", + Required: true, + Validations: []Validation{ + {Type: "email"}, + }, + }, + { + Name: "name", + Type: "string", + Default: stringPtr("Anonymous"), + }, + }, + }, + }, + { + Endpoint: &Endpoint{ + Method: "GET", + Path: "/users", + Entity: stringPtr("User"), + Description: stringPtr("List users"), + Auth: true, + Params: []EndpointParam{ + { + Name: "page", + Type: "int", + Source: "query", + }, + }, + Response: &ResponseSpec{ + Type: "list", + Format: stringPtr("json"), + Fields: []string{"id", "email", "name"}, + }, + }, + }, + { + Page: &Page{ + Name: "UserList", + Path: "/users", + Layout: "MainLayout", + Title: stringPtr("Users"), + Components: []Component{ + { + Type: "Table", + Entity: stringPtr("User"), + Config: []ComponentAttr{ + { + Fields: &ComponentFields{ + Fields: []string{"email", "name"}, + }, + }, + { + DataSource: &ComponentDataSource{ + Endpoint: "/users", + }, + }, + { + Pagination: &ComponentPagination{ + PageSize: intPtr(10), + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "entity with multiple relationship types", + input: `entity Post + author: uuid relates to User as one + tags: string relates to Tag as many through "post_tags" + comments: uuid relates to Comment as many via "post_id"`, + want: AST{ + Definitions: []Definition{ + { + Entity: &Entity{ + Name: "Post", + Fields: []Field{ + { + Name: "author", + Type: "uuid", + Relationship: &Relationship{ + Type: "User", + Cardinality: "one", + }, + }, + { + Name: "tags", + Type: "string", + Relationship: &Relationship{ + Type: "Tag", + Cardinality: "many", + Through: stringPtr("post_tags"), + }, + }, + { + Name: "comments", + Type: "uuid", + Relationship: &Relationship{ + Type: "Comment", + Cardinality: "many", + ForeignKey: stringPtr("post_id"), + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "endpoint with all HTTP methods", + input: `endpoint POST "/users" for User + endpoint GET "/users/{id}" for User + endpoint PUT "/users/{id}" for User + endpoint DELETE "/users/{id}" for User + endpoint PATCH "/users/{id}" for User`, + want: AST{ + Definitions: []Definition{ + { + Endpoint: &Endpoint{ + Method: "POST", + Path: "/users", + Entity: stringPtr("User"), + }, + }, + { + Endpoint: &Endpoint{ + Method: "GET", + Path: "/users/{id}", + Entity: stringPtr("User"), + }, + }, + { + Endpoint: &Endpoint{ + Method: "PUT", + Path: "/users/{id}", + Entity: stringPtr("User"), + }, + }, + { + Endpoint: &Endpoint{ + Method: "DELETE", + Path: "/users/{id}", + Entity: stringPtr("User"), + }, + }, + { + Endpoint: &Endpoint{ + Method: "PATCH", + Path: "/users/{id}", + Entity: stringPtr("User"), + }, + }, + }, + }, + wantErr: false, + }, + { + name: "component with all filter types", + input: `page TestPage at "/test" layout MainLayout + component Table for User + filters [name as text, age as number, created_at as date, active as select]`, + want: AST{ + Definitions: []Definition{ + { + Page: &Page{ + Name: "TestPage", + Path: "/test", + Layout: "MainLayout", + Components: []Component{ + { + Type: "Table", + Entity: stringPtr("User"), + Config: []ComponentAttr{ + { + Filters: &ComponentFilters{ + Filters: []ComponentFilter{ + {Field: "name", Type: "text"}, + {Field: "age", Type: "number"}, + {Field: "created_at", Type: "date"}, + {Field: "active", Type: "select"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid syntax should error", + input: `invalid syntax here`, + want: AST{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseInput(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !astEqual(got, tt.want) { + t.Errorf("ParseInput() mismatch.\nGot: %+v\nWant: %+v", got, tt.want) + } + }) + } +} + +// Custom comparison function for AST structures +func astEqual(got, want AST) bool { + if len(got.Definitions) != len(want.Definitions) { + return false + } + + for i := range got.Definitions { + if !definitionEqual(got.Definitions[i], want.Definitions[i]) { + return false + } + } + return true +} + +func definitionEqual(got, want Definition) bool { + if (got.Server == nil) != (want.Server == nil) { + return false + } + + if got.Server != nil && want.Server != nil { + return serverEqual(*got.Server, *want.Server) + } + + if (got.Entity == nil) != (want.Entity == nil) { + return false + } + + if got.Entity != nil && want.Entity != nil { + return entityEqual(*got.Entity, *want.Entity) + } + + if (got.Endpoint == nil) != (want.Endpoint == nil) { + return false + } + + if got.Endpoint != nil && want.Endpoint != nil { + return endpointEqual(*got.Endpoint, *want.Endpoint) + } + + if (got.Page == nil) != (want.Page == nil) { + return false + } + + if got.Page != nil && want.Page != nil { + return pageEqual(*got.Page, *want.Page) + } + + return true +} + +func serverEqual(got, want Server) bool { + if got.Name != want.Name { + fmt.Printf("Server Name mismatch: got=%s, want=%s\n", got.Name, want.Name) + return false + } + + if len(got.Settings) != len(want.Settings) { + fmt.Printf("Server Settings length mismatch: got=%d, want=%d\n", len(got.Settings), len(want.Settings)) + return false + } + + for i := range got.Settings { + if !serverSettingEqual(got.Settings[i], want.Settings[i]) { + fmt.Printf("Server Setting mismatch at index %d\n", i) + return false + } + } + + return true +} + +func serverSettingEqual(got, want ServerSetting) bool { + if (got.Host == nil) != (want.Host == nil) { + fmt.Printf("Server Host presence mismatch: got=%v, want=%v\n", got.Host != nil, want.Host != nil) + return false + } + + if got.Host != nil && want.Host != nil { + if *got.Host != *want.Host { + fmt.Printf("Server Host mismatch: got=%s, want=%s\n", *got.Host, *want.Host) + return false + } + } + + if (got.Port == nil) != (want.Port == nil) { + fmt.Printf("Server Port presence mismatch: got=%v, want=%v\n", got.Port != nil, want.Port != nil) + return false + } + + if got.Port != nil && want.Port != nil { + if *got.Port != *want.Port { + fmt.Printf("Server Port mismatch: got=%d, want=%d\n", *got.Port, *want.Port) + return false + } + } + + return true +} + +func entityEqual(got, want Entity) bool { + if got.Name != want.Name { + fmt.Printf("Entity Name mismatch: got=%s, want=%s\n", got.Name, want.Name) + return false + } + + if !stringPtrEqual(got.Description, want.Description) { + gotDesc := "nil" + wantDesc := "nil" + if got.Description != nil { + gotDesc = fmt.Sprintf(`"%s"`, *got.Description) + } + if want.Description != nil { + wantDesc = fmt.Sprintf(`"%s"`, *want.Description) + } + fmt.Printf("Description mismatch: got=%s, want=%s\n", gotDesc, wantDesc) + return false + } + + if len(got.Fields) != len(want.Fields) { + fmt.Printf("Fields length mismatch: got=%d, want=%d\n", len(got.Fields), len(want.Fields)) + return false + } + + for i := range got.Fields { + if !fieldEqual(got.Fields[i], want.Fields[i]) { + return false + } + } + + return true +} + +func endpointEqual(got, want Endpoint) bool { + if got.Method != want.Method { + fmt.Printf("Endpoint Method mismatch: got=%s, want=%s\n", got.Method, want.Method) + return false + } + + if got.Path != want.Path { + fmt.Printf("Endpoint Path mismatch: got=%s, want=%s\n", got.Path, want.Path) + return false + } + + if (got.Entity == nil) != (want.Entity == nil) { + fmt.Printf("Endpoint Entity presence mismatch: got=%v, want=%v\n", got.Entity, want.Entity) + return false + } + + if got.Entity != nil && want.Entity != nil { + if *got.Entity != *want.Entity { + fmt.Printf("Endpoint Entity mismatch: got=%+v, want=%+v\n", *got.Entity, *want.Entity) + return false + } + } + + if (got.Description == nil) != (want.Description == nil) { + fmt.Printf("Endpoint Description presence mismatch: got=%v, want=%v\n", got.Description, want.Description) + return false + } + + if got.Description != nil && want.Description != nil { + if *got.Description != *want.Description { + fmt.Printf("Endpoint Description mismatch: got=%s, want=%s\n", *got.Description, *want.Description) + return false + } + } + + if got.Auth != want.Auth { + fmt.Printf("Endpoint Auth mismatch: got=%v, want=%v\n", got.Auth, want.Auth) + return false + } + + if len(got.Params) != len(want.Params) { + fmt.Printf("Endpoint Params length mismatch: got=%d, want=%d\n", len(got.Params), len(want.Params)) + return false + } + + for i := range got.Params { + if got.Params[i].Name != want.Params[i].Name { + fmt.Printf("Endpoint Param Name mismatch at index %d: got=%s, want=%s\n", i, got.Params[i].Name, want.Params[i].Name) + return false + } + if got.Params[i].Type != want.Params[i].Type { + fmt.Printf("Endpoint Param Type mismatch at index %d: got=%s, want=%s\n", i, got.Params[i].Type, want.Params[i].Type) + return false + } + if got.Params[i].Required != want.Params[i].Required { + fmt.Printf("Endpoint Param Required mismatch at index %d: got=%v, want=%v\n", i, got.Params[i].Required, want.Params[i].Required) + return false + } + if got.Params[i].Source != want.Params[i].Source { + fmt.Printf("Endpoint Param Source mismatch at index %d: got=%s, want=%s\n", i, got.Params[i].Source, want.Params[i].Source) + return false + } + } + + if (got.Response == nil) != (want.Response == nil) { + fmt.Printf("Endpoint Response presence mismatch: got=%v, want=%v\n", got.Response, want.Response) + return false + } + + if got.Response != nil && want.Response != nil { + if got.Response.Type != want.Response.Type { + fmt.Printf("Endpoint Response Type mismatch: got=%s, want=%s\n", got.Response.Type, want.Response.Type) + return false + } + if !stringPtrEqual(got.Response.Format, want.Response.Format) { + fmt.Printf("Endpoint Response Format mismatch: got=%v, want=%v\n", got.Response.Format, want.Response.Format) + return false + } + if len(got.Response.Fields) != len(want.Response.Fields) { + fmt.Printf("Endpoint Response Fields length mismatch: got=%d, want=%d\n", len(got.Response.Fields), len(want.Response.Fields)) + return false + } + for i := range got.Response.Fields { + if got.Response.Fields[i] != want.Response.Fields[i] { + fmt.Printf("Endpoint Response Field mismatch at index %d: got=%s, want=%s\n", i, got.Response.Fields[i], want.Response.Fields[i]) + return false + } + } + } + + if (got.CustomLogic == nil) != (want.CustomLogic == nil) { + fmt.Printf("Endpoint CustomLogic presence mismatch: got=%v, want=%v\n", got.CustomLogic, want.CustomLogic) + return false + } + + if got.CustomLogic != nil && want.CustomLogic != nil { + if *got.CustomLogic != *want.CustomLogic { + fmt.Printf("Endpoint CustomLogic mismatch: got=%s, want=%s\n", *got.CustomLogic, *want.CustomLogic) + return false + } + } + + return true +} + +func pageEqual(got, want Page) bool { + if got.Name != want.Name { + fmt.Printf("Page Name mismatch: got=%s, want=%s\n", got.Name, want.Name) + return false + } + + if got.Path != want.Path { + fmt.Printf("Page Path mismatch: got=%s, want=%s\n", got.Path, want.Path) + return false + } + + if got.Layout != want.Layout { + fmt.Printf("Page Layout mismatch: got=%s, want=%s\n", got.Layout, want.Layout) + return false + } + + if !stringPtrEqual(got.Title, want.Title) { + gotTitle := "nil" + wantTitle := "nil" + if got.Title != nil { + gotTitle = *got.Title + } + if want.Title != nil { + wantTitle = *want.Title + } + fmt.Printf("Page Title mismatch: got=%s, want=%s\n", gotTitle, wantTitle) + return false + } + + if got.Auth != want.Auth { + fmt.Printf("Page Auth mismatch: got=%v, want=%v\n", got.Auth, want.Auth) + return false + } + + if len(got.Meta) != len(want.Meta) { + fmt.Printf("Page Meta length mismatch: got=%d, want=%d\n", len(got.Meta), len(want.Meta)) + return false + } + + for i := range got.Meta { + if got.Meta[i].Name != want.Meta[i].Name { + fmt.Printf("Page Meta Name mismatch at index %d: got=%s, want=%s\n", i, got.Meta[i].Name, want.Meta[i].Name) + return false + } + if got.Meta[i].Content != want.Meta[i].Content { + fmt.Printf("Page Meta Content mismatch at index %d: got=%s, want=%s\n", i, got.Meta[i].Content, want.Meta[i].Content) + return false + } + } + + if len(got.Components) != len(want.Components) { + fmt.Printf("Page Components length mismatch: got=%d, want=%d\n", len(got.Components), len(want.Components)) + return false + } + + for i := range got.Components { + if got.Components[i].Type != want.Components[i].Type { + fmt.Printf("Page Component Type mismatch at index %d: got=%s, want=%s\n", i, got.Components[i].Type, want.Components[i].Type) + return false + } + if !stringPtrEqual(got.Components[i].Entity, want.Components[i].Entity) { + fmt.Printf("Page Component Entity mismatch at index %d: got=%v, want=%v\n", i, got.Components[i].Entity, want.Components[i].Entity) + return false + } + if len(got.Components[i].Config) != len(want.Components[i].Config) { + fmt.Printf("Page Component Config length mismatch at index %d: got=%d, want=%d\n", i, len(got.Components[i].Config), len(want.Components[i].Config)) + return false + } + for j := range got.Components[i].Config { + if !componentAttrEqual(got.Components[i].Config[j], want.Components[i].Config[j]) { + fmt.Printf("Page Component Config mismatch at index %d.%d\n", i, j) + return false + } + } + } + + return true +} + +func componentAttrEqual(got, want ComponentAttr) bool { + if got.Fields != nil && want.Fields != nil { + if len(got.Fields.Fields) != len(want.Fields.Fields) { + fmt.Printf("Component Fields length mismatch: got=%d, want=%d\n", len(got.Fields.Fields), len(want.Fields.Fields)) + return false + } + for i := range got.Fields.Fields { + if got.Fields.Fields[i] != want.Fields.Fields[i] { + fmt.Printf("Component Field mismatch at index %d: got=%s, want=%s\n", i, got.Fields.Fields[i], want.Fields.Fields[i]) + return false + } + } + } + + if got.Actions != nil && want.Actions != nil { + if len(got.Actions.Actions) != len(want.Actions.Actions) { + fmt.Printf("Component Actions length mismatch: got=%d, want=%d\n", len(got.Actions.Actions), len(want.Actions.Actions)) + return false + } + for i := range got.Actions.Actions { + if got.Actions.Actions[i].Name != want.Actions.Actions[i].Name { + fmt.Printf("Component Action Name mismatch at index %d: got=%s, want=%s\n", i, got.Actions.Actions[i].Name, want.Actions.Actions[i].Name) + return false + } + if !stringPtrEqual(got.Actions.Actions[i].Endpoint, want.Actions.Actions[i].Endpoint) { + fmt.Printf("Component Action Endpoint mismatch at index %d: got=%v, want=%v\n", i, got.Actions.Actions[i].Endpoint, want.Actions.Actions[i].Endpoint) + return false + } + } + } + + if got.DataSource != nil && want.DataSource != nil { + if got.DataSource.Endpoint != want.DataSource.Endpoint { + fmt.Printf("Component DataSource Endpoint mismatch: got=%s, want=%s\n", got.DataSource.Endpoint, want.DataSource.Endpoint) + return false + } + } + + if got.Style != nil && want.Style != nil { + if !stringPtrEqual(got.Style.Theme, want.Style.Theme) { + gotTheme := "nil" + wantTheme := "nil" + if got.Style.Theme != nil { + gotTheme = *got.Style.Theme + } + if want.Style.Theme != nil { + wantTheme = *want.Style.Theme + } + fmt.Printf("Component Style Theme mismatch: got=%s, want=%s\n", gotTheme, wantTheme) + return false + } + if len(got.Style.Classes) != len(want.Style.Classes) { + fmt.Printf("Component Style Classes length mismatch: got=%d, want=%d\n", len(got.Style.Classes), len(want.Style.Classes)) + return false + } + for i := range got.Style.Classes { + if got.Style.Classes[i] != want.Style.Classes[i] { + fmt.Printf("Component Style Class mismatch at index %d: got=%s, want=%s\n", i, got.Style.Classes[i], want.Style.Classes[i]) + return false + } + } + } + + if got.Pagination != nil && want.Pagination != nil { + // If ComponentPagination exists, pagination is enabled + gotEnabled := true + wantEnabled := true + if gotEnabled != wantEnabled { + fmt.Printf("Component Pagination Enabled mismatch: got=%v, want=%v\n", gotEnabled, wantEnabled) + return false + } + if !intPtrEqual(got.Pagination.PageSize, want.Pagination.PageSize) { + fmt.Printf("Component Pagination PageSize mismatch: got=%v, want=%v\n", got.Pagination.PageSize, want.Pagination.PageSize) + return false + } + } + + if got.Filters != nil && want.Filters != nil { + if len(got.Filters.Filters) != len(want.Filters.Filters) { + fmt.Printf("Component Filters length mismatch: got=%d, want=%d\n", len(got.Filters.Filters), len(want.Filters.Filters)) + return false + } + for i := range got.Filters.Filters { + if got.Filters.Filters[i].Field != want.Filters.Filters[i].Field { + fmt.Printf("Component Filter Field mismatch at index %d: got=%s, want=%s\n", i, got.Filters.Filters[i].Field, want.Filters.Filters[i].Field) + return false + } + if got.Filters.Filters[i].Type != want.Filters.Filters[i].Type { + fmt.Printf("Component Filter Type mismatch at index %d: got=%s, want=%s\n", i, got.Filters.Filters[i].Type, want.Filters.Filters[i].Type) + return false + } + if !stringPtrEqual(got.Filters.Filters[i].Label, want.Filters.Filters[i].Label) { + fmt.Printf("Component Filter Label mismatch at index %d: got=%v, want=%v\n", i, got.Filters.Filters[i].Label, want.Filters.Filters[i].Label) + return false + } + } + } + + if got.Validation != want.Validation { + fmt.Printf("Component Validation mismatch: got=%v, want=%v\n", got.Validation, want.Validation) + return false + } + + return true +} + +func fieldEqual(got, want Field) bool { + if got.Name != want.Name { + fmt.Printf("Field Name mismatch: got=%s, want=%s\n", got.Name, want.Name) + return false + } + + if got.Type != want.Type { + fmt.Printf("Field Type mismatch: got=%s, want=%s\n", got.Type, want.Type) + return false + } + + if got.Required != want.Required { + fmt.Printf("Field Required mismatch: got=%v, want=%v\n", got.Required, want.Required) + return false + } + + if got.Unique != want.Unique { + fmt.Printf("Field Unique mismatch: got=%v, want=%v\n", got.Unique, want.Unique) + return false + } + + if len(got.Validations) != len(want.Validations) { + fmt.Printf("Field Validations length mismatch: got=%d, want=%d\n", len(got.Validations), len(want.Validations)) + return false + } + + for i := range got.Validations { + if got.Validations[i].Type != want.Validations[i].Type { + fmt.Printf("Field Validation Type mismatch at index %d: got=%s, want=%s\n", i, got.Validations[i].Type, want.Validations[i].Type) + return false + } + if !stringPtrEqual(got.Validations[i].Value, want.Validations[i].Value) { + fmt.Printf("Field Validation Value mismatch at index %d: got=%v, want=%v\n", i, got.Validations[i].Value, want.Validations[i].Value) + return false + } + } + + if (got.Relationship == nil) != (want.Relationship == nil) { + fmt.Printf("Field Relationship presence mismatch: got=%v, want=%v\n", got.Relationship, want.Relationship) + return false + } + + if got.Relationship != nil && want.Relationship != nil { + if got.Relationship.Type != want.Relationship.Type { + fmt.Printf("Field Relationship Type mismatch: got=%s, want=%s\n", got.Relationship.Type, want.Relationship.Type) + return false + } + if got.Relationship.Cardinality != want.Relationship.Cardinality { + fmt.Printf("Field Relationship Cardinality mismatch: got=%s, want=%s\n", got.Relationship.Cardinality, want.Relationship.Cardinality) + return false + } + if !stringPtrEqual(got.Relationship.ForeignKey, want.Relationship.ForeignKey) { + fmt.Printf("Field Relationship ForeignKey mismatch: got=%v, want=%v\n", got.Relationship.ForeignKey, want.Relationship.ForeignKey) + return false + } + if !stringPtrEqual(got.Relationship.Through, want.Relationship.Through) { + fmt.Printf("Field Relationship Through mismatch: got=%v, want=%v\n", got.Relationship.Through, want.Relationship.Through) + return false + } + } + + return true +} + +func stringPtrEqual(got, want *string) bool { + if (got == nil) != (want == nil) { + return false + } + if got != nil && want != nil { + return *got == *want + } + return true +} + +func intPtrEqual(got, want *int) bool { + if (got == nil) != (want == nil) { + return false + } + if got != nil && want != nil { + return *got == *want + } + return true +} + +// Helper functions for creating pointers +func stringPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +}