From a90c35e5424e8509cb0775d9b4321c23b7206c48 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 04:22:55 +0200 Subject: [PATCH] Add source-patched syntax profiles --- src/main.zig | 2 + src/syntax.zig | 196 +++++++++++++++++++++++++++++++++++++++++++++++++ src/tui.zig | 46 ++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/syntax.zig diff --git a/src/main.zig b/src/main.zig index 9d0bdbb..69e9dc4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,6 +11,7 @@ const repo = @import("repo.zig"); const session = @import("session.zig"); const socket = @import("socket.zig"); const symbol = @import("symbol.zig"); +const syntax = @import("syntax.zig"); const tui = @import("tui.zig"); pub const version = "0.1.0-dev"; @@ -136,6 +137,7 @@ test { _ = session; _ = socket; _ = symbol; + _ = syntax; _ = tui; } diff --git a/src/syntax.zig b/src/syntax.zig new file mode 100644 index 0000000..bf2e238 --- /dev/null +++ b/src/syntax.zig @@ -0,0 +1,196 @@ +const std = @import("std"); + +// Source-patched syntax profile registry for v1 coding feedback. +// req: coding/002, coding/003, ui/002, governance/002 + +test { + _ = spansAlloc; +} + +pub const Error = error{ + InvalidLanguage, +}; + +pub const Class = enum { + keyword, + builtin, + string, + comment, + number, + + pub fn text(self: Class) []const u8 { + return switch (self) { + .keyword => "keyword", + .builtin => "builtin", + .string => "string", + .comment => "comment", + .number => "number", + }; + } +}; + +pub const Span = struct { + start: usize, + end: usize, + class: Class, +}; + +pub const Profile = struct { + language: []const u8, + source_patched: bool, +}; + +pub fn profile(language: []const u8) ?Profile { + if (std.mem.eql(u8, language, "zig")) return .{ .language = "zig", .source_patched = true }; + if (std.mem.eql(u8, language, "text")) return .{ .language = "text", .source_patched = false }; + return null; +} + +pub fn spansAlloc(allocator: std.mem.Allocator, language: []const u8, source: []const u8) ![]Span { + const resolved = profile(language) orelse return Error.InvalidLanguage; + if (!resolved.source_patched) return allocator.alloc(Span, 0); + var spans = std.ArrayList(Span).empty; + errdefer spans.deinit(allocator); + var i: usize = 0; + while (i < source.len) { + if (source[i] == '/' and i + 1 < source.len and source[i + 1] == '/') { + const start = i; + i += 2; + while (i < source.len and source[i] != '\n') : (i += 1) {} + try spans.append(allocator, .{ .start = start, .end = i, .class = .comment }); + continue; + } + if (source[i] == '"') { + const start = i; + i += 1; + while (i < source.len) : (i += 1) { + if (source[i] == '\\') { + i += @intFromBool(i + 1 < source.len); + continue; + } + if (source[i] == '"') { + i += 1; + break; + } + } + try spans.append(allocator, .{ .start = start, .end = i, .class = .string }); + continue; + } + if (isDigit(source[i])) { + const start = i; + while (i < source.len and isDigit(source[i])) : (i += 1) {} + try spans.append(allocator, .{ .start = start, .end = i, .class = .number }); + continue; + } + if (isIdentStart(source[i])) { + const start = i; + i += 1; + while (i < source.len and isIdentContinue(source[i])) : (i += 1) {} + const token = source[start..i]; + if (keywordClass(token)) |class| try spans.append(allocator, .{ .start = start, .end = i, .class = class }); + continue; + } + i += 1; + } + return spans.toOwnedSlice(allocator); +} + +pub fn rowsAlloc(allocator: std.mem.Allocator, language: []const u8, source: []const u8) ![][]const u8 { + const spans = try spansAlloc(allocator, language, source); + defer allocator.free(spans); + var rows = std.ArrayList([]const u8).empty; + errdefer { + for (rows.items) |row| allocator.free(row); + rows.deinit(allocator); + } + const resolved = profile(language) orelse return Error.InvalidLanguage; + if (!resolved.source_patched) { + try rows.append(allocator, try allocator.dupe(u8, "syntax:text:fallback:no_spans")); + return rows.toOwnedSlice(allocator); + } + if (spans.len == 0) { + try rows.append(allocator, try std.fmt.allocPrint(allocator, "syntax:{s}:no_spans", .{resolved.language})); + return rows.toOwnedSlice(allocator); + } + for (spans) |span| { + const preview = try previewAlloc(allocator, source[span.start..span.end]); + defer allocator.free(preview); + try rows.append(allocator, try std.fmt.allocPrint( + allocator, + "syntax:{s}:{s}:{d}:{d}:{s}", + .{ resolved.language, span.class.text(), span.start, span.end, preview }, + )); + } + return rows.toOwnedSlice(allocator); +} + +fn keywordClass(token: []const u8) ?Class { + const keywords = [_][]const u8{ "pub", "const", "var", "fn", "return", "if", "else", "while", "for", "switch", "try", "catch", "defer", "test", "struct", "enum", "error", "union", "comptime", "inline", "break", "continue" }; + for (keywords) |keyword| if (std.mem.eql(u8, token, keyword)) return .keyword; + const builtins = [_][]const u8{ "true", "false", "null", "undefined", "usize", "u8", "i32", "void", "bool", "anyerror" }; + for (builtins) |builtin| if (std.mem.eql(u8, token, builtin)) return .builtin; + return null; +} + +fn previewAlloc(allocator: std.mem.Allocator, token: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + var i: usize = 0; + while (i < token.len and out.items.len < 48) { + const len = std.unicode.utf8ByteSequenceLength(token[i]) catch 1; + const end = @min(token.len, i + len); + const slice = token[i..end]; + if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == ':' or slice[0] == '|')) { + try out.append(allocator, '_'); + } else { + try out.appendSlice(allocator, slice); + } + i = end; + } + if (out.items.len == 0) try out.append(allocator, '_'); + return out.toOwnedSlice(allocator); +} + +fn isDigit(byte: u8) bool { + return byte >= '0' and byte <= '9'; +} + +fn isIdentStart(byte: u8) bool { + return (byte >= 'a' and byte <= 'z') or (byte >= 'A' and byte <= 'Z') or byte == '_'; +} + +fn isIdentContinue(byte: u8) bool { + return isIdentStart(byte) or isDigit(byte); +} + +fn freeRows(allocator: std.mem.Allocator, rows: []const []const u8) void { + for (rows) |row| allocator.free(row); + allocator.free(rows); +} + +test "regular: zig source profile produces deterministic highlight spans" { + const source = "pub const answer = 42; // ok\n"; + const spans = try spansAlloc(std.testing.allocator, "zig", source); + defer std.testing.allocator.free(spans); + try std.testing.expectEqual(@as(usize, 4), spans.len); + try std.testing.expectEqualDeep(Span{ .start = 0, .end = 3, .class = .keyword }, spans[0]); + try std.testing.expectEqualDeep(Span{ .start = 4, .end = 9, .class = .keyword }, spans[1]); + try std.testing.expectEqualDeep(Span{ .start = 19, .end = 21, .class = .number }, spans[2]); + try std.testing.expectEqualDeep(Span{ .start = 23, .end = 28, .class = .comment }, spans[3]); +} + +test "regular: zig syntax rows include class byte ranges and previews" { + const rows = try rowsAlloc(std.testing.allocator, "zig", "fn main() void { return; }"); + defer freeRows(std.testing.allocator, rows); + try std.testing.expectEqualStrings("syntax:zig:keyword:0:2:fn", rows[0]); + try std.testing.expectEqualStrings("syntax:zig:builtin:10:14:void", rows[1]); + try std.testing.expectEqualStrings("syntax:zig:keyword:17:23:return", rows[2]); +} + +test "adversarial: plain text falls back and unknown languages fail" { + const rows = try rowsAlloc(std.testing.allocator, "text", "pub const not highlighted"); + defer freeRows(std.testing.allocator, rows); + try std.testing.expectEqual(@as(usize, 1), rows.len); + try std.testing.expectEqualStrings("syntax:text:fallback:no_spans", rows[0]); + try std.testing.expectError(Error.InvalidLanguage, rowsAlloc(std.testing.allocator, "python", "def x(): pass")); +} diff --git a/src/tui.zig b/src/tui.zig index eb95ac9..1523195 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -7,6 +7,7 @@ const replay = @import("replay.zig"); const repo_mod = @import("repo.zig"); const session_mod = @import("session.zig"); const symbol_mod = @import("symbol.zig"); +const syntax_mod = @import("syntax.zig"); // First terminal thin client surface, scriptable for E2E-style tests. // req: session/001, session/003, ui/001, coding/001, testing/001, testing/002 @@ -146,6 +147,7 @@ pub const Client = struct { if (std.mem.eql(u8, line, "terminal_status")) return self.openStaticJobRow("terminal-status", "terminal_status:idle"); if (std.mem.eql(u8, line, "terminal_cancel")) return self.openStaticJobRow("terminal-status", "terminal_cancel:no_running_terminal"); if (std.mem.eql(u8, line, "terminal_exit")) return self.closeTerminalPanel(); + if (std.mem.startsWith(u8, line, "syntax_spans ")) return self.openSyntaxSpans(line[13..]); if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line); @@ -406,6 +408,14 @@ pub const Client = struct { self.message = null; } + fn openSyntaxSpans(self: *Client, language: []const u8) !void { + const snap = try self.session.snapshot(); + const rows = syntax_mod.rowsAlloc(self.allocator, language, snap.bytes) catch return Error.ProtocolRejected; + defer freeOwnedRows(self.allocator, rows); + try self.session.openListPanel("syntax-spans", rows); + self.message = null; + } + fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void { try self.session.openListPanel(title, &.{row}); self.message = null; @@ -1459,3 +1469,39 @@ test "adversarial: terminal command rejection does not corrupt editor" { try std.testing.expect(std.mem.indexOf(u8, frame, "terminal:status:exit_127") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "stderr:") != null); } + +test "regular: syntax spans panel renders zig highlight classes" { + var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 7 }); + defer client.deinit(); + + try client.handleTraceLine("open pub const answer = 42; // ok"); + try client.handleTraceLine("syntax_spans zig"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "syntax-spans") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:keyword:0:3:pub") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:keyword:4:9:const") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:number:") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:comment:") != null); +} + +test "regular: syntax text fallback renders no spans row" { + var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("open pub_const_plain_text"); + try client.handleTraceLine("syntax_spans text"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:text:fallback:no_spans") != null); +} + +test "adversarial: unsupported syntax language is rejected without changing buffer" { + var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("open safe"); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("syntax_spans python")); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("safe", snap.bytes); +}