summaryrefslogtreecommitdiff
path: root/src/syntax.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-24 10:56:05 -0300
committerGabriel Schneider <[email protected]>2026-08-25 09:42:07 -0300
commitc3d0b84b7961ae26d2d654e7120821cc2d83d20d (patch)
tree0b9a060a8ff0ee7d83c8df5ca797a99e0d66c53e /src/syntax.zig
parent70bde600793ea70bd68832018a154671c6bf1512 (diff)
downloadpardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.tar.gz
pardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.zip
host: the core owns the event loop; every platform becomes a vtable of optional methods
Diffstat (limited to 'src/syntax.zig')
-rw-r--r--src/syntax.zig191
1 files changed, 178 insertions, 13 deletions
diff --git a/src/syntax.zig b/src/syntax.zig
index def11420..1494ebd3 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -23,6 +23,7 @@ const LanguageFn = *const fn () callconv(.c) *const ts.Language;
pub const Syn = enum(u8) { none, keyword, string, number, comment };
const Spec = struct {
+ name: []const u8,
exts: []const []const u8,
language: *const fn () callconv(.c) *const ts.Language,
query_src: []const u8,
@@ -51,6 +52,7 @@ fn initSpecs() [specCount()]Spec {
inline for (grammar_manifest.all) |g| {
if (grammarSelected(g)) {
out[i] = .{
+ .name = g.name,
.exts = g.exts,
.language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }),
.query_src = @field(ts_queries, g.name ++ "_highlights"),
@@ -139,17 +141,17 @@ pub fn stop() void {
}
}
-const Selected = struct { lang: *const ts.Language, query: *ts.Query };
+const Selected = struct { name: []const u8, lang: *const ts.Language, query: *ts.Query };
// NOTE: don't lang.destroy() — the tree_sitter_*() languages are static
// singletons reused on every open; destroying one use-after-frees the next.
fn ensure(spec: *Spec) !Selected {
const lang = spec.language();
- if (spec.compiled_query) |query| return .{ .lang = lang, .query = query };
+ if (spec.compiled_query) |query| return .{ .name = spec.name, .lang = lang, .query = query };
var error_offset: u32 = 0;
const query = try ts.Query.create(lang, spec.query_src, &error_offset);
spec.compiled_query = query;
- return .{ .lang = lang, .query = query };
+ return .{ .name = spec.name, .lang = lang, .query = query };
}
fn forExt(ext: []const u8) !?Selected {
@@ -161,6 +163,56 @@ fn forExt(ext: []const u8) !?Selected {
return null;
}
+const LangAlias = struct { []const u8, []const u8 };
+const lang_aliases = [_]LangAlias{
+ .{ "js", "javascript" },
+ .{ "jsx", "javascript" },
+ .{ "mjs", "javascript" },
+ .{ "py", "python" },
+ .{ "sh", "bash" },
+ .{ "shell", "bash" },
+ .{ "zsh", "bash" },
+ .{ "bash", "bash" },
+ .{ "rs", "rust" },
+ .{ "c++", "cpp" },
+ .{ "cxx", "cpp" },
+ .{ "cc", "cpp" },
+ .{ "cs", "c_sharp" },
+ .{ "kt", "kotlin" },
+ .{ "rb", "ruby" },
+ .{ "ml", "ocaml" },
+ .{ "hs", "haskell" },
+};
+
+fn forLang(name: []const u8) !?Selected {
+ var canonical = name;
+ for (lang_aliases) |a| {
+ if (std.ascii.eqlIgnoreCase(name, a[0])) {
+ canonical = a[1];
+ break;
+ }
+ }
+ for (&specs) |*spec| {
+ if (std.ascii.eqlIgnoreCase(canonical, spec.name)) return try ensure(spec);
+ }
+ return null;
+}
+
+fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize) void {
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(sel.query, tree.rootNode());
+ while (cursor.nextMatch()) |match| {
+ for (match.captures) |cap| {
+ const syn = synFor(sel.query.captureNameForId(cap.index) orelse "");
+ if (syn == .none) continue;
+ var b: usize = base + cap.node.startByte();
+ const end = @min(base + @as(usize, cap.node.endByte()), styles.len);
+ while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
+ }
+ }
+}
+
// Queries compile on first use (`ensure`), never at startup. Pre-compiling the
// compact tier in Pardes.init cost EVERY boot ~120ms of ts_query__perform_analysis
// (55% of a Debug startup) to save ~40ms on the first .zig/.c/.cpp open — a pane
@@ -180,6 +232,9 @@ fn synFor(name: []const u8) Syn {
.{ "include", .keyword },
.{ "conditional", .keyword },
.{ "repeat", .keyword },
+ .{ "title", .keyword },
+ .{ "uri", .string },
+ .{ "reference", .number },
}) |m| {
if (std.mem.indexOf(u8, name, m[0]) != null) return m[1];
}
@@ -207,21 +262,86 @@ pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []c
const tree = parser.parseString(source, null) orelse return styles;
defer tree.destroy();
- const cursor = ts.QueryCursor.create();
- defer cursor.destroy();
- cursor.exec(selected.query, tree.rootNode());
- while (cursor.nextMatch()) |match| {
- for (match.captures) |cap| {
- const syn = synFor(selected.query.captureNameForId(cap.index) orelse "");
- if (syn == .none) continue;
- var b: usize = cap.node.startByte();
- const end = @min(@as(usize, cap.node.endByte()), styles.len);
- while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
+ runQuery(styles, selected, tree, 0);
+
+ if (std.mem.eql(u8, selected.name, "markdown")) injectCodeBlocks(styles, source, tree.rootNode());
+ return styles;
+}
+
+fn injectCodeBlocks(styles: []u8, source: []const u8, node: ts.Node) void {
+ if (std.mem.eql(u8, node.kind(), "fenced_code_block")) {
+ highlightCodeBlock(styles, source, node);
+ return;
+ }
+ var i: u32 = 0;
+ const count = node.childCount();
+ while (i < count) : (i += 1) {
+ if (node.child(i)) |c| injectCodeBlocks(styles, source, c);
+ }
+}
+
+fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node {
+ var i: u32 = 0;
+ const count = node.childCount();
+ while (i < count) : (i += 1) {
+ if (node.child(i)) |c| {
+ if (std.mem.eql(u8, c.kind(), kind)) return c;
}
}
+ return null;
+}
+
+fn highlightCodeBlock(styles: []u8, source: []const u8, block: ts.Node) void {
+ const info = childOfKind(block, "info_string") orelse return;
+ const lang_node = childOfKind(info, "language") orelse return;
+ const content_node = childOfKind(block, "code_fence_content") orelse return;
+ const langtext = source[lang_node.startByte()..lang_node.endByte()];
+ const sub_sel = (forLang(langtext) catch return) orelse return;
+ const cs: usize = content_node.startByte();
+ const ce: usize = content_node.endByte();
+ if (ce > source.len or cs > ce) return;
+
+ const parser = ts.Parser.create();
+ defer parser.destroy();
+ parser.setLanguage(sub_sel.lang) catch return;
+ const tree = parser.parseString(source[cs..ce], null) orelse return;
+ defer tree.destroy();
+ runQuery(styles, sub_sel, tree, cs);
+}
+
+/// One Syn byte per byte of content[start, end) for unified diffs/patches.
+/// Pure byte scan; independent of tree-sitter and the `enabled` flag. Caller frees.
+pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
+ const start_byte = @min(start_byte_raw, content.len);
+ const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
+ const source = content[start_byte..end_byte];
+ const styles = try gpa.alloc(u8, source.len);
+ errdefer gpa.free(styles);
+ @memset(styles, 0);
+
+ var offset: usize = 0;
+ var lines = std.mem.splitScalar(u8, source, '\n');
+ while (lines.next()) |line| {
+ const syn = diffLineSyn(line);
+ if (syn != .none) @memset(styles[offset .. offset + line.len], @intFromEnum(syn));
+ offset += line.len + 1;
+ }
return styles;
}
+fn diffLineSyn(line: []const u8) Syn {
+ if (std.mem.startsWith(u8, line, "@@")) return .keyword;
+ if (std.mem.startsWith(u8, line, "+++") or
+ std.mem.startsWith(u8, line, "---") or
+ std.mem.startsWith(u8, line, "diff ") or
+ std.mem.startsWith(u8, line, "index ") or
+ std.mem.startsWith(u8, line, "\\ No newline")) return .comment;
+ if (line.len == 0) return .none;
+ if (line[0] == '+') return .string;
+ if (line[0] == '-') return .number;
+ return .none;
+}
+
test "tree-sitter allocator callbacks preserve and free exact allocations" {
syntax_allocator = std.testing.allocator;
defer syntax_allocator = undefined;
@@ -263,3 +383,48 @@ test "default full grammar set highlights Typst source" {
defer std.testing.allocator.free(short_ext);
try std.testing.expectEqual(Syn.keyword, @as(Syn, @enumFromInt(short_ext[keyword_at])));
}
+
+test "markdown highlights headings and injects fenced code blocks" {
+ if (!enabled or !full_grammars) return;
+ start(std.testing.allocator);
+ defer stop();
+
+ const source = "# Heading\n\n```zig\nconst answer = 42;\n```\n";
+ const styles = try highlightFileRange(std.testing.allocator, "doc.md", source, 0, source.len);
+ defer std.testing.allocator.free(styles);
+
+ const title_at = std.mem.indexOf(u8, source, "Heading").?;
+ const num_at = std.mem.indexOf(u8, source, "42").?;
+ // the heading is markdown's own highlight; the number proves the zig
+ // grammar was injected into the fenced block and colored its contents.
+ try std.testing.expectEqual(Syn.keyword, @as(Syn, @enumFromInt(styles[title_at])));
+ try std.testing.expectEqual(Syn.number, @as(Syn, @enumFromInt(styles[num_at])));
+}
+
+test "highlightDiff colors unified diff lines by prefix" {
+ const diff =
+ "diff --git a/x b/x\n" ++
+ "--- a/x\n" ++
+ "+++ b/x\n" ++
+ "@@ -1,3 +1,3 @@\n" ++
+ " context\n" ++
+ "-old line\n" ++
+ "+new line\n";
+ const styles = try highlightDiff(std.testing.allocator, diff, 0, diff.len);
+ defer std.testing.allocator.free(styles);
+
+ const byteSyn = struct {
+ fn at(s: []const u8, src: []const u8, needle: []const u8) Syn {
+ const i = std.mem.indexOf(u8, src, needle).?;
+ return @enumFromInt(s[i]);
+ }
+ }.at;
+
+ try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "diff --git"));
+ try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "--- a/x"));
+ try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "+++ b/x"));
+ try std.testing.expectEqual(Syn.keyword, byteSyn(styles, diff, "@@ -1,3"));
+ try std.testing.expectEqual(Syn.none, byteSyn(styles, diff, " context"));
+ try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line"));
+ try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line"));
+}