const std = @import("std"); const pardes = @import("pardes.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); const filesystem = @import("fs.zig"); pub const Config = @import("locations_config.zig").Config; /// Legacy marked rows and hidden locations remain recognizable without metadata. /// Visible context locations are flush left and use Row.kind instead. pub fn isContextLine(line: []const u8) bool { return std.mem.startsWith(u8, line, " ") or std.mem.startsWith(u8, line, "| "); } pub const Row = struct { kind: enum { match, context }, declaration: bool = false, path: []u8, at: look.Spot, location_end: usize, code_start: usize, colors: []u8 = &.{}, }; pub fn freeRows(gpa: std.mem.Allocator, rows: []Row) void { for (rows) |row| { gpa.free(row.path); gpa.free(row.colors); } gpa.free(rows); } pub fn equalRows(a: []const Row, b: []const Row) bool { if (a.len != b.len) return false; for (a, b) |left, right| { if (left.kind != right.kind or left.declaration != right.declaration or !std.meta.eql(left.at, right.at) or left.location_end != right.location_end or left.code_start != right.code_start or !std.mem.eql(u8, left.path, right.path) or !std.mem.eql(u8, left.colors, right.colors)) return false; } return true; } /// Compare native producer rows with an already formatted, context-free view. /// Identical refreshes retain allocations, syntax and selection state. pub fn sameInput(input: []const u8, rendered: []const u8, rows: []const Row) bool { if (rows.len == 0) return std.mem.eql(u8, input, rendered); if (std.mem.endsWith(u8, input, "\n") != std.mem.endsWith(u8, rendered, "\n")) return false; var raw_lines = std.mem.splitScalar(u8, input, '\n'); var shown_lines = std.mem.splitScalar(u8, rendered, '\n'); for (rows) |row| { if (row.kind != .match) return false; const raw = raw_lines.next() orelse return false; const shown = shown_lines.next() orelse return false; const target = parseRaw(raw); if (target.end != row.location_end or row.code_start > shown.len or !std.mem.eql(u8, raw[0..target.end], shown[0..row.location_end]) or !std.mem.eql(u8, raw[target.code_start..], shown[row.code_start..])) return false; } if (raw_lines.next()) |tail| if (tail.len != 0) return false; return raw_lines.next() == null; } pub const Parsed = struct { path: []const u8, at: look.Spot, end: usize, code_start: usize, }; /// The tab separates alignment from code; source indentation after it is data. pub fn parse(line: []const u8) Parsed { if (std.mem.indexOfScalar(u8, line, '\t')) |tab| { const prefix = std.mem.trimEnd(u8, line[0..tab], " "); const target = parseRaw(prefix); if (target.at.line > 0) { const suffix = std.mem.trim(u8, prefix[target.end..], " "); var stars = true; for (suffix) |byte| if (byte != '*') { stars = false; break; }; if (stars) return .{ .path = target.path, .at = target.at, .end = target.end, .code_start = tab + 1 }; } } return parseRaw(line); } fn parseRaw(line: []const u8) Parsed { var at: usize = 1; while (at < line.len) : (at += 1) { if (line[at] != ':' or at + 1 == line.len or !std.ascii.isDigit(line[at + 1])) continue; const start = at; at += 1; while (at < line.len and (std.ascii.isDigit(line[at]) or line[at] == ':' or line[at] == '-')) at += 1; if (at < line.len and line[at] != ' ' and line[at] != '\t') continue; const target = look.parsePathLine(line[start..at]); if (target.at.line > 0 and target.end == at - start) return .{ .path = line[0..start], .at = target.at, .end = at, .code_start = @min(line.len, at + 1) }; } return .{ .path = line, .at = .{}, .end = line.len, .code_start = line.len }; } pub const Result = struct { content: []u8, rows: []Row, anchor: ?usize, }; const Pending = struct { path: []const u8, at: look.Spot, label: []const u8, code: []const u8, original: ?usize, hidden: bool = false, declaration: bool = false, depth: usize = 0, colors: []const u8 = &.{}, fn lessThan(_: void, a: Pending, b: Pending) bool { if (a.at.line != b.at.line) return a.at.line < b.at.line; return (a.original orelse std.math.maxInt(usize)) < (b.original orelse std.math.maxInt(usize)); } }; fn sourceText(p: *pardes.Pardes, arena: std.mem.Allocator, dir: []const u8, path: []const u8) ?[]const u8 { if (std.ascii.eqlIgnoreCase(std.fs.path.extension(path), ".pdf")) return null; const lexical = std.fs.path.resolvePosix(arena, &.{ dir, path }) catch return null; var pathbuf: [4096]u8 = undefined; const full = if (filesystem.resolve(p, path, dir, &pathbuf)) |resolved| resolved.path else lexical; for (p.panes) |slot| if (slot) |pane| { if (pane.file) |file| if (file.output == null and (std.mem.eql(u8, file.path, full) or std.mem.eql(u8, file.path, lexical))) return file.content; }; const bytes = filesystem.read(p, full) catch return null; defer p.gpa.free(bytes); return arena.dupe(u8, bytes) catch null; } /// Inputs are sorted results. Expand each source group, merge context with /// matches, then align once across the final rows. All temporary source data /// stays in the caller's scratch arena; returned text and rows are owned. pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?usize, expand: bool) !Result { const arena = p.scratch.allocator(); var matches: std.ArrayList(Pending) = .empty; const body = input[0 .. input.len - @intFromBool(std.mem.endsWith(u8, input, "\n"))]; var lines = std.mem.splitScalar(u8, body, '\n'); while (lines.next()) |line| { const target = parseRaw(line); if (target.at.line == 0) return .{ .content = try p.gpa.dupe(u8, input), .rows = &.{}, .anchor = anchor }; try matches.append(arena, .{ .path = target.path, .at = target.at, .label = line[0..target.end], .code = line[target.code_start..], .original = matches.items.len }); } var pending: std.ArrayList(Pending) = .empty; var first: usize = 0; while (first < matches.items.len) { var end = first + 1; while (end < matches.items.len and std.mem.eql(u8, matches.items[first].path, matches.items[end].path)) : (end += 1) {} const path = matches.items[first].path; const group_start = pending.items.len; try pending.appendSlice(arena, matches.items[first..end]); if (expand and (p.locations_config.context > 0 or p.locations_config.tscontext)) { if (sourceText(p, arena, dir, path)) |source| { var source_lines: std.ArrayList([]const u8) = .empty; var source_split = std.mem.splitScalar(u8, source, '\n'); while (source_split.next()) |line| try source_lines.append(arena, line); const count = source_lines.items.len; const wanted = try arena.alloc(u8, count); @memset(wanted, 0); const source_colors = syntax.highlightFileRange(arena, path, source, 0, source.len) catch &.{}; const declarations = if (p.locations_config.tscontext) syntax.contextDeclarations(arena, path, source) catch &.{} else &.{}; for (pending.items[group_start..]) |*match| { const row = match.at.line -| 1; if (row >= count) continue; const lo = row -| p.locations_config.context; const hi = @min(count -| 1, row +| p.locations_config.context); var context_row = lo; while (context_row <= hi) : (context_row += 1) { if (context_row == row) continue; wanted[context_row] |= 1; } for (declarations) |declaration| { if (declaration.start_line > row) break; if (declaration.end_line < row) continue; match.depth += 1; var header = declaration.start_line; while (header <= declaration.header_end_line and header < row) : (header += 1) wanted[header] |= 2; } } for (wanted, 0..) |kind, row| { if (kind > 0) { const line = source_lines.items[row]; const offset = @intFromPtr(line.ptr) - @intFromPtr(source.ptr); const declaration = kind & 2 != 0; const colors = if (!declaration and offset + line.len <= source_colors.len) source_colors[offset..][0..line.len] else &.{}; try appendContext(arena, &pending, path, line, row, declaration, declaration and !p.locations_config.tslocations, colors); } } } } std.mem.sort(Pending, pending.items[group_start..], {}, Pending.lessThan); // Keep every original result, but show each extra source row once. var write = group_start; for (pending.items[group_start..]) |row| { if (row.original == null and write > group_start and pending.items[write - 1].at.line == row.at.line) continue; pending.items[write] = row; write += 1; } pending.items.len = write; first = end; } var width: usize = 0; for (pending.items) |row| width = @max(width, labelWidth(row)); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); var rows: std.ArrayList(Row) = .empty; errdefer { for (rows.items) |row| { p.gpa.free(row.path); p.gpa.free(row.colors); } rows.deinit(p.gpa); } var mapped = anchor; for (pending.items, 0..) |row, index| { const before = out.written().len; const context = row.original == null; try out.writer.writeAll(row.label); const location_end = if (row.hidden) 0 else out.written().len - before; if (row.depth > 0) { try out.writer.writeByte(' '); try out.writer.splatByteAll('*', row.depth); } try out.writer.splatByteAll(' ', width - labelWidth(row)); try out.writer.writeByte('\t'); const code_start = out.written().len - before; try out.writer.writeAll(row.code); if (index + 1 < pending.items.len or std.mem.endsWith(u8, input, "\n")) try out.writer.writeByte('\n'); const owned_path = try p.gpa.dupe(u8, row.path); errdefer p.gpa.free(owned_path); const owned_colors = try p.gpa.dupe(u8, row.colors); errdefer p.gpa.free(owned_colors); try rows.append(p.gpa, .{ .kind = if (context) .context else .match, .declaration = row.declaration, .path = owned_path, .at = row.at, .location_end = location_end, .code_start = code_start, .colors = owned_colors }); if (anchor != null and row.original == anchor) mapped = index; } const owned_rows = try rows.toOwnedSlice(p.gpa); errdefer freeRows(p.gpa, owned_rows); return .{ .content = try out.toOwnedSlice(), .rows = owned_rows, .anchor = mapped }; } fn labelWidth(row: Pending) usize { return pardes.panes.File.displayWidth(row.label) + @as(usize, if (row.depth > 0) row.depth + 1 else 0); } fn appendContext(arena: std.mem.Allocator, rows: *std.ArrayList(Pending), path: []const u8, source: []const u8, row: usize, declaration: bool, hidden: bool, colors: []const u8) !void { try rows.append(arena, .{ .path = path, .at = .{ .line = row + 1 }, .label = if (hidden) "" else try std.fmt.allocPrint(arena, "{s}:{d}", .{ path, row + 1 }), .code = source, .original = null, .hidden = hidden, .declaration = declaration, .colors = colors, }); } test "locations tab delimiter preserves source indentation and spaced paths" { const row = parse("some directory/界.zig:12:3-5 \t\t value"); try std.testing.expectEqualStrings("some directory/界.zig", row.path); try std.testing.expectEqual(@as(usize, 12), row.at.line); try std.testing.expectEqualStrings("\t value", "some directory/界.zig:12:3-5 \t\t value"[row.code_start..]); } test "locations native results retain the first source tab" { const text = "some directory/a.zig:3:2 \t value"; const row = parseRaw(text); try std.testing.expectEqualStrings("some directory/a.zig", row.path); try std.testing.expectEqualStrings("\t value", text[row.code_start..]); }