diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-24 10:56:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 09:42:07 -0300 |
| commit | c3d0b84b7961ae26d2d654e7120821cc2d83d20d (patch) | |
| tree | 0b9a060a8ff0ee7d83c8df5ca797a99e0d66c53e /src/syntax.zig | |
| parent | 70bde600793ea70bd68832018a154671c6bf1512 (diff) | |
| download | pardes-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.zig | 191 |
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")); +} |
