feat(dx): lsp repositioning using sm

commit 1c89576ef4932f511a040aeca723745487d8a73c
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 16:58:19 2026 +0600

    sync

commit 0ee4cb79e3bcbe9990411f74b8b90402d1b57ffd
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 16:06:20 2026 +0600

    sync

commit 06630e1565d920ee91432174a91903086a315950
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 15:41:39 2026 +0600

    sync

commit f4c17b80756c8cfe1183ec2b3057893afbd78889
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 15:07:17 2026 +0600

    sync

commit bb534f820d8e00f4e476a1c6b0fae545f361f8c4
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 14:52:08 2026 +0600

    sync

commit 424b1219bf469688bd6d7e56ebd0b4428bef6255
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 14:15:29 2026 +0600

    sync

commit 7c0a3705fc6127aadb5db79dfb79ea38c4d2a6b8
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 13:53:16 2026 +0600

    sync

commit 1ef5afc45465eab4d4400e3b2f4521a766c42a5a
Author: Nurul Huda (Apon) <me@nurulhudaapon.com>
Date:   Sun Mar 22 13:34:32 2026 +0600

    sync
This commit is contained in:
Nurul Huda (Apon) 2026-03-22 17:05:11 +06:00
parent 1b285ab93d
commit 6e23df23c3
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
5 changed files with 757 additions and 89 deletions

View File

