diff --git a/AGENTS.md b/AGENTS.md index cb9afd47..e2811cd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,9 @@ Only Zig files need formatting checks for now. - Zig variables use snake_case. - Zig types and functions use camelCase. - Comments are encouraged for compiler/runtime logic, but keep them concise and useful. +- Any non-trivial code added must be properly commented in the code. Comments should explain intent, invariants, or tricky control flow, not restate obvious assignments. +- Any new Zig file under `src/` must start with a file docblock (`//! ...`) describing the general role of the file. +- Any new functions, structs, objects, properties, and enums introduced in Zig or Buzz code must have a docblock. ## Runtime And GC Rules diff --git a/src/Ast.zig b/src/Ast.zig index 3868acb3..42e8ecb9 100644 --- a/src/Ast.zig +++ b/src/Ast.zig @@ -21,6 +21,11 @@ pub const TokenIndex = u32; pub const TokenList = std.MultiArrayList(Token); pub const NodeList = std.MultiArrayList(Node); +pub const WalkStrategy = enum { + breadthFirst, + depthFirst, +}; + allocator: std.mem.Allocator, tokens: TokenList, nodes: NodeList, @@ -31,15 +36,21 @@ pub const Slice = struct { nodes: NodeList.Slice, root: ?Node.Index, - /// Do a breadth first walk of the AST, calling a callback for each node that can stop the walking from going deeper by returning true + /// Walk the AST, calling a callback for each node that can stop the walking from going deeper by returning true. /// ctx should have: /// - `fn processNode(ctx: @TypeOf(ctx), allocator: std.mem.Allocator, ast: Ast.Slice, node: Node.Index) error{OutOfMemory}!bool: returns true to stop walking deeper + /// ctx can optionally have, only used by depth-first walks: + /// - `fn enterNode(ctx: @TypeOf(ctx), allocator: std.mem.Allocator, ast: Ast.Slice, node: Node.Index) !void` + /// - `fn exitNode(ctx: @TypeOf(ctx), allocator: std.mem.Allocator, ast: Ast.Slice, node: Node.Index) !void` // FIXME: the context should have a error type we can reuse in processNode signature - pub fn walk(self: Slice, allocator: std.mem.Allocator, ctx: anytype, root: Node.Index) !void { - const tags = self.nodes.items(.tag); - const components = self.nodes.items(.components); + pub fn walk(self: Slice, allocator: std.mem.Allocator, ctx: anytype, root: Node.Index, comptime strategy: WalkStrategy) !void { + switch (strategy) { + .breadthFirst => try self.walkBreadthFirst(allocator, ctx, root), + .depthFirst => try self.walkDepthFirst(allocator, ctx, root), + } + } - // Hold previous node's leaves + fn walkBreadthFirst(self: Slice, allocator: std.mem.Allocator, ctx: anytype, root: Node.Index) !void { var node_queue = std.ArrayList(Node.Index).empty; try node_queue.append(allocator, root); defer node_queue.deinit(allocator); @@ -47,125 +58,205 @@ pub const Slice = struct { while (node_queue.items.len > 0) { const node = node_queue.orderedRemove(0); - // Stop if requested and if there's no neighbors to process - if (try ctx.processNode(allocator, self, node) and node_queue.items.len == 0) { - return; + if (try ctx.processNode(allocator, self, node)) { + continue; } - // Otherwise continue walking the tree - const comp = components[node]; - switch (tags[node]) { - .AnonymousObjectType => { - for (comp.AnonymousObjectType.fields) |field| { - try node_queue.append(allocator, field.type); - } - }, - .Is => { - try node_queue.appendSlice( - allocator, - &.{ - comp.Is.left, - comp.Is.constant, - }, - ); - }, - .As => { - try node_queue.appendSlice( - allocator, - &.{ - comp.As.left, - comp.As.constant, - }, - ); - }, - .AsyncCall => try node_queue.append(allocator, comp.AsyncCall), - .Binary => { - try node_queue.append(allocator, comp.Binary.left); - try node_queue.append(allocator, comp.Binary.right); - }, - .Block => try node_queue.appendSlice(allocator, comp.Block), - .BlockExpression => try node_queue.appendSlice(allocator, comp.BlockExpression), - .Match => { - try node_queue.append(allocator, comp.Match.value); + try self.appendWalkChildren(allocator, &node_queue, node, .breadthFirst); + } + } - for (comp.Match.branches) |branch| { - try node_queue.appendSlice(allocator, branch.conditions); - try node_queue.append(allocator, branch.expression); - } - }, - .Call => { - // Avoid loop between Call and Dot nodes - if (tags[comp.Call.callee] != .Dot or - components[comp.Call.callee].Dot.member_kind != .Call or - components[comp.Call.callee].Dot.value_or_call_or_enum.Call != node) - { - try node_queue.append(allocator, comp.Call.callee); - } - if (comp.Call.catch_default) |default| { - try node_queue.append(allocator, default); - } - for (comp.Call.arguments) |arg| { - try node_queue.append(allocator, arg.value); - } - }, - .Dot => { - try node_queue.append(allocator, comp.Dot.callee); - if (comp.Dot.generic_resolve) |generic_resolve| { - try node_queue.append(allocator, generic_resolve); - } - switch (comp.Dot.member_kind) { - .Value => try node_queue.append(allocator, comp.Dot.value_or_call_or_enum.Value.value), - .Call => { - // We avoid the actual Call node, we're only interested in the Call's parts - const call = components[comp.Dot.value_or_call_or_enum.Call].Call; - if (call.catch_default) |default| { - try node_queue.append(allocator, default); - } - for (call.arguments) |arg| { - try node_queue.append(allocator, arg.value); - } - }, - .Ref, .EnumCase => {}, - } - }, - .DoUntil => { + fn walkDepthFirst(self: Slice, allocator: std.mem.Allocator, ctx: anytype, node: Node.Index) !void { + try self.enterWalkNode(allocator, ctx, node); + + const skip_children = try ctx.processNode(allocator, self, node); + if (!skip_children) { + var children = std.ArrayList(Node.Index).empty; + defer children.deinit(allocator); + + try self.appendWalkChildren(allocator, &children, node, .depthFirst); + + for (children.items) |child| { + try self.walkDepthFirst(allocator, ctx, child); + } + } + + try self.exitWalkNode(allocator, ctx, node); + } + + fn contextType(comptime Context: type) type { + return switch (@typeInfo(Context)) { + .pointer => |pointer| pointer.child, + else => Context, + }; + } + + fn enterWalkNode(self: Slice, allocator: std.mem.Allocator, ctx: anytype, node: Node.Index) !void { + const Context = contextType(@TypeOf(ctx)); + + if (@hasDecl(Context, "enterNode")) { + try ctx.enterNode(allocator, self, node); + } + } + + fn exitWalkNode(self: Slice, allocator: std.mem.Allocator, ctx: anytype, node: Node.Index) !void { + const Context = contextType(@TypeOf(ctx)); + + if (@hasDecl(Context, "exitNode")) { + try ctx.exitNode(allocator, self, node); + } + } + + fn appendWalkChildren( + self: Slice, + allocator: std.mem.Allocator, + node_queue: *std.ArrayList(Node.Index), + node: Node.Index, + comptime strategy: WalkStrategy, + ) !void { + const tags = self.nodes.items(.tag); + const components = self.nodes.items(.components); + const comp = components[node]; + + switch (tags[node]) { + .AnonymousObjectType => { + for (comp.AnonymousObjectType.fields) |field| { + try node_queue.append(allocator, field.type); + } + }, + .Is => { + try node_queue.appendSlice( + allocator, + &.{ + comp.Is.left, + comp.Is.constant, + }, + ); + }, + .As => { + try node_queue.appendSlice( + allocator, + &.{ + comp.As.left, + comp.As.constant, + }, + ); + }, + .AsyncCall => try node_queue.append(allocator, comp.AsyncCall), + .Binary => { + try node_queue.append(allocator, comp.Binary.left); + try node_queue.append(allocator, comp.Binary.right); + }, + .Block => try node_queue.appendSlice(allocator, comp.Block), + .BlockExpression => try node_queue.appendSlice(allocator, comp.BlockExpression), + .Match => { + try node_queue.append(allocator, comp.Match.value); + + for (comp.Match.branches) |branch| { + try node_queue.appendSlice(allocator, branch.conditions); + try node_queue.append(allocator, branch.expression); + } + + if (comp.Match.else_branch) |else_branch| { + try node_queue.append(allocator, else_branch); + } + }, + .Call => { + // Avoid loop between Call and Dot nodes + if (tags[comp.Call.callee] != .Dot or + components[comp.Call.callee].Dot.member_kind != .Call or + components[comp.Call.callee].Dot.value_or_call_or_enum.Call != node) + { + try node_queue.append(allocator, comp.Call.callee); + } + if (comp.Call.catch_default) |default| { + try node_queue.append(allocator, default); + } + for (comp.Call.arguments) |arg| { + try node_queue.append(allocator, arg.value); + } + }, + .Dot => { + try node_queue.append(allocator, comp.Dot.callee); + if (comp.Dot.generic_resolve) |generic_resolve| { + try node_queue.append(allocator, generic_resolve); + } + switch (comp.Dot.member_kind) { + .Value => try node_queue.append(allocator, comp.Dot.value_or_call_or_enum.Value.value), + .Call => { + // We avoid the actual Call node, we're only interested in the Call's parts + const call = components[comp.Dot.value_or_call_or_enum.Call].Call; + if (call.catch_default) |default| { + try node_queue.append(allocator, default); + } + for (call.arguments) |arg| { + try node_queue.append(allocator, arg.value); + } + }, + .Ref, .EnumCase => {}, + } + }, + .DoUntil => switch (strategy) { + .breadthFirst => { try node_queue.append(allocator, comp.DoUntil.condition); try node_queue.append(allocator, comp.DoUntil.body); }, - .Enum => { - if (comp.Enum.case_type) |case_type| { - try node_queue.append(allocator, case_type); - } - for (comp.Enum.cases) |case| { - if (case.value) |value| { - try node_queue.append(allocator, value); - } - } + .depthFirst => { + try node_queue.append(allocator, comp.DoUntil.body); + try node_queue.append(allocator, comp.DoUntil.condition); }, - .Export => { - if (comp.Export.declaration) |decl| { - try node_queue.append(allocator, decl); + }, + .Enum => { + if (comp.Enum.case_type) |case_type| { + try node_queue.append(allocator, case_type); + } + for (comp.Enum.cases) |case| { + if (case.value) |value| { + try node_queue.append(allocator, value); } - }, - .Expression => try node_queue.append(allocator, comp.Expression), - .FiberType => { - try node_queue.append(allocator, comp.FiberType.return_type); - try node_queue.append(allocator, comp.FiberType.yield_type); - }, - .For => { + } + }, + .Export => { + if (comp.Export.declaration) |decl| { + try node_queue.append(allocator, decl); + } + }, + .Expression => try node_queue.append(allocator, comp.Expression), + .FiberType => { + try node_queue.append(allocator, comp.FiberType.return_type); + try node_queue.append(allocator, comp.FiberType.yield_type); + }, + .For => switch (strategy) { + .breadthFirst => { try node_queue.append(allocator, comp.For.condition); try node_queue.append(allocator, comp.For.body); try node_queue.appendSlice(allocator, comp.For.init_declarations); try node_queue.appendSlice(allocator, comp.For.post_loop); }, - .ForceUnwrap => try node_queue.append(allocator, comp.ForceUnwrap.unwrapped), - .ForEach => { + .depthFirst => { + try node_queue.appendSlice(allocator, comp.For.init_declarations); + try node_queue.append(allocator, comp.For.condition); + try node_queue.appendSlice(allocator, comp.For.post_loop); + try node_queue.append(allocator, comp.For.body); + }, + }, + .ForceUnwrap => try node_queue.append(allocator, comp.ForceUnwrap.unwrapped), + .ForEach => switch (strategy) { + .breadthFirst => { try node_queue.append(allocator, comp.ForEach.iterable); try node_queue.append(allocator, comp.ForEach.body); try node_queue.append(allocator, comp.ForEach.key); try node_queue.append(allocator, comp.ForEach.value); }, - .Function => { + .depthFirst => { + try node_queue.append(allocator, comp.ForEach.key); + try node_queue.append(allocator, comp.ForEach.value); + try node_queue.append(allocator, comp.ForEach.iterable); + try node_queue.append(allocator, comp.ForEach.body); + }, + }, + .Function => switch (strategy) { + .breadthFirst => { if (comp.Function.body) |body| { try node_queue.append(allocator, body); } @@ -174,33 +265,44 @@ pub const Slice = struct { try node_queue.append(allocator, signature); } }, - .FunctionType => { - if (comp.FunctionType.return_type) |return_type| { - try node_queue.append(allocator, return_type); + .depthFirst => { + if (comp.Function.function_signature) |signature| { + try node_queue.append(allocator, signature); } - if (comp.FunctionType.yield_type) |yield_type| { - try node_queue.append(allocator, yield_type); + if (comp.Function.body) |body| { + try node_queue.append(allocator, body); } + }, + }, + .FunctionType => { + if (comp.FunctionType.return_type) |return_type| { + try node_queue.append(allocator, return_type); + } - try node_queue.appendSlice(allocator, comp.FunctionType.error_types); + if (comp.FunctionType.yield_type) |yield_type| { + try node_queue.append(allocator, yield_type); + } - for (comp.FunctionType.arguments) |arg| { - try node_queue.append(allocator, arg.type); + try node_queue.appendSlice(allocator, comp.FunctionType.error_types); - if (arg.default) |default| { - try node_queue.append(allocator, default); - } + for (comp.FunctionType.arguments) |arg| { + try node_queue.append(allocator, arg.type); + + if (arg.default) |default| { + try node_queue.append(allocator, default); } - }, - .FunDeclaration => try node_queue.append(allocator, comp.FunDeclaration.function), - .GenericResolve => { - try node_queue.append(allocator, comp.GenericResolve.expression); - try node_queue.appendSlice(allocator, comp.GenericResolve.resolved_types); - }, - .GenericResolveType => try node_queue.appendSlice(allocator, comp.GenericResolveType), - .Grouping => try node_queue.append(allocator, comp.Grouping), - .If => { + } + }, + .FunDeclaration => try node_queue.append(allocator, comp.FunDeclaration.function), + .GenericResolve => { + try node_queue.append(allocator, comp.GenericResolve.expression); + try node_queue.appendSlice(allocator, comp.GenericResolve.resolved_types); + }, + .GenericResolveType => try node_queue.appendSlice(allocator, comp.GenericResolveType), + .Grouping => try node_queue.append(allocator, comp.Grouping), + .If => switch (strategy) { + .breadthFirst => { try node_queue.append(allocator, comp.If.condition); try node_queue.append(allocator, comp.If.body); if (comp.If.casted_type) |casted_type| { @@ -210,91 +312,111 @@ pub const Slice = struct { try node_queue.append(allocator, else_branch); } }, - .List => { + .depthFirst => { + try node_queue.append(allocator, comp.If.condition); + if (comp.If.casted_type) |casted_type| { + try node_queue.append(allocator, casted_type); + } + try node_queue.append(allocator, comp.If.body); + if (comp.If.else_branch) |else_branch| { + try node_queue.append(allocator, else_branch); + } + }, + }, + .List => switch (strategy) { + .breadthFirst => { try node_queue.appendSlice(allocator, comp.List.items); if (comp.List.explicit_item_type) |item_type| { try node_queue.append(allocator, item_type); } }, - .ListType => try node_queue.append(allocator, comp.ListType), - .Map => { - if (comp.Map.explicit_key_type) |key_type| { - try node_queue.append(allocator, key_type); + .depthFirst => { + if (comp.List.explicit_item_type) |item_type| { + try node_queue.append(allocator, item_type); } + try node_queue.appendSlice(allocator, comp.List.items); + }, + }, + .ListType => try node_queue.append(allocator, comp.ListType), + .Map => { + if (comp.Map.explicit_key_type) |key_type| { + try node_queue.append(allocator, key_type); + } - if (comp.Map.explicit_value_type) |value_type| { - try node_queue.append(allocator, value_type); - } + if (comp.Map.explicit_value_type) |value_type| { + try node_queue.append(allocator, value_type); + } - for (comp.Map.entries) |entry| { - try node_queue.append(allocator, entry.key); - try node_queue.append(allocator, entry.value); - } - }, - .MapType => { - try node_queue.append(allocator, comp.MapType.key_type); - try node_queue.append(allocator, comp.MapType.value_type); - }, - .NamedVariable => if (comp.NamedVariable.value) |value| - try node_queue.append(allocator, value), - .ObjectDeclaration => { - try node_queue.appendSlice(allocator, comp.ObjectDeclaration.protocols); - for (comp.ObjectDeclaration.members) |member| { - if (member.property_type) |property_type| { - try node_queue.append(allocator, property_type); - } - if (member.method_or_default_value) |value| { - try node_queue.append(allocator, value); - } - } - }, - .ObjectInit => { - if (comp.ObjectInit.object) |object| { - try node_queue.append(allocator, object); - } - for (comp.ObjectInit.properties) |property| { - try node_queue.append(allocator, property.value); + for (comp.Map.entries) |entry| { + try node_queue.append(allocator, entry.key); + try node_queue.append(allocator, entry.value); + } + }, + .MapType => { + try node_queue.append(allocator, comp.MapType.key_type); + try node_queue.append(allocator, comp.MapType.value_type); + }, + .NamedVariable => if (comp.NamedVariable.value) |value| + try node_queue.append(allocator, value), + .ObjectDeclaration => { + try node_queue.appendSlice(allocator, comp.ObjectDeclaration.protocols); + for (comp.ObjectDeclaration.members) |member| { + if (member.property_type) |property_type| { + try node_queue.append(allocator, property_type); } - }, - .Out => try node_queue.append(allocator, comp.Out), - .ProtocolDeclaration => for (comp.ProtocolDeclaration.methods) |method| { - try node_queue.append(allocator, method.method); - }, - .Range => { - try node_queue.append(allocator, comp.Range.low); - try node_queue.append(allocator, comp.Range.high); - }, - .Resolve => try node_queue.append(allocator, comp.Resolve), - .Resume => try node_queue.append(allocator, comp.Resume), - .Return => if (comp.Return.value) |value| - try node_queue.append(allocator, value), - .String => for (comp.String) |el| - try node_queue.append(allocator, el), - .Subscript => { - try node_queue.append(allocator, comp.Subscript.subscripted); - try node_queue.append(allocator, comp.Subscript.index); - if (comp.Subscript.value) |value| { + if (member.method_or_default_value) |value| { try node_queue.append(allocator, value); } - }, - .Throw => try node_queue.append(allocator, comp.Throw.expression), - .Try => { - try node_queue.append(allocator, comp.Try.body); - if (comp.Try.unconditional_clause) |clause| { - try node_queue.append(allocator, clause); - } - for (comp.Try.clauses) |clause| { - try node_queue.append(allocator, clause.type_def); - try node_queue.append(allocator, clause.body); - } - }, - .TypeExpression => try node_queue.append(allocator, comp.TypeExpression), - .TypeOfExpression => try node_queue.append(allocator, comp.TypeOfExpression), - .Unary => try node_queue.append(allocator, comp.Unary.expression), - .Unwrap => try node_queue.append(allocator, comp.Unwrap.unwrapped), - .UserType => if (comp.UserType.generic_resolve) |generic_resolve| - try node_queue.append(allocator, generic_resolve), - .VarDeclaration => { + } + }, + .ObjectInit => { + if (comp.ObjectInit.object) |object| { + try node_queue.append(allocator, object); + } + for (comp.ObjectInit.properties) |property| { + try node_queue.append(allocator, property.value); + } + }, + .Out => try node_queue.append(allocator, comp.Out), + .ProtocolDeclaration => for (comp.ProtocolDeclaration.methods) |method| { + try node_queue.append(allocator, method.method); + }, + .Range => { + try node_queue.append(allocator, comp.Range.low); + try node_queue.append(allocator, comp.Range.high); + }, + .Resolve => try node_queue.append(allocator, comp.Resolve), + .Resume => try node_queue.append(allocator, comp.Resume), + .Return => if (comp.Return.value) |value| + try node_queue.append(allocator, value), + .String => for (comp.String) |el| + try node_queue.append(allocator, el), + .Subscript => { + try node_queue.append(allocator, comp.Subscript.subscripted); + try node_queue.append(allocator, comp.Subscript.index); + if (comp.Subscript.value) |value| { + try node_queue.append(allocator, value); + } + }, + .Throw => try node_queue.append(allocator, comp.Throw.expression), + .Try => { + try node_queue.append(allocator, comp.Try.body); + if (comp.Try.unconditional_clause) |clause| { + try node_queue.append(allocator, clause); + } + for (comp.Try.clauses) |clause| { + try node_queue.append(allocator, clause.type_def); + try node_queue.append(allocator, clause.body); + } + }, + .TypeExpression => try node_queue.append(allocator, comp.TypeExpression), + .TypeOfExpression => try node_queue.append(allocator, comp.TypeOfExpression), + .Unary => try node_queue.append(allocator, comp.Unary.expression), + .Unwrap => try node_queue.append(allocator, comp.Unwrap.unwrapped), + .UserType => if (comp.UserType.generic_resolve) |generic_resolve| + try node_queue.append(allocator, generic_resolve), + .VarDeclaration => switch (strategy) { + .breadthFirst => { if (comp.VarDeclaration.value) |value| { try node_queue.append(allocator, value); } @@ -302,29 +424,198 @@ pub const Slice = struct { try node_queue.append(allocator, type_def); } }, - .While => { - try node_queue.append(allocator, comp.While.condition); - try node_queue.append(allocator, comp.While.body); + .depthFirst => { + if (comp.VarDeclaration.type) |type_def| { + try node_queue.append(allocator, type_def); + } + if (comp.VarDeclaration.value) |value| { + try node_queue.append(allocator, value); + } }, - .Yield => try node_queue.append(allocator, comp.Yield), - .AnonymousEnumCase, - .Boolean, - .Break, - .Continue, - .Import, - .Integer, - .Double, - .Null, - .GenericType, - .Namespace, - .Pattern, - .SimpleType, - .StringLiteral, - .Void, - .Zdef, - => {}, + }, + .While => { + try node_queue.append(allocator, comp.While.condition); + try node_queue.append(allocator, comp.While.body); + }, + .Yield => try node_queue.append(allocator, comp.Yield), + .AnonymousEnumCase, + .Boolean, + .Break, + .Continue, + .Import, + .Integer, + .Double, + .Null, + .GenericType, + .Namespace, + .Pattern, + .SimpleType, + .StringLiteral, + .Void, + .Zdef, + => {}, + } + } + + /// Restores the visible declaration list when the depth-first walk exits a lexical scope. + const ScopeCheckpoint = struct { + /// Node whose exit ends this scope. + end_node: Node.Index, + /// Visible declaration count before entering this scope. + visible_len: usize, + }; + + /// Depth-first walk context that snapshots declarations visible at a source offset. + const ScopeAwareContext = struct { + /// Root node of the visibility query. + root: Node.Index, + /// Source byte offset whose lexical environment is being queried. + offset: usize, + /// Declarations currently visible at the walk position, in declaration order. + visible: std.ArrayList(Node.Index) = .empty, + /// Stack of lexical scopes and the visible list size each scope must restore. + scope_checkpoints: std.ArrayList(ScopeCheckpoint) = .empty, + /// Captured visible declaration snapshot once the requested offset is reached. + result: ?[]const Node.Index = null, + + /// Frees scratch state owned by the context; `result` is returned to the caller. + fn deinit(self: *ScopeAwareContext, allocator: std.mem.Allocator) void { + self.visible.deinit(allocator); + self.scope_checkpoints.deinit(allocator); + } + + /// Copies the currently visible declarations once a lookup reaches its target. + fn captureResult(self: *ScopeAwareContext, allocator: std.mem.Allocator) std.mem.Allocator.Error!void { + if (self.result == null) { + self.result = try allocator.dupe(Node.Index, self.visible.items); } } + + /// Marks a lexical scope boundary and remembers how many declarations were visible on entry. + fn pushScope(self: *ScopeAwareContext, allocator: std.mem.Allocator, end_node: Node.Index) std.mem.Allocator.Error!void { + try self.scope_checkpoints.append( + allocator, + .{ + .end_node = end_node, + .visible_len = self.visible.items.len, + }, + ); + } + + /// Enters scopes before processing children; some parser scopes end at a child body node. + pub fn enterNode( + self: *ScopeAwareContext, + allocator: std.mem.Allocator, + ast: Self.Slice, + node: Self.Node.Index, + ) std.mem.Allocator.Error!void { + if (self.result != null) { + return; + } + + const components = ast.nodes.items(.components); + switch (ast.nodes.items(.tag)[node]) { + .Function, .BlockExpression => try self.pushScope(allocator, node), + .If => try self.pushScope(allocator, components[node].If.body), + .While => try self.pushScope(allocator, components[node].While.body), + .DoUntil => try self.pushScope(allocator, components[node].DoUntil.body), + .For, .ForEach => try self.pushScope(allocator, node), + else => if (ast.nodes.items(.ends_scope)[node] != null and + (self.scope_checkpoints.items.len == 0 or + self.scope_checkpoints.items[self.scope_checkpoints.items.len - 1].end_node != node)) + { + try self.pushScope(allocator, node); + }, + } + } + + /// Exits scopes whose lifetime ends at this node and drops declarations from those scopes. + pub fn exitNode( + self: *ScopeAwareContext, + allocator: std.mem.Allocator, + ast: Self.Slice, + node: Self.Node.Index, + ) std.mem.Allocator.Error!void { + const node_ends_current_scope = self.scope_checkpoints.items.len > 0 and + self.scope_checkpoints.items[self.scope_checkpoints.items.len - 1].end_node == node; + + if (self.result == null and (node_ends_current_scope or node == self.root)) { + const token_offsets = ast.tokens.items(.offset); + const lexemes = ast.tokens.items(.lexeme); + const locations = ast.nodes.items(.location); + const end_locations = ast.nodes.items(.end_location); + const node_start = token_offsets[locations[node]]; + const node_end = token_offsets[end_locations[node]] + lexemes[end_locations[node]].len; + + // Only lexical scopes can be the fallback capture point. Other nodes, such as + // imports, can have ranges from another source and must not stop the walk early. + if ((node_start <= self.offset and self.offset <= node_end) or node == self.root) { + try self.captureResult(allocator); + } + } + + while (self.scope_checkpoints.items.len > 0 and + self.scope_checkpoints.items[self.scope_checkpoints.items.len - 1].end_node == node) + { + const checkpoint = self.scope_checkpoints.pop().?; + self.visible.shrinkRetainingCapacity(checkpoint.visible_len); + } + } + + /// Captures visible declarations at the source offset and records declarations as they become visible. + pub fn processNode( + self: *ScopeAwareContext, + allocator: std.mem.Allocator, + ast: Self.Slice, + node: Self.Node.Index, + ) std.mem.Allocator.Error!bool { + if (self.result != null) { + return true; + } + + const token_offsets = ast.tokens.items(.offset); + const lexemes = ast.tokens.items(.lexeme); + const locations = ast.nodes.items(.location); + const end_locations = ast.nodes.items(.end_location); + const node_start = token_offsets[locations[node]]; + + if (node_start >= self.offset) { + try self.captureResult(allocator); + return true; + } + + if (ast.nodes.items(.tag)[node] == .VarDeclaration) { + const declaration = ast.nodes.items(.components)[node].VarDeclaration; + const node_end = token_offsets[end_locations[node]] + lexemes[end_locations[node]].len; + + const declaration_is_before_offset = node_end <= self.offset; + const offset_inside_declaration = node_start <= self.offset and self.offset < node_end; + + if (!declaration.implicit and declaration_is_before_offset and !offset_inside_declaration) { + try self.visible.append(allocator, node); + } + } + + return false; + } + }; + + /// Returns declarations visible in the lexical scope at `offset` in the source. + pub fn visibleSymbolsAtOffset( + self: Self.Slice, + allocator: std.mem.Allocator, + root: Node.Index, + offset: usize, + ) ![]const Node.Index { + var ctx = ScopeAwareContext{ + .root = root, + .offset = offset, + }; + defer ctx.deinit(allocator); + + try self.walk(allocator, &ctx, root, .depthFirst); + + return ctx.result orelse &.{}; } const UsesFiberContext = struct { @@ -348,7 +639,7 @@ pub const Slice = struct { pub fn usesFiber(self: Self.Slice, allocator: std.mem.Allocator, node: Node.Index) !bool { var ctx = UsesFiberContext{}; - try self.walk(allocator, &ctx, node); + try self.walk(allocator, &ctx, node, .breadthFirst); return ctx.result; } @@ -531,7 +822,7 @@ pub const Slice = struct { pub fn isConstant(self: Self.Slice, allocator: std.mem.Allocator, node: Node.Index) !bool { var ctx = IsConstantContext{}; - try self.walk(allocator, &ctx, node); + try self.walk(allocator, &ctx, node, .breadthFirst); return ctx.result orelse false; } @@ -580,7 +871,7 @@ pub const Slice = struct { if (complexity_score.* == null) { var ctx = ComplexityContext{}; - try self.walk(allocator, &ctx, node); + try self.walk(allocator, &ctx, node, .breadthFirst); complexity_score.* = ctx.score; } @@ -609,7 +900,7 @@ pub const Slice = struct { pub fn namespace(self: Self.Slice, allocator: std.mem.Allocator, node: Node.Index) !?[]const TokenIndex { var ctx = NamespaceContext{}; - try self.walk(allocator, &ctx, node); + try self.walk(allocator, &ctx, node, .breadthFirst); return ctx.namespace; } diff --git a/src/Codegen.zig b/src/Codegen.zig index f3a800b7..17f34893 100644 --- a/src/Codegen.zig +++ b/src/Codegen.zig @@ -1959,7 +1959,7 @@ fn generateFunction(self: *Self, node: Ast.Node.Index, breaks: ?*Breaks) Error!? if (function_type != .ScriptEntryPoint and function_type != .Repl) { // `extern` functions don't have upvalues - if (function_type == .Extern) { + if (function_type == .Extern and self.flavor.resolveDynLib()) { try self.OP_CONSTANT( locations[node], components.native.?.toValue(), diff --git a/src/Parser.zig b/src/Parser.zig index 90754a38..af40e08d 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -18,38 +18,10 @@ const Reporter = @import("Reporter.zig"); const StringParser = @import("StringParser.zig"); const Perf = @import("Perf.zig"); const pcre = if (!is_wasm) @import("pcre.zig") else void; -const buzz_api = @import("lib/buzz_api.zig"); +const static_libraries = @import("lib/static_libraries.zig"); const print = @import("io.zig").print; -const libs = if (!is_wasm) - std.StaticStringMap(std.StaticStringMap(buzz_api.NativeFn)).initComptime( - &.{ - &.{ @import("lib/buzz_buffer.zig").library.name, @import("lib/buzz_buffer.zig").library.methods }, - &.{ @import("lib/buzz_crypto.zig").library.name, @import("lib/buzz_crypto.zig").library.methods }, - &.{ @import("lib/buzz_debug.zig").library.name, @import("lib/buzz_debug.zig").library.methods }, - &.{ @import("lib/buzz_ffi.zig").library.name, @import("lib/buzz_ffi.zig").library.methods }, - &.{ @import("lib/buzz_fs.zig").library.name, @import("lib/buzz_fs.zig").library.methods }, - &.{ @import("lib/buzz_gc.zig").library.name, @import("lib/buzz_gc.zig").library.methods }, - &.{ @import("lib/buzz_http.zig").library.name, @import("lib/buzz_http.zig").library.methods }, - &.{ @import("lib/buzz_io.zig").library.name, @import("lib/buzz_io.zig").library.methods }, - &.{ @import("lib/buzz_math.zig").library.name, @import("lib/buzz_math.zig").library.methods }, - &.{ @import("lib/buzz_os.zig").library.name, @import("lib/buzz_os.zig").library.methods }, - &.{ @import("lib/buzz_serialize.zig").library.name, @import("lib/buzz_serialize.zig").library.methods }, - &.{ @import("lib/buzz_std.zig").library.name, @import("lib/buzz_std.zig").library.methods }, - }, - ) -else - std.StaticStringMap(std.StaticStringMap(buzz_api.NativeFn)).initComptime( - &.{ - &.{ @import("lib/buzz_buffer.zig").library.name, @import("lib/buzz_buffer.zig").library.methods }, - &.{ @import("lib/buzz_crypto.zig").library.name, @import("lib/buzz_crypto.zig").library.methods }, - &.{ @import("lib/buzz_debug.zig").library.name, @import("lib/buzz_debug.zig").library.methods }, - &.{ @import("lib/buzz_gc.zig").library.name, @import("lib/buzz_gc.zig").library.methods }, - &.{ @import("lib/buzz_math.zig").library.name, @import("lib/buzz_math.zig").library.methods }, - &.{ @import("lib/buzz_serialize.zig").library.name, @import("lib/buzz_serialize.zig").library.methods }, - &.{ @import("lib/buzz_std.zig").library.name, @import("lib/buzz_std.zig").library.methods }, - }, - ); +const libs = static_libraries.nativeLibraries(is_wasm); pub const Dlib = struct { pub const LookupFn = *const fn ([*:0]const u8) callconv(.c) ?obj.NativeFn; @@ -748,6 +720,10 @@ pub fn consume(self: *Self, tag: Token.Tag, comptime message: []const u8) !void }, ); + if (tag == .Semicolon) { + return CompileError.Recoverable; + } + // We don't recover from this return Error.CantCompile; }, @@ -983,8 +959,8 @@ pub fn parse(self: *Self, source: []const u8, file_name: ?[]const u8, name: []co ); var entry = Ast.Function.Entry{ - .test_slots = undefined, - .test_locations = undefined, + .test_slots = &.{}, + .test_locations = &.{}, }; self.script_name = name; @@ -1000,7 +976,7 @@ pub fn parse(self: *Self, source: []const u8, file_name: ?[]const u8, name: []co var statements = std.ArrayList(Ast.Node.Index).empty; while (!(try self.match(.Eof))) { - if (self.declarationOrStatement(null) catch |err| { + if (self.declarationOrStatement(null) catch |err| recover: { if (function_type != .Repl and err == error.ReachedMaximumMemoryUsage) { return err; } @@ -1016,22 +992,41 @@ pub fn parse(self: *Self, source: []const u8, file_name: ?[]const u8, name: []co }, ); } - self.discardFramesUntil(enclosing_frame); - return null; + + break :recover null; }) |decl| { try statements.append(self.gc.allocator, decl); } else { - self.reporter.reportErrorAt( - .syntax, - self.ast.tokens.get(self.current_token.? - 1), - self.ast.tokens.get(self.current_token.? - 1), - "Expected statement", - ); + // If declarationOrStatement already reported an error, avoid adding + // a secondary diagnostic from a possibly stale recovery token. + if (self.reporter.last_error == null) { + self.reporter.reportErrorAt( + .syntax, + self.ast.tokens.get(self.current_token.? - 1), + self.ast.tokens.get(self.current_token.? - 1), + "Expected statement", + ); + } break; } } + + // Failed imported scripts are only used for their diagnostics; nested import + // recovery can rewind the shared AST before this parser publishes a root. + if (self.imported and self.reporter.last_error != null) { + return null; + } + self.ast.nodes.items(.components)[body_node].Block = try statements.toOwnedSlice(self.gc.allocator); + // Once parsing has failed, later entry/global analysis can touch partial + // declarations. Keep the partial root for tooling and stop semantic setup. + if (self.reporter.last_error != null) { + self.ast.nodes.items(.components)[function_node].Function.entry = entry; + self.ast.root = self.endFrame(); + return null; + } + // If top level, search `main` or `test` function(s) and call them // Then put any exported globals on the stack if (function_type == .ScriptEntryPoint) { @@ -1104,8 +1099,7 @@ pub fn parse(self: *Self, source: []const u8, file_name: ?[]const u8, name: []co ]; } - const root_node = self.endFrame(); - self.ast.root = if (self.reporter.last_error != null) null else root_node; + self.ast.root = self.endFrame(); return if (self.reporter.last_error != null) null else self.ast; } @@ -1211,72 +1205,80 @@ fn beginFrame(self: *Self, function_type: obj.ObjFunction.FunctionType, function } fn endFrame(self: *Self) Ast.Node.Index { - var i: usize = 0; - while (i < self.current.?.local_count) : (i += 1) { - const local = self.current.?.locals[i]; - - if (self.flavor != .Repl) { - // Check discarded locals - if (!local.isReferenced(self.ast)) { - const location = self.ast.tokens.get(local.name); - self.reporter.warnFmt( - .unused_argument, - location, - location, - "Local `{s}` is never referenced", - .{ - self.ast.tokens.items(.lexeme)[local.name], - }, - ); - } + const current = self.current.?; + const current_node = current.function_node; - // Check var local never assigned - if (!local.isAssigned(self.ast)) { - const location = self.ast.tokens.get(local.name); - self.reporter.warnFmt( - .unassigned_final_local, - location, - location, - "Local `{s}` is declared `var` but is never re-assigned", - .{ - self.ast.tokens.items(.lexeme)[local.name], - }, - ); - } + // Malformed sources can leave type annotations partially built; diagnostics + // are enough in that state, so skip warning-only analysis while unwinding. + if (self.reporter.last_error == null) { + var i: usize = 0; + while (i < current.local_count) : (i += 1) { + self.warnAboutLocal(current.locals[i], true); } - } - // If global scope, check unused globals - const function_type = self.ast.nodes.items(.type_def)[self.current.?.function_node].?.resolved_type.?.Function.function_type; - if (function_type == .Script or function_type == .ScriptEntryPoint) { - for (self.globals.items) |global| { - if (!global.isReferenced(self.ast)) { - const type_def_str = global.type_def.toStringAlloc(self.gc.allocator, false) catch unreachable; - defer self.gc.allocator.free(type_def_str); + // If global scope, check unused globals + const function_type = self.ast.nodes.items(.type_def)[current.function_node].?.resolved_type.?.Function.function_type; + if (function_type == .Script or function_type == .ScriptEntryPoint) { + for (self.globals.items) |global| { + if (!global.isReferenced(self.ast)) { + const type_def_str = global.type_def.toStringAlloc(self.gc.allocator, false) catch unreachable; + defer self.gc.allocator.free(type_def_str); - const location = self.ast.tokens.get(global.qualified_name.firstToken()); + const location = self.ast.tokens.get(global.qualified_name.firstToken()); - self.reporter.warnFmt( - .unused_argument, - location, - location, - "Unused global of type `{s}`", - .{ - type_def_str, - }, - ); + self.reporter.warnFmt( + .unused_argument, + location, + location, + "Unused global of type `{s}`", + .{ + type_def_str, + }, + ); + } } } } - const current = self.current.?; - const current_node = current.function_node; self.current = current.enclosing; self.destroyFrame(current); return current_node; } +/// Emits diagnostics for a local before its parser frame slot is discarded. +fn warnAboutLocal(self: *Self, local: Local, comptime check_assignment: bool) void { + if (self.flavor == .Repl) { + return; + } + + if (!local.isReferenced(self.ast)) { + const location = self.ast.tokens.get(local.name); + self.reporter.warnFmt( + .unused_argument, + location, + location, + "Local `{s}` is never referenced", + .{ + self.ast.tokens.items(.lexeme)[local.name], + }, + ); + } + + if (check_assignment and !local.isAssigned(self.ast)) { + const location = self.ast.tokens.get(local.name); + self.reporter.warnFmt( + .unassigned_final_local, + location, + location, + "Local `{s}` is declared `var` but is never re-assigned", + .{ + self.ast.tokens.items(.lexeme)[local.name], + }, + ); + } +} + fn beginScope(self: *Self, at: ?Ast.Node.Index) !void { try self.current.?.scopes.append(self.gc.allocator, at); self.current.?.scope_depth += 1; @@ -1290,6 +1292,7 @@ fn endScope(self: *Self) ![]Ast.Close { while (current.local_count > 0 and current.locals[current.local_count - 1].depth > current.scope_depth) { const local = current.locals[current.local_count - 1]; + self.warnAboutLocal(local, false); if (local.captured) { try closing.append( @@ -1645,6 +1648,22 @@ fn declaration(self: *Self, docblock: ?Ast.TokenIndex) Error!?Ast.Node.Index { return node; } +/// Recovers after a statement-boundary error without discarding completed siblings. +fn recoverDeclarationOrStatement(self: *Self, err: Error) Error!void { + switch (err) { + error.Recoverable => {}, + else => return err, + } + + if (self.check(.RightBrace) or self.check(.Eof)) { + // synchronize() normally clears panic_mode, but at block end or EOF there are + // no tokens left to skip before returning to the enclosing parser frame. + self.reporter.panic_mode = false; + } else { + try self.synchronize(); + } +} + fn declarationOrStatement(self: *Self, loop_scope: ?LoopScope) !?Ast.Node.Index { const global_scope = self.current.?.scope_depth == 0; const docblock = if (global_scope and try self.match(.Docblock)) @@ -1652,12 +1671,31 @@ fn declarationOrStatement(self: *Self, loop_scope: ?LoopScope) !?Ast.Node.Index else null; - return try self.declaration(docblock) orelse - try self.statement( + // Recover at the declaration/statement boundary so every caller keeps parsing the + // completed prefix of its current list when a statement is incomplete. + const node = recover: { + if (self.declaration(docblock) catch |err| { + try self.recoverDeclarationOrStatement(err); + break :recover null; + }) |decl| { + break :recover decl; + } + + break :recover self.statement( docblock, false, loop_scope, - ); + ) catch |err| { + try self.recoverDeclarationOrStatement(err); + break :recover null; + }; + }; + + if (self.reporter.panic_mode) { + try self.synchronize(); + } + + return node; } // When a break statement, will return index of jump to patch @@ -1807,6 +1845,13 @@ fn addGlobal( } try self.resolvePlaceholder(global.type_def, global_type, final); + // Forward references create global placeholders before their + // declaration node exists. Once the placeholder resolves, keep + // the global table pointing at the real declaration so tooling + // and later semantic passes can recover the source location. + global.node = node; + global.final = final; + global.mutable = mutable; if (self.flavor == .Ast) { for (global.placeholder_referrers.items) |referrer| { @@ -5942,11 +5987,21 @@ fn matchStatementOrExpression(self: *Self, is_statement: bool) Error!Ast.Node.In try self.consume(.Arrow, "Expected `->`"); + const body_is_block = is_statement and try self.match(.LeftBrace); + const body = if (body_is_block) block: { + try self.beginScope(null); + break :block try self.block(null); // We accept a lexical block when it's a match statement + } else try self.expression(false); // Otherwise it should be an expression + + if (body_is_block) { + self.ast.nodes.items(.ends_scope)[body] = try self.endScope(); + } + try branches.append( self.gc.allocator, .{ .conditions = try conditions.toOwnedSlice(self.gc.allocator), - .expression = try self.expression(false), + .expression = body, }, ); @@ -5957,7 +6012,18 @@ fn matchStatementOrExpression(self: *Self, is_statement: bool) Error!Ast.Node.In const else_branch = if (try self.match(.Else)) else_branch: { try self.consume(.Arrow, "Expected `->` after `else`"); - break :else_branch try self.expression(false); + + const body_is_block = is_statement and try self.match(.LeftBrace); + const body = if (body_is_block) block: { + try self.beginScope(null); + break :block try self.block(null); // We accept a lexical block when it's a match statement + } else try self.expression(false); // Otherwise it should be an expression + + if (body_is_block) { + self.ast.nodes.items(.ends_scope)[body] = try self.endScope(); + } + + break :else_branch body; } else null; if (else_branch != null) { @@ -6188,6 +6254,12 @@ fn namedVariable(self: *Self, qualified_name: Ast.QualifiedName, can_assign: boo slot = try self.declarePlaceholder(qualified_name.name, null); var_def = self.globals.items[slot].type_def; slot_type = .Global; + if (self.flavor == .Ast) { + try self.globals.items[slot].placeholder_referrers.append( + self.gc.allocator, + @intCast(node_slot), + ); + } if (qualified_name.namespace.len > 0) { self.reporter.reportErrorFmt( @@ -6700,7 +6772,7 @@ fn function( } if (function_type == .Extern) { - if (self.flavor.resolveImports()) { + if (self.flavor.resolveDynLib() and self.flavor.resolveImports()) { const native_opt = try self.importStaticLibSymbol( self.script_name, function_typedef.resolved_type.?.Function.name.string, @@ -6732,8 +6804,6 @@ fn function( // Search for a dylib/so/dll with the same name as the current script if (native_opt) |native| { self.ast.nodes.items(.components)[function_node].Function.native = native; - } else if (!self.flavor.resolveDynLib()) { - self.ast.nodes.items(.components)[function_node].Function.native = undefined; } else { return error.BuzzNoDll; } @@ -7517,6 +7587,7 @@ fn exportStatement(self: *Self, docblock: ?Ast.TokenIndex) Error!Ast.Node.Index .tag = .Export, .location = start_location, .end_location = self.current_token.? - 1, + .docblock = docblock, .type_def = global.type_def, .components = .{ .Export = .{ @@ -7543,7 +7614,6 @@ fn exportStatement(self: *Self, docblock: ?Ast.TokenIndex) Error!Ast.Node.Index .tag = .Export, .location = start_location, .end_location = self.current_token.? - 1, - .docblock = docblock, .type_def = self.ast.nodes.items(.type_def)[decl], .components = .{ .Export = .{ @@ -9068,99 +9138,30 @@ fn searchZdefLibPaths(self: *Self, file_name: []const u8) ![][]const u8 { } fn readStaticScript(self: *Self, file_name: []const u8) ?[2][]const u8 { - // We can't build the file path dynamically - return if (std.mem.eql(u8, file_name, "std")) - [_][]const u8{ - @embedFile("lib/std.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "gc")) - [_][]const u8{ - @embedFile("lib/gc.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "math")) - [_][]const u8{ - @embedFile("lib/math.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "debug")) - [_][]const u8{ - @embedFile("lib/debug.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "buffer")) - [_][]const u8{ - @embedFile("lib/buffer.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "serialize")) - [_][]const u8{ - @embedFile("lib/serialize.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "errors")) - [_][]const u8{ - @embedFile("lib/errors.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "test")) - [_][]const u8{ - @embedFile("lib/testing.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "crypto")) - [_][]const u8{ - @embedFile("lib/crypto.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "ffi")) - [_][]const u8{ - @embedFile("lib/ffi.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "fs")) - [_][]const u8{ - @embedFile("lib/fs.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "io")) - [_][]const u8{ - @embedFile("lib/io.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "os")) - [_][]const u8{ - @embedFile("lib/os.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "http")) - [_][]const u8{ - @embedFile("lib/http.buzz"), - file_name, - } - else if (std.mem.eql(u8, file_name, "toml")) - [_][]const u8{ - @embedFile("lib/toml.buzz"), - file_name, - } - else none: { - // If wasm it will not fallback to importing actual files - if (is_wasm) { - const location = self.ast.tokens.get(self.current_token.? - 1); - self.reporter.reportErrorFmt( - .script_not_found, - location, - location, - "buzz script `{s}` not found", - .{ - file_name, - }, - ); + inline for (static_libraries.all) |library| { + if (std.mem.eql(u8, file_name, library.name)) { + return [_][]const u8{ + @embedFile("lib/" ++ library.header_path), + library.name, + }; } + } - break :none null; - }; + // If wasm it will not fallback to importing actual files + if (is_wasm) { + const location = self.ast.tokens.get(self.current_token.? - 1); + self.reporter.reportErrorFmt( + .script_not_found, + location, + location, + "buzz script `{s}` not found", + .{ + file_name, + }, + ); + } + + return null; } fn readScript(self: *Self, file_name: []const u8) !?[2][]const u8 { @@ -9268,7 +9269,7 @@ fn checkImportedNamespaceCollision(self: *Self, namespace: []const Ast.TokenInde self.ast.tokens.get(namespace[namespace.len - 1]), self.ast.tokens.get(own_namespace[0]), self.ast.tokens.get(own_namespace[own_namespace.len - 1]), - "The namespace `{s} already exists`", + "The namespace `{s}` already exists", .{ namespace_str.items, }, @@ -9296,7 +9297,7 @@ fn checkImportedNamespaceCollision(self: *Self, namespace: []const Ast.TokenInde self.ast.tokens.get(namespace[namespace.len - 1]), self.ast.tokens.get(global.qualified_name.namespace[0]), self.ast.tokens.get(global.qualified_name.namespace[global.qualified_name.namespace.len - 1]), - "The namespace `{s} already exists`", + "The namespace `{s}` already exists", .{ namespace_str.items, }, @@ -9362,7 +9363,8 @@ fn importScript( // We need to share the token and node list with the import so TokenIndex and Node.Index are usable parser.ast = self.ast; parser.current_token = self.current_token; - const token_before_import = self.ast.tokens.get(self.current_token.?); + const previous_current_token = self.current_token.?; + const token_before_import = self.ast.tokens.get(previous_current_token); const previous_root = self.ast.root; const previous_tokens_len = self.ast.tokens.len; const previous_node_len = self.ast.nodes.len; @@ -9427,10 +9429,14 @@ fn importScript( self.ast.tokens.set(parser.current_token.?, token_before_import); self.current_token = parser.current_token; } else { + // Failed imports can still grow or reallocate the shared token/node + // lists, so restore from the imported parser's current AST copy. + self.ast = parser.ast; self.ast.root = previous_root; self.ast.nodes.shrinkRetainingCapacity(previous_node_len); self.ast.tokens.shrinkRetainingCapacity(previous_tokens_len); - self.current_token = @intCast(previous_tokens_len); + self.ast.tokens.set(previous_current_token, token_before_import); + self.current_token = previous_current_token; self.reporter.last_error = parser.reporter.last_error; } @@ -9463,7 +9469,7 @@ fn importScript( const namespace = prefix orelse global.qualified_name.namespace; if (self.namespace) |own_namespace| { - if (namespace.len > own_namespace.len) { + if (namespace.len >= own_namespace.len) { var common: ?usize = null; for (own_namespace, 0..) |part, i| { if (!std.mem.eql(u8, lexemes[part], lexemes[namespace[i]])) { @@ -9714,6 +9720,7 @@ fn importStatement(self: *Self) Error!Ast.Node.Index { } try self.consume(.Semicolon, "Expected `;` after statement."); + const end_location = self.current_token.? - 1; const import = if (self.reporter.last_error == null) try self.importScript( @@ -9743,7 +9750,7 @@ fn importStatement(self: *Self) Error!Ast.Node.Index { .{ .tag = .Import, .location = start_location, - .end_location = self.current_token.? - 1, + .end_location = end_location, .components = .{ .Import = .{ .prefix = prefix, diff --git a/src/Reporter.zig b/src/Reporter.zig index c9fc2ce7..37ba3d99 100644 --- a/src/Reporter.zig +++ b/src/Reporter.zig @@ -139,9 +139,9 @@ pub const Error = enum(u8) { default_value_type = 104, unexhaustive_match = 105, match_condition_type = 106, + match_duplicate_condition = 107, }; -// Inspired by https://github.com/zesterer/ariadne pub const ReportKind = enum { @"error", warning, diff --git a/src/TypeChecker.zig b/src/TypeChecker.zig index 59022715..c109afad 100644 --- a/src/TypeChecker.zig +++ b/src/TypeChecker.zig @@ -3,6 +3,7 @@ const o = @import("obj.zig"); const Reporter = @import("Reporter.zig"); const Ast = @import("Ast.zig"); const GC = @import("GC.zig"); +const Value = @import("value.zig").Value; const BuildOptions = @import("build_options"); const io = @import("io.zig"); @@ -1811,12 +1812,122 @@ fn checkMatch(ast: Ast.Slice, reporter: *Reporter, gc: *GC, _: ?Ast.Node.Index, condition_type_def.?, "Bad `match` condition type", ); + had_error = true; } } }, } } + if (!had_error) { + const numeric_match_value = !value_type_def.optional and + (value_type_def.def_type == .Integer or value_type_def.def_type == .Double); + var seen_values = std.ArrayList(Value).empty; + defer seen_values.deinit(gc.allocator); + var seen_conditions = std.ArrayList(Ast.Node.Index).empty; + defer seen_conditions.deinit(gc.allocator); + + for (node_components.branches) |branch| { + for (branch.conditions) |condition| { + const is_constant = ast.isConstant(gc.allocator, condition) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => false, + }; + if (!is_constant) { + continue; + } + + const condition_value = ast.toValue(condition, gc) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }; + + for (seen_values.items, seen_conditions.items) |seen_value, seen_condition| { + const duplicate_condition = condition_value.eql(seen_value); + var overlapping_condition = false; + + // Numeric range matches use rg.contains semantics: normalized half-open intervals. + if (!duplicate_condition and numeric_match_value) { + const condition_type_def = type_defs[condition] orelse continue; + const seen_condition_type_def = type_defs[seen_condition] orelse continue; + const condition_is_range = !condition_type_def.optional and + condition_type_def.def_type == .Range; + const seen_condition_is_range = !seen_condition_type_def.optional and + seen_condition_type_def.def_type == .Range; + + if (condition_is_range and seen_condition_is_range) { + const condition_range = o.ObjRange.cast(condition_value.obj()).?; + const seen_range = o.ObjRange.cast(seen_value.obj()).?; + const condition_low = @min(condition_range.low, condition_range.high); + const condition_high = @max(condition_range.low, condition_range.high); + const seen_low = @min(seen_range.low, seen_range.high); + const seen_high = @max(seen_range.low, seen_range.high); + + overlapping_condition = @max(condition_low, seen_low) < @min(condition_high, seen_high); + } else if (condition_is_range and seen_value.isNumber()) { + const condition_range = o.ObjRange.cast(condition_value.obj()).?; + const condition_low: f64 = @floatFromInt(@min(condition_range.low, condition_range.high)); + const condition_high: f64 = @floatFromInt(@max(condition_range.low, condition_range.high)); + const seen_number = if (seen_value.isInteger()) + @as(f64, @floatFromInt(seen_value.integer())) + else + seen_value.double(); + + overlapping_condition = (value_type_def.def_type == .Double or + seen_value.isInteger() or + (std.math.isFinite(seen_number) and seen_number == @trunc(seen_number))) and + seen_number >= condition_low and seen_number < condition_high; + } else if (seen_condition_is_range and condition_value.isNumber()) { + const seen_range = o.ObjRange.cast(seen_value.obj()).?; + const seen_low: f64 = @floatFromInt(@min(seen_range.low, seen_range.high)); + const seen_high: f64 = @floatFromInt(@max(seen_range.low, seen_range.high)); + const condition_number = if (condition_value.isInteger()) + @as(f64, @floatFromInt(condition_value.integer())) + else + condition_value.double(); + + overlapping_condition = (value_type_def.def_type == .Double or + condition_value.isInteger() or + (std.math.isFinite(condition_number) and condition_number == @trunc(condition_number))) and + condition_number >= seen_low and condition_number < seen_high; + } + } + + if (duplicate_condition) { + reporter.reportWithOrigin( + .match_duplicate_condition, + ast.tokens.get(locations[condition]), + ast.tokens.get(end_locations[condition]), + ast.tokens.get(locations[seen_condition]), + ast.tokens.get(end_locations[seen_condition]), + "Duplicate `match` condition", + .{}, + "first used here", + ); + return true; + } + + if (overlapping_condition) { + reporter.reportWithOrigin( + .match_duplicate_condition, + ast.tokens.get(locations[condition]), + ast.tokens.get(end_locations[condition]), + ast.tokens.get(locations[seen_condition]), + ast.tokens.get(end_locations[seen_condition]), + "Overlapping `match` condition", + .{}, + "overlaps with this condition", + ); + return true; + } + } + + try seen_values.append(gc.allocator, condition_value); + try seen_conditions.append(gc.allocator, condition); + } + } + } + return had_error; } diff --git a/src/lib/static_libraries.zig b/src/lib/static_libraries.zig new file mode 100644 index 00000000..5fd44e08 --- /dev/null +++ b/src/lib/static_libraries.zig @@ -0,0 +1,113 @@ +//! Shared registry for statically bundled Buzz headers and native libraries. + +const std = @import("std"); +const buzz_api = @import("buzz_api.zig"); + +/// Describes a statically bundled Buzz library. +pub const Library = struct { + /// Name used by Buzz imports. + name: []const u8, + /// Header file installed under `lib/buzz` and embedded from `src/lib`. + header_path: []const u8, + /// Native Zig implementation beside this file, when the library exposes externs. + zig_path: ?[]const u8, + /// Whether the native Zig implementation is available in wasm builds. + wasm_native: bool, +}; + +/// Libraries bundled with the compiler and runtime. +pub const all = [_]Library{ + .{ .name = "buffer", .header_path = "buffer.buzz", .zig_path = "buzz_buffer.zig", .wasm_native = true }, + .{ .name = "crypto", .header_path = "crypto.buzz", .zig_path = "buzz_crypto.zig", .wasm_native = true }, + .{ .name = "debug", .header_path = "debug.buzz", .zig_path = "buzz_debug.zig", .wasm_native = true }, + .{ .name = "errors", .header_path = "errors.buzz", .zig_path = null, .wasm_native = false }, + .{ .name = "ffi", .header_path = "ffi.buzz", .zig_path = "buzz_ffi.zig", .wasm_native = false }, + .{ .name = "fs", .header_path = "fs.buzz", .zig_path = "buzz_fs.zig", .wasm_native = false }, + .{ .name = "gc", .header_path = "gc.buzz", .zig_path = "buzz_gc.zig", .wasm_native = true }, + .{ .name = "http", .header_path = "http.buzz", .zig_path = "buzz_http.zig", .wasm_native = false }, + .{ .name = "io", .header_path = "io.buzz", .zig_path = "buzz_io.zig", .wasm_native = false }, + .{ .name = "math", .header_path = "math.buzz", .zig_path = "buzz_math.zig", .wasm_native = true }, + .{ .name = "os", .header_path = "os.buzz", .zig_path = "buzz_os.zig", .wasm_native = false }, + .{ .name = "serialize", .header_path = "serialize.buzz", .zig_path = "buzz_serialize.zig", .wasm_native = true }, + .{ .name = "std", .header_path = "std.buzz", .zig_path = "buzz_std.zig", .wasm_native = true }, + .{ .name = "test", .header_path = "testing.buzz", .zig_path = null, .wasm_native = false }, + .{ .name = "toml", .header_path = "toml.buzz", .zig_path = null, .wasm_native = false }, +}; + +/// Returns the library registered for a Buzz import name. +pub fn byName(name: []const u8) ?Library { + inline for (all) |library| { + if (std.mem.eql(u8, name, library.name)) { + return library; + } + } + + return null; +} + +/// Returns the native method map for statically linked libraries on the target. +pub fn nativeLibraries(comptime is_wasm: bool) std.StaticStringMap(std.StaticStringMap(buzz_api.NativeFn)) { + comptime { + const count = nativeLibraryCount(is_wasm); + var entries: [count]struct { []const u8, std.StaticStringMap(buzz_api.NativeFn) } = undefined; + var index = 0; + + for (all) |library| { + if (hasNativeLibrary(library, is_wasm)) { + entries[index] = .{ library.name, nativeMethods(library) }; + index += 1; + } + } + + return std.StaticStringMap(std.StaticStringMap(buzz_api.NativeFn)).initComptime(entries); + } +} + +/// Counts native libraries available on the selected target. +fn nativeLibraryCount(comptime is_wasm: bool) comptime_int { + comptime { + var count = 0; + + for (all) |library| { + if (hasNativeLibrary(library, is_wasm)) { + count += 1; + } + } + + return count; + } +} + +/// Returns whether a library has a native implementation for the selected target. +fn hasNativeLibrary(comptime library: Library, comptime is_wasm: bool) bool { + return library.zig_path != null and (!is_wasm or library.wasm_native); +} + +/// Returns native methods for a registry entry with a native Zig implementation. +fn nativeMethods(comptime library: Library) std.StaticStringMap(buzz_api.NativeFn) { + const zig_path = library.zig_path.?; + + if (std.mem.eql(u8, zig_path, "buzz_buffer.zig")) return checkedNativeMethods(library, @import("buzz_buffer.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_crypto.zig")) return checkedNativeMethods(library, @import("buzz_crypto.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_debug.zig")) return checkedNativeMethods(library, @import("buzz_debug.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_ffi.zig")) return checkedNativeMethods(library, @import("buzz_ffi.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_fs.zig")) return checkedNativeMethods(library, @import("buzz_fs.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_gc.zig")) return checkedNativeMethods(library, @import("buzz_gc.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_http.zig")) return checkedNativeMethods(library, @import("buzz_http.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_io.zig")) return checkedNativeMethods(library, @import("buzz_io.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_math.zig")) return checkedNativeMethods(library, @import("buzz_math.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_os.zig")) return checkedNativeMethods(library, @import("buzz_os.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_serialize.zig")) return checkedNativeMethods(library, @import("buzz_serialize.zig").library); + if (std.mem.eql(u8, zig_path, "buzz_std.zig")) return checkedNativeMethods(library, @import("buzz_std.zig").library); + + @compileError("unknown native library path: " ++ zig_path); +} + +/// Verifies a native library against its registry entry and returns its methods. +fn checkedNativeMethods(comptime library: Library, comptime native_library: anytype) std.StaticStringMap(buzz_api.NativeFn) { + if (!std.mem.eql(u8, native_library.name, library.name)) { + @compileError("native library name mismatch for " ++ library.zig_path.?); + } + + return native_library.methods; +} diff --git a/src/lsp.zig b/src/lsp.zig index e805e674..221cb715 100644 --- a/src/lsp.zig +++ b/src/lsp.zig @@ -8,10 +8,12 @@ const GC = @import("GC.zig"); const TypeRegistry = @import("TypeRegistry.zig"); const Parser = @import("Parser.zig"); const Reporter = @import("Reporter.zig"); +const Scanner = @import("Scanner.zig"); const CodeGen = @import("Codegen.zig"); const Token = @import("Token.zig"); const Renderer = @import("renderer.zig").Renderer; const o = @import("obj.zig"); +const static_libraries = @import("lib/static_libraries.zig"); const log = std.log.scoped(.buzz_lsp); @@ -66,9 +68,12 @@ const Document = struct { ast: Ast, errors: []const Reporter.Report, - /// Symbols collected in the document + /// Document symbols collected from parser globals. symbols: std.ArrayList(lsp.types.DocumentSymbol) = .empty, + /// Global and imported labels available for completion. + completion_labels: std.StringArrayHashMapUnmanaged(void) = .empty, + /// Cache for previous gotoDefinition definitions: std.AutoHashMapUnmanaged(Ast.Node.Index, ?Definition) = .empty, @@ -115,12 +120,15 @@ const Document = struct { const owned_uri = try allocator.dupe(u8, uri); const std_lib_script_name = staticScriptNameFromUri(owned_uri); + const document_script_name = std_lib_script_name orelse + (try localPathFromFileUri(allocator, owned_uri)) orelse + owned_uri; // If there's parsing error `parse` does not return the AST, but we can still use it however incomplete const ast = (parser.parse( std.mem.span(src), - if (std_lib_script_name != null) owned_uri else null, - std_lib_script_name orelse owned_uri, + owned_uri, + document_script_name, ) catch parser.ast) orelse parser.ast; @@ -144,6 +152,14 @@ const Document = struct { .errors = errors.items, }; + try doc.collectGlobalSymbols(allocator, parser.globals.items); + + // Keywords are document-independent, so store them with the precomputed + // global labels once instead of rebuilding them on every completion. + for (Token.keywords.keys()) |keyword| { + try doc.completion_labels.put(allocator, keyword, {}); + } + if (ast.root != null) { doc.computeInlayHints() catch return error.OutOfMemory; try doc.collectMemberDocblocks(&imports); @@ -152,6 +168,226 @@ const Document = struct { return doc; } + /// Collects current-file document symbols and all global completion labels from parser globals. + fn collectGlobalSymbols(self: *Document, allocator: std.mem.Allocator, globals: []const Parser.Global) !void { + const ast_slice = self.ast.slice(); + const tags = ast_slice.nodes.items(.tag); + const components = ast_slice.nodes.items(.components); + const locations = ast_slice.nodes.items(.location); + const end_locations = ast_slice.nodes.items(.end_location); + const lexemes = ast_slice.tokens.items(.lexeme); + + // Parser globals are the resolved top-level visibility table; collecting + // from it keeps document symbols and imported completions in one place. + for (globals) |global| { + if (global.hidden) { + continue; + } + + if (global.type_def.def_type == .Placeholder) { + continue; + } + + const name = lexemes[global.qualified_name.name]; + if (name.len == 1 and name[0] == '_') { + continue; + } + + // Imported globals are completion-only in this document. Their + // defining node can belong to the imported script, so collect the + // label before applying current-document symbol checks. + if (global.imported_from != null) { + var label = std.Io.Writer.Allocating.init(allocator); + + if (global.qualified_name.namespace.len > 0) { + for (global.qualified_name.namespace) |part| { + try label.writer.print("{s}\\", .{lexemes[part]}); + } + } + + try label.writer.writeAll(name); + + try self.completion_labels.put(allocator, label.written(), {}); + continue; + } + + const node_index: usize = @intCast(global.node); + if (node_index == 0 or node_index >= ast_slice.nodes.len) { + continue; + } + + try self.completion_labels.put(allocator, name, {}); + + const node = global.node; + switch (tags[node]) { + .VarDeclaration => { + const comp = components[node].VarDeclaration; + if (comp.slot_type == .Global) { + try self.symbols.append( + allocator, + .{ + .name = name, + .detail = try global.type_def.toStringAlloc(allocator, false), + .kind = if (!global.type_def.isMutable() and comp.final) + .Constant + else + .Variable, + .range = tokenToRange(ast_slice, locations[node], end_locations[node]), + .selectionRange = tokenToRange(ast_slice, locations[node], end_locations[node]), + }, + ); + } + }, + .Enum => { + const comp = components[node].Enum; + var children = std.ArrayList(lsp.types.DocumentSymbol).empty; + + for (comp.cases) |case| { + const range = tokenToRange( + ast_slice, + case.name, + if (case.value) |value| + end_locations[value] + else + case.name, + ); + + try children.append( + allocator, + .{ + .name = lexemes[case.name], + .kind = .EnumMember, + .range = range, + .selectionRange = range, + }, + ); + } + + try self.symbols.append( + allocator, + .{ + .name = name, + .detail = try global.type_def.toStringAlloc(allocator, false), + .kind = .Enum, + .range = tokenToRange(ast_slice, locations[node], end_locations[node]), + .selectionRange = tokenToRange(ast_slice, locations[node], end_locations[node]), + .children = try children.toOwnedSlice(allocator), + }, + ); + }, + .ObjectDeclaration => { + var children = std.ArrayList(lsp.types.DocumentSymbol).empty; + + if (global.type_def.def_type == .Object and global.type_def.resolved_type != null) { + var it = global.type_def.resolved_type.?.Object.fields.iterator(); + while (it.next()) |kv| { + const field = kv.value_ptr.*; + + try children.append( + allocator, + .{ + .name = field.name, + .detail = try field.type_def.toStringAlloc(allocator, false), + .kind = if (field.method) + .Method + else + .Property, + .range = tokenToRange(ast_slice, field.location, field.location), + .selectionRange = tokenToRange(ast_slice, field.location, field.location), + }, + ); + } + } + + try self.symbols.append( + allocator, + .{ + .name = name, + .detail = try global.type_def.toStringAlloc(allocator, false), + .kind = .Struct, + .range = tokenToRange(ast_slice, locations[node], end_locations[node]), + .selectionRange = tokenToRange(ast_slice, locations[node], end_locations[node]), + .children = try children.toOwnedSlice(allocator), + }, + ); + }, + .ProtocolDeclaration => { + var children = std.ArrayList(lsp.types.DocumentSymbol).empty; + + if (global.type_def.def_type == .Protocol and global.type_def.resolved_type != null) { + var it = global.type_def.resolved_type.?.Protocol.methods.iterator(); + while (it.next()) |kv| { + const method = kv.value_ptr.*; + const method_location = global.type_def.resolved_type.?.Protocol.methods_locations.get(kv.key_ptr.*).?; + const range = tokenToRange(ast_slice, method_location, method_location); + + try children.append( + allocator, + .{ + .name = kv.key_ptr.*, + .detail = try method.type_def.toStringAlloc(allocator, false), + .kind = .Method, + .range = range, + .selectionRange = range, + }, + ); + } + } + + try self.symbols.append( + allocator, + .{ + .name = name, + .detail = try global.type_def.toStringAlloc(allocator, false), + .kind = .Interface, + .range = tokenToRange(ast_slice, locations[node], end_locations[node]), + .selectionRange = tokenToRange(ast_slice, locations[node], end_locations[node]), + .children = try children.toOwnedSlice(allocator), + }, + ); + }, + .FunDeclaration => fun: { + if (global.type_def.def_type != .Function or global.type_def.resolved_type == null) { + break :fun; + } + + const fun_def = global.type_def.resolved_type.?.Function; + switch (fun_def.function_type) { + .Method, + .Script, + .ScriptEntryPoint, + .Anonymous, + .Repl, + => break :fun, + .Function, + .EntryPoint, + .Test, + .Extern, + .Abstract, + => {}, + } + + const function_node = components[node].FunDeclaration.function; + const function_comp = components[function_node].Function; + try self.symbols.append( + allocator, + .{ + .name = if (fun_def.function_type == .Test) + lexemes[function_comp.test_message.?] + else + fun_def.name.string, + .detail = try global.type_def.toStringAlloc(allocator, false), + .kind = .Function, + .range = tokenToRange(ast_slice, locations[function_node], end_locations[function_node]), + .selectionRange = tokenToRange(ast_slice, locations[function_node], end_locations[function_node]), + }, + ); + }, + else => {}, + } + } + } + pub fn deinit(self: *Document) void { self.arena.deinit(); } @@ -183,6 +419,8 @@ const Document = struct { const NodeUnderPositionContext = struct { result: ?Ast.Node.Index = null, + /// URI of the client document being searched. + uri: []const u8, position: lsp.types.Position, pub fn processNode( @@ -195,12 +433,19 @@ const Document = struct { const location = ast.tokens.get(locations[node]); const end_locations = ast.nodes.items(.end_location); const end_location = ast.tokens.get(end_locations[node]); + const script_names = ast.tokens.items(.script_name); // Ignore root node and imports if (locations[node] == 0) { return false; } + // Walking from the document root must only visit document-local + // nodes. Imported scripts share the backing token/node lists, so + // assert the parser did not attach an imported token to this range. + std.debug.assert(std.mem.eql(u8, script_names[locations[node]], self.uri)); + std.debug.assert(std.mem.eql(u8, script_names[end_locations[node]], self.uri)); + // If outside of the node range, don't go deeper if (self.position.line < location.line or self.position.line > end_location.line or @@ -228,10 +473,16 @@ const Document = struct { if (!nodeEntry.found_existing) { var node_ctx = NodeUnderPositionContext{ + .uri = self.uri, .position = position, }; - self.ast.slice().walk(allocator, &node_ctx, self.ast.root.?) catch |err| { + self.ast.slice().walk( + allocator, + &node_ctx, + self.ast.root.?, + .breadthFirst, + ) catch |err| { log.err("nodeUnderPosition: {any}", .{err}); }; @@ -621,6 +872,7 @@ const Document = struct { allocator, &ctx, self.ast.root.?, + .breadthFirst, ); } @@ -634,6 +886,7 @@ const Document = struct { allocator, &ctx, self.ast.root.?, + .breadthFirst, ); var imports_it = imports.valueIterator(); @@ -642,6 +895,7 @@ const Document = struct { allocator, &ctx, import.function, + .breadthFirst, ); } } @@ -686,10 +940,9 @@ const Handler = struct { allocator: std.mem.Allocator, transport: *lsp.Transport, documents: std.StringHashMapUnmanaged(Document) = .empty, - offset_encoding: lsp.offsets.Encoding = .@"utf-16", pub fn initialize( - self: *Handler, + _: *Handler, allocator: std.mem.Allocator, params: lsp.types.requests.get("initialize").?.Params.?, ) !lsp.types.requests.get("initialize").?.Result { @@ -715,11 +968,7 @@ const Handler = struct { .version = version.written(), }, .capabilities = .{ - .positionEncoding = switch (self.offset_encoding) { - .@"utf-8" => .@"utf-8", - .@"utf-16" => .@"utf-16", - .@"utf-32" => .@"utf-32", - }, + .positionEncoding = .@"utf-8", .textDocumentSync = .{ .text_document_sync_options = .{ .openClose = true, @@ -762,11 +1011,13 @@ const Handler = struct { .documentFormattingProvider = .{ .bool = true, }, + .completionProvider = .{ + .triggerCharacters = &.{"\\"}, + }, // Keeping those here so I don't forget about them // NYI - .completionProvider = null, .referencesProvider = null, .documentHighlightProvider = null, .workspaceSymbolProvider = null, @@ -929,13 +1180,13 @@ const Handler = struct { const start_idx = lsp.offsets.positionToIndex( old_text, range.start, - self.offset_encoding, + .@"utf-8", ); const end_idx = lsp.offsets.positionToIndex( old_text, range.end, - self.offset_encoding, + .@"utf-8", ); var new_text = std.ArrayList(u8).empty; @@ -975,243 +1226,98 @@ const Handler = struct { kv.value.deinit(); } - const DocumentSymbolContext = struct { - document: *Document, - - pub fn processNode( - self: DocumentSymbolContext, - _: std.mem.Allocator, - ast: Ast.Slice, - node: Ast.Node.Index, - ) (std.mem.Allocator.Error || std.fmt.BufPrintError || error{WriteFailed})!bool { - const lexemes = ast.tokens.items(.lexeme); - const locations = ast.nodes.items(.location); - const end_locations = ast.nodes.items(.end_location); - const components = ast.nodes.items(.components)[node]; - const type_def = ast.nodes.items(.type_def)[node]; - const allocator = self.document.arena.allocator(); - - switch (ast.nodes.items(.tag)[node]) { - .VarDeclaration => { - const name = lexemes[components.VarDeclaration.name]; - - if (components.VarDeclaration.slot_type == .Global and (name.len > 1 or name[0] != '_')) { - try self.document.symbols.append( - allocator, - .{ - .name = lexemes[components.VarDeclaration.name], - .detail = if (type_def) |td| - try td.toStringAlloc(allocator, false) - else - null, - .kind = if (type_def != null and !type_def.?.isMutable() and components.VarDeclaration.final) - .Constant - else - .Variable, - .range = tokenToRange(ast, locations[node], end_locations[node]), - .selectionRange = tokenToRange(ast, locations[node], end_locations[node]), - }, - ); - } - }, - .Enum => { - var children = std.ArrayList(lsp.types.DocumentSymbol).empty; - - for (components.Enum.cases) |case| { - const range = tokenToRange( - ast, - case.name, - if (case.value) |value| - end_locations[value] - else - case.name, - ); - - try children.append( - allocator, - .{ - .name = lexemes[case.name], - .kind = .EnumMember, - .range = range, - .selectionRange = range, - }, - ); - } - - try self.document.symbols.append( - allocator, - .{ - .name = lexemes[components.Enum.name], - .detail = if (type_def) |td| - try td.toStringAlloc(allocator, false) - else - null, - .kind = .Enum, - .range = tokenToRange(ast, locations[node], end_locations[node]), - .selectionRange = tokenToRange(ast, locations[node], end_locations[node]), - .children = try children.toOwnedSlice(allocator), - }, - ); - }, - .ObjectDeclaration => { - var children = std.ArrayList(lsp.types.DocumentSymbol).empty; - - if (type_def) |td| { - var it = td.resolved_type.?.Object.fields.iterator(); - while (it.next()) |kv| { - const field = kv.value_ptr.*; + pub fn @"textDocument/documentSymbol"( + self: Handler, + _: std.mem.Allocator, + notification: lsp.types.requests.get("textDocument/documentSymbol").?.Params.?, + ) !lsp.types.requests.get("textDocument/documentSymbol").?.Result { + if (self.documents.getEntry(notification.textDocument.uri)) |kv| { + return .{ + .document_symbols = kv.value_ptr.symbols.items, + }; + } - try children.append( - allocator, - .{ - .name = field.name, - .detail = try field.type_def.toStringAlloc(allocator, false), - .kind = if (field.method) - .Method - else - .Property, - .range = tokenToRange(ast, field.location, field.location), - .selectionRange = tokenToRange(ast, field.location, field.location), - }, - ); - } - } + return null; + } - try self.document.symbols.append( - allocator, - .{ - .name = lexemes[components.ObjectDeclaration.name], - .detail = if (type_def) |td| - try td.toStringAlloc(allocator, false) - else - null, - .kind = .Struct, - .range = tokenToRange(ast, locations[node], end_locations[node]), - .selectionRange = tokenToRange(ast, locations[node], end_locations[node]), - .children = try children.toOwnedSlice(allocator), - }, - ); - }, - .ProtocolDeclaration => { - var children = std.ArrayList(lsp.types.DocumentSymbol).empty; + pub fn @"textDocument/completion"( + self: Handler, + allocator: std.mem.Allocator, + notification: lsp.types.requests.get("textDocument/completion").?.Params.?, + ) !lsp.types.requests.get("textDocument/completion").?.Result { + var result = std.ArrayList(lsp.types.completion.Item).empty; + + if (self.documents.getPtr(notification.textDocument.uri)) |document| { + const cursor_offset = @min( + lsp.offsets.positionToIndex( + std.mem.span(document.src), + notification.position, + .@"utf-8", + ), + std.mem.span(document.src).len, + ); + const completion_prefix = completionPrefixAtOffset( + std.mem.span(document.src), + document.uri, + document.ast.slice(), + cursor_offset, + ); - if (type_def) |td| { - var it = td.resolved_type.?.Protocol.methods.iterator(); - while (it.next()) |kv| { - const method = kv.value_ptr.*; + // Globals and keyword completion items + for (document.completion_labels.keys()) |label| { + try appendCompletionItem( + &result, + allocator, + label, + completion_prefix, + ); + } - const method_location = td.resolved_type.?.Protocol.methods_locations.get(kv.key_ptr.*).?; - const range = tokenToRange(ast, method_location, method_location); + // Locals reachable at that offset in the source + var local_labels = std.StringArrayHashMapUnmanaged(void).empty; + defer local_labels.deinit(allocator); - try children.append( - allocator, - .{ - .name = kv.key_ptr.*, - .detail = try method.type_def.toStringAlloc(allocator, false), - .kind = .Method, - .range = range, - .selectionRange = range, - }, - ); - } - } + if (document.ast.root) |root| { + const ast = document.ast.slice(); + const tags = ast.nodes.items(.tag); + const components = ast.nodes.items(.components); + const lexemes = ast.tokens.items(.lexeme); - try self.document.symbols.append( - allocator, - .{ - .name = lexemes[components.ProtocolDeclaration.name], - .detail = if (type_def) |td| - try td.toStringAlloc(allocator, false) - else - null, - .kind = .Interface, - .range = tokenToRange(ast, locations[node], end_locations[node]), - .selectionRange = tokenToRange(ast, locations[node], end_locations[node]), - .children = try children.toOwnedSlice(allocator), - }, - ); - }, - .Function => fun: { - if (type_def) |td| { - const fun_def = td.resolved_type.?.Function; - - switch (fun_def.function_type) { - .Method, // Already covered when looking at ObjectDeclaration - .Script, // Imported script - .ScriptEntryPoint, // Script entry point - .Anonymous, // No name to list - .Repl, // Should not happen - => break :fun, - .Function, - .EntryPoint, - .Test, - .Extern, - .Abstract, - => {}, + for (try ast.visibleSymbolsAtOffset( + allocator, + root, + cursor_offset, + )) |symbol_node| { + if (tags[symbol_node] == .VarDeclaration) { + const decl = components[symbol_node].VarDeclaration; + const label = lexemes[decl.name]; + + if (document.completion_labels.getIndex(label) != null or + (try local_labels.getOrPut(allocator, label)).found_existing) + { + continue; } - try self.document.symbols.append( + try appendCompletionItem( + &result, allocator, - .{ - .name = if (fun_def.function_type == .Test) - lexemes[components.Function.test_message.?] - else - fun_def.name.string, - .detail = try td.toStringAlloc(allocator, false), - .kind = .Function, - .range = tokenToRange(ast, locations[node], end_locations[node]), - .selectionRange = tokenToRange(ast, locations[node], end_locations[node]), - }, + label, + completion_prefix, ); } - }, - else => {}, + } } - return false; - } - }; + // If after a `.`, complete we member - pub fn @"textDocument/documentSymbol"( - self: Handler, - _: std.mem.Allocator, - notification: lsp.types.requests.get("textDocument/documentSymbol").?.Params.?, - ) !lsp.types.requests.get("textDocument/documentSymbol").?.Result { - if (self.documents.getEntry(notification.textDocument.uri)) |kv| { - if (kv.value_ptr.ast.root) |root| { - if (kv.value_ptr.symbols.items.len == 0) { - var document = kv.value_ptr.*; - - const ctx = DocumentSymbolContext{ - .document = &document, - }; - - document.ast.slice().walk(self.allocator, ctx, root) catch |err| { - log.err("textDocument/documentSymbol: {any}", .{err}); - - document.symbols = .empty; - }; - - kv.value_ptr.* = document; - } - - return .{ - .document_symbols = kv.value_ptr.symbols.items, - }; - } + // Do we already have Dot node under the cursor (meaning we're at something like `callee.me| `) + // Else find the node living just before `.` and treat it as the callee } - return null; - } - pub fn @"textDocument/completion"( - _: Handler, - _: std.mem.Allocator, - _: lsp.types.requests.get("textDocument/completion").?.Params.?, - ) !lsp.types.requests.get("textDocument/completion").?.Result { return .{ .completion_list = .{ .isIncomplete = false, - .items = &.{}, + .items = try result.toOwnedSlice(allocator), }, }; } @@ -1221,8 +1327,7 @@ const Handler = struct { _: std.mem.Allocator, notification: lsp.types.requests.get("textDocument/hover").?.Params.?, ) !lsp.types.requests.get("textDocument/hover").?.Result { - if (self.documents.getEntry(notification.textDocument.uri)) |kv| { - var document = kv.value_ptr.*; + if (self.documents.getPtr(notification.textDocument.uri)) |document| { const allocator = document.arena.allocator(); if (try document.nodeUnderPosition(notification.position)) |origin| { @@ -1416,6 +1521,140 @@ const Handler = struct { } }; +/// Prefix text and replacement range for a completion request at the cursor. +const CompletionPrefix = struct { + /// Text already present in the document that completion labels must match. + text: []const u8, + + /// LSP range replaced by the selected completion item. + range: lsp.types.Range, +}; + +/// Appends a completion item, applying prefix filtering and replacement edits when available. +fn appendCompletionItem( + result: *std.ArrayList(lsp.types.completion.Item), + allocator: std.mem.Allocator, + label: []const u8, + prefix: ?CompletionPrefix, +) !void { + if (prefix) |completion_prefix| { + if (!std.mem.startsWith(u8, label, completion_prefix.text)) { + return; + } + + try result.append( + allocator, + .{ + .label = label, + .textEdit = .{ + .text_edit = .{ + .range = completion_prefix.range, + .newText = label, + }, + }, + }, + ); + } else { + try result.append( + allocator, + .{ .label = label }, + ); + } +} + +/// Finds the contiguous completion prefix immediately before or under the cursor. +fn completionPrefixAtOffset( + source: []const u8, + uri: []const u8, + ast: Ast.Slice, + cursor_offset: usize, +) ?CompletionPrefix { + const bounded_cursor = @min(cursor_offset, source.len); + const tags = ast.tokens.items(.tag); + const lexemes = ast.tokens.items(.lexeme); + const offsets = ast.tokens.items(.offset); + const script_names = ast.tokens.items(.script_name); + const utility_tokens = ast.tokens.items(.utility_token); + + // `current_prefix_*` describes the contiguous run of prefix tokens we are + // currently scanning. For `std\pr`, it starts at `std` and ends after `pr`. + var current_prefix_start_token: ?usize = null; + var current_prefix_end: usize = 0; + + // Once the cursor is inside the current run, this becomes the first token + // of the prefix to replace. + var prefix_start_token: ?usize = null; + + for (tags, 0..) |tag, index| { + if (utility_tokens[index] or + !std.mem.eql(u8, script_names[index], uri) or + !isCompletionPrefixToken(tag, lexemes[index])) + { + continue; + } + + const token_start = offsets[index]; + const token_end = token_start + lexemes[index].len; + if (token_start >= bounded_cursor) { + continue; + } + + // A gap means whitespace, punctuation, or another non-prefix token broke + // the typed prefix. Start a new run from the current token. + if (current_prefix_start_token == null or token_start != current_prefix_end) { + current_prefix_start_token = index; + } + current_prefix_end = token_end; + + // The cursor is inside or just after this token, so the current run is + // the text the selected completion item should replace. + if (bounded_cursor <= token_end) { + prefix_start_token = current_prefix_start_token; + break; + } + } + + if (prefix_start_token == null or offsets[prefix_start_token.?] >= bounded_cursor) { + return null; + } + + const prefix_start = offsets[prefix_start_token.?]; + const text = source[prefix_start..bounded_cursor]; + + // This should be redundant with the contiguous-token tracking above, but it + // keeps the edit conservative if scanner recovery ever exposes odd gaps. + if (std.mem.indexOfAny(u8, text, " \t\r\n") != null) { + return null; + } + + // Token columns are already LSP UTF-8 columns; use the first prefix token + // for the range start and the typed byte length for the range end. + const range_start = tokenToRange( + ast, + @intCast(prefix_start_token.?), + @intCast(prefix_start_token.?), + ).start; + + return .{ + .text = text, + .range = .{ + .start = range_start, + .end = .{ + .line = range_start.line, + .character = range_start.character + @as(u32, @intCast(text.len)), + }, + }, + }; +} + +/// Returns whether a token can be part of a typed completion prefix. +fn isCompletionPrefixToken(tag: Token.Tag, lexeme: []const u8) bool { + return tag == .AntiSlash or + tag == .Identifier or + Token.keywords.get(lexeme) != null; +} + +/// Builds an LSP range from AST token locations. fn tokenToRange(ast: Ast.Slice, location: Ast.TokenIndex, end_location: Ast.TokenIndex) lsp.types.Range { const lines = ast.tokens.items(.line); const columns = ast.tokens.items(.column); @@ -1432,6 +1671,24 @@ fn tokenToRange(ast: Ast.Slice, location: Ast.TokenIndex, end_location: Ast.Toke }; } +/// Converts a local `file://` document URI into the script path used by parser semantics. +/// For example, `file:///tmp/project/main.buzz` becomes `/tmp/project/main.buzz`. +fn localPathFromFileUri(allocator: std.mem.Allocator, uri: []const u8) !?[]const u8 { + const parsed = std.Uri.parse(uri) catch return null; + if (!std.ascii.eqlIgnoreCase(parsed.scheme, "file")) { + return null; + } + + if (parsed.host) |host| { + const raw_host = try host.toRawMaybeAlloc(allocator); + if (raw_host.len > 0 and !std.ascii.eqlIgnoreCase(raw_host, "localhost")) { + return null; + } + } + + return try parsed.path.toRawMaybeAlloc(allocator); +} + fn scriptNameToUri(io: std.Io, allocator: std.mem.Allocator, buzz_lib_path: []const u8, script_name: []const u8) ![]const u8 { if (isClientUri(script_name)) { return script_name; @@ -1479,39 +1736,18 @@ fn isClientUri(text: []const u8) bool { } fn staticScriptFileName(script_name: []const u8) ?[]const u8 { - if (std.mem.eql(u8, script_name, "std")) return "std.buzz"; - if (std.mem.eql(u8, script_name, "gc")) return "gc.buzz"; - if (std.mem.eql(u8, script_name, "math")) return "math.buzz"; - if (std.mem.eql(u8, script_name, "debug")) return "debug.buzz"; - if (std.mem.eql(u8, script_name, "buffer")) return "buffer.buzz"; - if (std.mem.eql(u8, script_name, "serialize")) return "serialize.buzz"; - if (std.mem.eql(u8, script_name, "errors")) return "errors.buzz"; - if (std.mem.eql(u8, script_name, "test")) return "testing.buzz"; - if (std.mem.eql(u8, script_name, "crypto")) return "crypto.buzz"; - if (std.mem.eql(u8, script_name, "ffi")) return "ffi.buzz"; - if (std.mem.eql(u8, script_name, "fs")) return "fs.buzz"; - if (std.mem.eql(u8, script_name, "io")) return "io.buzz"; - if (std.mem.eql(u8, script_name, "os")) return "os.buzz"; - if (std.mem.eql(u8, script_name, "http")) return "http.buzz"; - - return null; + return if (static_libraries.byName(script_name)) |library| + library.header_path + else + null; } fn staticScriptNameFromUri(uri: []const u8) ?[]const u8 { - if (isStaticScriptUri(uri, "std.buzz")) return "std"; - if (isStaticScriptUri(uri, "gc.buzz")) return "gc"; - if (isStaticScriptUri(uri, "math.buzz")) return "math"; - if (isStaticScriptUri(uri, "debug.buzz")) return "debug"; - if (isStaticScriptUri(uri, "buffer.buzz")) return "buffer"; - if (isStaticScriptUri(uri, "serialize.buzz")) return "serialize"; - if (isStaticScriptUri(uri, "errors.buzz")) return "errors"; - if (isStaticScriptUri(uri, "testing.buzz")) return "test"; - if (isStaticScriptUri(uri, "crypto.buzz")) return "crypto"; - if (isStaticScriptUri(uri, "ffi.buzz")) return "ffi"; - if (isStaticScriptUri(uri, "fs.buzz")) return "fs"; - if (isStaticScriptUri(uri, "io.buzz")) return "io"; - if (isStaticScriptUri(uri, "os.buzz")) return "os"; - if (isStaticScriptUri(uri, "http.buzz")) return "http"; + inline for (static_libraries.all) |library| { + if (isStaticScriptUri(uri, library.header_path)) { + return library.name; + } + } return null; } @@ -1534,62 +1770,3 @@ fn isStaticScriptUri(uri: []const u8, file_name: []const u8) bool { return std.mem.endsWith(u8, uri, src_lib) or std.mem.endsWith(u8, uri, installed_lib); } - -test "scriptNameToUri converts paths to LSP URIs" { - const allocator = std.heap.page_allocator; - - try expectScriptNameUri(allocator, "file:///tmp/already.buzz", "file:///tmp/already.buzz"); - try expectScriptNameUri(allocator, "untitled:Untitled-1", "untitled:Untitled-1"); - - try expectScriptNameUri(allocator, "/tmp/buzz lsp.buzz", "file:///tmp/buzz%20lsp.buzz"); - - try expectScriptNameUri(allocator, "std", "file:///tmp/buzz%20lib/std.buzz"); - try expectScriptNameUri(allocator, "test", "file:///tmp/buzz%20lib/testing.buzz"); - try std.testing.expectEqualStrings( - "buffer", - staticScriptNameFromUri("file:///repo/src/lib/buffer.buzz").?, - ); - try std.testing.expectEqualStrings( - "test", - staticScriptNameFromUri("file:///repo/src/lib/testing.buzz").?, - ); - try std.testing.expect(staticScriptNameFromUri("file:///repo/not-lib/buffer.buzz") == null); - - const expected_relative_path = try testRelativePath(allocator, "src/lsp.zig"); - const expected_relative_uri = try fileUri(allocator, expected_relative_path); - - try expectScriptNameUri(allocator, "src/lsp.zig", expected_relative_uri); - - const expected_weird_path = try testRelativePath(allocator, "foo:STUPIDSHIT"); - const expected_weird_uri = try fileUri(allocator, expected_weird_path); - - try expectScriptNameUri(allocator, "foo:STUPIDSHIT", expected_weird_uri); -} - -fn expectScriptNameUri(allocator: std.mem.Allocator, script_name: []const u8, expected: []const u8) !void { - const uri = try scriptNameToUri(std.testing.io, allocator, "/tmp/buzz lib", script_name); - - try std.testing.expectEqualStrings(expected, uri); -} - -fn fileUri(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { - return std.fmt.allocPrint( - allocator, - "{f}", - .{std.Uri{ - .scheme = "file", - .host = .{ .percent_encoded = "" }, - .path = .{ .raw = path }, - }}, - ); -} - -fn testRelativePath(allocator: std.mem.Allocator, relative: []const u8) ![]u8 { - var cwd_buffer: [std.Io.Dir.max_path_bytes]u8 = undefined; - const cwd_len = try std.process.currentPath(std.testing.io, &cwd_buffer); - - return std.fs.path.join( - allocator, - &.{ cwd_buffer[0..cwd_len], relative }, - ); -} diff --git a/src/renderer.zig b/src/renderer.zig index c404eeff..cd727e0e 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -26,6 +26,8 @@ pub const Renderer = struct { ais: *AutoIndentingStream, indent: u16 = 0, + /// Suppresses declaration docblocks while an exported declaration wrapper renders them. + suppress_docblock: bool = false, pub const Error = error{ AccessDenied, @@ -556,9 +558,9 @@ pub const Renderer = struct { fn renderFunDeclaration(self: *Self, node: Ast.Node.Index, space: Space) Error!void { // docblock - if (self.ast.nodes.items(.docblock)[node]) |docblock| { + if (!self.suppress_docblock and self.ast.nodes.items(.docblock)[node] != null) { try self.renderExpectedToken( - docblock, + self.ast.nodes.items(.docblock)[node].?, .Docblock, .None, ); @@ -2030,6 +2032,15 @@ pub const Renderer = struct { const components = self.ast.nodes.items(.components)[node].Enum; const tags = self.ast.tokens.items(.tag); + // docblock + if (!self.suppress_docblock and self.ast.nodes.items(.docblock)[node] != null) { + try self.renderExpectedToken( + self.ast.nodes.items(.docblock)[node].?, + .Docblock, + .None, + ); + } + // enum try self.renderExpectedToken( locations[node], @@ -2134,8 +2145,21 @@ pub const Renderer = struct { fn renderExport(self: *Self, node: Ast.Node.Index, space: Space) Error!void { const locations = self.ast.nodes.items(.location); + const docblocks = self.ast.nodes.items(.docblock); const components = self.ast.nodes.items(.components)[node].Export; + const docblock = if (components.declaration) |decl| + docblocks[decl] orelse docblocks[node] + else + docblocks[node]; + if (docblock) |d| { + try self.renderExpectedToken( + d, + .Docblock, + .None, + ); + } + // export try self.renderExpectedToken( locations[node], @@ -2144,6 +2168,10 @@ pub const Renderer = struct { ); if (components.declaration) |decl| { + const suppress_docblock = self.suppress_docblock; + self.suppress_docblock = true; + defer self.suppress_docblock = suppress_docblock; + try self.renderNode(decl, space); } else { try self.renderQualifiedName( @@ -2652,7 +2680,12 @@ pub const Renderer = struct { for (branch.conditions, 0..) |condition, idx| { try self.renderNode( condition, - if (conditions_ends_with_comma) .Newline else .Space, + if (conditions_ends_with_comma) + .Newline + else if (idx == branch.conditions.len - 1) + .Space + else + .None, ); if (idx == branch.conditions.len - 1) { @@ -2794,9 +2827,9 @@ pub const Renderer = struct { .Object.fields; // docblock - if (self.ast.nodes.items(.docblock)[node]) |docblock| { + if (!self.suppress_docblock and self.ast.nodes.items(.docblock)[node] != null) { try self.renderExpectedToken( - docblock, + self.ast.nodes.items(.docblock)[node].?, .Docblock, .None, ); @@ -3063,9 +3096,9 @@ pub const Renderer = struct { const components = self.ast.nodes.items(.components)[node].ProtocolDeclaration; // docblock - if (self.ast.nodes.items(.docblock)[node]) |docblock| { + if (!self.suppress_docblock and self.ast.nodes.items(.docblock)[node] != null) { try self.renderExpectedToken( - docblock, + self.ast.nodes.items(.docblock)[node].?, .Docblock, .None, ); @@ -3450,9 +3483,9 @@ pub const Renderer = struct { const components = self.ast.nodes.items(.components)[node].VarDeclaration; // docblock - if (self.ast.nodes.items(.docblock)[node]) |docblock| { + if (!self.suppress_docblock and self.ast.nodes.items(.docblock)[node] != null) { try self.renderExpectedToken( - docblock, + self.ast.nodes.items(.docblock)[node].?, .Docblock, .None, ); diff --git a/tests/behavior/common-namespace.buzz b/tests/behavior/common-namespace.buzz index 5367f9d0..0c332129 100644 --- a/tests/behavior/common-namespace.buzz +++ b/tests/behavior/common-namespace.buzz @@ -1,8 +1,10 @@ namespace commom\part; import "tests/utils/common-namespace"; +import "tests/utils/common-namespace-sibling"; import "std"; test "Import with common part in namespace" { std\assert(here\message == "hello world"); + std\assert(sibling\siblingMessage == "hello sibling"); } diff --git a/tests/behavior/match.buzz b/tests/behavior/match.buzz index 93e36d57..6757d253 100644 --- a/tests/behavior/match.buzz +++ b/tests/behavior/match.buzz @@ -4,6 +4,12 @@ object MatchBox { value: int = 1, } +/// Event object used by match statement block tests. +object MatchEvent { + /// Event kind. + kind: str, +} + enum MatchEnum { one, two, @@ -16,6 +22,15 @@ fun statementMatch(value: int) > void { } } +/// Exercise match conditions that are only known at runtime. +fun dynamicConditionMatch(value: int, dynamic: int) > str { + return match (value) { + dynamic -> "dynamic", + 1 -> "literal", + else -> "other", + }; +} + test "match expression and statement" { final value = 2; @@ -32,6 +47,57 @@ test "match expression and statement" { statementMatch(3); } +test "match dynamic conditions may equal literals" { + std\assert( + dynamicConditionMatch(1, dynamic: 1) == "dynamic", + message: "runtime condition values may overlap literal conditions", + ); +} + +/// Exercise statement match branches whose bodies are lexical blocks. +fun statementBlockMatch(value: int) > str { + var result = "unset"; + var selected = 0; + + match (value) { + 1 -> { + final branch = "one"; + selected = selected + 1; + result = branch; + }, + 2 -> { + final branch = "two"; + selected = selected + 10; + result = branch; + }, + else -> { + final event = MatchEvent{ + kind = "other", + }; + result = event.kind; + }, + } + + return "{result}:{selected}"; +} + +test "match statement branch blocks" { + std\assert( + statementBlockMatch(1) == "one:1", + message: "match statement branch block can run multiple statements", + ); + + std\assert( + statementBlockMatch(2) == "two:10", + message: "match statement branch blocks have sibling lexical scopes", + ); + + std\assert( + statementBlockMatch(3) == "other:0", + message: "match statement else branch block can contain object initialization", + ); +} + test "match string and pattern" { final string_value = "hello joe"; final pattern_value = $"hello [a-z]+"; diff --git a/tests/compile_errors/import-syntax-error.buzz b/tests/compile_errors/import-syntax-error.buzz new file mode 100644 index 00000000..14f60774 --- /dev/null +++ b/tests/compile_errors/import-syntax-error.buzz @@ -0,0 +1,4 @@ +// Type mismatch: got type `str`, expected `int` +import "tests/compile_errors/utils/import-syntax-error"; + +test "imported syntax errors are reported" {} diff --git a/tests/compile_errors/match-duplicate-condition-folded.buzz b/tests/compile_errors/match-duplicate-condition-folded.buzz new file mode 100644 index 00000000..b1177c71 --- /dev/null +++ b/tests/compile_errors/match-duplicate-condition-folded.buzz @@ -0,0 +1,8 @@ +// Duplicate `match` condition +test "match duplicate folded condition" { + _ = match (2) { + 1 + 1 -> "folded", + 2 -> "literal", + else -> "other", + }; +} diff --git a/tests/compile_errors/match-duplicate-condition-number.buzz b/tests/compile_errors/match-duplicate-condition-number.buzz new file mode 100644 index 00000000..c65a5e51 --- /dev/null +++ b/tests/compile_errors/match-duplicate-condition-number.buzz @@ -0,0 +1,8 @@ +// Duplicate `match` condition +test "match duplicate numeric condition" { + _ = match (1) { + 1 -> "int", + 1.0 -> "double", + else -> "other", + }; +} diff --git a/tests/compile_errors/match-duplicate-condition-same-branch.buzz b/tests/compile_errors/match-duplicate-condition-same-branch.buzz new file mode 100644 index 00000000..b8b1587d --- /dev/null +++ b/tests/compile_errors/match-duplicate-condition-same-branch.buzz @@ -0,0 +1,7 @@ +// Duplicate `match` condition +test "match duplicate condition in same branch" { + _ = match (1) { + 1, 1 -> "one", + else -> "other", + }; +} diff --git a/tests/compile_errors/match-overlapping-folded-range.buzz b/tests/compile_errors/match-overlapping-folded-range.buzz new file mode 100644 index 00000000..2020c3be --- /dev/null +++ b/tests/compile_errors/match-overlapping-folded-range.buzz @@ -0,0 +1,8 @@ +// Overlapping `match` condition +test "match overlapping folded range condition" { + _ = match (4) { + 1..(2 + 3) -> "folded", + 4..8 -> "range", + else -> "other", + }; +} diff --git a/tests/compile_errors/match-overlapping-range.buzz b/tests/compile_errors/match-overlapping-range.buzz new file mode 100644 index 00000000..eeca2b10 --- /dev/null +++ b/tests/compile_errors/match-overlapping-range.buzz @@ -0,0 +1,8 @@ +// Overlapping `match` condition +test "match overlapping range condition" { + _ = match (4) { + 1..5 -> "left", + 4..8 -> "right", + else -> "other", + }; +} diff --git a/tests/compile_errors/match-range-containing-literal.buzz b/tests/compile_errors/match-range-containing-literal.buzz new file mode 100644 index 00000000..5125b160 --- /dev/null +++ b/tests/compile_errors/match-range-containing-literal.buzz @@ -0,0 +1,8 @@ +// Overlapping `match` condition +test "match range containing literal condition" { + _ = match (3) { + 1..5 -> "range", + 3 -> "literal", + else -> "other", + }; +} diff --git a/tests/compile_errors/utils/import-syntax-error.buzz b/tests/compile_errors/utils/import-syntax-error.buzz new file mode 100644 index 00000000..95623138 --- /dev/null +++ b/tests/compile_errors/utils/import-syntax-error.buzz @@ -0,0 +1,9 @@ +fun importedSyntaxError() > void { + final value = 1; + final other = "42"; + final unused = 42; + + if (value == other) { + // print("hello"); + } +} diff --git a/tests/utils/common-namespace-sibling.buzz b/tests/utils/common-namespace-sibling.buzz new file mode 100644 index 00000000..80d8187a --- /dev/null +++ b/tests/utils/common-namespace-sibling.buzz @@ -0,0 +1,4 @@ +namespace commom\sibling; + +/// Message exported from a sibling namespace. +export final siblingMessage = "hello sibling";