diff --git a/src/internal/ast/ast_test.go b/src/internal/ast/ast_test.go new file mode 100644 index 0000000..6b6a165 --- /dev/null +++ b/src/internal/ast/ast_test.go @@ -0,0 +1,316 @@ +package ast + +import ( + "reflect" + "testing" + + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/token" +) + +var _ Assignable = (*VariableExpr)(nil) + +func TestOnlyAssignableExpressionsImplementAssignable(t *testing.T) { + targetField, ok := reflect.TypeOf(AddStmt{}).FieldByName("Target") + if !ok || targetField.Type != reflect.TypeOf((*Assignable)(nil)).Elem() { + t.Fatalf("AddStmt.Target must use Assignable, got %v", targetField.Type) + } + + var expression Expression = &IntLiteral{Value: 10} + if _, ok := expression.(Assignable); ok { + t.Fatal("integer literals must not be assignable") + } + + expression = &VariableExpr{Name: Identifier{Name: "coins"}} + if _, ok := expression.(Assignable); !ok { + t.Fatal("variables must be assignable") + } + + expression = &AccessExpr{Tokens: []token.Token{{Type: token.Ident, Lexeme: "coins"}}} + if _, ok := expression.(Assignable); !ok { + t.Fatal("access expressions must be assignable") + } +} + +func TestLeafExpressionsRepresentNilAndPatterns(t *testing.T) { + var nilExpression Expression = &NilLiteral{} + if _, ok := nilExpression.(*NilLiteral); !ok { + t.Fatalf("expected nil literal, got %T", nilExpression) + } + + pattern := &PatternExpr{Tokens: []token.Token{ + {Type: token.Ident, Lexeme: "coins"}, + {Type: token.Ident, Lexeme: "of"}, + {Type: token.Ident, Lexeme: "player"}, + }} + var patternExpression Expression = pattern + got, ok := patternExpression.(*PatternExpr) + if !ok || len(got.Tokens) != 3 || got.Tokens[0].Lexeme != "coins" || got.Tokens[2].Lexeme != "player" { + t.Fatalf("unexpected pattern expression: %#v", patternExpression) + } +} + +func TestRequireIsNotATopLevelDeclaration(t *testing.T) { + var node Node = &RequireDecl{} + if _, ok := node.(Declaration); ok { + t.Fatal("require declarations must remain separate from top-level declarations") + } +} + +func TestFileCarriesMetadataRequiresAndTopLevelDeclarations(t *testing.T) { + path := &StringExpr{Parts: []StringPart{&StringText{Value: "abc/shop"}}} + alias := &Identifier{Name: "shop"} + global := &GlobalAssignment{ + Public: true, + Target: &VariableExpr{Name: Identifier{Name: "tax"}}, + Value: &FloatLiteral{Value: 0.1}, + } + function := &FunctionDecl{ + Public: true, + Name: Identifier{Name: "price"}, + Parameters: []Parameter{{ + Name: Identifier{Name: "value"}, + Type: &TypeRef{Name: Identifier{Name: "float"}}, + }}, + ReturnType: &TypeRef{Name: Identifier{Name: "float"}}, + } + event := &EventDecl{Name: []Identifier{{Name: "scoreboard"}, {Name: "update"}}} + + file := File{ + Metadata: []MetadataEntry{ + {Key: "namespace", Value: "example"}, + {Key: "tags", Value: "load, tick"}, + }, + Requirements: []*RequireDecl{{Path: path, Alias: alias}}, + Declarations: []Declaration{global, function, event}, + } + + if len(file.Metadata) != 2 || file.Metadata[0].Key != "namespace" || file.Metadata[0].Value != "example" { + t.Fatalf("unexpected metadata: %#v", file.Metadata) + } + if len(file.Requirements) != 1 || file.Requirements[0].Path != path || file.Requirements[0].Alias.Name != "shop" { + t.Fatalf("unexpected requirements: %#v", file.Requirements) + } + if len(file.Declarations) != 3 { + t.Fatalf("expected three declarations, got %d", len(file.Declarations)) + } + + gotGlobal, ok := file.Declarations[0].(*GlobalAssignment) + if !ok || !gotGlobal.Public || gotGlobal.Target.Name.Name != "tax" { + t.Fatalf("unexpected global declaration: %#v", file.Declarations[0]) + } + gotFunction, ok := file.Declarations[1].(*FunctionDecl) + if !ok || !gotFunction.Public || gotFunction.Name.Name != "price" || gotFunction.Parameters[0].Type.Name.Name != "float" { + t.Fatalf("unexpected function declaration: %#v", file.Declarations[1]) + } + gotEvent, ok := file.Declarations[2].(*EventDecl) + if !ok || gotEvent.Name[0].Name != "scoreboard" || gotEvent.Name[1].Name != "update" { + t.Fatalf("unexpected event declaration: %#v", file.Declarations[2]) + } +} + +func TestTypeRefRepresentsNestedGenericTypes(t *testing.T) { + typeRef := TypeRef{ + Name: Identifier{Name: "map"}, + Arguments: []*TypeRef{ + {Name: Identifier{Name: "string"}}, + { + Name: Identifier{Name: "list"}, + Arguments: []*TypeRef{{Name: Identifier{Name: "int"}}}, + }, + }, + } + + if typeRef.Name.Name != "map" || len(typeRef.Arguments) != 2 { + t.Fatalf("unexpected outer type: %#v", typeRef) + } + if typeRef.Arguments[0].Name.Name != "string" { + t.Fatalf("unexpected key type: %#v", typeRef.Arguments[0]) + } + listType := typeRef.Arguments[1] + if listType.Name.Name != "list" || len(listType.Arguments) != 1 || listType.Arguments[0].Name.Name != "int" { + t.Fatalf("unexpected value type: %#v", listType) + } +} + +func TestStatementsRepresentDocumentedForms(t *testing.T) { + conditionA := &BoolLiteral{Value: true} + conditionB := &BoolLiteral{Value: false} + elseBlock := &Block{} + ifStatement := &IfStmt{ + Condition: conditionA, + Then: Block{Statements: []Statement{&StopStmt{}}}, + ElseIf: []ElseIfClause{{Condition: conditionB}}, + Else: elseBlock, + } + + if ifStatement.Condition != conditionA || len(ifStatement.Then.Statements) != 1 { + t.Fatalf("unexpected if branch: %#v", ifStatement) + } + if len(ifStatement.ElseIf) != 1 || ifStatement.ElseIf[0].Condition != conditionB || ifStatement.Else != elseBlock { + t.Fatalf("unexpected else branches: %#v", ifStatement) + } + + value := &IntLiteral{Value: 3} + target := &VariableExpr{Name: Identifier{Name: "coins"}} + statements := []Statement{ + &AssignmentStmt{Target: target, Value: value}, + &AddStmt{Value: value, Target: target}, + ifStatement, + &LoopTimesStmt{Count: value}, + &LoopRangeStmt{Start: &IntLiteral{Value: 1}, End: value}, + &LoopPlayersStmt{}, + &LoopEntitiesStmt{Radius: &IntLiteral{Value: 10}, Around: &CallExpr{Callee: QualifiedName{Parts: []Identifier{{Name: "player"}}}}}, + &ReturnStmt{Value: target}, + &ReturnStmt{}, + &StopStmt{}, + &EffectStmt{Tokens: []token.Token{{Type: token.Ident, Lexeme: "send"}}}, + &ExprStmt{Expression: target}, + } + wantTypes := []reflect.Type{ + reflect.TypeOf(&AssignmentStmt{}), + reflect.TypeOf(&AddStmt{}), + reflect.TypeOf(&IfStmt{}), + reflect.TypeOf(&LoopTimesStmt{}), + reflect.TypeOf(&LoopRangeStmt{}), + reflect.TypeOf(&LoopPlayersStmt{}), + reflect.TypeOf(&LoopEntitiesStmt{}), + reflect.TypeOf(&ReturnStmt{}), + reflect.TypeOf(&ReturnStmt{}), + reflect.TypeOf(&StopStmt{}), + reflect.TypeOf(&EffectStmt{}), + reflect.TypeOf(&ExprStmt{}), + } + + for index, statement := range statements { + if reflect.TypeOf(statement) != wantTypes[index] { + t.Fatalf("statement %d: expected %v, got %T", index, wantTypes[index], statement) + } + } +} + +func TestExpressionsPreserveSyntaxTreeStructure(t *testing.T) { + multiply := &BinaryExpr{ + Left: &IntLiteral{Value: 2}, + Operator: token.Star, + Right: &IntLiteral{Value: 3}, + } + add := &BinaryExpr{ + Left: &IntLiteral{Value: 1}, + Operator: token.Plus, + Right: multiply, + } + negated := &UnaryExpr{Operator: token.Not, Operand: &GroupExpr{Expression: add}} + call := &CallExpr{ + Callee: QualifiedName{Parts: []Identifier{{Name: "shop"}, {Name: "finalPrice"}}}, + Arguments: []Expression{negated}, + ExplicitParens: true, + } + + if add.Operator != token.Plus || add.Right != multiply || multiply.Operator != token.Star { + t.Fatalf("unexpected binary tree: %#v", add) + } + group, ok := negated.Operand.(*GroupExpr) + if !ok || group.Expression != add { + t.Fatalf("unexpected unary/group expression: %#v", negated) + } + if call.Callee.Parts[0].Name != "shop" || call.Callee.Parts[1].Name != "finalPrice" { + t.Fatalf("unexpected qualified name: %#v", call.Callee) + } + if !call.ExplicitParens || len(call.Arguments) != 1 || call.Arguments[0] != negated { + t.Fatalf("unexpected call: %#v", call) + } +} + +func TestCollectionAndRangeExpressionsPreserveElements(t *testing.T) { + one := &IntLiteral{Value: 1} + two := &IntLiteral{Value: 2} + list := &ListExpr{Elements: []Expression{one, two}} + mapExpression := &MapExpr{Entries: []MapEntry{{ + Key: &StringExpr{Parts: []StringPart{&StringText{Value: "coins"}}}, + Value: two, + }}} + rangeExpression := &RangeExpr{Start: one, End: two} + + if len(list.Elements) != 2 || list.Elements[0] != one || list.Elements[1] != two { + t.Fatalf("unexpected list: %#v", list) + } + if len(mapExpression.Entries) != 1 || mapExpression.Entries[0].Value != two { + t.Fatalf("unexpected map: %#v", mapExpression) + } + key, ok := mapExpression.Entries[0].Key.(*StringExpr) + if !ok || key.Parts[0].(*StringText).Value != "coins" { + t.Fatalf("unexpected map key: %#v", mapExpression.Entries[0].Key) + } + if rangeExpression.Start != one || rangeExpression.End != two { + t.Fatalf("unexpected range: %#v", rangeExpression) + } +} + +func TestStringsAndVariablesPreserveStructuredParts(t *testing.T) { + coins := &VariableExpr{ + Qualifier: &Identifier{Name: "shop"}, + Name: Identifier{Name: "player"}, + Accesses: []VariableAccess{ + &FieldAccess{Field: Identifier{Name: "stats"}}, + &IndexAccess{Index: &VariableExpr{Name: Identifier{Name: "index"}, Local: true}}, + &EmptyIndexAccess{}, + }, + } + stringExpression := &StringExpr{ + Quote: '"', + Parts: []StringPart{ + &StringText{Raw: `Coins: {{`, Value: "Coins: {"}, + &StringInterpolation{Expression: coins}, + &StringText{Raw: `}}`, Value: "}"}, + }, + } + + if stringExpression.Quote != '"' || len(stringExpression.Parts) != 3 { + t.Fatalf("unexpected string: %#v", stringExpression) + } + if stringExpression.Parts[0].(*StringText).Value != "Coins: {" || stringExpression.Parts[2].(*StringText).Value != "}" { + t.Fatalf("unexpected decoded text: %#v", stringExpression.Parts) + } + interpolation := stringExpression.Parts[1].(*StringInterpolation) + if interpolation.Expression != coins { + t.Fatalf("unexpected interpolation: %#v", interpolation) + } + if coins.Qualifier.Name != "shop" || coins.Name.Name != "player" || coins.Local || len(coins.Accesses) != 3 { + t.Fatalf("unexpected variable: %#v", coins) + } + if coins.Accesses[0].(*FieldAccess).Field.Name != "stats" { + t.Fatalf("unexpected field access: %#v", coins.Accesses[0]) + } + if !coins.Accesses[1].(*IndexAccess).Index.(*VariableExpr).Local { + t.Fatalf("expected local index variable: %#v", coins.Accesses[1]) + } +} + +func TestSpanHelpersPreserveSourceCoordinates(t *testing.T) { + first := diagnostic.Span{ + StartLine: 2, StartColumn: 3, EndLine: 2, EndColumn: 4, + StartOffset: 5, EndOffset: 6, + } + last := diagnostic.Span{ + StartLine: 4, StartColumn: 1, EndLine: 4, EndColumn: 8, + StartOffset: 20, EndOffset: 27, + } + want := diagnostic.Span{ + StartLine: 2, StartColumn: 3, EndLine: 4, EndColumn: 8, + StartOffset: 5, EndOffset: 27, + } + + if got := JoinSpans(first, last); got != want { + t.Fatalf("expected joined span %#v, got %#v", want, got) + } + + left := &IntLiteral{NodeBase: NodeBase{SourceSpan: first}, Value: 1} + right := &IntLiteral{NodeBase: NodeBase{SourceSpan: last}, Value: 2} + if got := SpanBetween(left, right); got != want { + t.Fatalf("expected node span %#v, got %#v", want, got) + } + if left.Span() != first || right.Span() != last { + t.Fatalf("span propagation changed child spans: left=%#v right=%#v", left.Span(), right.Span()) + } +} diff --git a/src/internal/ast/declaration.go b/src/internal/ast/declaration.go new file mode 100644 index 0000000..f663c93 --- /dev/null +++ b/src/internal/ast/declaration.go @@ -0,0 +1,60 @@ +package ast + +type File struct { + NodeBase + Metadata []MetadataEntry + Requirements []*RequireDecl + Declarations []Declaration +} + +type MetadataEntry struct { + NodeBase + Key string + Value string +} + +type RequireDecl struct { + NodeBase + Path *StringExpr + Alias *Identifier +} + +type FunctionDecl struct { + NodeBase + Public bool + Name Identifier + Parameters []Parameter + ReturnType *TypeRef + Body Block +} + +func (*FunctionDecl) declarationNode() {} + +type Parameter struct { + NodeBase + Name Identifier + Type *TypeRef +} + +type TypeRef struct { + NodeBase + Name Identifier + Arguments []*TypeRef +} + +type EventDecl struct { + NodeBase + Name []Identifier + Body Block +} + +func (*EventDecl) declarationNode() {} + +type GlobalAssignment struct { + NodeBase + Public bool + Target *VariableExpr + Value Expression +} + +func (*GlobalAssignment) declarationNode() {} diff --git a/src/internal/ast/expression.go b/src/internal/ast/expression.go new file mode 100644 index 0000000..d2ef9b8 --- /dev/null +++ b/src/internal/ast/expression.go @@ -0,0 +1,165 @@ +package ast + +import "github.com/puff-lang/puff/internal/token" + +type NilLiteral struct { + NodeBase +} + +func (*NilLiteral) expressionNode() {} + +type BoolLiteral struct { + NodeBase + Value bool +} + +func (*BoolLiteral) expressionNode() {} + +type IntLiteral struct { + NodeBase + Value int64 +} + +func (*IntLiteral) expressionNode() {} + +type FloatLiteral struct { + NodeBase + Value float64 +} + +func (*FloatLiteral) expressionNode() {} + +type UnaryExpr struct { + NodeBase + Operator token.Type + Operand Expression +} + +func (*UnaryExpr) expressionNode() {} + +type BinaryExpr struct { + NodeBase + Left Expression + Operator token.Type + Right Expression +} + +func (*BinaryExpr) expressionNode() {} + +type GroupExpr struct { + NodeBase + Expression Expression +} + +func (*GroupExpr) expressionNode() {} + +type QualifiedName struct { + NodeBase + Parts []Identifier +} + +type CallExpr struct { + NodeBase + Callee QualifiedName + Arguments []Expression + ExplicitParens bool +} + +func (*CallExpr) expressionNode() {} + +type ListExpr struct { + NodeBase + Elements []Expression +} + +func (*ListExpr) expressionNode() {} + +type MapExpr struct { + NodeBase + Entries []MapEntry +} + +func (*MapExpr) expressionNode() {} + +type MapEntry struct { + NodeBase + Key Expression + Value Expression +} + +type RangeExpr struct { + NodeBase + Start Expression + End Expression +} + +func (*RangeExpr) expressionNode() {} + +type StringExpr struct { + NodeBase + Quote byte + Parts []StringPart +} + +func (*StringExpr) expressionNode() {} + +type StringText struct { + NodeBase + Raw string + Value string +} + +func (*StringText) stringPartNode() {} + +type StringInterpolation struct { + NodeBase + Expression Expression +} + +func (*StringInterpolation) stringPartNode() {} + +type VariableExpr struct { + NodeBase + Qualifier *Identifier + Name Identifier + Local bool + Accesses []VariableAccess +} + +func (*VariableExpr) expressionNode() {} +func (*VariableExpr) assignableNode() {} + +type FieldAccess struct { + NodeBase + Field Identifier +} + +func (*FieldAccess) variableAccessNode() {} + +type IndexAccess struct { + NodeBase + Index Expression +} + +func (*IndexAccess) variableAccessNode() {} + +type EmptyIndexAccess struct { + NodeBase +} + +func (*EmptyIndexAccess) variableAccessNode() {} + +type PatternExpr struct { + NodeBase + Tokens []token.Token +} + +func (*PatternExpr) expressionNode() {} + +type AccessExpr struct { + NodeBase + Tokens []token.Token +} + +func (*AccessExpr) expressionNode() {} +func (*AccessExpr) assignableNode() {} diff --git a/src/internal/ast/node.go b/src/internal/ast/node.go new file mode 100644 index 0000000..4ecc7f2 --- /dev/null +++ b/src/internal/ast/node.go @@ -0,0 +1,73 @@ +package ast + +import "github.com/puff-lang/puff/internal/diagnostic" + +type Node interface { + Span() diagnostic.Span + node() +} + +type Declaration interface { + Node + declarationNode() +} + +type Statement interface { + Node + statementNode() +} + +type Expression interface { + Node + expressionNode() +} + +type Assignable interface { + Expression + assignableNode() +} + +type StringPart interface { + Node + stringPartNode() +} + +type VariableAccess interface { + Node + variableAccessNode() +} + +type NodeBase struct { + SourceSpan diagnostic.Span +} + +func (base NodeBase) Span() diagnostic.Span { + return base.SourceSpan +} + +func (NodeBase) node() {} + +func JoinSpans(first, last diagnostic.Span) diagnostic.Span { + return diagnostic.Span{ + StartLine: first.StartLine, + StartColumn: first.StartColumn, + EndLine: last.EndLine, + EndColumn: last.EndColumn, + StartOffset: first.StartOffset, + EndOffset: last.EndOffset, + } +} + +func SpanBetween(first, last Node) diagnostic.Span { + return JoinSpans(first.Span(), last.Span()) +} + +type Identifier struct { + NodeBase + Name string +} + +type Block struct { + NodeBase + Statements []Statement +} diff --git a/src/internal/ast/statement.go b/src/internal/ast/statement.go new file mode 100644 index 0000000..6f721ea --- /dev/null +++ b/src/internal/ast/statement.go @@ -0,0 +1,95 @@ +package ast + +import "github.com/puff-lang/puff/internal/token" + +type AssignmentStmt struct { + NodeBase + Target *VariableExpr + Value Expression +} + +func (*AssignmentStmt) statementNode() {} + +type AddStmt struct { + NodeBase + Value Expression + Target Assignable +} + +func (*AddStmt) statementNode() {} + +type IfStmt struct { + NodeBase + Condition Expression + Then Block + ElseIf []ElseIfClause + Else *Block +} + +func (*IfStmt) statementNode() {} + +type ElseIfClause struct { + NodeBase + Condition Expression + Body Block +} + +type LoopTimesStmt struct { + NodeBase + Count Expression + Body Block +} + +func (*LoopTimesStmt) statementNode() {} + +type LoopRangeStmt struct { + NodeBase + Start Expression + End Expression + Body Block +} + +func (*LoopRangeStmt) statementNode() {} + +type LoopPlayersStmt struct { + NodeBase + Body Block +} + +func (*LoopPlayersStmt) statementNode() {} + +type LoopEntitiesStmt struct { + NodeBase + Radius Expression + Around Expression + Body Block +} + +func (*LoopEntitiesStmt) statementNode() {} + +type ReturnStmt struct { + NodeBase + Value Expression +} + +func (*ReturnStmt) statementNode() {} + +type StopStmt struct { + NodeBase +} + +func (*StopStmt) statementNode() {} + +type ExprStmt struct { + NodeBase + Expression Expression +} + +func (*ExprStmt) statementNode() {} + +type EffectStmt struct { + NodeBase + Tokens []token.Token +} + +func (*EffectStmt) statementNode() {}