@ -94,6 +94,8 @@ pub const TranspileContext = struct {
zx_initialized: bool = false,
/// Track client components (components with @rendering attribute)
client_components: std.ArrayList(ClientComponentMetadata),
/// Optional position of an open parenthesis in source to map the next generated parenthesis to
paren_byte: ?u32 = null,
allocator: std.mem.Allocator,
pub const TranspileOptions = struct {
@ -143,6 +145,17 @@ pub const TranspileContext = struct {
try self.writeWithMapping(bytes, pos.line, pos.column);
}
fn addMapping(self: *TranspileContext, source_byte: u32, ast: *const Ast) !void {
if (!self.track_mappings) return;
const pos = ast.getLineColumn(source_byte);
try self.sourcemap_builder.addMapping(.{
.generated_line = self.current_line,
.generated_column = self.current_column,
.source_line = pos.line,
.source_column = pos.column,
});
}
fn updatePosition(self: *TranspileContext, bytes: []const u8) void {
for (bytes) |byte| {
if (byte == '\n') {
@ -448,6 +461,24 @@ pub fn transpileReturn(self: *Ast, node: ts.Node, ctx: *TranspileContext) !void
// This should NOT initialize _zx here - that's done in the parent block
const zx_block_node = findZxBlockInReturn(node);
// Find the parenthesis position if present
var paren_byte: ?u32 = null;
const child_count = node.childCount();
var i: u32 = 0;
while (i < child_count) : (i += 1) {
const child = node.child(i) orelse continue;
if (NodeKind.fromNode(child) == .parenthesized_expression) {
// The first child of parenthesized_expression is '('
const open_paren = child.child(0);
if (open_paren) |p| {
if (std.mem.eql(u8, p.kind(), "(")) {
paren_byte = p.startByte();
}
}
break;
}
}
if (zx_block_node) |zx_node| {
// Find the element inside the zx_block
const zx_child_count = zx_node.childCount();
@ -461,8 +492,8 @@ pub fn transpileReturn(self: *Ast, node: ts.Node, ctx: *TranspileContext) !void
// Check if we need to initialize _zx with allocator
const allocator_value = try getAllocatorAttribute(self, child);
try ctx.writeM("var", node.startByte(), self);
try ctx.write(" _zx = @import(\"zx\").");
// Synthesized _zx init no source mapping (it's boilerplate)
try ctx.write("var _zx = @import(\"zx\").");
if (allocator_value) |alloc| {
try ctx.write("allocInit(");
try ctx.write(alloc);
@ -474,8 +505,13 @@ pub fn transpileReturn(self: *Ast, node: ts.Node, ctx: *TranspileContext) !void
// Mark that _zx is now initialized for nested ZX blocks
ctx.zx_initialized = true;
try ctx.writeIndent();
// Map generated `return` to the source `return` keyword
try ctx.writeM("return", node.startByte(), self);
try ctx.write(" ");
// Set the paren position for the upcoming element transpilation
ctx.paren_byte = paren_byte;
try transpileElement(self, child, ctx, true);
// Reset the flag after processing the return statement
ctx.zx_initialized = false;
@ -636,22 +672,39 @@ pub fn transpileFragment(self: *Ast, node: ts.Node, ctx: *TranspileContext, is_r
var children = std.ArrayList(ts.Node){};
defer children.deinit(ctx.output.allocator);
var end_tag_start_byte: u32 = node.endByte();
var end_tag_end_byte: u32 = node.endByte();
const child_count = node.childCount();
var i: u32 = 0;
while (i < child_count) : (i += 1) {
const child = node.child(i) orelse continue;
if (NodeKind.fromNode(child) == .zx_child) {
const kind = NodeKind.fromNode(child);
if (kind == .zx_child) {
try children.append(ctx.output.allocator, child);
} else if (kind == .zx_end_tag) {
end_tag_start_byte = child.startByte();
end_tag_end_byte = child.endByte();
}
}
// Fragment is just like a regular element but with .fragment tag and no attributes
try ctx.writeM("_zx.ele", node.startByte(), self);
try ctx.write("(\n");
try ctx.write("_zx.ele");
if (ctx.paren_byte) |p| {
try ctx.writeM("(", p, self);
ctx.paren_byte = null;
} else {
try ctx.write("(");
}
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".fragment,\n");
// Map both start and end tag to the fragment name for tooltips
try ctx.addMapping(end_tag_start_byte, self);
try ctx.writeM(".", node.startByte(), self);
try ctx.addMapping(end_tag_start_byte, self);
try ctx.writeM("fragment", node.startByte(), self);
try ctx.writeM(",\n", end_tag_start_byte, self);
try ctx.writeIndent();
try ctx.write(".{\n");
@ -687,7 +740,7 @@ pub fn transpileFragment(self: *Ast, node: ts.Node, ctx: *TranspileContext, is_r
ctx.indent_level -= 1;
try ctx.writeIndent();
try ctx.write(")");
try ctx.writeM(")", end_tag_end_byte, self);
}
pub fn isCustomComponent(tag: []const u8) bool {
@ -727,6 +780,7 @@ pub fn transpileSelfClosing(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
_ = is_root;
var tag_name: ?[]const u8 = null;
var tag_name_byte: u32 = node.startByte();
var attributes = std.ArrayList(ZxAttribute){};
defer attributes.deinit(ctx.output.allocator);
@ -737,7 +791,10 @@ pub fn transpileSelfClosing(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
const child = node.child(i) orelse continue;
switch (NodeKind.fromNode(child)) {
.zx_tag_name => tag_name = try self.getNodeText(child),
.zx_tag_name => {
tag_name = try self.getNodeText(child);
tag_name_byte = child.startByte();
},
.zx_attribute, .zx_builtin_attribute, .zx_regular_attribute, .zx_shorthand_attribute, .zx_builtin_shorthand_attribute, .zx_spread_attribute => {
const attr = try parseAttribute(self, child);
if (attr.isValid()) {
@ -751,9 +808,9 @@ pub fn transpileSelfClosing(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
const tag = tag_name orelse return;
if (isCustomComponent(tag)) {
try writeCustomComponent(self, node, tag, attributes.items, &.{}, ctx);
try writeCustomComponent(self, node, tag, node.startByte(), node.endByte(), node.endByte(), attributes.items, &.{}, ctx);
} else {
try writeHtmlElement(self, node, tag, attributes.items, &.{}, ctx, false);
try writeHtmlElement(self, node, tag, node.startByte(), node.endByte(), node.endByte(), attributes.items, &.{}, ctx, false);
}
}
@ -762,11 +819,15 @@ pub fn transpileFullElement(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
// Parse element structure
var tag_name: ?[]const u8 = null;
var tag_name_byte: u32 = node.startByte();
var attributes = std.ArrayList(ZxAttribute){};
defer attributes.deinit(ctx.output.allocator);
var children = std.ArrayList(ts.Node){};
defer children.deinit(ctx.output.allocator);
var end_tag_start_byte: u32 = node.endByte();
var end_tag_end_byte: u32 = node.endByte();
const child_count = node.childCount();
var i: u32 = 0;
while (i < child_count) : (i += 1) {
@ -781,7 +842,10 @@ pub fn transpileFullElement(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
const tag_child = child.child(j) orelse continue;
switch (NodeKind.fromNode(tag_child)) {
.zx_tag_name => tag_name = try self.getNodeText(tag_child),
.zx_tag_name => {
tag_name = try self.getNodeText(tag_child);
tag_name_byte = tag_child.startByte();
},
.zx_attribute, .zx_builtin_attribute, .zx_regular_attribute, .zx_shorthand_attribute, .zx_builtin_shorthand_attribute, .zx_spread_attribute => {
const attr = try parseAttribute(self, tag_child);
if (attr.isValid()) {
@ -793,6 +857,10 @@ pub fn transpileFullElement(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
}
},
.zx_child => try children.append(ctx.output.allocator, child),
.zx_end_tag => {
end_tag_start_byte = child.startByte();
end_tag_end_byte = child.endByte();
},
else => {},
}
}
@ -801,7 +869,7 @@ pub fn transpileFullElement(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
// Custom component with children
if (isCustomComponent(tag)) {
try writeCustomComponent(self, node, tag, attributes.items, children.items, ctx);
try writeCustomComponent(self, node, tag, node.startByte(), end_tag_start_byte, end_tag_end_byte, attributes.items, children.items, ctx);
return;
}
@ -810,11 +878,11 @@ pub fn transpileFullElement(self: *Ast, node: ts.Node, ctx: *TranspileContext, i
const preserve_whitespace = parent_preserve_whitespace or isPreElement(tag);
// Regular HTML element (with optional whitespace preservation for <pre>)
try writeHtmlElement(self, node, tag, attributes.items, children.items, ctx, preserve_whitespace);
try writeHtmlElement(self, node, tag, node.startByte(), end_tag_start_byte, end_tag_end_byte, attributes.items, children.items, ctx, preserve_whitespace);
}
/// Write a custom component: _zx.cmp(Component, .{ .prop = value }) or _zx.client(...) for React CSR
fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes: []const ZxAttribute, children: []const ts.Node, ctx: *TranspileContext) error{OutOfMemory}!void {
fn writeCustomComponent(self: *Ast, _: ts.Node, tag: []const u8, tag_name_byte: u32, end_tag_start_byte: u32, end_tag_end_byte: u32, attributes: []const ZxAttribute, children: []const ts.Node, ctx: *TranspileContext) error{OutOfMemory}!void {
// Check if this is a client-side rendered component (@rendering={.react} or @rendering={.client})
var rendering_value: ?[]const u8 = null;
for (attributes) |attr| {
@ -891,14 +959,27 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
try ctx.client_components.append(ctx.allocator, client_cmp);
// Write _zx.client(.{ .name = "Name", .path = "path", .id = "id" }, .{ props })
try ctx.writeM("_zx.client", node.startByte(), self);
try ctx.write("(.{ .name = \"");
try ctx.write(componentDisplayName(tag));
try ctx.write("_zx.client");
if (ctx.paren_byte) |p| {
try ctx.writeM("(", p, self);
ctx.paren_byte = null;
} else {
try ctx.write("(");
}
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".{ .name = \"");
try ctx.writeM(componentDisplayName(tag), tag_name_byte, self);
try ctx.write("\", .path = \"");
try ctx.write(full_path);
try ctx.write("\", .id = \"");
try ctx.write(client_cmp.id);
try ctx.write("\" }, .{");
try ctx.write("\" },\n");
try ctx.writeIndent();
try ctx.write(".{");
// Write props (non-builtin attributes)
var first_prop = true;
@ -907,8 +988,11 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
if (!first_prop) try ctx.write(",");
first_prop = false;
try ctx.write(" .");
try ctx.write(attr.name);
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".");
try ctx.writeM(attr.name, attr.name_byte_offset, self);
try ctx.write(" = ");
// Handle template strings, zx_blocks, and regular values
if (attr.template_string_node) |template_node| {
@ -918,9 +1002,18 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
} else {
try ctx.writeM(attr.value, attr.value_byte_offset, self);
}
ctx.indent_level -= 1;
}
try ctx.write(" })");
if (!first_prop) {
try ctx.write("\n");
try ctx.writeIndent();
}
try ctx.write("}\n");
ctx.indent_level -= 1;
try ctx.writeIndent();
try ctx.writeM(")", end_tag_end_byte, self);
return;
}
@ -956,10 +1049,22 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
try ctx.client_components.append(ctx.allocator, client_cmp);
// Write _zx.cmp(Component, .{ .name = ..., .client = .{ .name = ..., .id = ... } }, .{ props })
try ctx.writeM("_zx.cmp", node.startByte(), self);
try ctx.write("(");
try ctx.write(tag);
try ctx.write(", ");
try ctx.write("_zx.cmp");
if (ctx.paren_byte) |p| {
try ctx.writeM("(", p, self);
ctx.paren_byte = null;
} else {
try ctx.write("(");
}
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.addMapping(end_tag_start_byte, self);
try ctx.writeM(tag, tag_name_byte, self);
try ctx.writeM(",\n", end_tag_start_byte, self);
try ctx.writeIndent();
try ctx.write(".{ .name = \"");
try ctx.write(componentDisplayName(tag));
try ctx.write("\", .client = .{ .name = \"");
@ -968,7 +1073,10 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
// try ctx.write(full_path);
try ctx.write("\", .id = \"");
try ctx.write(client_cmp.id);
try ctx.write("\" } }, .{");
try ctx.write("\" } },\n");
try ctx.writeIndent();
try ctx.write(".{");
// Write props (non-builtin attributes)
var first_prop = true;
@ -977,8 +1085,11 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
if (!first_prop) try ctx.write(",");
first_prop = false;
try ctx.write(" .");
try ctx.write(attr.name);
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".");
try ctx.writeM(attr.name, attr.name_byte_offset, self);
try ctx.write(" = ");
// Handle template strings, zx_blocks, and regular values
if (attr.template_string_node) |template_node| {
@ -988,18 +1099,37 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
} else {
try ctx.writeM(attr.value, attr.value_byte_offset, self);
}
ctx.indent_level -= 1;
}
try ctx.write(" },)");
if (!first_prop) {
try ctx.write("\n");
try ctx.writeIndent();
}
try ctx.write("},\n");
ctx.indent_level -= 1;
try ctx.writeIndent();
try ctx.writeM(")", end_tag_end_byte, self);
return;
}
{
// Regular cmp component: _zx.cmp(Func, .{ .name = ..., options }, .{ props })
try ctx.writeM("_zx.cmp", node.startByte(), self);
try ctx.write("(");
try ctx.write(tag);
try ctx.write(", ");
try ctx.write("_zx.cmp");
if (ctx.paren_byte) |p| {
try ctx.writeM("(", p, self);
ctx.paren_byte = null;
} else {
try ctx.write("(");
}
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.addMapping(end_tag_start_byte, self);
try ctx.writeM(tag, tag_name_byte, self);
try ctx.writeM(",\n", end_tag_start_byte, self);
var spreads = std.ArrayList(ZxAttribute){};
defer spreads.deinit(ctx.output.allocator);
@ -1026,18 +1156,22 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
const has_children = children.len > 0;
// Write options parameter first (name + builtin attributes)
try ctx.writeIndent();
try ctx.write(".{ .name = \"");
try ctx.write(componentDisplayName(tag));
try ctx.write("\"");
try writeComponentBuiltinOptions(self, builtin_attrs.items, ctx, true);
try ctx.write(" }, ");
try ctx.write(" },\n");
// Case 1: Single spread
if (spreads.items.len == 1 and !has_regular_props and !has_children) {
try ctx.writeIndent();
try ctx.writeM(spreads.items[0].value, spreads.items[0].value_byte_offset, self);
try ctx.write("\n");
}
// Case 2: Multiple spreads with other props or children - use propsM
else if (has_spread) {
try ctx.writeIndent();
var need_merge = false;
if (spreads.items.len > 0) {
try ctx.write("_zx.propsM(");
@ -1059,8 +1193,11 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
if (!first_prop) try ctx.write(",");
first_prop = false;
try ctx.write(" .");
try ctx.write(attr.name);
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".");
try ctx.writeM(attr.name, attr.name_byte_offset, self);
try ctx.write(" = ");
// Handle template strings, zx_blocks, and regular values
if (attr.template_string_node) |template_node| {
@ -1070,22 +1207,33 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
} else {
try ctx.writeM(attr.value, attr.value_byte_offset, self);
}
ctx.indent_level -= 1;
}
// Add children prop
if (has_children) {
if (!first_prop) try ctx.write(",");
try ctx.write(" .children = ");
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".children = ");
try writeChildrenValue(self, children, ctx);
ctx.indent_level -= 1;
}
try ctx.write(" }");
if (!first_prop) {
try ctx.write("\n");
try ctx.writeIndent();
}
try ctx.write("}");
}
if (need_merge) try ctx.write(")");
try ctx.write("\n");
}
// Case 3: Regular attrs
else {
try ctx.writeIndent();
try ctx.write(".{");
var first_prop = true;
@ -1093,8 +1241,11 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
if (!first_prop) try ctx.write(",");
first_prop = false;
try ctx.write(" .");
try ctx.write(attr.name);
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".");
try ctx.writeM(attr.name, attr.name_byte_offset, self);
try ctx.write(" = ");
// Handle template strings, zx_blocks, and regular values
if (attr.template_string_node) |template_node| {
@ -1104,19 +1255,30 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
} else {
try ctx.writeM(attr.value, attr.value_byte_offset, self);
}
ctx.indent_level -= 1;
}
// Add children prop
if (has_children) {
if (!first_prop) try ctx.write(",");
try ctx.write(" .children = ");
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.write(".children = ");
try writeChildrenValue(self, children, ctx);
ctx.indent_level -= 1;
}
try ctx.write(" }");
if (!first_prop) {
try ctx.write("\n");
try ctx.writeIndent();
}
try ctx.write("}\n");
}
try ctx.write(",)");
ctx.indent_level -= 1;
try ctx.writeIndent();
try ctx.writeM(",)", end_tag_end_byte, self);
}
}
@ -1150,7 +1312,8 @@ fn writeComponentBuiltinOptions(self: *Ast, builtin_attrs: []const ZxAttribute,
}
} else {
try ctx.write(" .");
try ctx.write(attr.name[1..]); // Skip @ prefix
const name = if (attr.name[0] == '@') attr.name[1..] else attr.name;
try ctx.writeM(name, attr.name_byte_offset, self);
try ctx.write(" = ");
}
@ -1188,15 +1351,26 @@ fn writeChildrenValue(self: *Ast, children: []const ts.Node, ctx: *TranspileCont
/// Write a regular HTML element: _zx.ele(.tag, .{ ... })
/// When preserve_whitespace is true (e.g. for <pre>), text nodes won't be trimmed
fn writeHtmlElement(self: *Ast, node: ts.Node, tag: []const u8, attributes: []const ZxAttribute, children: []const ts.Node, ctx: *TranspileContext, preserve_whitespace: bool) !void {
try ctx.writeM("_zx.ele", node.startByte(), self);
try ctx.write("(\n");
fn writeHtmlElement(self: *Ast, node: ts.Node, tag: []const u8, tag_name_byte: u32, end_tag_start_byte: u32, end_tag_end_byte: u32, attributes: []const ZxAttribute, children: []const ts.Node, ctx: *TranspileContext, preserve_whitespace: bool) !void {
_ = node;
// _zx.ele( is synthesized no source mapping
try ctx.write("_zx.ele");
if (ctx.paren_byte) |p| {
try ctx.writeM("(", p, self);
ctx.paren_byte = null;
} else {
try ctx.write("(");
}
try ctx.write("\n");
ctx.indent_level += 1;
try ctx.writeIndent();
try ctx.writeM(".", node.startByte(), self);
try ctx.write(tag);
try ctx.write(",\n");
// Map both start and end tags to the tag name for tooltips
try ctx.addMapping(end_tag_start_byte, self);
try ctx.writeM(".", tag_name_byte, self);
try ctx.addMapping(end_tag_start_byte, self);
try ctx.writeM(tag, tag_name_byte, self);
try ctx.writeM(",\n", end_tag_start_byte, self);
// Write options struct
try ctx.writeIndent();
@ -1235,7 +1409,7 @@ fn writeHtmlElement(self: *Ast, node: ts.Node, tag: []const u8, attributes: []co
ctx.indent_level -= 1;
try ctx.writeIndent();
try ctx.write(")");
try ctx.writeM(")", end_tag_end_byte, self);
}
/// Transpile a child node. When preserve_whitespace is true (e.g. inside <pre>),
@ -1909,6 +2083,7 @@ fn findSpecialChild(node: ts.Node) ?ts.Node {
pub const ZxAttribute = struct {
name: []const u8,
name_byte_offset: u32,
value: []const u8,
value_byte_offset: u32,
is_builtin: bool,
@ -2007,17 +2182,17 @@ fn writeAttributes(self: *Ast, attributes: []const ZxAttribute, ctx: *TranspileC
// Handle template strings with _zx.attrf
if (attr.template_string_node) |template_node| {
try transpileTemplateStringAttr(self, attr.name, template_node, ctx);
try transpileTemplateStringAttr(self, attr, template_node, ctx);
} else if (attr.zx_block_node) |zx_node| {
// If value contains a zx_block, transpile it instead of writing raw text
try ctx.write("_zx.attr(\"");
try ctx.write(attr.name);
try ctx.writeM(attr.name, attr.name_byte_offset, self);
try ctx.write("\", ");
try transpileBlock(self, zx_node, ctx);
try ctx.write("),\n");
} else {
try ctx.write("_zx.attr(\"");
try ctx.write(attr.name);
try ctx.writeM(attr.name, attr.name_byte_offset, self);
try ctx.write("\", ");
try ctx.writeM(attr.value, attr.value_byte_offset, self);
try ctx.write("),\n");
@ -2110,7 +2285,7 @@ fn transpileTemplateStringProp(self: *Ast, template_node: ts.Node, ctx: *Transpi
}
/// Transpile a template string attribute to _zx.attrf("name", "format", .{ values })
fn transpileTemplateStringAttr(self: *Ast, attr_name: []const u8, template_node: ts.Node, ctx: *TranspileContext) error{OutOfMemory}!void {
fn transpileTemplateStringAttr(self: *Ast, attr: ZxAttribute, template_node: ts.Node, ctx: *TranspileContext) error{OutOfMemory}!void {
// Collect template content and substitutions
var format_parts = std.ArrayList(u8){};
defer format_parts.deinit(ctx.output.allocator);
@ -2176,7 +2351,7 @@ fn transpileTemplateStringAttr(self: *Ast, attr_name: []const u8, template_node:
// Write _zx.attrf("name", "format", .{ values })
try ctx.write("_zx.attrf(\"");
try ctx.write(attr_name);
try ctx.writeM(attr.name, attr.name_byte_offset, self);
try ctx.write("\", \"");
try ctx.write(format_parts.items);
try ctx.write("\", .{\n");
@ -2202,6 +2377,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
const attr_node = switch (node_kind) {
.zx_attribute => node.child(0) orelse return ZxAttribute{
.name = "",
.name_byte_offset = node.startByte(),
.value = "\"\"",
.value_byte_offset = node.startByte(),
.is_builtin = false,
@ -2220,6 +2396,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
const clean_name = extractCleanIdentifierName(full_name);
return ZxAttribute{
.name = clean_name,
.name_byte_offset = n.startByte(),
.value = full_name,
.value_byte_offset = n.startByte(),
.is_builtin = false,
@ -2228,6 +2405,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
}
return ZxAttribute{
.name = "",
.name_byte_offset = node.startByte(),
.value = "\"\"",
.value_byte_offset = node.startByte(),
.is_builtin = false,
@ -2243,6 +2421,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
const attr_name = try std.fmt.allocPrint(self.allocator, "@{s}", .{var_name});
return ZxAttribute{
.name = attr_name,
.name_byte_offset = n.startByte(),
.value = var_name,
.value_byte_offset = n.startByte(),
.is_builtin = true,
@ -2251,6 +2430,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
}
return ZxAttribute{
.name = "",
.name_byte_offset = node.startByte(),
.value = "\"\"",
.value_byte_offset = node.startByte(),
.is_builtin = false,
@ -2264,6 +2444,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
const expr_text = try self.getNodeText(e);
return ZxAttribute{
.name = "",
.name_byte_offset = e.startByte(),
.value = expr_text,
.value_byte_offset = e.startByte(),
.is_builtin = false,
@ -2272,6 +2453,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
}
return ZxAttribute{
.name = "",
.name_byte_offset = node.startByte(),
.value = "",
.value_byte_offset = node.startByte(),
.is_builtin = false,
@ -2297,6 +2479,7 @@ pub fn parseAttribute(self: *Ast, node: ts.Node) !ZxAttribute {
return ZxAttribute{
.name = name,
.name_byte_offset = if (name_node) |n| n.startByte() else node.startByte(),
.value = value,
.value_byte_offset = value_offset,
.is_builtin = is_builtin,

View File

@ -21,9 +21,24 @@ pub const DecodedMap = struct {
/// Returns the closest mapping at or before the given source position.
pub fn sourceToGenerated(self: DecodedMap, line: i32, column: i32) ?Mapping {
var best: ?Mapping = null;
var is_exact = false;
for (self.entries) |m| {
if (m.source_line == line and m.source_column == column) {
if (!is_exact or m.generated_line < best.?.generated_line or
(m.generated_line == best.?.generated_line and m.generated_column < best.?.generated_column))
{
best = m;
is_exact = true;
}
continue;
}
if (is_exact) continue; // Exact matches are always better than "at or before"
if (m.source_line > line) continue;
if (m.source_line == line and m.source_column > column) continue;
if (best) |b| {
if (m.source_line > b.source_line or
(m.source_line == b.source_line and m.source_column > b.source_column))
@ -34,6 +49,7 @@ pub const DecodedMap = struct {
best = m;
}
}
if (best) |b| {
// Adjust the generated column by the offset from the exact mapping point
const col_offset = if (b.source_line == line) column - b.source_column else 0;
@ -50,20 +66,22 @@ pub const DecodedMap = struct {
/// Map a generated (.zig) position back to the source (original .zx) position.
/// Returns the closest mapping at or before the given generated position.
pub fn generatedToSource(self: DecodedMap, line: i32, column: i32) ?Mapping {
var low: usize = 0;
var high: usize = self.entries.len;
var best: ?Mapping = null;
for (self.entries) |m| {
if (m.generated_line > line) continue;
if (m.generated_line == line and m.generated_column > column) continue;
if (best) |b| {
if (m.generated_line > b.generated_line or
(m.generated_line == b.generated_line and m.generated_column > b.generated_column))
{
best = m;
}
} else {
while (low < high) {
const mid = low + (high - low) / 2;
const m = self.entries[mid];
if (m.generated_line < line or (m.generated_line == line and m.generated_column <= column)) {
best = m;
low = mid + 1;
} else {
high = mid;
}
}
if (best) |b| {
const col_offset = if (b.generated_line == line) column - b.generated_column else 0;
return .{

View File

@ -1,9 +1,3 @@
//! Implements a language server using the `lsp.basic_server` abstraction.
//! This server forwards all requests and notifications to zls (Zig Language Server).
//! For .zx files, it transpiles to Zig via zx.Ast and remaps positions using sourcemaps.
// Increase comptime branch quota BEFORE importing libraries
// This is needed because basic_server analyzes all handler methods at comptime
comptime {
@setEvalBranchQuota(100_000);
}
@ -28,13 +22,10 @@ pub fn main() !void {
@setEvalBranchQuota(100_000);
// LSP implementations typically communicate over stdio (stdin and stdout)
var read_buffer: [256]u8 = undefined;
var stdio_transport: lsp.Transport.Stdio = .init(&read_buffer, .stdin(), .stdout());
const transport: *lsp.Transport = &stdio_transport.transport;
// Resolve global_cache_path (e.g. ~/Library/Caches/zls on macOS, ~/.cache/zls on Linux)
// ZLS needs this to resolve build_runner_path and builtin_path
const global_cache_path: ?[]const u8 = blk: {
const home = std.process.getEnvVarOwned(gpa, "HOME") catch break :blk null;
const cache_suffix = if (builtin.os.tag == .macos) "Library/Caches/zls" else ".cache/zls";
@ -50,8 +41,6 @@ pub fn main() !void {
.config = &config,
}) catch unreachable;
// The handler is a user provided type that stores the state of the
// language server and provides callbacks for the desired LSP messages.
var handler: Handler = .init(gpa, zls_server);
defer handler.deinit();
@ -66,12 +55,14 @@ pub fn main() !void {
const ZxFileState = struct {
decoded_map: sourcemap.DecodedMap,
zig_uri: []const u8,
source: []const u8, // current .zx source (needed for incremental didChange)
source: []const u8,
zig_source: []const u8,
fn deinit(self: *ZxFileState, allocator: std.mem.Allocator) void {
self.decoded_map.deinit();
allocator.free(self.zig_uri);
allocator.free(self.source);
allocator.free(self.zig_source);
}
};
@ -139,6 +130,7 @@ pub const Handler = struct {
.decoded_map = decoded,
.zig_uri = zig_uri,
.source = try handler.allocator.dupe(u8, source),
.zig_source = try handler.allocator.dupe(u8, result.zx_source),
});
return try handler.allocator.dupe(u8, result.zx_source);
@ -147,10 +139,16 @@ pub const Handler = struct {
/// Remap a position from source (.zx) to generated (.zig) coordinates.
fn remapPositionToGenerated(handler: *Handler, uri: []const u8, pos: lsp.types.Position) struct { uri: []const u8, pos: lsp.types.Position } {
const state = handler.zx_files.get(uri) orelse return .{ .uri = uri, .pos = pos };
if (state.decoded_map.sourceToGenerated(@intCast(pos.line), @intCast(pos.character))) |m| {
// Convert UTF-16 column to byte column for mapping lookup
const byte_col = utf16ToByteOnLine(state.source, @intCast(pos.line), @intCast(pos.character));
if (state.decoded_map.sourceToGenerated(@intCast(pos.line), @intCast(byte_col))) |m| {
// Mapping returns byte column, convert back to UTF-16 for LSP
const gen_utf16 = byteToUtf16OnLine(state.zig_source, @intCast(m.generated_line), @intCast(m.generated_column));
return .{
.uri = state.zig_uri,
.pos = .{ .line = @intCast(m.generated_line), .character = @intCast(m.generated_column) },
.pos = .{ .line = @intCast(m.generated_line), .character = @intCast(gen_utf16) },
};
}
return .{ .uri = state.zig_uri, .pos = pos };
@ -158,12 +156,15 @@ pub const Handler = struct {
/// Remap a position from generated (.zig) back to source (.zx) coordinates.
fn remapPositionToSource(handler: *Handler, uri: []const u8, pos: lsp.types.Position) lsp.types.Position {
// Find the zx_uri that maps to this zig_uri
var it = handler.zx_files.iterator();
while (it.next()) |entry| {
if (std.mem.eql(u8, entry.value_ptr.zig_uri, uri)) {
if (entry.value_ptr.decoded_map.generatedToSource(@intCast(pos.line), @intCast(pos.character))) |m| {
return .{ .line = @intCast(m.source_line), .character = @intCast(m.source_column) };
const state = entry.value_ptr;
const byte_col = utf16ToByteOnLine(state.zig_source, @intCast(pos.line), @intCast(pos.character));
if (state.decoded_map.generatedToSource(@intCast(pos.line), @intCast(byte_col))) |m| {
const src_utf16 = byteToUtf16OnLine(state.source, @intCast(m.source_line), @intCast(m.source_column));
return .{ .line = @intCast(m.source_line), .character = @intCast(src_utf16) };
}
return pos;
}
@ -279,15 +280,12 @@ pub const Handler = struct {
for (params.contentChanges) |change| {
switch (change) {
.literal_1 => |full| {
// Full document sync use the text directly
if (needs_free) handler.allocator.free(full_text);
full_text = full.text;
needs_free = false;
},
.literal_0 => |inc| {
// Incremental change apply to current source
const new_text = applyIncrementalChange(handler.allocator, full_text, inc.range, inc.text) catch {
// On failure, skip this change
continue;
};
if (needs_free) handler.allocator.free(full_text);
@ -304,7 +302,6 @@ pub const Handler = struct {
defer handler.allocator.free(zig_source);
const state = handler.zx_files.get(params.textDocument.uri).?;
// Send full document replacement to ZLS
handler.zls.sendNotificationSync(arena, "textDocument/didChange", .{
.textDocument = .{
.uri = state.zig_uri,
@ -596,8 +593,39 @@ pub const Handler = struct {
arena: std.mem.Allocator,
params: lsp.types.DocumentFormattingParams,
) error{OutOfMemory}!?[]const lsp.types.TextEdit {
const result = handler.zls.sendRequestSync(arena, "textDocument/formatting", params) catch null;
return result;
if (isZxUri(params.textDocument.uri)) {
if (handler.zx_files.get(params.textDocument.uri)) |state| {
const source_z = try handler.allocator.dupeZ(u8, state.source);
defer handler.allocator.free(source_z);
const format_result = zx.Ast.fmt(handler.allocator, source_z) catch |err| {
std.log.err("zx.Ast.fmt failed for {s}: {s}", .{ params.textDocument.uri, @errorName(err) });
return null;
};
var format_result_cl = format_result;
defer format_result_cl.deinit(handler.allocator);
if (std.mem.eql(u8, format_result.source, state.source)) {
return null;
}
const line_count = std.mem.count(u8, state.source, "\n");
const last_line_start = if (std.mem.lastIndexOfScalar(u8, state.source, '\n')) |idx| idx + 1 else 0;
const last_line_len = state.source.len - last_line_start;
const utf16_col = byteToUtf16OnLine(state.source, @intCast(line_count), @intCast(last_line_len));
const edits = try arena.alloc(lsp.types.TextEdit, 1);
edits[0] = .{
.range = .{
.start = .{ .line = 0, .character = 0 },
.end = .{ .line = @intCast(line_count), .character = @intCast(utf16_col) },
},
.newText = try arena.dupe(u8, format_result.source),
};
return edits;
}
}
return handler.zls.sendRequestSync(arena, "textDocument/formatting", params) catch null;
}
/// https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction
@ -684,4 +712,48 @@ pub const Handler = struct {
std.log.err("zls handleResponse failed: {}", .{err});
};
}
/// Convert a byte offset on a specific line to a UTF-16 code unit offset.
fn byteToUtf16OnLine(source: []const u8, line_idx: i32, byte_col: i32) i32 {
var it = std.mem.splitScalar(u8, source, '\n');
var curr_line: i32 = 0;
while (it.next()) |line| : (curr_line += 1) {
if (curr_line == line_idx) {
const limit = if (byte_col > line.len) line.len else @as(usize, @intCast(byte_col));
var utf16_count: i32 = 0;
var i: usize = 0;
while (i < limit) {
const byte_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
const char = std.unicode.utf8Decode(line[i..@min(line.len, i + byte_len)]) catch line[i];
utf16_count += if (char > 0xFFFF) @as(i32, 2) else @as(i32, 1);
i += byte_len;
}
return utf16_count;
}
}
return byte_col;
}
// TODO: Remove neccessity of conversions
/// Convert a UTF-16 code unit offset on a specific line to a byte offset.
fn utf16ToByteOnLine(source: []const u8, line_idx: i32, utf16_col: i32) i32 {
var it = std.mem.splitScalar(u8, source, '\n');
var curr_line: i32 = 0;
while (it.next()) |line| : (curr_line += 1) {
if (curr_line == line_idx) {
var utf16_count: i32 = 0;
var byte_count: i32 = 0;
var i: usize = 0;
while (i < line.len and utf16_count < utf16_col) {
const byte_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
const char = std.unicode.utf8Decode(line[i..@min(line.len, i + byte_len)]) catch line[i];
utf16_count += if (char > 0xFFFF) @as(i32, 2) else @as(i32, 1);
byte_count += @intCast(byte_len);
i += byte_len;
}
return byte_count;
}
}
return utf16_col;
}
};

394
test/core/dx.zig Normal file
View File

@ -0,0 +1,394 @@
const std = @import("std");
const testing = std.testing;
const zx = @import("zx");
const sourcemap = zx.sourcemap;
test "sm > VLQ encode/decode roundtrip" {
const allocator = testing.allocator;
// Build a sourcemap with known mappings
var builder = sourcemap.Builder.init(allocator);
defer builder.deinit();
const mappings = [_]sourcemap.Mapping{
.{ .generated_line = 0, .generated_column = 0, .source_line = 0, .source_column = 0 },
.{ .generated_line = 0, .generated_column = 10, .source_line = 0, .source_column = 5 },
.{ .generated_line = 1, .generated_column = 4, .source_line = 1, .source_column = 4 },
.{ .generated_line = 2, .generated_column = 8, .source_line = 3, .source_column = 12 },
.{ .generated_line = 5, .generated_column = 0, .source_line = 10, .source_column = 0 },
};
for (&mappings) |m| {
try builder.addMapping(m);
}
var sm = try builder.build();
defer sm.deinit(@constCast(&allocator).*);
// Decode and verify roundtrip
var decoded = try sm.decode(allocator);
defer decoded.deinit();
try testing.expectEqual(mappings.len, decoded.entries.len);
for (&mappings, decoded.entries) |expected, actual| {
try testing.expectEqual(expected.generated_line, actual.generated_line);
try testing.expectEqual(expected.generated_column, actual.generated_column);
try testing.expectEqual(expected.source_line, actual.source_line);
try testing.expectEqual(expected.source_column, actual.source_column);
}
}
test "sm > sourceToGenerated exact match" {
const allocator = testing.allocator;
var builder = sourcemap.Builder.init(allocator);
defer builder.deinit();
// Simulate: source line 2, col 4 maps to generated line 5, col 8
try builder.addMapping(.{ .generated_line = 5, .generated_column = 8, .source_line = 2, .source_column = 4 });
// source line 2, col 10 maps to generated line 5, col 20
try builder.addMapping(.{ .generated_line = 5, .generated_column = 20, .source_line = 2, .source_column = 10 });
var sm = try builder.build();
defer sm.deinit(@constCast(&allocator).*);
var decoded = try sm.decode(allocator);
defer decoded.deinit();
// Exact match
const result = decoded.sourceToGenerated(2, 4).?;
try testing.expectEqual(@as(i32, 5), result.generated_line);
try testing.expectEqual(@as(i32, 8), result.generated_column);
// Position between two mappings on same source line should use closest before
const between = decoded.sourceToGenerated(2, 7).?;
try testing.expectEqual(@as(i32, 5), between.generated_line);
// col 7 is 3 past the mapping at col 4, so generated col = 8 + 3 = 11
try testing.expectEqual(@as(i32, 11), between.generated_column);
}
test "sm > generatedToSource exact match" {
const allocator = testing.allocator;
var builder = sourcemap.Builder.init(allocator);
defer builder.deinit();
try builder.addMapping(.{ .generated_line = 3, .generated_column = 0, .source_line = 1, .source_column = 0 });
try builder.addMapping(.{ .generated_line = 3, .generated_column = 15, .source_line = 1, .source_column = 8 });
var sm = try builder.build();
defer sm.deinit(@constCast(&allocator).*);
var decoded = try sm.decode(allocator);
defer decoded.deinit();
// Exact match
const result = decoded.generatedToSource(3, 0).?;
try testing.expectEqual(@as(i32, 1), result.source_line);
try testing.expectEqual(@as(i32, 0), result.source_column);
// Position between mappings
const between = decoded.generatedToSource(3, 5).?;
try testing.expectEqual(@as(i32, 1), between.source_line);
try testing.expectEqual(@as(i32, 5), between.source_column);
}
test "sm > lookup returns null for unmapped position" {
const allocator = testing.allocator;
var builder = sourcemap.Builder.init(allocator);
defer builder.deinit();
try builder.addMapping(.{ .generated_line = 5, .generated_column = 0, .source_line = 3, .source_column = 0 });
var sm = try builder.build();
defer sm.deinit(@constCast(&allocator).*);
var decoded = try sm.decode(allocator);
defer decoded.deinit();
// Line before any mapping should return null
const result = decoded.sourceToGenerated(0, 0);
try testing.expectEqual(@as(?sourcemap.Mapping, null), result);
}
test "sm > e2e simple element transpilation" {
const allocator = testing.allocator;
const source =
\\pub fn Page(allocator: zx.Allocator) zx.Component {
\\ return (
\\ <div>Hello</div>
\\ );
\\}
\\
\\const zx = @import("zx");
;
const source_z = try allocator.dupeZ(u8, source);
defer allocator.free(source_z);
var result = try zx.Ast.parse(allocator, source_z, .{ .map = .inlined });
defer result.deinit(allocator);
// Sourcemap should be present
try testing.expect(result.sourcemap != null);
const sm = result.sourcemap.?;
var decoded = try sm.decode(allocator);
defer decoded.deinit();
// Should have mappings
try testing.expect(decoded.entries.len > 0);
const raw_zig = result.zx_source;
// "pub" at source line 0, col 0 should map to generated line 0, col 0
const pub_mapping = decoded.sourceToGenerated(0, 0).?;
try testing.expectEqual(@as(i32, 0), pub_mapping.generated_line);
try testing.expectEqual(@as(i32, 0), pub_mapping.generated_column);
// Verify the generated position actually contains "pub"
const gen_offset = lineColToOffset(raw_zig, pub_mapping.generated_line, pub_mapping.generated_column);
try testing.expect(gen_offset != null);
if (gen_offset) |off| {
try testing.expect(std.mem.startsWith(u8, raw_zig[off..], "pub"));
}
// "const zx" at source line 6, col 0 should map to a generated position containing "const zx"
const const_mapping = decoded.sourceToGenerated(6, 0).?;
const const_offset = lineColToOffset(raw_zig, const_mapping.generated_line, const_mapping.generated_column);
try testing.expect(const_offset != null);
if (const_offset) |off| {
try testing.expect(std.mem.startsWith(u8, raw_zig[off..], "const"));
}
}
test "sm > e2e generatedToSource roundtrip for zig code" {
const allocator = testing.allocator;
const source =
\\const std = @import("std");
\\
\\pub fn hello() void {
\\ std.debug.print("hello\n", .{});
\\}
;
const source_z = try allocator.dupeZ(u8, source);
defer allocator.free(source_z);
var result = try zx.Ast.parse(allocator, source_z, .{ .map = .inlined });
defer result.deinit(allocator);
try testing.expect(result.sourcemap != null);
const sm = result.sourcemap.?;
var decoded = try sm.decode(allocator);
defer decoded.deinit();
// Pure zig code should map 1:1 (source == generated for passthrough code)
// "const" at line 0, col 0
const m = decoded.sourceToGenerated(0, 0).?;
try testing.expectEqual(@as(i32, 0), m.generated_line);
try testing.expectEqual(@as(i32, 0), m.generated_column);
// Reverse lookup should also work
const rev = decoded.generatedToSource(m.generated_line, m.generated_column).?;
try testing.expectEqual(@as(i32, 0), rev.source_line);
try testing.expectEqual(@as(i32, 0), rev.source_column);
}
test "sm > e2e expression in element" {
const allocator = testing.allocator;
const source =
\\pub fn Page(allocator: zx.Allocator) zx.Component {
\\ const name = "world";
\\ return (
\\ <p>Hello {name}</p>
\\ );
\\}
\\
\\const zx = @import("zx");
;
const source_z = try allocator.dupeZ(u8, source);
defer allocator.free(source_z);
var result = try zx.Ast.parse(allocator, source_z, .{ .map = .inlined });
defer result.deinit(allocator);
try testing.expect(result.sourcemap != null);
const sm = result.sourcemap.?;
var decoded = try sm.decode(allocator);
defer decoded.deinit();
const raw_zig = result.zx_source;
// "const name" at source line 1, col 4 should map to a valid generated position
const name_mapping = decoded.sourceToGenerated(1, 4).?;
const name_offset = lineColToOffset(raw_zig, name_mapping.generated_line, name_mapping.generated_column);
try testing.expect(name_offset != null);
if (name_offset) |off| {
try testing.expect(std.mem.startsWith(u8, raw_zig[off..], "const"));
}
// The expression {name} at source line 3 should map somewhere in generated that contains "name"
// Find "name" in the source it's at line 3, the expression is after "Hello "
// In .zx, line 3 is: " <p>Hello {name}</p>"
// "name" starts at col 16 (after 8 spaces + "<p>Hello {")
const expr_mapping = decoded.sourceToGenerated(3, 17).?;
const expr_offset = lineColToOffset(raw_zig, expr_mapping.generated_line, expr_mapping.generated_column);
try testing.expect(expr_offset != null);
}
test "sm > all test files produce valid sourcemaps" {
const allocator = testing.allocator;
const test_files = [_][]const u8{
"test/data/element/nested.zx",
"test/data/expression/text.zx",
"test/data/control_flow/if.zx",
"test/data/control_flow/for.zx",
"test/data/component/basic.zx",
"test/data/attribute/dynamic.zx",
};
for (test_files) |path| {
const source = std.fs.cwd().readFileAlloc(allocator, path, std.math.maxInt(usize)) catch continue;
defer allocator.free(source);
const source_z = try allocator.dupeZ(u8, source);
defer allocator.free(source_z);
var result = zx.Ast.parse(allocator, source_z, .{ .map = .inlined, .path = path }) catch continue;
defer result.deinit(allocator);
// Sourcemap must be present
if (result.sourcemap) |sm| {
// Must decode without error
var decoded = try sm.decode(allocator);
defer decoded.deinit();
// Should have at least some mappings
try testing.expect(decoded.entries.len > 0);
// All generated positions should be within the raw zig source bounds
const raw_zig = result.zx_source;
for (decoded.entries) |entry| {
const offset = lineColToOffset(raw_zig, entry.generated_line, entry.generated_column);
if (offset == null) {
std.debug.print("FAIL: {s}: mapping gen {d}:{d} is out of bounds (raw_zig len={d})\n", .{
path,
entry.generated_line,
entry.generated_column,
raw_zig.len,
});
return error.MappingOutOfBounds;
}
}
} else {
std.debug.print("FAIL: {s}: no sourcemap generated\n", .{path});
return error.NoSourceMap;
}
}
}
test "sm > generate sourcemap debug files" {
if (!shouldGenerateDebugFiles()) return;
const allocator = testing.allocator;
const test_files = [_]struct { zx_path: []const u8, name: []const u8 }{
.{ .zx_path = "test/data/element/nested.zx", .name = "nested" },
.{ .zx_path = "test/data/expression/text.zx", .name = "text" },
.{ .zx_path = "test/data/control_flow/if.zx", .name = "if" },
.{ .zx_path = "test/data/control_flow/for.zx", .name = "for" },
.{ .zx_path = "test/data/component/basic.zx", .name = "basic" },
.{ .zx_path = "test/data/attribute/dynamic.zx", .name = "dynamic" },
};
// Ensure output directory exists
std.fs.cwd().makePath(".zig-cache/tmp/.zx/sourcemap-debug") catch {};
for (test_files) |tf| {
const source = std.fs.cwd().readFileAlloc(allocator, tf.zx_path, std.math.maxInt(usize)) catch continue;
defer allocator.free(source);
const source_z = try allocator.dupeZ(u8, source);
defer allocator.free(source_z);
var result = zx.Ast.parse(allocator, source_z, .{ .map = .inlined, .path = tf.zx_path }) catch continue;
defer result.deinit(allocator);
const sm = result.sourcemap orelse continue;
// Generate the sourcemap JSON
const gen_file = try std.fmt.allocPrint(allocator, "{s}.zig", .{tf.name});
defer allocator.free(gen_file);
const src_file = try std.fmt.allocPrint(allocator, "{s}.zx", .{tf.name});
defer allocator.free(src_file);
const map_json = try sm.toJSON(allocator, gen_file, src_file, source, result.zx_source);
defer allocator.free(map_json);
// Write the .map JSON file
const map_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/.zx/sourcemap-debug/{s}.zig.map", .{tf.name});
defer allocator.free(map_path);
try writeFile(map_path, map_json);
// Write the raw transpiled .zig with inline sourcemap comment
// Format: //# sourceMappingURL=data:application/json;base64,<base64-encoded-json>
const b64_len = std.base64.standard.Encoder.calcSize(map_json.len);
const b64_buf = try allocator.alloc(u8, b64_len);
defer allocator.free(b64_buf);
_ = std.base64.standard.Encoder.encode(b64_buf, map_json);
const inline_zig = try std.fmt.allocPrint(
allocator,
"{s}\n//# sourceMappingURL=data:application/json;base64,{s}\n",
.{ result.zx_source, b64_buf },
);
defer allocator.free(inline_zig);
const zig_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/.zx/sourcemap-debug/{s}.zig", .{tf.name});
defer allocator.free(zig_path);
try writeFile(zig_path, inline_zig);
// Also write the original .zx source for reference
const zx_out_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/.zx/sourcemap-debug/{s}.zx", .{tf.name});
defer allocator.free(zx_out_path);
try writeFile(zx_out_path, source);
std.debug.print(" wrote: {s} + .map + .zx\n", .{zig_path});
}
std.debug.print("\nSourcemap debug files written to .zig-cache/tmp/.zx/sourcemap-debug/\n", .{});
std.debug.print("Visualize at: https://evanw.github.io/source-map-visualization/\n", .{});
std.debug.print(" - Paste the .zig content as 'generated'\n", .{});
std.debug.print(" - Paste the .map content as 'source map'\n", .{});
}
fn shouldGenerateDebugFiles() bool {
const val = std.process.getEnvVarOwned(testing.allocator, "SM_DEBUG") catch return false;
testing.allocator.free(val);
return true;
}
fn writeFile(path: []const u8, content: []const u8) !void {
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
try file.writeAll(content);
}
fn lineColToOffset(source: []const u8, line: i32, col: i32) ?usize {
var current_line: i32 = 0;
var i: usize = 0;
while (current_line < line and i < source.len) {
if (source[i] == '\n') current_line += 1;
i += 1;
}
if (current_line != line) return null;
const offset = i + @as(usize, @intCast(col));
if (offset > source.len) return null;
return offset;
}

View File

@ -9,6 +9,7 @@ test {
_ = @import("core/html.zig");
_ = @import("core/routing.zig");
_ = @import("core/vdom.zig");
_ = @import("core/dx.zig");
}
pub const std_options = std.Options{