From 1b023fdd6687048440817d65cbc3fe25d48c9508 Mon Sep 17 00:00:00 2001 From: Walter Pinto Date: Sun, 4 Oct 2026 15:11:23 +0100 Subject: [PATCH] feat(common): move the WIT ast parser to tools/common and add a lenient mode The WIT parser lived on the wit branch while the Kotlin wasm rules and please_wit both parsed WIT with regexes. Moving it to main lets every branch share it. Lenient mode skips declarations it does not model (use, variant, flags, include) for callers that only need names; the strict mode now reports them by name instead of a token-type error. Item 3 of #66. Co-Authored-By: Claude Sonnet 5.5 --- tools/common/wit/ast/BUILD | 21 + tools/common/wit/ast/ast.go | 152 +++++ tools/common/wit/ast/ast_test.go | 178 ++++++ tools/common/wit/ast/parser.go | 891 ++++++++++++++++++++++++++++ tools/common/wit/ast/parser_test.go | 700 ++++++++++++++++++++++ 5 files changed, 1942 insertions(+) create mode 100644 tools/common/wit/ast/BUILD create mode 100644 tools/common/wit/ast/ast.go create mode 100644 tools/common/wit/ast/ast_test.go create mode 100644 tools/common/wit/ast/parser.go create mode 100644 tools/common/wit/ast/parser_test.go diff --git a/tools/common/wit/ast/BUILD b/tools/common/wit/ast/BUILD new file mode 100644 index 0000000..8caaaad --- /dev/null +++ b/tools/common/wit/ast/BUILD @@ -0,0 +1,21 @@ +subinclude("///go//build_defs:go") + +go_library( + name = "ast", + srcs = [ + "ast.go", + "parser.go", + ], + visibility = ["//tools/..."], +) + +go_test( + name = "ast_test", + srcs = [ + "ast_test.go", + "parser_test.go", + ], + deps = [ + ":ast", + ], +) diff --git a/tools/common/wit/ast/ast.go b/tools/common/wit/ast/ast.go new file mode 100644 index 0000000..0a7b92b --- /dev/null +++ b/tools/common/wit/ast/ast.go @@ -0,0 +1,152 @@ +package ast + +import ( + "fmt" + "strings" +) + +// TypeKind distinguishes between primitives, compound types, and user references. +type TypeKind int + +const ( + KindPrimitive TypeKind = iota + KindList + KindOption + KindResult + KindTuple + KindNamed +) + +// TypeRef represents a type reference in WIT. +type TypeRef struct { + Kind TypeKind + Name string // e.g. "s32", "string", or user-defined type name + TypeArgs []*TypeRef // For list, option, result, tuple<...> +} + +func (t *TypeRef) String() string { + if t == nil { + return "unit" + } + switch t.Kind { + case KindPrimitive, KindNamed: + return t.Name + case KindList: + if len(t.TypeArgs) > 0 { + return fmt.Sprintf("list<%s>", t.TypeArgs[0].String()) + } + return "list" + case KindOption: + if len(t.TypeArgs) > 0 { + return fmt.Sprintf("option<%s>", t.TypeArgs[0].String()) + } + return "option" + case KindResult: + if len(t.TypeArgs) == 2 { + return fmt.Sprintf("result<%s, %s>", t.TypeArgs[0].String(), t.TypeArgs[1].String()) + } else if len(t.TypeArgs) == 1 { + return fmt.Sprintf("result<%s>", t.TypeArgs[0].String()) + } + return "result" + case KindTuple: + var inner []string + for _, arg := range t.TypeArgs { + inner = append(inner, arg.String()) + } + return fmt.Sprintf("tuple<%s>", strings.Join(inner, ", ")) + } + return t.Name +} + +// Param represents a parameter in a WIT function. +type Param struct { + Name string + Type *TypeRef +} + +// Function represents a function declared in a WIT interface. +type Function struct { + Name string + Doc string + Params []Param + Results *TypeRef // nil means void / unit +} + +// Field represents a field in a WIT record. +type Field struct { + Name string + Type *TypeRef +} + +// Record represents a record structure declared in a WIT interface. +type Record struct { + Name string + Doc string + Fields []Field +} + +// EnumCase represents a single case in an enum. +type EnumCase struct { + Name string +} + +// Enum represents an enum declared in a WIT interface. +type Enum struct { + Name string + Doc string + Cases []EnumCase +} + +// TypeDef represents a type alias declared in WIT. +type TypeDef struct { + Name string + Type *TypeRef +} + +// Resource represents a resource declaration in a WIT interface. +type Resource struct { + Name string + Doc string + Constructor *Function + Methods []Function + Static []Function +} + +// Interface represents an interface block in WIT. +type Interface struct { + Name string + Doc string + Functions []Function + Records []Record + Enums []Enum + TypeDefs []TypeDef + Resources []Resource +} + +// World represents a world definition in WIT. +type World struct { + Name string + Doc string + Imports []string + Exports []string +} + +// Package represents a WIT package containing interfaces and worlds. +type Package struct { + Namespace string + Name string + Version string + Interfaces []Interface + Worlds []World +} + +// FullName returns the canonical package identifier (e.g. "namespace:name"). +func (p *Package) FullName() string { + if p.Namespace != "" && p.Name != "" { + return fmt.Sprintf("%s:%s", p.Namespace, p.Name) + } + if p.Name != "" { + return p.Name + } + return p.Namespace +} diff --git a/tools/common/wit/ast/ast_test.go b/tools/common/wit/ast/ast_test.go new file mode 100644 index 0000000..015f92e --- /dev/null +++ b/tools/common/wit/ast/ast_test.go @@ -0,0 +1,178 @@ +package ast + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseSimpleWit(t *testing.T) { + input := ` +// Package comment +package test:structures@0.1.0; + +interface two-sum { + solve: func(nums: list, target: s32) -> list; + reset: func(); +} + +world two-sum-world { + export two-sum; +} +` + pkg, err := ParseContent(input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + + if pkg.Namespace != "test" { + t.Errorf("expected namespace 'test', got %q", pkg.Namespace) + } + if pkg.Name != "structures" { + t.Errorf("expected name 'structures', got %q", pkg.Name) + } + if pkg.Version != "0.1.0" { + t.Errorf("expected version '0.1.0', got %q", pkg.Version) + } + + if len(pkg.Interfaces) != 1 { + t.Fatalf("expected 1 interface, got %d", len(pkg.Interfaces)) + } + iface := pkg.Interfaces[0] + if iface.Name != "two-sum" { + t.Errorf("expected interface 'two-sum', got %q", iface.Name) + } + + if len(iface.Functions) != 2 { + t.Fatalf("expected 2 functions, got %d", len(iface.Functions)) + } + + fn1 := iface.Functions[0] + if fn1.Name != "solve" { + t.Errorf("expected fn 'solve', got %q", fn1.Name) + } + if len(fn1.Params) != 2 { + t.Fatalf("expected 2 params, got %d", len(fn1.Params)) + } + if fn1.Params[0].Name != "nums" || fn1.Params[0].Type.Kind != KindList { + t.Errorf("unexpected param 0: %+v", fn1.Params[0]) + } + if fn1.Params[1].Name != "target" || fn1.Params[1].Type.Name != "s32" { + t.Errorf("unexpected param 1: %+v", fn1.Params[1]) + } + if fn1.Results == nil || fn1.Results.Kind != KindList { + t.Errorf("unexpected results: %+v", fn1.Results) + } + + fn2 := iface.Functions[1] + if fn2.Name != "reset" || fn2.Results != nil { + t.Errorf("unexpected fn2: %+v", fn2) + } + + if len(pkg.Worlds) != 1 { + t.Fatalf("expected 1 world, got %d", len(pkg.Worlds)) + } + world := pkg.Worlds[0] + if world.Name != "two-sum-world" { + t.Errorf("expected world 'two-sum-world', got %q", world.Name) + } + if len(world.Exports) != 1 || world.Exports[0] != "two-sum" { + t.Errorf("unexpected exports: %v", world.Exports) + } +} + +func TestParseRecordsAndEnums(t *testing.T) { + input := ` +package test:types; + +interface data-types { + /* Multi line comment + about point + */ + record point { + x: f64, + y: f64, + } + + enum color { + red, + green, + blue, + } + + type point-alias = point; + + get-color: func(p: point) -> option; + calc: func(a: s32) -> result; +} +` + pkg, err := ParseContent(input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + + if len(pkg.Interfaces) != 1 { + t.Fatalf("expected 1 interface, got %d", len(pkg.Interfaces)) + } + iface := pkg.Interfaces[0] + + if len(iface.Records) != 1 { + t.Fatalf("expected 1 record, got %d", len(iface.Records)) + } + rec := iface.Records[0] + if rec.Name != "point" || len(rec.Fields) != 2 { + t.Errorf("unexpected record: %+v", rec) + } + + if len(iface.Enums) != 1 { + t.Fatalf("expected 1 enum, got %d", len(iface.Enums)) + } + enm := iface.Enums[0] + if enm.Name != "color" || len(enm.Cases) != 3 { + t.Errorf("unexpected enum: %+v", enm) + } + + if len(iface.TypeDefs) != 1 { + t.Fatalf("expected 1 type def, got %d", len(iface.TypeDefs)) + } + + if len(iface.Functions) != 2 { + t.Fatalf("expected 2 functions, got %d", len(iface.Functions)) + } + + fn1 := iface.Functions[0] + if fn1.Results == nil || fn1.Results.Kind != KindOption { + t.Errorf("expected option result, got %+v", fn1.Results) + } + + fn2 := iface.Functions[1] + if fn2.Results == nil || fn2.Results.Kind != KindResult { + t.Errorf("expected result result, got %+v", fn2.Results) + } +} + +func TestParsePath(t *testing.T) { + tmpDir := t.TempDir() + witFile := filepath.Join(tmpDir, "test.wit") + content := ` +package test:structures; + +interface disjoint-set { + find: func(x: s32) -> s32; +} +` + if err := os.WriteFile(witFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pkg, err := ParsePath(tmpDir) + if err != nil { + t.Fatalf("ParsePath failed: %v", err) + } + if pkg.FullName() != "test:structures" { + t.Errorf("expected test:structures, got %q", pkg.FullName()) + } + if len(pkg.Interfaces) != 1 || pkg.Interfaces[0].Name != "disjoint-set" { + t.Errorf("unexpected interfaces: %+v", pkg.Interfaces) + } +} diff --git a/tools/common/wit/ast/parser.go b/tools/common/wit/ast/parser.go new file mode 100644 index 0000000..704b4f7 --- /dev/null +++ b/tools/common/wit/ast/parser.go @@ -0,0 +1,891 @@ +package ast + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +// TokenType represents lexical token categories in WIT. +type TokenType int + +const ( + TokenEOF TokenType = iota + TokenIdent + TokenColon + TokenSemicolon + TokenComma + TokenLBrace + TokenRBrace + TokenLParen + TokenRParen + TokenLAngle + TokenRAngle + TokenArrow + TokenEquals + TokenAt +) + +type Token struct { + Type TokenType + Value string + Line int +} + +// Lexer tokenizes WIT source code. +type Lexer struct { + src []rune + pos int + length int + line int +} + +func NewLexer(input string) *Lexer { + src := []rune(input) + return &Lexer{ + src: src, + pos: 0, + length: len(src), + line: 1, + } +} + +func (l *Lexer) peek() rune { + if l.pos >= l.length { + return 0 + } + return l.src[l.pos] +} + +func (l *Lexer) next() rune { + if l.pos >= l.length { + return 0 + } + r := l.src[l.pos] + l.pos++ + if r == '\n' { + l.line++ + } + return r +} + +func (l *Lexer) skipWhitespaceAndComments() { + for l.pos < l.length { + r := l.peek() + if unicode.IsSpace(r) { + l.next() + continue + } + if r == '/' && l.pos+1 < l.length { + nextR := l.src[l.pos+1] + if nextR == '/' { + // Single line comment + for l.pos < l.length && l.peek() != '\n' { + l.next() + } + continue + } else if nextR == '*' { + // Multi line comment + l.next() // consume '/' + l.next() // consume '*' + for l.pos < l.length { + if l.peek() == '*' && l.pos+1 < l.length && l.src[l.pos+1] == '/' { + l.next() + l.next() + break + } + l.next() + } + continue + } + } + break + } +} + +func (l *Lexer) NextToken() Token { + l.skipWhitespaceAndComments() + + if l.pos >= l.length { + return Token{Type: TokenEOF, Value: "", Line: l.line} + } + + startLine := l.line + r := l.next() + + switch r { + case ':': + return Token{Type: TokenColon, Value: ":", Line: startLine} + case ';': + return Token{Type: TokenSemicolon, Value: ";", Line: startLine} + case ',': + return Token{Type: TokenComma, Value: ",", Line: startLine} + case '{': + return Token{Type: TokenLBrace, Value: "{", Line: startLine} + case '}': + return Token{Type: TokenRBrace, Value: "}", Line: startLine} + case '(': + return Token{Type: TokenLParen, Value: "(", Line: startLine} + case ')': + return Token{Type: TokenRParen, Value: ")", Line: startLine} + case '<': + return Token{Type: TokenLAngle, Value: "<", Line: startLine} + case '>': + return Token{Type: TokenRAngle, Value: ">", Line: startLine} + case '=': + return Token{Type: TokenEquals, Value: "=", Line: startLine} + case '@': + return Token{Type: TokenAt, Value: "@", Line: startLine} + case '-': + if l.peek() == '>' { + l.next() + return Token{Type: TokenArrow, Value: "->", Line: startLine} + } + } + + // Identifiers (including kebab-case, dots, and colons in package paths or versions) + if unicode.IsLetter(r) || r == '_' || r == '%' { + var val []rune + val = append(val, r) + for l.pos < l.length { + peeked := l.peek() + if unicode.IsLetter(peeked) || unicode.IsDigit(peeked) || peeked == '_' || peeked == '-' { + val = append(val, l.next()) + } else { + break + } + } + return Token{Type: TokenIdent, Value: string(val), Line: startLine} + } + + // Digits (e.g. in versions) + if unicode.IsDigit(r) { + var val []rune + val = append(val, r) + for l.pos < l.length { + peeked := l.peek() + if unicode.IsDigit(peeked) || peeked == '.' { + val = append(val, l.next()) + } else { + break + } + } + return Token{Type: TokenIdent, Value: string(val), Line: startLine} + } + + return Token{Type: TokenIdent, Value: string(r), Line: startLine} +} + +// Parser parses WIT tokens into an AST. +type Parser struct { + tokens []Token + pos int + + // Lenient skips declarations the parser does not model (use, variant, flags, ...) + // instead of failing on them. It is for callers that only need names, such as + // discovering the worlds and the package of a file. Code generators must stay strict: + // a skipped variant would silently be missing from what they generate. + Lenient bool +} + +func NewParser(tokens []Token) *Parser { + return &Parser{tokens: tokens, pos: 0} +} + +func (p *Parser) peek() Token { + if p.pos >= len(p.tokens) { + return Token{Type: TokenEOF} + } + return p.tokens[p.pos] +} + +func (p *Parser) next() Token { + tok := p.peek() + if p.pos < len(p.tokens) { + p.pos++ + } + return tok +} + +func (p *Parser) expect(tt TokenType) (Token, error) { + tok := p.next() + if tok.Type != tt { + return tok, fmt.Errorf("line %d: expected token type %v, got %v (%q)", tok.Line, tt, tok.Type, tok.Value) + } + return tok, nil +} + +// unsupportedDeclarations are WIT declarations that can appear in an interface or a world +// and that this parser does not model. +var unsupportedDeclarations = map[string]bool{ + "use": true, "variant": true, "flags": true, "include": true, "union": true, +} + +// skipDeclaration consumes one declaration without interpreting it: tokens up to a +// semicolon outside braces, or up to the brace that closes the block it opens. +func (p *Parser) skipDeclaration() { + depth := 0 + for p.peek().Type != TokenEOF { + switch p.next().Type { + case TokenLBrace: + depth++ + case TokenRBrace: + depth-- + if depth <= 0 { + return + } + case TokenSemicolon: + if depth == 0 { + return + } + } + } +} + +// unsupported handles a declaration this parser does not model: it is skipped when +// Lenient, and an error naming it otherwise. +func (p *Parser) unsupported(tok Token) error { + if !p.Lenient { + return fmt.Errorf("line %d: %q declarations are not supported", tok.Line, tok.Value) + } + p.skipDeclaration() + return nil +} + +func (p *Parser) expectIdent() (string, error) { + tok := p.next() + if tok.Type != TokenIdent { + return "", fmt.Errorf("line %d: expected identifier, got %q", tok.Line, tok.Value) + } + return tok.Value, nil +} + +// Parse parses the whole WIT file into a Package AST. +func (p *Parser) Parse() (*Package, error) { + pkg := &Package{} + + for p.peek().Type != TokenEOF { + tok := p.peek() + if tok.Type == TokenIdent { + switch tok.Value { + case "package": + p.next() + if err := p.parsePackage(pkg); err != nil { + return nil, err + } + case "interface": + p.next() + iface, err := p.parseInterface() + if err != nil { + return nil, err + } + pkg.Interfaces = append(pkg.Interfaces, *iface) + case "world": + p.next() + w, err := p.parseWorld() + if err != nil { + return nil, err + } + pkg.Worlds = append(pkg.Worlds, *w) + default: + // Skip unexpected token at top level + p.next() + } + } else { + p.next() + } + } + + return pkg, nil +} + +func (p *Parser) parsePackage(pkg *Package) error { + ns, err := p.expectIdent() + if err != nil { + return err + } + pkg.Namespace = ns + + if p.peek().Type == TokenColon { + p.next() + name, err := p.expectIdent() + if err != nil { + return err + } + pkg.Name = name + } else { + pkg.Name = pkg.Namespace + pkg.Namespace = "" + } + + if p.peek().Type == TokenAt { + p.next() + ver, err := p.expectIdent() + if err == nil { + pkg.Version = ver + } + } + + if p.peek().Type == TokenSemicolon { + p.next() + } + return nil +} + +func (p *Parser) parseInterface() (*Interface, error) { + name, err := p.expectIdent() + if err != nil { + return nil, err + } + + if _, err := p.expect(TokenLBrace); err != nil { + return nil, err + } + + iface := &Interface{Name: name} + + for p.peek().Type != TokenRBrace && p.peek().Type != TokenEOF { + tok := p.peek() + if tok.Type == TokenIdent { + switch tok.Value { + case "record": + p.next() + rec, err := p.parseRecord() + if err != nil { + return nil, err + } + iface.Records = append(iface.Records, *rec) + case "enum": + p.next() + enm, err := p.parseEnum() + if err != nil { + return nil, err + } + iface.Enums = append(iface.Enums, *enm) + case "type": + p.next() + tdef, err := p.parseTypeDef() + if err != nil { + return nil, err + } + iface.TypeDefs = append(iface.TypeDefs, *tdef) + case "resource": + p.next() + res, err := p.parseResource() + if err != nil { + return nil, err + } + iface.Resources = append(iface.Resources, *res) + case "use", "variant", "flags", "include", "union": + if err := p.unsupported(tok); err != nil { + return nil, err + } + default: + // Function declaration: : func(...) [-> ...] ; + fn, err := p.parseFunction() + if err != nil { + return nil, err + } + iface.Functions = append(iface.Functions, *fn) + } + } else { + p.next() + } + } + + if _, err := p.expect(TokenRBrace); err != nil { + return nil, err + } + + return iface, nil +} + +func (p *Parser) parseFunction() (*Function, error) { + name, err := p.expectIdent() + if err != nil { + return nil, err + } + + if _, err := p.expect(TokenColon); err != nil { + return nil, err + } + + funcKeyword, err := p.expectIdent() + if err != nil || funcKeyword != "func" { + return nil, fmt.Errorf("expected 'func', got %q", funcKeyword) + } + + if _, err := p.expect(TokenLParen); err != nil { + return nil, err + } + + var params []Param + for p.peek().Type != TokenRParen && p.peek().Type != TokenEOF { + pName, err := p.expectIdent() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenColon); err != nil { + return nil, err + } + pType, err := p.parseTypeRef() + if err != nil { + return nil, err + } + params = append(params, Param{Name: pName, Type: pType}) + + if p.peek().Type == TokenComma { + p.next() + } else { + break + } + } + + if _, err := p.expect(TokenRParen); err != nil { + return nil, err + } + + var resultType *TypeRef + if p.peek().Type == TokenArrow { + p.next() + rt, err := p.parseTypeRef() + if err != nil { + return nil, err + } + resultType = rt + } + + if p.peek().Type == TokenSemicolon { + p.next() + } + + return &Function{ + Name: name, + Params: params, + Results: resultType, + }, nil +} + +func (p *Parser) parseTypeRef() (*TypeRef, error) { + tok := p.peek() + if tok.Type != TokenIdent { + return nil, fmt.Errorf("line %d: expected type name, got %q", tok.Line, tok.Value) + } + p.next() + typeName := tok.Value + + switch typeName { + case "list": + if p.peek().Type == TokenLAngle { + p.next() + inner, err := p.parseTypeRef() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenRAngle); err != nil { + return nil, err + } + return &TypeRef{Kind: KindList, Name: "list", TypeArgs: []*TypeRef{inner}}, nil + } + return &TypeRef{Kind: KindList, Name: "list"}, nil + + case "option": + if p.peek().Type == TokenLAngle { + p.next() + inner, err := p.parseTypeRef() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenRAngle); err != nil { + return nil, err + } + return &TypeRef{Kind: KindOption, Name: "option", TypeArgs: []*TypeRef{inner}}, nil + } + return &TypeRef{Kind: KindOption, Name: "option"}, nil + + case "result": + var typeArgs []*TypeRef + if p.peek().Type == TokenLAngle { + p.next() + // Handle result<_, E> or result or result + if p.peek().Type == TokenIdent && p.peek().Value == "_" { + p.next() + typeArgs = append(typeArgs, &TypeRef{Kind: KindPrimitive, Name: "_"}) + } else { + t1, err := p.parseTypeRef() + if err != nil { + return nil, err + } + typeArgs = append(typeArgs, t1) + } + + if p.peek().Type == TokenComma { + p.next() + t2, err := p.parseTypeRef() + if err != nil { + return nil, err + } + typeArgs = append(typeArgs, t2) + } + + if _, err := p.expect(TokenRAngle); err != nil { + return nil, err + } + } + return &TypeRef{Kind: KindResult, Name: "result", TypeArgs: typeArgs}, nil + + case "tuple": + var typeArgs []*TypeRef + if p.peek().Type == TokenLAngle { + p.next() + for p.peek().Type != TokenRAngle && p.peek().Type != TokenEOF { + t, err := p.parseTypeRef() + if err != nil { + return nil, err + } + typeArgs = append(typeArgs, t) + if p.peek().Type == TokenComma { + p.next() + } else { + break + } + } + if _, err := p.expect(TokenRAngle); err != nil { + return nil, err + } + } + return &TypeRef{Kind: KindTuple, Name: "tuple", TypeArgs: typeArgs}, nil + + case "u8", "u16", "u32", "u64", "s8", "s16", "s32", "s64", "f32", "f64", "char", "bool", "string": + return &TypeRef{Kind: KindPrimitive, Name: typeName}, nil + + default: + return &TypeRef{Kind: KindNamed, Name: typeName}, nil + } +} + +func (p *Parser) parseRecord() (*Record, error) { + name, err := p.expectIdent() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenLBrace); err != nil { + return nil, err + } + + var fields []Field + for p.peek().Type != TokenRBrace && p.peek().Type != TokenEOF { + fName, err := p.expectIdent() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenColon); err != nil { + return nil, err + } + fType, err := p.parseTypeRef() + if err != nil { + return nil, err + } + fields = append(fields, Field{Name: fName, Type: fType}) + + if p.peek().Type == TokenComma { + p.next() + } else { + break + } + } + + if _, err := p.expect(TokenRBrace); err != nil { + return nil, err + } + return &Record{Name: name, Fields: fields}, nil +} + +func (p *Parser) parseEnum() (*Enum, error) { + name, err := p.expectIdent() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenLBrace); err != nil { + return nil, err + } + + var cases []EnumCase + for p.peek().Type != TokenRBrace && p.peek().Type != TokenEOF { + cName, err := p.expectIdent() + if err != nil { + return nil, err + } + cases = append(cases, EnumCase{Name: cName}) + + if p.peek().Type == TokenComma { + p.next() + } else { + break + } + } + + if _, err := p.expect(TokenRBrace); err != nil { + return nil, err + } + return &Enum{Name: name, Cases: cases}, nil +} + +func (p *Parser) parseTypeDef() (*TypeDef, error) { + name, err := p.expectIdent() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenEquals); err != nil { + return nil, err + } + tRef, err := p.parseTypeRef() + if err != nil { + return nil, err + } + if p.peek().Type == TokenSemicolon { + p.next() + } + return &TypeDef{Name: name, Type: tRef}, nil +} + +func (p *Parser) parseResource() (*Resource, error) { + name, err := p.expectIdent() + if err != nil { + return nil, err + } + + res := &Resource{Name: name} + + if p.peek().Type == TokenSemicolon { + p.next() + return res, nil + } + + if _, err := p.expect(TokenLBrace); err != nil { + return nil, err + } + + for p.peek().Type != TokenRBrace && p.peek().Type != TokenEOF { + tok := p.peek() + if tok.Type == TokenIdent { + switch tok.Value { + case "constructor": + p.next() + fn, err := p.parseConstructor() + if err != nil { + return nil, err + } + res.Constructor = fn + case "static": + p.next() + fn, err := p.parseFunction() + if err != nil { + return nil, err + } + res.Static = append(res.Static, *fn) + default: + fn, err := p.parseFunction() + if err != nil { + return nil, err + } + res.Methods = append(res.Methods, *fn) + } + } else { + p.next() + } + } + + if _, err := p.expect(TokenRBrace); err != nil { + return nil, err + } + + return res, nil +} + +func (p *Parser) parseConstructor() (*Function, error) { + if _, err := p.expect(TokenLParen); err != nil { + return nil, err + } + + var params []Param + for p.peek().Type != TokenRParen && p.peek().Type != TokenEOF { + pName, err := p.expectIdent() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenColon); err != nil { + return nil, err + } + pType, err := p.parseTypeRef() + if err != nil { + return nil, err + } + params = append(params, Param{Name: pName, Type: pType}) + + if p.peek().Type == TokenComma { + p.next() + } else { + break + } + } + + if _, err := p.expect(TokenRParen); err != nil { + return nil, err + } + + if p.peek().Type == TokenSemicolon { + p.next() + } + + return &Function{ + Name: "constructor", + Params: params, + }, nil +} + +func (p *Parser) parseWorld() (*World, error) { + name, err := p.expectIdent() + if err != nil { + return nil, err + } + if _, err := p.expect(TokenLBrace); err != nil { + return nil, err + } + + w := &World{Name: name} + + for p.peek().Type != TokenRBrace && p.peek().Type != TokenEOF { + tok := p.peek() + if tok.Type == TokenIdent { + if tok.Value == "export" { + p.next() + item, err := p.expectIdent() + if err == nil { + w.Exports = append(w.Exports, item) + } + if p.peek().Type == TokenSemicolon { + p.next() + } + } else if tok.Value == "import" { + p.next() + item, err := p.expectIdent() + if err == nil { + w.Imports = append(w.Imports, item) + } + if p.peek().Type == TokenSemicolon { + p.next() + } + } else if unsupportedDeclarations[tok.Value] || tok.Value == "type" || tok.Value == "record" || tok.Value == "enum" || tok.Value == "resource" { + // Declarations inside a world are not modelled; skipping one as a whole keeps + // the braces of `use x.{y}` or of a record from ending the world early. + p.skipDeclaration() + } else { + p.next() + } + } else { + p.next() + } + } + + if _, err := p.expect(TokenRBrace); err != nil { + return nil, err + } + return w, nil +} + +// ParseContent parses WIT content from a string. +func ParseContent(content string) (*Package, error) { + lexer := NewLexer(content) + var tokens []Token + for { + tok := lexer.NextToken() + tokens = append(tokens, tok) + if tok.Type == TokenEOF { + break + } + } + parser := NewParser(tokens) + return parser.Parse() +} + +// ParseContentLenient is ParseContent for callers that only need names: declarations the +// parser does not model are skipped instead of failing (see Parser.Lenient). +func ParseContentLenient(content string) (*Package, error) { + lexer := NewLexer(content) + var tokens []Token + for { + tok := lexer.NextToken() + tokens = append(tokens, tok) + if tok.Type == TokenEOF { + break + } + } + parser := NewParser(tokens) + parser.Lenient = true + return parser.Parse() +} + +// ParseFileLenient reads a .wit file and parses it with ParseContentLenient. +func ParseFileLenient(path string) (*Package, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseContentLenient(string(data)) +} + +// ParseFile parses a single .wit file. +func ParseFile(path string) (*Package, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseContent(string(data)) +} + +// ParsePath parses .wit files in a file or directory, merging into a single Package AST. +func ParsePath(witPath string) (*Package, error) { + var files []string + fi, err := os.Stat(witPath) + if err != nil { + return nil, err + } + + if fi.IsDir() { + err := filepath.Walk(witPath, func(path string, info os.FileInfo, err error) error { + if err == nil && info != nil && !info.IsDir() && strings.HasSuffix(path, ".wit") { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, err + } + } else { + files = append(files, witPath) + } + + merged := &Package{} + for _, file := range files { + pkg, err := ParseFile(file) + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", file, err) + } + if merged.Namespace == "" && pkg.Namespace != "" { + merged.Namespace = pkg.Namespace + } + if merged.Name == "" && pkg.Name != "" { + merged.Name = pkg.Name + } + if merged.Version == "" && pkg.Version != "" { + merged.Version = pkg.Version + } + merged.Interfaces = append(merged.Interfaces, pkg.Interfaces...) + merged.Worlds = append(merged.Worlds, pkg.Worlds...) + } + + return merged, nil +} diff --git a/tools/common/wit/ast/parser_test.go b/tools/common/wit/ast/parser_test.go new file mode 100644 index 0000000..374957c --- /dev/null +++ b/tools/common/wit/ast/parser_test.go @@ -0,0 +1,700 @@ +package ast + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLexer_Punctuation(t *testing.T) { + input := ": ; , { } ( ) < > = @ ->" + lexer := NewLexer(input) + + expected := []struct { + tokType TokenType + val string + }{ + {TokenColon, ":"}, + {TokenSemicolon, ";"}, + {TokenComma, ","}, + {TokenLBrace, "{"}, + {TokenRBrace, "}"}, + {TokenLParen, "("}, + {TokenRParen, ")"}, + {TokenLAngle, "<"}, + {TokenRAngle, ">"}, + {TokenEquals, "="}, + {TokenAt, "@"}, + {TokenArrow, "->"}, + {TokenEOF, ""}, + } + + for i, exp := range expected { + tok := lexer.NextToken() + if tok.Type != exp.tokType { + t.Errorf("step %d: expected token type %v, got %v (%q)", i, exp.tokType, tok.Type, tok.Value) + } + if tok.Value != exp.val { + t.Errorf("step %d: expected value %q, got %q", i, exp.val, tok.Value) + } + } +} + +func TestLexer_IdentifiersAndNumbers(t *testing.T) { + input := "identifier kebab-case_name _private %interface 123 0.2.1" + lexer := NewLexer(input) + + expected := []string{ + "identifier", + "kebab-case_name", + "_private", + "%interface", + "123", + "0.2.1", + } + + for _, exp := range expected { + tok := lexer.NextToken() + if tok.Type != TokenIdent { + t.Errorf("expected TokenIdent for %q, got %v", exp, tok.Type) + } + if tok.Value != exp { + t.Errorf("expected value %q, got %q", exp, tok.Value) + } + } + + last := lexer.NextToken() + if last.Type != TokenEOF { + t.Errorf("expected EOF, got %v", last.Type) + } +} + +func TestLexer_CommentsAndWhitespace(t *testing.T) { + input := ` + // Single line comment + pkg1 + // Another comment with symbols: -> = {} + pkg2 + /* Multi + line + comment */ + pkg3 + /*** Star comment ***/ + pkg4 + ` + lexer := NewLexer(input) + + expected := []string{"pkg1", "pkg2", "pkg3", "pkg4"} + for _, exp := range expected { + tok := lexer.NextToken() + if tok.Type != TokenIdent || tok.Value != exp { + t.Errorf("expected ident %q, got %+v", exp, tok) + } + } + + if tok := lexer.NextToken(); tok.Type != TokenEOF { + t.Errorf("expected EOF, got %+v", tok) + } +} + +func TestLexer_HyphenMinus(t *testing.T) { + input := "- ->" + lexer := NewLexer(input) + + tok1 := lexer.NextToken() + if tok1.Type != TokenIdent || tok1.Value != "-" { + t.Errorf("expected '-' ident, got %+v", tok1) + } + + tok2 := lexer.NextToken() + if tok2.Type != TokenArrow || tok2.Value != "->" { + t.Errorf("expected '->' arrow, got %+v", tok2) + } +} + +func TestParser_PackageSyntax(t *testing.T) { + tests := []struct { + name string + input string + expectedNS string + expectedPkg string + expectedVer string + }{ + { + name: "full package with version", + input: "package my-org:my-pkg@1.2.3;", + expectedNS: "my-org", + expectedPkg: "my-pkg", + expectedVer: "1.2.3", + }, + { + name: "package without version", + input: "package org:pkg;", + expectedNS: "org", + expectedPkg: "pkg", + expectedVer: "", + }, + { + name: "single identifier package", + input: "package standalone;", + expectedNS: "", + expectedPkg: "standalone", + expectedVer: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg, err := ParseContent(tt.input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + if pkg.Namespace != tt.expectedNS { + t.Errorf("expected namespace %q, got %q", tt.expectedNS, pkg.Namespace) + } + if pkg.Name != tt.expectedPkg { + t.Errorf("expected name %q, got %q", tt.expectedPkg, pkg.Name) + } + if pkg.Version != tt.expectedVer { + t.Errorf("expected version %q, got %q", tt.expectedVer, pkg.Version) + } + }) + } +} + +func TestParser_EmptyInterface(t *testing.T) { + input := ` + package test:pkg; + interface empty-api {} + ` + pkg, err := ParseContent(input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + if len(pkg.Interfaces) != 1 { + t.Fatalf("expected 1 interface, got %d", len(pkg.Interfaces)) + } + if pkg.Interfaces[0].Name != "empty-api" { + t.Errorf("expected empty-api, got %q", pkg.Interfaces[0].Name) + } + if len(pkg.Interfaces[0].Functions) != 0 { + t.Errorf("expected 0 functions, got %d", len(pkg.Interfaces[0].Functions)) + } +} + +func TestParser_FunctionVariants(t *testing.T) { + input := ` + package test:pkg; + + interface api { + no-args-no-ret: func(); + no-args-with-ret: func() -> string; + single-arg-no-ret: func(msg: string); + multiple-args-with-ret: func(a: s32, b: s32, flag: bool) -> result; + } + ` + pkg, err := ParseContent(input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + + iface := pkg.Interfaces[0] + if len(iface.Functions) != 4 { + t.Fatalf("expected 4 functions, got %d", len(iface.Functions)) + } + + // 1. no-args-no-ret + fn0 := iface.Functions[0] + if fn0.Name != "no-args-no-ret" || len(fn0.Params) != 0 || fn0.Results != nil { + t.Errorf("unexpected fn0: %+v", fn0) + } + + // 2. no-args-with-ret + fn1 := iface.Functions[1] + if fn1.Name != "no-args-with-ret" || len(fn1.Params) != 0 || fn1.Results == nil || fn1.Results.Name != "string" { + t.Errorf("unexpected fn1: %+v", fn1) + } + + // 3. single-arg-no-ret + fn2 := iface.Functions[2] + if fn2.Name != "single-arg-no-ret" || len(fn2.Params) != 1 || fn2.Params[0].Name != "msg" || fn2.Results != nil { + t.Errorf("unexpected fn2: %+v", fn2) + } + + // 4. multiple-args-with-ret + fn3 := iface.Functions[3] + if fn3.Name != "multiple-args-with-ret" || len(fn3.Params) != 3 { + t.Errorf("unexpected fn3: %+v", fn3) + } + if fn3.Results == nil || fn3.Results.Kind != KindResult || len(fn3.Results.TypeArgs) != 2 { + t.Errorf("unexpected fn3 results: %+v", fn3.Results) + } +} + +func TestParser_CompoundAndPrimitiveTypes(t *testing.T) { + input := ` + package test:types; + + interface type-matrix { + test-primitives: func( + p1: u8, p2: u16, p3: u32, p4: u64, + p5: s8, p6: s16, p7: s32, p8: s64, + p9: f32, p10: f64, p11: char, p12: bool, p13: string + ); + + test-bare-containers: func(l: list, o: option, r: result, tup: tuple); + + test-tuples: func(t1: tuple); + + test-result-blank: func() -> result<_, string>; + test-result-single: func() -> result; + + test-nested: func(matrix: list>, complex: list>>); + } + ` + pkg, err := ParseContent(input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + + iface := pkg.Interfaces[0] + if len(iface.Functions) != 6 { + t.Fatalf("expected 6 functions, got %d", len(iface.Functions)) + } + + // Primitives + fnPrim := iface.Functions[0] + if len(fnPrim.Params) != 13 { + t.Fatalf("expected 13 primitive params, got %d", len(fnPrim.Params)) + } + for _, p := range fnPrim.Params { + if p.Type.Kind != KindPrimitive { + t.Errorf("expected primitive kind for %s (%s), got %v", p.Name, p.Type.Name, p.Type.Kind) + } + } + + // Bare containers + fnBare := iface.Functions[1] + if len(fnBare.Params) != 4 { + t.Fatalf("expected 4 bare params, got %d", len(fnBare.Params)) + } + if fnBare.Params[0].Type.Kind != KindList || len(fnBare.Params[0].Type.TypeArgs) != 0 { + t.Errorf("expected bare list, got %+v", fnBare.Params[0].Type) + } + if fnBare.Params[1].Type.Kind != KindOption || len(fnBare.Params[1].Type.TypeArgs) != 0 { + t.Errorf("expected bare option, got %+v", fnBare.Params[1].Type) + } + if fnBare.Params[2].Type.Kind != KindResult || len(fnBare.Params[2].Type.TypeArgs) != 0 { + t.Errorf("expected bare result, got %+v", fnBare.Params[2].Type) + } + if fnBare.Params[3].Type.Kind != KindTuple || len(fnBare.Params[3].Type.TypeArgs) != 0 { + t.Errorf("expected bare tuple, got %+v", fnBare.Params[3].Type) + } + + // Tuples + fnTup := iface.Functions[2] + tupType := fnTup.Params[0].Type + if tupType.Kind != KindTuple || len(tupType.TypeArgs) != 3 { + t.Errorf("expected tuple with 3 args, got %+v", tupType) + } + if tupType.String() != "tuple" { + t.Errorf("expected string representation 'tuple', got %q", tupType.String()) + } + + // result<_, E> + fnResBlank := iface.Functions[3] + if fnResBlank.Results == nil || fnResBlank.Results.Kind != KindResult || len(fnResBlank.Results.TypeArgs) != 2 { + t.Fatalf("expected result<_, string>, got %+v", fnResBlank.Results) + } + if fnResBlank.Results.TypeArgs[0].Name != "_" { + t.Errorf("expected first arg '_', got %q", fnResBlank.Results.TypeArgs[0].Name) + } + + // result (single argument) + fnResSingle := iface.Functions[4] + if fnResSingle.Results == nil || fnResSingle.Results.Kind != KindResult || len(fnResSingle.Results.TypeArgs) != 1 { + t.Fatalf("expected result, got %+v", fnResSingle.Results) + } + + // Nested types + fnNested := iface.Functions[5] + matParam := fnNested.Params[0].Type + if matParam.Kind != KindList || matParam.TypeArgs[0].Kind != KindList { + t.Errorf("expected nested list>, got %s", matParam.String()) + } +} + +func TestParser_RecordsAndEnumsDetailed(t *testing.T) { + input := ` + package app:models; + + interface entities { + record empty-rec {} + + record user { + id: u64, + username: string, + roles: list, + } + + enum empty-enum {} + + enum role { + admin, + viewer, + editor, + } + + type user-alias = user; + type id-alias = u64; + } + ` + pkg, err := ParseContent(input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + + iface := pkg.Interfaces[0] + + // Records + if len(iface.Records) != 2 { + t.Fatalf("expected 2 records, got %d", len(iface.Records)) + } + if iface.Records[0].Name != "empty-rec" || len(iface.Records[0].Fields) != 0 { + t.Errorf("unexpected empty record: %+v", iface.Records[0]) + } + userRec := iface.Records[1] + if userRec.Name != "user" || len(userRec.Fields) != 3 { + t.Fatalf("unexpected user record: %+v", userRec) + } + if userRec.Fields[0].Name != "id" || userRec.Fields[0].Type.Name != "u64" { + t.Errorf("field 0 mismatch: %+v", userRec.Fields[0]) + } + if userRec.Fields[2].Name != "roles" || userRec.Fields[2].Type.Kind != KindList { + t.Errorf("field 2 mismatch: %+v", userRec.Fields[2]) + } + + // Enums + if len(iface.Enums) != 2 { + t.Fatalf("expected 2 enums, got %d", len(iface.Enums)) + } + if iface.Enums[0].Name != "empty-enum" || len(iface.Enums[0].Cases) != 0 { + t.Errorf("unexpected empty enum: %+v", iface.Enums[0]) + } + roleEnum := iface.Enums[1] + if roleEnum.Name != "role" || len(roleEnum.Cases) != 3 { + t.Fatalf("unexpected role enum: %+v", roleEnum) + } + if roleEnum.Cases[0].Name != "admin" || roleEnum.Cases[1].Name != "viewer" || roleEnum.Cases[2].Name != "editor" { + t.Errorf("enum cases mismatch: %+v", roleEnum.Cases) + } + + // TypeDefs + if len(iface.TypeDefs) != 2 { + t.Fatalf("expected 2 type defs, got %d", len(iface.TypeDefs)) + } + if iface.TypeDefs[0].Name != "user-alias" || iface.TypeDefs[0].Type.Kind != KindNamed { + t.Errorf("unexpected typedef 0: %+v", iface.TypeDefs[0]) + } + if iface.TypeDefs[1].Name != "id-alias" || iface.TypeDefs[1].Type.Kind != KindPrimitive { + t.Errorf("unexpected typedef 1: %+v", iface.TypeDefs[1]) + } +} + +func TestParser_WorldImportsAndExports(t *testing.T) { + input := ` + package app:system; + + interface logger { + log: func(msg: string); + } + + interface handler { + handle: func(); + } + + world app-world { + import logger; + import config; + export handler; + export metrics; + } + ` + pkg, err := ParseContent(input) + if err != nil { + t.Fatalf("ParseContent failed: %v", err) + } + + if len(pkg.Worlds) != 1 { + t.Fatalf("expected 1 world, got %d", len(pkg.Worlds)) + } + + w := pkg.Worlds[0] + if w.Name != "app-world" { + t.Errorf("expected world name app-world, got %q", w.Name) + } + if len(w.Imports) != 2 || w.Imports[0] != "logger" || w.Imports[1] != "config" { + t.Errorf("unexpected imports: %v", w.Imports) + } + if len(w.Exports) != 2 || w.Exports[0] != "handler" || w.Exports[1] != "metrics" { + t.Errorf("unexpected exports: %v", w.Exports) + } +} + +func TestParser_TypeRefStringRepresentation(t *testing.T) { + var nilRef *TypeRef + if nilRef.String() != "unit" { + t.Errorf("expected 'unit' for nil TypeRef, got %q", nilRef.String()) + } + + listRef := &TypeRef{Kind: KindList, Name: "list", TypeArgs: []*TypeRef{{Kind: KindPrimitive, Name: "s32"}}} + if listRef.String() != "list" { + t.Errorf("expected 'list', got %q", listRef.String()) + } + + optRef := &TypeRef{Kind: KindOption, Name: "option", TypeArgs: []*TypeRef{{Kind: KindPrimitive, Name: "string"}}} + if optRef.String() != "option" { + t.Errorf("expected 'option', got %q", optRef.String()) + } + + res1Ref := &TypeRef{Kind: KindResult, Name: "result", TypeArgs: []*TypeRef{{Kind: KindPrimitive, Name: "s32"}}} + if res1Ref.String() != "result" { + t.Errorf("expected 'result', got %q", res1Ref.String()) + } + + res2Ref := &TypeRef{Kind: KindResult, Name: "result", TypeArgs: []*TypeRef{ + {Kind: KindPrimitive, Name: "s32"}, + {Kind: KindPrimitive, Name: "string"}, + }} + if res2Ref.String() != "result" { + t.Errorf("expected 'result', got %q", res2Ref.String()) + } + + tupRef := &TypeRef{Kind: KindTuple, Name: "tuple", TypeArgs: []*TypeRef{ + {Kind: KindPrimitive, Name: "u8"}, + {Kind: KindPrimitive, Name: "u16"}, + }} + if tupRef.String() != "tuple" { + t.Errorf("expected 'tuple', got %q", tupRef.String()) + } +} + +func TestParser_ErrorCases(t *testing.T) { + errorInputs := []struct { + name string + input string + }{ + {"missing interface name", "interface { }"}, + {"missing opening brace", "interface api }"}, + {"missing colon after func name", "interface api { my-fn func(); }"}, + {"missing func keyword", "interface api { my-fn: notfunc(); }"}, + {"unclosed param list", "interface api { my-fn: func(x: s32 ; }"}, + {"unclosed record brace", "interface api { record user { id: u64 }"}, + {"missing equals in type alias", "interface api { type my-type s32; }"}, + } + + for _, tt := range errorInputs { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseContent(tt.input) + if err == nil { + t.Errorf("expected error for %q, but got nil", tt.input) + } + }) + } +} + +func TestParser_ParseFileAndPathErrors(t *testing.T) { + // Parse non-existent file + _, err := ParseFile("/non/existent/path/test.wit") + if err == nil { + t.Errorf("expected error for non-existent file, got nil") + } + + // ParsePath on non-existent path + _, err = ParsePath("/non/existent/path") + if err == nil { + t.Errorf("expected error for non-existent path, got nil") + } + + // ParsePath on directory with multiple WIT files + tmpDir := t.TempDir() + f1 := filepath.Join(tmpDir, "pkg.wit") + f2 := filepath.Join(tmpDir, "iface2.wit") + + c1 := ` + package multi:test@1.0.0; + interface one { + fn1: func(); + } + ` + c2 := ` + interface two { + fn2: func(); + } + world multi-world { + export one; + export two; + } + ` + if err := os.WriteFile(f1, []byte(c1), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f2, []byte(c2), 0644); err != nil { + t.Fatal(err) + } + + pkg, err := ParsePath(tmpDir) + if err != nil { + t.Fatalf("ParsePath failed on multi-file directory: %v", err) + } + if pkg.FullName() != "multi:test" { + t.Errorf("expected package full name multi:test, got %q", pkg.FullName()) + } + if pkg.Version != "1.0.0" { + t.Errorf("expected version 1.0.0, got %q", pkg.Version) + } + if len(pkg.Interfaces) != 2 { + t.Errorf("expected 2 interfaces, got %d", len(pkg.Interfaces)) + } + if len(pkg.Worlds) != 1 { + t.Errorf("expected 1 world, got %d", len(pkg.Worlds)) + } + + // Single file directly to ParsePath + pkgSingle, err := ParsePath(f1) + if err != nil { + t.Fatalf("ParsePath failed on single file: %v", err) + } + if len(pkgSingle.Interfaces) != 1 || pkgSingle.Interfaces[0].Name != "one" { + t.Errorf("unexpected single file package: %+v", pkgSingle) + } + + // Malformed file in directory + badFile := filepath.Join(tmpDir, "bad.wit") + if err := os.WriteFile(badFile, []byte("interface broken {"), 0644); err != nil { + t.Fatal(err) + } + _, err = ParsePath(tmpDir) + if err == nil { + t.Errorf("expected error when directory contains malformed WIT, got nil") + } +} + +func TestParser_Resource(t *testing.T) { + content := ` + package babel:structures; + + interface disjoint-set { + resource disjoint-set { + constructor(); + make-set: func(x: s32); + find: func(x: s32) -> option; + union: func(x: s32, y: s32); + is-connected: func(x: s32, y: s32) -> bool; + static reset-all: func(); + } + + resource handle-only; + } + ` + + pkg, err := ParseContent(content) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + if len(pkg.Interfaces) != 1 { + t.Fatalf("expected 1 interface, got %d", len(pkg.Interfaces)) + } + + iface := pkg.Interfaces[0] + if len(iface.Resources) != 2 { + t.Fatalf("expected 2 resources, got %d", len(iface.Resources)) + } + + ds := iface.Resources[0] + if ds.Name != "disjoint-set" { + t.Errorf("expected resource name disjoint-set, got %q", ds.Name) + } + if ds.Constructor == nil { + t.Fatalf("expected constructor, got nil") + } + if len(ds.Constructor.Params) != 0 { + t.Errorf("expected 0 constructor params, got %d", len(ds.Constructor.Params)) + } + if len(ds.Methods) != 4 { + t.Fatalf("expected 4 methods, got %d", len(ds.Methods)) + } + if ds.Methods[0].Name != "make-set" || len(ds.Methods[0].Params) != 1 { + t.Errorf("unexpected method 0: %+v", ds.Methods[0]) + } + if ds.Methods[3].Name != "is-connected" || ds.Methods[3].Results.Name != "bool" { + t.Errorf("unexpected method 3: %+v", ds.Methods[3]) + } + if len(ds.Static) != 1 || ds.Static[0].Name != "reset-all" { + t.Errorf("expected 1 static method reset-all, got %+v", ds.Static) + } + + handle := iface.Resources[1] + if handle.Name != "handle-only" { + t.Errorf("expected resource handle-only, got %q", handle.Name) + } +} + +const unmodelled = `package local:demo; + +interface types { + use other.{thing}; + variant shape { circle(u32), square } + flags perms { read, write } + area: func(s: u32) -> u32; +} + +world first { + use types.{thing}; + export types; +} + +world second { import log: func(m: string); } +` + +func TestParseContentLenientSkipsUnmodelledDeclarations(t *testing.T) { + pkg, err := ParseContentLenient(unmodelled) + if err != nil { + t.Fatal(err) + } + if pkg.FullName() != "local:demo" { + t.Errorf("package = %q", pkg.FullName()) + } + if len(pkg.Worlds) != 2 || pkg.Worlds[0].Name != "first" || pkg.Worlds[1].Name != "second" { + t.Errorf("worlds = %+v", pkg.Worlds) + } + if len(pkg.Worlds[0].Exports) != 1 || pkg.Worlds[0].Exports[0] != "types" { + t.Errorf("exports of first = %v", pkg.Worlds[0].Exports) + } + if len(pkg.Interfaces) != 1 || len(pkg.Interfaces[0].Functions) != 1 || pkg.Interfaces[0].Functions[0].Name != "area" { + t.Errorf("interfaces = %+v", pkg.Interfaces) + } +} + +func TestParseContentStaysStrictOnUnmodelledDeclarations(t *testing.T) { + _, err := ParseContent(unmodelled) + if err == nil || !strings.Contains(err.Error(), `"use" declarations are not supported`) { + t.Errorf("err = %v", err) + } +} + +func TestParseFileLenient(t *testing.T) { + path := filepath.Join(t.TempDir(), "a.wit") + if err := os.WriteFile(path, []byte(unmodelled), 0644); err != nil { + t.Fatal(err) + } + pkg, err := ParseFileLenient(path) + if err != nil || len(pkg.Worlds) != 2 { + t.Fatalf("pkg = %+v, err = %v", pkg, err) + } + if _, err := ParseFileLenient(path + ".missing"); err == nil { + t.Error("expected an error for a missing file") + } +}