mirror of
https://github.com/ziex-dev/ziex.git
synced 2026-07-22 11:39:34 -06:00
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:
parent
fefaf63c49
commit
8af83ad708
@ -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();
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
17
test/data/fmt/whitespace.zx
Normal file
17
test/data/fmt/whitespace.zx
Normal 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");
|
||||
17
test/data/fmt/whitespace_out.zx
Normal file
17
test/data/fmt/whitespace_out.zx
Normal 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");
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user