fix: resolve prefixed space and forced multiline on zx_text (#34)

Co-authored-by: Nurul Huda (Apon) <me@nurulhudaapon.com>
This commit is contained in:
Mohabbat 2025-12-28 21:20:58 +06:00 committed by GitHub
parent fefaf63c49
commit 8af83ad708
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 148 additions and 39 deletions

View File

@ -8,6 +8,7 @@ const NodeKind = Parse.NodeKind;
pub const FormatContext = struct {
indent_level: u32 = 0,
in_block: bool = false,
suppress_leading_space: bool = false,
fn writeIndent(self: *FormatContext, w: *std.io.Writer) !void {
for (0..self.indent_level * 4) |_| try w.writeAll(" ");
@ -294,7 +295,7 @@ pub fn renderNodeWithContext(
try renderEndTag(self, node, w);
},
.zx_text => {
try renderText(self, node, w);
try renderText(self, node, w, ctx);
},
.zx_child => {
try renderChild(self, node, w, ctx);
@ -454,6 +455,7 @@ fn renderFragment(
}
try w.writeAll("<>");
ctx.suppress_leading_space = false;
// Check if we have meaningful content
const has_meaningful_content = blk: {
@ -561,6 +563,7 @@ fn renderElement(
// Render start tag
if (start_tag_node) |st| {
try renderStartTag(self, st, w);
ctx.suppress_leading_space = false;
}
// - If there's whitespace/newline between start tag and first content -> vertical
@ -618,39 +621,27 @@ fn renderElement(
for (content_nodes.items) |child| {
const child_kind = NodeKind.fromNode(child);
if (child_kind == .zx_child) {
// Calculate newline count between last content and this child
const newline_count = countNewlines(self.source, last_content_end, child.startByte());
// Check if child has meaningful content
// In inline mode, also consider spaces-only (no newlines) as meaningful
// In inline mode (or if on same line in vertical mode), also consider spaces-only as meaningful
const is_meaningful = hasMeaningfulContent(self, child) or
(!is_vertical and hasInlineSpacesOnly(self, child));
((!is_vertical or newline_count == 0) and hasInlineSpacesOnly(self, child));
if (!is_meaningful) continue;
// Check if this child should be on a new line
if (is_vertical) {
// Check for blank lines between last content and this child
const child_start = child.startByte();
const has_blank_line = blk: {
if (last_content_end < child_start and child_start <= self.source.len) {
const between = self.source[last_content_end..child_start];
// Count newlines - if more than 1, there's a blank line
var newline_count: usize = 0;
for (between) |c| {
if (c == '\n') {
newline_count += 1;
if (newline_count > 1) break :blk true;
}
}
}
break :blk false;
};
if (is_vertical and (!rendered_any or newline_count > 0)) {
try w.writeAll("\n");
// Add one extra newline if there was a blank line in source
if (has_blank_line and rendered_any) {
if (newline_count > 1 and rendered_any) {
try w.writeAll("\n");
}
try ctx.writeIndent(w);
ctx.suppress_leading_space = true;
}
try renderChildInner(self, child, w, ctx, !is_vertical);
try renderChildInner(self, child, w, ctx, !is_vertical or newline_count == 0);
ctx.suppress_leading_space = false; // Reset just in case
last_content_end = child.endByte();
rendered_any = true;
} else {
@ -664,6 +655,7 @@ fn renderElement(
ctx.indent_level -= 1;
try w.writeAll("\n");
try ctx.writeIndent(w);
ctx.suppress_leading_space = true; // End tag starts on new line
} else if (is_vertical) {
ctx.indent_level -= 1;
}
@ -748,6 +740,7 @@ fn renderText(
self: *Ast,
node: ts.Node,
w: *std.io.Writer,
ctx: *FormatContext,
) !void {
const start_byte = node.startByte();
const end_byte = node.endByte();
@ -760,21 +753,59 @@ fn renderText(
// If text is only spaces (no newlines/tabs), collapse to single space
// If it contains newlines or tabs, skip it (layout whitespace)
const has_newline_or_tab = std.mem.indexOfAny(u8, text, "\n\r\t") != null;
if (!has_newline_or_tab and text.len > 0) try w.writeAll(" ");
if (!has_newline_or_tab and text.len > 0 and !ctx.suppress_leading_space) try w.writeAll(" ");
ctx.suppress_leading_space = false;
return;
}
const has_leading_ws = text.len > 0 and std.ascii.isWhitespace(text[0]);
const has_trailing_ws = text.len > 0 and std.ascii.isWhitespace(text[text.len - 1]);
// Calculate leading whitespace length using pointer arithmetic
const leading_ws_len = @intFromPtr(trimmed.ptr) - @intFromPtr(text.ptr);
const leading_ws = text[0..leading_ws_len];
const has_leading_ws = leading_ws_len > 0;
// Write leading space if there was leading whitespace
// Determine if we should print a space
var should_print_space = false;
if (has_leading_ws) {
if (ctx.suppress_leading_space) {
// If we are at the start of a line, check if the whitespace is just indentation
// or if it contains explicit spaces beyond the expected indentation.
// Find the last newline in leading whitespace
const last_nl = std.mem.lastIndexOfScalar(u8, leading_ws, '\n');
var spaces_after_nl: usize = 0;
if (last_nl) |nl_idx| {
spaces_after_nl = leading_ws.len - nl_idx - 1;
} else {
spaces_after_nl = leading_ws.len;
}
const expected_indent = ctx.indent_level * 4;
// If we have more spaces than expected indentation, preserve one space
if (spaces_after_nl > expected_indent) {
should_print_space = true;
}
} else {
// Normal case: collapse whitespace to a single space
should_print_space = true;
}
}
// If we have leading whitespace but decided NOT to print a space because of suppression/newlines,
// we should check if the trimmed content itself starts with something that might need separation
// if it was inline. But here we are handling text node boundaries.
// Write leading space if needed
if (should_print_space) {
try w.writeAll(" ");
}
ctx.suppress_leading_space = false;
// Write the trimmed content (no internal whitespace normalization for now)
try w.writeAll(trimmed);
const has_trailing_ws = text.len > 0 and std.ascii.isWhitespace(text[text.len - 1]);
// Write trailing space if there was trailing whitespace
if (has_trailing_ws) {
try w.writeAll(" ");
@ -796,6 +827,19 @@ fn renderTemplateString(
try w.writeAll(self.source[start_byte..end_byte]);
}
fn countNewlines(source: []const u8, start: usize, end: usize) usize {
if (start >= end or end > source.len) return 0;
const text = source[start..end];
var count: usize = 0;
for (text) |c| {
if (c == '\n') {
count += 1;
if (count > 1) return count;
}
}
return count;
}
/// Check if a child node has meaningful (non-whitespace) content
fn hasMeaningfulContent(self: *Ast, node: ts.Node) bool {
const child_count = node.childCount();

View File

@ -286,6 +286,10 @@ test "escaping_quotes" {
try test_fmt("escaping/quotes");
}
test "whitespace" {
try expect_fmt("fmt/whitespace");
}
test "performance > fmt" {
// if (true) return error.Todo;
const MAX_TIME_MS = 50.0 * 8; // 50ms is on M1 Pro
@ -294,7 +298,7 @@ test "performance > fmt" {
var total_time_ns: f64 = 0.0;
inline for (TestFileCache.test_files) |comptime_path| {
const start_time = std.time.nanoTimestamp();
try test_fmt_inner(comptime_path, true);
try test_fmt_inner(comptime_path, false, true);
const end_time = std.time.nanoTimestamp();
const duration = @as(f64, @floatFromInt(end_time - start_time));
total_time_ns += duration;
@ -311,19 +315,23 @@ test "performance > fmt" {
}
fn test_fmt(comptime file_path: []const u8) !void {
try test_fmt_inner(file_path, false);
try test_fmt_inner(file_path, false, false);
}
fn test_fmt_inner(comptime file_path: []const u8, comptime no_expect: bool) !void {
fn expect_fmt(comptime file_path: []const u8) !void {
try test_fmt_inner(file_path, true, false);
}
fn test_fmt_inner(comptime file_path: []const u8, comptime has_diff_expected: bool, comptime no_expect: bool) !void {
const allocator = std.testing.allocator;
const cache = test_file_cache orelse return error.CacheNotInitialized;
const cache = if (test_file_cache) |*c| c else return error.CacheNotInitialized;
// Construct paths for .zx and .zig files
const source_path = file_path ++ ".zx";
const expected_source_path = file_path ++ ".zx";
const expected_source_path = if (has_diff_expected) file_path ++ "_out.zx" else file_path ++ ".zx";
// Get pre-loaded source file
const source = cache.get(source_path) orelse return error.FileNotFound;
const source = try cache.get(source_path) orelse return error.FileNotFound;
const source_z = try allocator.dupeZ(u8, source);
defer allocator.free(source_z);
@ -332,7 +340,7 @@ fn test_fmt_inner(comptime file_path: []const u8, comptime no_expect: bool) !voi
defer result.deinit(allocator);
// Get pre-loaded expected file
const expected_source = cache.get(expected_source_path) orelse {
const expected_source = try cache.get(expected_source_path) orelse {
std.log.err("Expected file not found: {s}\n", .{expected_source_path});
return error.FileNotFound;
};

View File

@ -0,0 +1,17 @@
pub fn FmtWhitespace(allocator: zx.Allocator) zx.Component {
return (
<div>
<span>| </span>
<span> |</span>
<span> | </span>
<span>| </span>
<span> |</span>
<span> | </span>
<span>
|
</span>
</div>
);
}
const zx = @import("zx");

View File

@ -0,0 +1,17 @@
pub fn FmtWhitespace(allocator: zx.Allocator) zx.Component {
return (
<div>
<span>| </span>
<span> |</span>
<span> | </span>
<span>| </span>
<span> |</span>
<span> | </span>
<span>
|
</span>
</div>
);
}
const zx = @import("zx");

View File

@ -136,8 +136,31 @@ pub const TestFileCache = struct {
self.files.deinit();
}
pub fn get(self: *const TestFileCache, path: []const u8) ?[]const u8 {
return self.files.get(path);
pub fn get(self: *TestFileCache, path: []const u8) !?[]const u8 {
// Check if file is already in cache
if (self.files.get(path)) |content| {
return content;
}
// File not in cache, try to fetch it from disk
const base_path = "test/data/";
const full_path = try std.fmt.allocPrint(self.allocator, "{s}{s}", .{ base_path, path });
defer self.allocator.free(full_path);
const content = std.fs.cwd().readFileAlloc(
self.allocator,
full_path,
std.math.maxInt(usize),
) catch |err| switch (err) {
error.FileNotFound => return null,
else => return err,
};
// Cache the file for future calls
const cache_key = try std.fmt.allocPrint(self.allocator, "{s}", .{path});
try self.files.put(cache_key, content);
return content;
}
};

View File

@ -421,7 +421,7 @@ fn test_transpile(comptime file_path: []const u8) !void {
fn test_transpile_inner(comptime file_path: []const u8, comptime no_expect: bool) !void {
const allocator = std.testing.allocator;
const cache = test_file_cache orelse return error.CacheNotInitialized;
const cache = if (test_file_cache) |*c| c else return error.CacheNotInitialized;
// Construct paths for .zx and .zig files
const source_path = file_path ++ ".zx";
@ -430,7 +430,7 @@ fn test_transpile_inner(comptime file_path: []const u8, comptime no_expect: bool
const output_zig_path = "test/data/" ++ file_path ++ ".zig";
// Get pre-loaded source file
const source = cache.get(source_path) orelse return error.FileNotFound;
const source = try cache.get(source_path) orelse return error.FileNotFound;
const source_z = try allocator.dupeZ(u8, source);
defer allocator.free(source_z);
@ -454,7 +454,7 @@ fn test_transpile_inner(comptime file_path: []const u8, comptime no_expect: bool
}
// Get pre-loaded expected file
const expected_source = cache.get(expected_source_path) orelse {
const expected_source = try cache.get(expected_source_path) orelse {
std.log.err("Expected file not found: {s}\n", .{expected_source_path});
return error.FileNotFound;
};