Add source-patched syntax profiles

This commit is contained in:
slhx agent
2026-06-21 04:22:55 +02:00
parent 2ec9840837
commit a90c35e542
3 changed files with 244 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ const repo = @import("repo.zig");
const session = @import("session.zig"); const session = @import("session.zig");
const socket = @import("socket.zig"); const socket = @import("socket.zig");
const symbol = @import("symbol.zig"); const symbol = @import("symbol.zig");
const syntax = @import("syntax.zig");
const tui = @import("tui.zig"); const tui = @import("tui.zig");
pub const version = "0.1.0-dev"; pub const version = "0.1.0-dev";
@@ -136,6 +137,7 @@ test {
_ = session; _ = session;
_ = socket; _ = socket;
_ = symbol; _ = symbol;
_ = syntax;
_ = tui; _ = tui;
} }
+196
View File
@@ -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"));
}
+46
View File
@@ -7,6 +7,7 @@ const replay = @import("replay.zig");
const repo_mod = @import("repo.zig"); const repo_mod = @import("repo.zig");
const session_mod = @import("session.zig"); const session_mod = @import("session.zig");
const symbol_mod = @import("symbol.zig"); const symbol_mod = @import("symbol.zig");
const syntax_mod = @import("syntax.zig");
// First terminal thin client surface, scriptable for E2E-style tests. // First terminal thin client surface, scriptable for E2E-style tests.
// req: session/001, session/003, ui/001, coding/001, testing/001, testing/002 // 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_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_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.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, "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_open ")) return self.applyProtocolCommand(line);
if (std.mem.startsWith(u8, line, "list_filter ")) 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; 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 { fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void {
try self.session.openListPanel(title, &.{row}); try self.session.openListPanel(title, &.{row});
self.message = null; 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, "terminal:status:exit_127") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "stderr:") != 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);
}