From 2db5c3bfe55dec33d4fb99607b306247560178e3 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Mon, 9 Jun 2025 12:39:37 -0500 Subject: [PATCH] tables/arrays --- parser/ast.go | 56 ++++++++++++++++++++ parser/lexer.go | 6 +++ parser/parser.go | 79 ++++++++++++++++++++++++++++ parser/parser_test.go | 118 +++++++++++++++++++++++++++++++++++++++++- parser/token.go | 3 ++ 5 files changed, 261 insertions(+), 1 deletion(-) diff --git a/parser/ast.go b/parser/ast.go index b90017a..21a9ae7 100644 --- a/parser/ast.go +++ b/parser/ast.go @@ -97,3 +97,59 @@ func (ie *InfixExpression) expressionNode() {} func (ie *InfixExpression) String() string { return fmt.Sprintf("(%s %s %s)", ie.Left.String(), ie.Operator, ie.Right.String()) } + +// TablePair represents a key-value pair in a table +type TablePair struct { + Key Expression // nil for array-style elements + Value Expression +} + +func (tp *TablePair) String() string { + if tp.Key == nil { + return tp.Value.String() + } + return fmt.Sprintf("%s = %s", tp.Key.String(), tp.Value.String()) +} + +// TableLiteral represents table literals {} +type TableLiteral struct { + Pairs []TablePair +} + +func (tl *TableLiteral) expressionNode() {} +func (tl *TableLiteral) String() string { + var pairs []string + for _, pair := range tl.Pairs { + pairs = append(pairs, pair.String()) + } + return fmt.Sprintf("{%s}", joinStrings(pairs, ", ")) +} + +// IsArray returns true if this table contains only array-style elements +func (tl *TableLiteral) IsArray() bool { + for _, pair := range tl.Pairs { + if pair.Key != nil { + return false + } + } + return true +} + +// joinStrings joins string slice with separator +func joinStrings(strs []string, sep string) string { + if len(strs) == 0 { + return "" + } + if len(strs) == 1 { + return strs[0] + } + + var result string + for i, s := range strs { + if i > 0 { + result += sep + } + result += s + } + return result +} diff --git a/parser/lexer.go b/parser/lexer.go index b94a30f..84ebd10 100644 --- a/parser/lexer.go +++ b/parser/lexer.go @@ -149,6 +149,12 @@ func (l *Lexer) NextToken() Token { tok = Token{Type: LPAREN, Literal: string(l.ch), Line: l.line, Column: l.column} case ')': tok = Token{Type: RPAREN, Literal: string(l.ch), Line: l.line, Column: l.column} + case '{': + tok = Token{Type: LBRACE, Literal: string(l.ch), Line: l.line, Column: l.column} + case '}': + tok = Token{Type: RBRACE, Literal: string(l.ch), Line: l.line, Column: l.column} + case ',': + tok = Token{Type: COMMA, Literal: string(l.ch), Line: l.line, Column: l.column} case '"': tok.Type = STRING tok.Literal = l.readString() diff --git a/parser/parser.go b/parser/parser.go index 6813d05..dc45201 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -46,6 +46,7 @@ func NewParser(lexer *Lexer) *Parser { p.registerPrefix(FALSE, p.parseBooleanLiteral) p.registerPrefix(NIL, p.parseNilLiteral) p.registerPrefix(LPAREN, p.parseGroupedExpression) + p.registerPrefix(LBRACE, p.parseTableLiteral) p.infixParseFns = make(map[TokenType]func(Expression) Expression) p.registerInfix(PLUS, p.parseInfixExpression) @@ -215,6 +216,76 @@ func (p *Parser) parseGroupedExpression() Expression { return exp } +func (p *Parser) parseTableLiteral() Expression { + table := &TableLiteral{} + table.Pairs = []TablePair{} + + if p.peekTokenIs(RBRACE) { + p.nextToken() + return table + } + + p.nextToken() + + for { + // Check for EOF + if p.curTokenIs(EOF) { + p.addError("unexpected end of input, expected }") + return nil + } + + pair := TablePair{} + + // Check if this is a key=value pair + if p.curTokenIs(IDENT) && p.peekTokenIs(ASSIGN) { + pair.Key = &Identifier{Value: p.curToken.Literal} + p.nextToken() // move to = + p.nextToken() // move past = + + // Check for EOF after = + if p.curTokenIs(EOF) { + p.addError("expected expression after assignment operator") + return nil + } + + pair.Value = p.parseExpression(LOWEST) + } else { + // Array-style element + pair.Value = p.parseExpression(LOWEST) + } + + if pair.Value == nil { + return nil + } + + table.Pairs = append(table.Pairs, pair) + + if !p.peekTokenIs(COMMA) { + break + } + + p.nextToken() // consume comma + p.nextToken() // move to next element + + // Allow trailing comma + if p.curTokenIs(RBRACE) { + break + } + + // Check for EOF after comma + if p.curTokenIs(EOF) { + p.addError("expected next token to be }") + return nil + } + } + + if !p.expectPeek(RBRACE) { + return nil + } + + return table +} + func (p *Parser) parseInfixExpression(left Expression) Expression { expression := &InfixExpression{ Left: left, @@ -281,6 +352,8 @@ func (p *Parser) noPrefixParseFnError(t TokenType) { message = fmt.Sprintf("unexpected operator '%s', missing left operand", tokenTypeString(t)) case RPAREN: message = "unexpected closing parenthesis" + case RBRACE: + message = "unexpected closing brace" case EOF: message = "unexpected end of input" default: @@ -350,6 +423,12 @@ func tokenTypeString(t TokenType) string { return "(" case RPAREN: return ")" + case LBRACE: + return "{" + case RBRACE: + return "}" + case COMMA: + return "," case VAR: return "var" case EOF: diff --git a/parser/parser_test.go b/parser/parser_test.go index 0208ca9..1c0a8ee 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -40,6 +40,119 @@ func TestLiterals(t *testing.T) { } } +func TestTableLiterals(t *testing.T) { + tests := []struct { + input string + expectedPairs int + isArray bool + description string + }{ + {"{}", 0, true, "empty table"}, + {"{1, 2, 3}", 3, true, "array-like table"}, + {"{a = 1, b = 2}", 2, false, "hash-like table"}, + {`{"hello", "world"}`, 2, true, "string array"}, + {"{x = true, y = false}", 2, false, "boolean hash"}, + {"{1}", 1, true, "single element array"}, + {"{key = nil}", 1, false, "nil value hash"}, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + l := NewLexer(tt.input) + p := NewParser(l) + expr := p.parseExpression(LOWEST) + checkParserErrors(t, p) + + table, ok := expr.(*TableLiteral) + if !ok { + t.Fatalf("expected TableLiteral, got %T", expr) + } + + if len(table.Pairs) != tt.expectedPairs { + t.Errorf("expected %d pairs, got %d", tt.expectedPairs, len(table.Pairs)) + } + + if table.IsArray() != tt.isArray { + t.Errorf("expected IsArray() = %t, got %t", tt.isArray, table.IsArray()) + } + }) + } +} + +func TestTableAssignments(t *testing.T) { + tests := []struct { + input string + identifier string + pairCount int + isArray bool + description string + }{ + {"arr = {1, 2, 3}", "arr", 3, true, "array assignment"}, + {"hash = {x = 1, y = 2}", "hash", 2, false, "hash assignment"}, + {"empty = {}", "empty", 0, true, "empty table assignment"}, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + l := NewLexer(tt.input) + p := NewParser(l) + program := p.ParseProgram() + checkParserErrors(t, p) + + if len(program.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(program.Statements)) + } + + stmt, ok := program.Statements[0].(*AssignStatement) + if !ok { + t.Fatalf("expected AssignStatement, got %T", program.Statements[0]) + } + + if stmt.Name.Value != tt.identifier { + t.Errorf("expected identifier %s, got %s", tt.identifier, stmt.Name.Value) + } + + table, ok := stmt.Value.(*TableLiteral) + if !ok { + t.Fatalf("expected TableLiteral, got %T", stmt.Value) + } + + if len(table.Pairs) != tt.pairCount { + t.Errorf("expected %d pairs, got %d", tt.pairCount, len(table.Pairs)) + } + + if table.IsArray() != tt.isArray { + t.Errorf("expected IsArray() = %t, got %t", tt.isArray, table.IsArray()) + } + }) + } +} + +func TestTableStringRepresentation(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"{}", "{}"}, + {"{1, 2}", "{1.00, 2.00}"}, + {"{x = 1}", "{x = 1.00}"}, + {"{a = 1, b = 2}", "{a = 1.00, b = 2.00}"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + l := NewLexer(tt.input) + p := NewParser(l) + expr := p.parseExpression(LOWEST) + checkParserErrors(t, p) + + if expr.String() != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, expr.String()) + } + }) + } +} + func TestAssignStatements(t *testing.T) { tests := []struct { input string @@ -162,6 +275,9 @@ func TestParsingErrors(t *testing.T) { {"1 +", "expected expression after operator '+'", 1, 3}, {"@", "unexpected token '@'", 1, 1}, {"invalid@", "unexpected identifier", 1, 1}, + {"{1, 2", "expected next token to be }", 1, 6}, + {"{a =", "expected expression after assignment operator", 1, 4}, + {"{a = 1,", "expected next token to be }", 1, 8}, } for _, tt := range tests { @@ -171,7 +287,7 @@ func TestParsingErrors(t *testing.T) { // Decide parsing strategy based on the type of error we're testing switch tt.input { - case "(1 + 2", "+ 5", "1 +": + case "(1 + 2", "+ 5", "1 +", "{1, 2", "{a =", "{a = 1,": // These are expression-level errors p.parseExpression(LOWEST) default: diff --git a/parser/token.go b/parser/token.go index a2e7168..af7f8bf 100644 --- a/parser/token.go +++ b/parser/token.go @@ -22,6 +22,9 @@ const ( // Delimiters LPAREN // ( RPAREN // ) + LBRACE // { + RBRACE // } + COMMA // , // Keywords VAR