diff --git a/src/Parser.zig b/src/Parser.zig index 8076e31e..22f0b97a 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -3777,6 +3777,7 @@ fn parseObjType(self: *Self, generic_types: ?std.AutoArrayHashMapUnmanaged(*obj. try self.consume(.RightBrace, "Expected `}` after object body."); object_type.optional = try self.match(.Question); + object_type.resolved_type.?.Object.is_tuple = obj_is_tuple; try object_type.resolved_type.?.Object.sortFieldIndexes(self.gc.allocator); @@ -5012,6 +5013,8 @@ fn anonymousObjectInit(self: *Self, _: bool) Error!Ast.Node.Index { } } + object_type.resolved_type.?.Object.is_tuple = obj_is_tuple; + try object_type.resolved_type.?.Object.sortFieldIndexes(self.gc.allocator); try self.consume(.RightBrace, "Expected `}` after object initialization."); @@ -5046,7 +5049,24 @@ fn dot(self: *Self, can_assign: bool, callee: Ast.Node.Index) Error!Ast.Node.Ind ); } - try self.consume(.Identifier, "Expected property name after `.`"); + if (try self.match(.IntegerValue)) { + const index_token = self.current_token.? - 1; + const index = self.ast.tokens.items(.lexeme)[index_token]; + + // Tuple shorthand is validated semantically by the typechecker once + // placeholders can resolve; the parser only rejects non-decimal forms. + if (index.len != 1 or index[0] < '0' or index[0] > '3') { + self.reporter.reportErrorAt( + .syntax, + self.ast.tokens.get(index_token), + self.ast.tokens.get(index_token), + "Tuple property shorthand only accepts indexes 0, 1, 2 or 3", + ); + } + } else { + try self.consume(.Identifier, "Expected property name after `.`"); + } + const member_name_token = self.current_token.? - 1; const member_name = self.ast.tokens.items(.lexeme)[member_name_token]; diff --git a/src/Scanner.zig b/src/Scanner.zig index 2562ac47..fe105dd5 100644 --- a/src/Scanner.zig +++ b/src/Scanner.zig @@ -276,6 +276,9 @@ fn atIdentifier(self: *Self) Token { } const string_token = self.string(false); + if (string_token.tag == .Error) { + return string_token; + } self.token_index += 1; return .{ @@ -609,6 +612,9 @@ pub fn highlight(self: *Self, out: *std.Io.Writer, true_color: bool) void { var previous_offset: usize = 0; var token = self.scanToken() catch unreachable; while (token.tag != .Eof and token.tag != .Error) { + const token_end = self.current.offset; + const source_lexeme = self.source[token.offset..token_end]; + // If there some whitespace or comments between tokens? // In gray because either whitespace or comment if (token.offset > previous_offset) { @@ -754,12 +760,12 @@ pub fn highlight(self: *Self, out: *std.Io.Writer, true_color: bool) void { .Docblock => if (true_color) Color.comment else Color.dim, .Eof, .Error => unreachable, }, - token.lexeme, + source_lexeme, Color.reset, }, ) catch unreachable; - previous_offset = token.offset + token.lexeme.len; + previous_offset = token_end; token = self.scanToken() catch unreachable; } @@ -769,6 +775,26 @@ pub fn highlight(self: *Self, out: *std.Io.Writer, true_color: bool) void { } } +test "highlight preserves free identifier source spelling" { + var out = std.Io.Writer.Allocating.init(std.testing.allocator); + defer out.deinit(); + + var scanner = Self.init(std.testing.allocator, "test", "@\"hello\""); + scanner.highlight(&out.writer, false); + + try std.testing.expectEqualStrings("@\"hello\"\x1b[0m", out.written()); +} + +test "highlight preserves unterminated free identifier source" { + var out = std.Io.Writer.Allocating.init(std.testing.allocator); + defer out.deinit(); + + var scanner = Self.init(std.testing.allocator, "test", "@\"hello"); + scanner.highlight(&out.writer, false); + + try std.testing.expectEqualStrings("@\"hello", out.written()); +} + pub const Color = struct { pub const black = "\x1b[30m"; pub const red = "\x1b[31m"; diff --git a/src/TypeChecker.zig b/src/TypeChecker.zig index 4e11f7b4..cadff1b2 100644 --- a/src/TypeChecker.zig +++ b/src/TypeChecker.zig @@ -867,6 +867,7 @@ fn checkDot(ast: Ast.Slice, reporter: *Reporter, gc: *GC, _: ?Ast.Node.Index, no const locations = ast.nodes.items(.location); const end_locations = ast.nodes.items(.end_location); const tags = ast.tokens.items(.tag); + const lexemes = ast.tokens.items(.lexeme); var had_error = false; @@ -907,10 +908,26 @@ fn checkDot(ast: Ast.Slice, reporter: *Reporter, gc: *GC, _: ?Ast.Node.Index, no had_error = true; } + if (tags[components.identifier] == .IntegerValue and + (callee_type.def_type != .ObjectInstance or + !callee_type.resolved_type.?.ObjectInstance.of + .resolved_type.?.Object + .is_tuple) and + callee_type.def_type != .Placeholder) + { + reporter.reportErrorAt( + .field_access, + ast.tokens.get(components.identifier), + ast.tokens.get(components.identifier), + "Tuple index shorthand is only allowed on tuples", + ); + had_error = true; + } + switch (callee_type.def_type) { .Fiber, .Pattern, .String => {}, .ForeignContainer, .ObjectInstance, .Object => { - const field_name = ast.tokens.items(.lexeme)[components.identifier]; + const field_name = lexemes[components.identifier]; const field = switch (callee_type.def_type) { .ObjectInstance => callee_type.resolved_type.?.ObjectInstance.of .resolved_type.?.Object @@ -1135,7 +1152,7 @@ fn checkDot(ast: Ast.Slice, reporter: *Reporter, gc: *GC, _: ?Ast.Node.Index, no } }, .ProtocolInstance => if (components.member_kind == .Call) { - const field_name = ast.tokens.items(.lexeme)[components.identifier]; + const field_name = lexemes[components.identifier]; const field = callee_type.resolved_type.?.ProtocolInstance.of .resolved_type.?.Protocol .methods @@ -1166,7 +1183,7 @@ fn checkDot(ast: Ast.Slice, reporter: *Reporter, gc: *GC, _: ?Ast.Node.Index, no .Enum => {}, .EnumInstance => {}, .List, .Map, .Range => if (components.member_kind == .Call) { - const identifier = ast.tokens.items(.lexeme)[components.identifier]; + const identifier = lexemes[components.identifier]; switch (callee_type.def_type) { .List => if (callee_type.resolved_type.?.List.methods.get(identifier)) |member| { diff --git a/src/TypeRegistry.zig b/src/TypeRegistry.zig index dbb0d141..f9c43f18 100644 --- a/src/TypeRegistry.zig +++ b/src/TypeRegistry.zig @@ -256,6 +256,7 @@ fn hashHelper(hasher: *std.hash.Wyhash, type_def: *const o.ObjTypeDef) void { if (resolved.Object.anonymous) { // If anonymous, we must take the whole type into account // But since it'type_def anonymous, we only need to worry about fields type knowing there'type_def no method, static, etc. + std.hash.autoHash(hasher, resolved.Object.is_tuple); var it = resolved.Object.fields.iterator(); while (it.next()) |kv| { std.hash.autoHash( diff --git a/src/obj.zig b/src/obj.zig index a58ef8e6..0cfc89f8 100644 --- a/src/obj.zig +++ b/src/obj.zig @@ -1796,6 +1796,8 @@ pub const ObjObject = struct { placeholders: std.StringHashMapUnmanaged(Placeholder), static_placeholders: std.StringHashMapUnmanaged(Placeholder), anonymous: bool, + /// True when an anonymous object was parsed with tuple notation. + is_tuple: bool, conforms_to: std.AutoHashMapUnmanaged(*ObjTypeDef, void), generic_types: std.AutoArrayHashMapUnmanaged(*ObjString, *ObjTypeDef), @@ -1822,6 +1824,7 @@ pub const ObjObject = struct { .placeholders = .{}, .static_placeholders = .{}, .anonymous = anonymous, + .is_tuple = false, .conforms_to = .{}, .generic_types = .{}, }; @@ -4566,6 +4569,7 @@ pub const ObjTypeDef = struct { old_object_def.qualified_name, old_object_def.anonymous, ); + resolved.is_tuple = old_object_def.is_tuple; resolved.generic_types.deinit(type_registry.gc.allocator); resolved.generic_types = try old_object_def.generic_types.clone(type_registry.gc.allocator); @@ -5462,6 +5466,7 @@ pub const ObjTypeDef = struct { // If both are anonymous object type, we can deeply compare them if (!expected.Object.anonymous or !actual.Object.anonymous or + expected.Object.is_tuple != actual.Object.is_tuple or expected.Object.fields.count() != actual.Object.fields.count()) { return false; diff --git a/src/renderer.zig b/src/renderer.zig index 4fe91ae7..64f9f820 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -2298,9 +2298,9 @@ pub const Renderer = struct { ); // identifier - try self.renderExpectedToken( + try self.renderOneOfExpectedToken( components.identifier, - .Identifier, + &.{ .Identifier, .IntegerValue }, switch (components.member_kind) { .Ref, .EnumCase => space, .Value => .Space, diff --git a/tests/behavior/tuples.buzz b/tests/behavior/tuples.buzz index 14f0901f..2513f3ac 100644 --- a/tests/behavior/tuples.buzz +++ b/tests/behavior/tuples.buzz @@ -11,7 +11,10 @@ fun pack(names: [str]) > obj{ :str, :str, :str } { test "Tuples" { final tuple = .{ "joe", "doe" }; - std\assert(tuple.@"0" == "joe" and tuple.@"1" == "doe"); + std\assert( + tuple.@"0" == "joe" and tuple.0 == "joe" and tuple.@"1" == "doe" + and tuple.1 == "doe" + ); final name = "Joe"; final age = 42; @@ -19,9 +22,22 @@ test "Tuples" { // Forced tuple final another = .{ (name), (age) }; - std\assert(another.@"0" == "Joe" and another.@"1" == 42); + std\assert( + another.@"0" == "Joe" and another.0 == "Joe" and another.@"1" == 42 + and another.1 == 42 + ); final names = pack([ "Joe", "John", "James" ]); - std\assert(names.@"0" == "Joe" and names.@"1" == "John"); + std\assert( + names.@"0" == "Joe" and names.0 == "Joe" and names.@"1" == "John" + and names.1 == "John" + ); + + final positions = .{ 0, 1, 2, 3 }; + + std\assert( + positions.0 == 0 and positions.1 == 1 and positions.2 == 2 + and positions.3 == 3 + ); } diff --git a/tests/compile_errors/tuple-shorthand-anonymous-numeric-field.buzz b/tests/compile_errors/tuple-shorthand-anonymous-numeric-field.buzz new file mode 100644 index 00000000..9d1e57df --- /dev/null +++ b/tests/compile_errors/tuple-shorthand-anonymous-numeric-field.buzz @@ -0,0 +1,8 @@ +// Tuple index shorthand is only allowed on tuples +test "Tuple shorthand rejects anonymous numeric fields" { + final value = .{ + @"0" = 1, + }; + + _ = value.0; +} diff --git a/tests/compile_errors/tuple-shorthand-binary-index.buzz b/tests/compile_errors/tuple-shorthand-binary-index.buzz new file mode 100644 index 00000000..5d58ce67 --- /dev/null +++ b/tests/compile_errors/tuple-shorthand-binary-index.buzz @@ -0,0 +1,6 @@ +// Tuple property shorthand only accepts indexes 0, 1, 2 or 3 +test "Tuple shorthand rejects binary index" { + final tuple = .{ 1, 2, 3, 4 }; + + _ = tuple.0b10; +} diff --git a/tests/compile_errors/tuple-shorthand-hex-index.buzz b/tests/compile_errors/tuple-shorthand-hex-index.buzz new file mode 100644 index 00000000..dcc39490 --- /dev/null +++ b/tests/compile_errors/tuple-shorthand-hex-index.buzz @@ -0,0 +1,6 @@ +// Tuple property shorthand only accepts indexes 0, 1, 2 or 3 +test "Tuple shorthand rejects hexadecimal index" { + final tuple = .{ 1, 2, 3, 4 }; + + _ = tuple.0x10; +} diff --git a/tests/compile_errors/tuple-shorthand-named-object.buzz b/tests/compile_errors/tuple-shorthand-named-object.buzz new file mode 100644 index 00000000..45604ae9 --- /dev/null +++ b/tests/compile_errors/tuple-shorthand-named-object.buzz @@ -0,0 +1,18 @@ +// Tuple index shorthand is only allowed on tuples + +/// Object used to verify tuple shorthand does not apply to named numeric fields. +object NumericField { + /// Returns the numeric field using invalid tuple shorthand. + fun get() > int => this.0; + + /// Field intentionally named like a tuple property. + @"0": int, +} + +test "Tuple shorthand is tuple-only" { + final value = NumericField{ + @"0" = 1, + }; + + _ = value.get(); +} diff --git a/tests/compile_errors/tuple-shorthand-out-of-range.buzz b/tests/compile_errors/tuple-shorthand-out-of-range.buzz new file mode 100644 index 00000000..2fea12c6 --- /dev/null +++ b/tests/compile_errors/tuple-shorthand-out-of-range.buzz @@ -0,0 +1,6 @@ +// Tuple property shorthand only accepts indexes 0, 1, 2 or 3 +test "Tuple shorthand rejects out-of-range index" { + final tuple = .{ 1, 2, 3, 4 }; + + _ = tuple.4; +} diff --git a/tests/compile_errors/tuple-shorthand-underscore-index.buzz b/tests/compile_errors/tuple-shorthand-underscore-index.buzz new file mode 100644 index 00000000..352a0e12 --- /dev/null +++ b/tests/compile_errors/tuple-shorthand-underscore-index.buzz @@ -0,0 +1,6 @@ +// Tuple property shorthand only accepts indexes 0, 1, 2 or 3 +test "Tuple shorthand rejects underscore index" { + final tuple = .{ 1, 2, 3, 4 }; + + _ = tuple.0_0; +}