diff options
63 files changed, 501 insertions, 4272 deletions
@@ -171,7 +171,7 @@ records one open structural gap against helix and the waiver that proves it. src/ core events (pardes.zig), panes, edit, look, exec, layout, fs, syntax src/ninep/ the 9P control tree src/detached/ the wire, the detached core, the frontend client -src/lsp/ ZLS and external language-server backends +src/lsp/ the language-server client and its seam test/ harnesses, snapshot goldens, helix cases build/ build-time helpers (the snapshot suite) docs/ see above @@ -30,19 +30,6 @@ const PerfBuild = struct { pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .patch = 0 }; -const zls_version = "0.16.1-dev+3e0d0820"; - -comptime { - const plus = std.mem.indexOfScalar(u8, zls_version, '+') orelse - @compileError("zls_version must end in +<short commit>"); - const short = zls_version[plus + 1 ..]; - const url = zon.dependencies.zls.url; - const hash = std.mem.indexOfScalar(u8, url, '#') orelse - @compileError("the zls dependency url must pin a commit with #<sha>"); - if (!std.mem.startsWith(u8, url[hash + 1 ..], short)) @compileError( - "zls_version pins commit " ++ short ++ ", build.zig.zon pins " ++ url[hash + 1 ..], - ); -} pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; const gui_shaders = [_][]const u8{ @@ -168,14 +155,10 @@ pub fn build(b: *std.Build) void { if (!enable_mupdf) pdf_pointer_bench_step.dependOn(&b.addFail("pdf-pointer-bench is unavailable with -Dmupdf=false").step); - const shaders_step = b.step("shaders", "refresh paired shaders/prebuilt/*.spv + *.glsl snapshots (glslc)"); + const shaders_step = b.step("shaders", "refresh the committed shaders/prebuilt/*.spv (glslc)"); const update_shaders = b.addUpdateSourceFiles(); for (gui_shaders) |name| { update_shaders.addCopyFileToSource(compileGlsl(b, name), b.fmt("shaders/prebuilt/{s}.spv", .{name})); - update_shaders.addCopyFileToSource( - shaderSource(b, name), - b.fmt("shaders/prebuilt/{s}.glsl", .{name}), - ); } shaders_step.dependOn(&update_shaders.step); @@ -284,7 +267,6 @@ pub fn build(b: *std.Build) void { // The conditional dependencies, hoisted so one `wireCore` call at the end // can name them all. The unconditional ones are ordinary consts below. - var zls_mod: ?*std.Build.Module = null; var ts_mod: ?*std.Build.Module = null; var ts_queries_opts: ?*std.Build.Step.Options = null; var mupdf_core_mod: ?*std.Build.Module = null; @@ -312,8 +294,6 @@ pub fn build(b: *std.Build) void { if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) root_mod.linkSystemLibrary("util", .{}); - const zls_backend = !freestanding_core; - const esp32p4_cols = b.option(u16, "esp32p4-cols", "the board's grid width in cells (-Dplatform=esp32p4 only)") orelse 56; const esp32p4_rows = b.option(u16, "esp32p4-rows", "the board's grid height in cells (-Dplatform=esp32p4 only)") orelse 14; const theme_animation = b.option(bool, "theme-animation", "animate chrome colors across a theme change (default: off for esp32p4)"); @@ -333,7 +313,6 @@ pub fn build(b: *std.Build) void { const shell_cfg: ShellConfig = .{ .tree_sitter_grammars = tree_sitter_grammars, .tracy = tracy != null, - .zls_backend = zls_backend, .mupdf = enable_mupdf, .esp32p4_cols = esp32p4_cols, .esp32p4_rows = esp32p4_rows, @@ -341,21 +320,11 @@ pub fn build(b: *std.Build) void { .workspace_tag = workspace_tag, .prebuilt_shaders = prebuilt_shaders, .embed_sources = embed_sources, - .zig_lib_dir = b.graph.zig_lib_directory.path orelse "", .commit = if (stamp_commit) gitCommit(b) else null, }; const opts = shellOptions(b, shell_cfg, platform); const gui_opts: ?*std.Build.Step.Options = if (also_gui) shellOptions(b, shell_cfg, .gui) else null; - if (zls_backend) { - const zls_dep = b.dependency("zls", .{ - .target = target, - .optimize = optimize, - .@"version-string" = @as([]const u8, zls_version), - }); - zls_mod = zls_dep.module("zls"); - } - if (tracy) |tracy_path| { for ([_]*std.Build.Module{ root_mod, core_module }) |mod| { mod.addIncludePath(.{ .cwd_relative = tracy_path }); @@ -943,7 +912,6 @@ pub fn build(b: *std.Build) void { // BEFORE the fold below, which reads what these imports put in the table. for (core_mods) |m| wireCore(m, .{ .themes = themes_mod, - .zls = zls_mod, .tree_sitter = ts_mod, .ts_libs = ts_libs.items, .ts_queries = ts_queries_opts, @@ -956,58 +924,18 @@ pub fn build(b: *std.Build) void { .uucode = uucode_mod, }); - var module_count: usize = 0; - { - var it = root_mod.import_table.iterator(); - while (it.next()) |e| { - const lp = e.value_ptr.*.root_source_file orelse continue; - switch (lp) { - .src_path, .cwd_relative => module_count += 1, - else => continue, - } - } - } - const mod_names = b.allocator.alloc([]const u8, module_count) catch @panic("OOM"); - const mod_roots = b.allocator.alloc([]const u8, module_count) catch @panic("OOM"); - var module_index: usize = 0; - { - var it = root_mod.import_table.iterator(); - while (it.next()) |e| { - const lp = e.value_ptr.*.root_source_file orelse continue; - // `generated` is a build artifact (the options modules): it has no - // path until make() runs, and pointing an editor at one is useless - const abs = switch (lp) { - .src_path => |sp| sp.owner.pathFromRoot(sp.sub_path), - .cwd_relative => |cr| cr, - else => continue, - }; - mod_names[module_index] = e.key_ptr.*; - mod_roots[module_index] = abs; - module_index += 1; - } - } - std.debug.assert(module_index == module_count); - for ([_]?*std.Build.Step.Options{ opts, gui_opts }) |maybe| if (maybe) |o| { - o.addOption([]const []const u8, "module_names", mod_names); - o.addOption([]const []const u8, "module_roots", mod_roots); - // Whose imports those are: a .zig file outside this tree has none of them. - o.addOption([]const u8, "project_root", b.build_root.path orelse ""); - }; const iso_off = b.addOptions(); iso_off.addOption(bool, "isolated", false); const iso_on = b.addOptions(); iso_on.addOption(bool, "isolated", true); - // Consulted only for a gui shell, so `-Dprebuilt-shaders` alone decides it - // here; the platform half of that condition is the branch it sits in. - const gui_effect_source_dir = if (prebuilt_shaders) "shaders/prebuilt" else "shaders"; for (core_mods, core_shells) |mod, shell| { mod.addOptions("pardes_config", if (also_gui and shell == .gui) gui_opts.? else opts); mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off); if (shell == .gui) for (gui_shaders) |name| mod.addAnonymousImport( b.fmt("effect-source-{s}.glsl", .{name}), - .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) else shaderSource(b, name) }, + .{ .root_source_file = shaderSource(b, name) }, ); if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{ .root_source_file = b.path("shaders/crt.ci.metal"), @@ -1442,7 +1370,7 @@ pub fn build(b: *std.Build) void { if (b.args) |args| run_lspbench.addArgs(args); run_lspbench.setCwd(b.path(".")); run_lspbench.has_side_effects = true; - const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [--check] [repo-root])"); + const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [--check] [repo-root]), or one query (-- probe <kind> <file> <line>:<col> [arg] [--reps N])"); lspbench_step.dependOn(&run_lspbench.step); const run_lspbench_check = b.addRunArtifact(lspbench); run_lspbench_check.addArg("--check"); @@ -1452,20 +1380,6 @@ pub fn build(b: *std.Build) void { const lspbench_check_step = b.step("lspbench-check", "require every configured language probe to complete correctly"); lspbench_check_step.dependOn(&run_lspbench_check.step); - const lspprobe = b.addExecutable(.{ - .name = "pardes-lspprobe", - .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("tools/lspprobe.zig"), - .link_libc = true, - }), - }); - lspprobe.root_module.addImport("pardes", core_module); - const run_lspprobe = b.addRunArtifact(lspprobe); - if (b.args) |args| run_lspprobe.addArgs(args); - const lspprobe_step = b.step("lspprobe", "one language query against the real seam (-- <kind> <file> <line>:<col> [arg] [--reps N])"); - lspprobe_step.dependOn(&run_lspprobe.step); const locations_bench_step = b.step("locations-bench", "measure cold/warm result formatting (-Doptimize=ReleaseFast; CSV on stderr)"); if (optimize != .ReleaseFast) { @@ -1918,7 +1832,6 @@ fn gitCommit(b: *std.Build) ?[]const u8 { const ShellConfig = struct { tree_sitter_grammars: TreeSitterGrammars, tracy: bool, - zls_backend: bool, mupdf: bool, esp32p4_cols: u16, esp32p4_rows: u16, @@ -1930,7 +1843,6 @@ const ShellConfig = struct { workspace_tag: ?bool, prebuilt_shaders: bool, embed_sources: bool, - zig_lib_dir: []const u8, commit: ?[]const u8, }; @@ -1942,22 +1854,16 @@ fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build. o.addOption(bool, "syntax_minimal_grammars", cfg.tree_sitter_grammars == .minimal or cfg.tree_sitter_grammars == .full); o.addOption(bool, "syntax_full_grammars", cfg.tree_sitter_grammars == .full); o.addOption(bool, "enable_tracy", cfg.tracy); - o.addOption(bool, "zls_backend", cfg.zls_backend); o.addOption(bool, "mupdf", cfg.mupdf); o.addOption(u16, "esp32p4_cols", cfg.esp32p4_cols); o.addOption(u16, "esp32p4_rows", cfg.esp32p4_rows); o.addOption(bool, "workspace_tag", cfg.workspace_tag orelse (platform != .macos)); o.addOption(bool, "theme_animation", cfg.theme_animation orelse (platform != .esp32p4)); - // Meaningful only for the SDL shell. Keeping the platform condition here - // prevents Config/EffectCode from describing tty/macOS/web as "prebuilt". - o.addOption(bool, "gui_shader_sources_prebuilt", platform == .gui and cfg.prebuilt_shaders); - o.addOption([]const u8, "zls_version", if (cfg.zls_backend) zls_version else "none"); o.addOption(bool, "embed_sources", cfg.embed_sources); o.addOption([]const u8, "version", zon.version); // ...and the commit it was built from, when there is one and the build // is stamped at all (see `stamp_commit`). Read at configure time. o.addOption(?[]const u8, "commit", cfg.commit); - o.addOption([]const u8, "zig_lib_dir", cfg.zig_lib_dir); return o; } @@ -2042,7 +1948,6 @@ fn vendoredThemes(b: *std.Build, io: std.Io) []const []const u8 { fn wireCore(m: *std.Build.Module, d: struct { themes: ?*std.Build.Module = null, - zls: ?*std.Build.Module = null, tree_sitter: ?*std.Build.Module = null, ts_libs: []const *std.Build.Step.Compile = &.{}, ts_queries: ?*std.Build.Step.Options = null, @@ -2055,7 +1960,6 @@ fn wireCore(m: *std.Build.Module, d: struct { uucode: ?*std.Build.Module = null, }) void { if (d.themes) |x| m.addImport("generated_themes", x); - if (d.zls) |x| m.addImport("zls", x); if (d.tree_sitter) |x| m.addImport("tree-sitter", x); for (d.ts_libs) |lib| m.linkLibrary(lib); if (d.ts_queries) |x| m.addOptions("ts_queries", x); diff --git a/build.zig.zon b/build.zig.zon index e766e158..c052341a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .pardes, - .version = "0.0.5", + .version = "0.0.6", .dependencies = .{ // 9P sessions and transports live in the published cloud9 package, so // the manifest pins the build: `zig build` fetches this commit into @@ -12,20 +12,6 @@ .url = "git+https://git.sr.ht/~gbrls/cloud9#f35b7ed09d0a170e4ffe55b5286c28f74c9a8760", .hash = "cloud9-0.1.0-yt86quEKHgCwewf5XRlEZji1JCmdsfufsrZYGZqzHj5_", }, - // ZLS as a LIBRARY, not a language server: src/lsp_zls.zig imports the - // `zls` module its build.zig publishes and calls the analyser in - // process — no zls binary, no JSON-RPC, no handshake. - // - // Fetched like every other dependency, so it lands in zig-pkg/ and the - // build is reproducible from the .zon alone. Pinned to a COMMIT on the - // 0.16.x branch rather than a tag: master requires Zig 0.17-dev, and no - // tagged release both builds on 0.16 and exports these internals. - // 0.16.x pins `minimum_zig_version = "0.16.0"` exactly, which is why - // this needs none of the patches an older checkout did. - .zls = .{ - .url = "git+https://github.com/zigtools/zls#3e0d082084be43e36865136a138c1fe2023b33ca", - .hash = "zls-0.16.1-dev-rmm5ftfOJgAzIdWUGVB08ycFbud2O3viKnGF1HZgfKZJ", - }, // Pinned to the exact libvaxis commit ghostty is built against, so it // is compatible with ghostty's uucode configuration (the one we share // with it via `external_uucode` in build.zig). diff --git a/docs/design.typ b/docs/design.typ index ec9bd5c4..80c8bb49 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1989,8 +1989,8 @@ rules as Look. Tutor: embedded text as file pane. *Language.* The native host snapshots each request and runs it outside the UI -loop. Zig uses the in-process ZLS backend; configured external language servers -use the JSON-RPC client. Results become output rows or edits, applied only while +loop. Every language's server, Zig's zls included, is a child process the +JSON-RPC client speaks to. Results become output rows or edits, applied only while the request's pane and revision still match. Web and board builds have no language backend. See `docs/lsp.md` for configuration and commands. diff --git a/docs/lsp.md b/docs/lsp.md index 82586344..22cf1b90 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -1,8 +1,9 @@ # Language intelligence Native hosts, including detached sessions, run language queries on workers. -Zig uses the linked ZLS analyser; other languages use the protocol client in -`src/lsp/lsp_client.zig`. Web and board builds have no language backend. +Every language, Zig included, goes through the protocol client in +`src/lsp/lsp_client.zig`, which runs its server as a child process: Zig's is +`zls`, which must be on PATH. Web and board builds have no language backend. `Lspinfo` (`SPC l i`) reports backend versions, server state, capabilities, recent timings and errors. `Lspwhy` (`SPC l w`) traces resolution at the cursor. @@ -37,23 +38,23 @@ without starting a query. Formatting and same-file rename apply as one undo transaction. A workspace rename spanning other files opens a preview; it does not partially apply the -rename. The linked Zig analyser resolves current-file references and relative -imports, but does not run the build runner to discover dependency modules. +rename. ## Configuration and testing -`PARDES_LSP_RS`, `_C`, `_GO`, `_TS` and `_PY` override the server executable -for each language. An empty value disables that server. `ZIG_LIB_DIR` overrides -the stdlib path recorded at build time. `Lspinfo` shows the effective settings. +`PARDES_LSP_ZIG`, `_RS`, `_C`, `_GO`, `_TS` and `_PY` override the server +executable for each language. An empty value disables that server. `Lspinfo` +shows the effective settings. The protocol client keeps one child server per configured language, negotiates position encoding, and handles both push and pull diagnostics. Startup and request waits have deadlines; failed starts back off before retrying. Worker status messages reach the host event queue. -`zig build lspprobe -- gd <file> <line>:<column>` queries the same backend from -the command line. `zig build lspbench` measures it. Snapshot tests use a Zig -mock server with deterministic answers while exercising the real client, +`zig build lspbench -- probe gd <file> <line>:<column>` queries the same backend from +the command line. `zig build lspbench` measures it. The Zig snapshot tests +(lsp, lspcomplete, lspdebug and the rest) run the real `zls`; the protocol +ones use a Zig mock server with deterministic answers while exercising the real client, including process startup, framing, edits and undo. `zig build fs-test` also checks language-worker delivery in TTY and detached sessions over 9P. diff --git a/docs/web.md b/docs/web.md index bbbed8a3..0a9783c9 100644 --- a/docs/web.md +++ b/docs/web.md @@ -80,7 +80,7 @@ What is genuinely absent is narrower than "no IO". The module has no threads and no host filesystem, so there is no startup config directory, init file, ThemeFile, or DumpThemes; LOOK resolves against the build-generated source archive rather than disk, and no language -BACKEND is compiled in (`zls_backend` is off for wasm — which also means +BACKEND is compiled in (there is none for wasm — which also means `lsp.supports` is empty, so the core never even raises a language query there, and the host leaves `lsp` null). @@ -114,7 +114,7 @@ land in the per-instance fallback, so a pane with no child is SILENT rather than synthetic; `tty_taken` is never taken; any save the page declines resolves against the core's virtual filesystem, which is also where every read comes from — the core reads files itself and never asks a host; `watch_file` records -the request; `lsp` answers an empty row set at once (`zls_backend` is off for +the request; `lsp` answers an empty row set at once (no backend is built for wasm, so the core never raises a query there anyway) and `pipe` answers failure; `wait_input` is null because wasm must never block, and `watch_theme` and `dump_themes` are not reachable without a config directory. Of the diff --git a/shaders/prebuilt/bloom-down.frag.glsl b/shaders/prebuilt/bloom-down.frag.glsl deleted file mode 100644 index 3465926d..00000000 --- a/shaders/prebuilt/bloom-down.frag.glsl +++ /dev/null @@ -1,39 +0,0 @@ -#version 450 - -// Bloom, down (docs/effects.md G4): one step of a dual Kawase filter, a -// source half as big again sampled at five points, the first step over the -// frame through a bright pass in linear light. Written to a float target. - -layout(set = 2, binding = 0) uniform sampler2D source; -layout(std140, set = 3, binding = 0) uniform BloomStep { - // xy: a source texel; zw: a target texel. - vec4 texels; - // x: the bright pass's threshold on a channel's linear peak (above the - // page's own), 0 for none (a later step); y: its soft knee. - vec4 pass; -} u; - -layout(location = 0) out vec4 o_col; - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 tap(vec2 uv) { - vec3 c = texture(source, uv).rgb; - if (u.pass.x <= 0.0) return c; - c = toLinear(c); - float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(u.pass.x, u.pass.x + u.pass.y, peak); -} - -void main() { - vec2 uv = gl_FragCoord.xy * u.texels.zw; - vec2 h = 0.5 * u.texels.xy; - vec3 sum = tap(uv) * 4.0; - sum += tap(uv - h); - sum += tap(uv + h); - sum += tap(uv + vec2(h.x, -h.y)); - sum += tap(uv - vec2(h.x, -h.y)); - o_col = vec4(sum / 8.0, 1.0); -} diff --git a/shaders/prebuilt/bloom-up.frag.glsl b/shaders/prebuilt/bloom-up.frag.glsl deleted file mode 100644 index 368076ed..00000000 --- a/shaders/prebuilt/bloom-up.frag.glsl +++ /dev/null @@ -1,26 +0,0 @@ -#version 450 - -// Bloom, up (docs/effects.md G4): one step of a dual Kawase filter back up, -// a tent of eight points over the smaller level. Float target. - -layout(set = 2, binding = 0) uniform sampler2D source; -layout(std140, set = 3, binding = 0) uniform BloomStep { - vec4 texels; - vec4 pass; -} u; - -layout(location = 0) out vec4 o_col; - -void main() { - vec2 uv = gl_FragCoord.xy * u.texels.zw; - vec2 h = 0.5 * u.texels.xy; - vec3 sum = texture(source, uv + vec2(-2.0 * h.x, 0.0)).rgb; - sum += texture(source, uv + vec2(-h.x, h.y)).rgb * 2.0; - sum += texture(source, uv + vec2(0.0, 2.0 * h.y)).rgb; - sum += texture(source, uv + vec2(h.x, h.y)).rgb * 2.0; - sum += texture(source, uv + vec2(2.0 * h.x, 0.0)).rgb; - sum += texture(source, uv + vec2(h.x, -h.y)).rgb * 2.0; - sum += texture(source, uv + vec2(0.0, -2.0 * h.y)).rgb; - sum += texture(source, uv + vec2(-h.x, -h.y)).rgb * 2.0; - o_col = vec4(sum / 12.0, 1.0); -} diff --git a/shaders/prebuilt/decor.frag.glsl b/shaders/prebuilt/decor.frag.glsl deleted file mode 100644 index bb1ef0d6..00000000 --- a/shaders/prebuilt/decor.frag.glsl +++ /dev/null @@ -1,83 +0,0 @@ -#version 450 - -// Decor, drawn by ui.vert.glsl like a cell, its track's transition included, -// and blended as the overlay pipeline blends (backgroundLayerBlend). Ink (a -// cursor) is drawn with the opacity uniform at 1. -// -// Flat: a hard-edged fill (a rule, a rail, a grip mark, a bar cursor). Its -// colour rides in the cell's ground (a_bg), its coverage in a_fg.r, so the -// result is the overlay pipeline's exactly. -// -// Checker (flag 0x08000000): two colours in squares, or in stripes, see main. -// -// Soft (flag 0x10000000): a rectangle's shadow, rim or glow blurred by a Gaussian, -// in closed form (the product of two erf edges), on one quad grown by 3 -// sigma: no blur pass. Everything is in the quad's own 0..1 space, so a -// track's transform carries it: sigma per axis in the uv, the caster's -// offset from its shadow in fg.gb, the strength in fg.r. The caster's own -// rectangle is left alone (it is drawn already), and the alpha is pre-warped -// so black darkens in linear light, then dithered so it never bands. - -layout(std140, set = 3, binding = 0) uniform UiUniforms { - float background_opacity; -} u_ui; - -layout(location = 0) in vec2 v_uv; -layout(location = 1) in vec3 v_fg; -layout(location = 2) in vec3 v_bg; -layout(location = 3) in vec2 v_cell_uv; -layout(location = 4) flat in uvec4 v_effect; -layout(location = 5) flat in vec4 v_clip; - -layout(location = 0) out vec4 o_col; - -// erf to about 4e-4 (Winitzki's approximation): smooth, cheap, odd. -float erfApprox(float x) { - float x2 = x * x; - const float a = 0.147; - float e = sqrt(1.0 - exp(-x2 * (1.2732395 + a * x2) / (1.0 + a * x2))); - return x < 0.0 ? -e : e; -} - -// How much of [lo, hi] a Gaussian of sigma s centred at x covers. -float edge(float x, float lo, float hi, float s) { - float k = 0.70710678 / s; - return 0.5 * (erfApprox((x - lo) * k) - erfApprox((x - hi) * k)); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void main() { - if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || - gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; - float opacity = clamp(u_ui.background_opacity, 0.0, 1.0); - // Checker (flag 0x08000000): squares of v_uv.x pixels from the quad's - // own corner, so it moves with its rail, in the ground's colour and the - // ink's (lapis's scroll track). - vec2 in_quad = v_cell_uv / max(vec2(dFdx(v_cell_uv.x), dFdy(v_cell_uv.y)), vec2(1e-6)); - if ((v_effect.x & 0x08000000u) != 0u) { - ivec2 q = ivec2(floor(in_quad / max(v_uv.x, 1.0))); - // v_uv.y set: stripes across the quad (lapis's title bar), not squares. - int k = v_uv.y > 0.5 ? q.y : q.x + q.y; - o_col = vec4(((k & 1) == 0 ? v_bg : v_fg) * opacity, 1.0); - return; - } - if ((v_effect.x & 0x10000000u) == 0u) { - o_col = vec4(v_bg * opacity, v_fg.r); - return; - } - vec2 s = v_uv; - vec2 m = 3.0 * s; - vec2 p = v_cell_uv; - vec2 caster0 = m - v_fg.gb; - vec2 caster1 = 1.0 - m - v_fg.gb; - if (all(greaterThanEqual(p, caster0)) && all(lessThan(p, caster1))) discard; - float a = v_fg.r * edge(p.x, m.x, 1.0 - m.x, s.x) * edge(p.y, m.y, 1.0 - m.y, s.y); - // Black darkens in linear light; a light colour (a rim, a glow) is laid - // on as it is, within §8.2's 25%. - if (all(equal(v_bg, vec3(0.0)))) a = 1.0 - pow(max(1.0 - a, 0.0), 1.0 / 2.2); - a = clamp(a + (ign(gl_FragCoord.xy) - 0.5) / 255.0, 0.0, 1.0); - o_col = vec4(v_bg * opacity, a); -} diff --git a/shaders/prebuilt/image.frag.glsl b/shaders/prebuilt/image.frag.glsl deleted file mode 100644 index 0cc1b2ee..00000000 --- a/shaders/prebuilt/image.frag.glsl +++ /dev/null @@ -1,45 +0,0 @@ -#version 450 - -layout(set = 2, binding = 0) uniform sampler2D u_image; -layout(std140, set = 3, binding = 0) uniform UiUniforms { - float background_opacity; -} u_ui; - -layout(location = 0) in vec2 v_uv; -layout(location = 1) in vec2 v_panel_uv; -layout(location = 2) flat in uvec4 v_effect; -layout(location = 0) out vec4 o_col; - -float cellNoise(uint serial, uint col, uint row) { - uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu); - x ^= x >> 16; - x *= 0x7feb352du; - x ^= x >> 15; - x *= 0x846ca68bu; - x ^= x >> 16; - return float(x & 0xffffu) / 65535.0; -} - -void main() { - vec4 source = texture(u_image, v_uv); - // Keep source coverage for destination subtraction; the pipeline scales - // source alpha by the same opacity through its alpha blend constant. - source.rgb *= clamp(u_ui.background_opacity, 0.0, 1.0); - uint effect = v_effect.x & 0x7fffffffu; - bool oldLayer = (v_effect.x & 0x80000000u) != 0u; - float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); - uvec2 dimensions = max( - uvec2(v_effect.w & 0xffffu, v_effect.w >> 16), - uvec2(1u)); - uvec2 cellCoord = min( - uvec2(floor(max(v_panel_uv, vec2(0.0)) * vec2(dimensions))), - dimensions - 1u); - float noise = cellNoise(v_effect.z, cellCoord.x, cellCoord.y); - if (effect == 3u) { - bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress); - if (oldLayer == reveal) discard; - o_col = source; - return; - } - o_col = source; -} diff --git a/shaders/prebuilt/image.vert.glsl b/shaders/prebuilt/image.vert.glsl deleted file mode 100644 index 04196ce4..00000000 --- a/shaders/prebuilt/image.vert.glsl +++ /dev/null @@ -1,49 +0,0 @@ -#version 450 - -// One instance per Surface image attachment. The CPU has already converted -// the pane's cell rectangle and the aspect-preserving contain fit to NDC. -layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1 -layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1 -layout(location = 2) in vec4 a_panel_rect; -layout(location = 3) in vec4 a_present_rect; -layout(location = 4) in uvec4 a_effect; - -layout(location = 0) out vec2 v_uv; -layout(location = 1) out vec2 v_panel_uv; -layout(location = 2) flat out uvec4 v_effect; - -void main() { - vec2 pos; - int corner = gl_VertexIndex % 6; - if (corner == 0) { - pos = vec2(a_rect.x, a_rect.y); - v_uv = vec2(a_uv_rect.x, a_uv_rect.y); - } else if (corner == 1) { - pos = vec2(a_rect.z, a_rect.y); - v_uv = vec2(a_uv_rect.z, a_uv_rect.y); - } else if (corner == 2) { - pos = vec2(a_rect.z, a_rect.w); - v_uv = vec2(a_uv_rect.z, a_uv_rect.w); - } else if (corner == 3) { - pos = vec2(a_rect.x, a_rect.y); - v_uv = vec2(a_uv_rect.x, a_uv_rect.y); - } else if (corner == 4) { - pos = vec2(a_rect.z, a_rect.w); - v_uv = vec2(a_uv_rect.z, a_uv_rect.w); - } else { - pos = vec2(a_rect.x, a_rect.w); - v_uv = vec2(a_uv_rect.x, a_uv_rect.w); - } - v_panel_uv = v_uv; - uint effect = a_effect.x & 0x7fffffffu; - if (effect != 0u) { - vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy; - panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x; - panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y; - v_panel_uv = (pos - a_panel_rect.xy) / panel_size; - if (effect == 1u || effect == 2u || effect == 5u) - pos = mix(a_present_rect.xy, a_present_rect.zw, v_panel_uv); - } - v_effect = a_effect; - gl_Position = vec4(pos, 0.0, 1.0); -} diff --git a/shaders/prebuilt/overlay.frag.glsl b/shaders/prebuilt/overlay.frag.glsl deleted file mode 100644 index b5ac8d5a..00000000 --- a/shaders/prebuilt/overlay.frag.glsl +++ /dev/null @@ -1,17 +0,0 @@ -#version 450 - -layout(std140, set = 3, binding = 0) uniform UiUniforms { - float background_opacity; -} u_ui; - -layout(location = 0) in vec4 v_color; - -layout(location = 0) out vec4 o_col; - -void main() { - // Preserve shape coverage for destination subtraction. The pipeline uses - // the matching opacity blend constant for source alpha, so graphics laid - // over a background with the same opacity do not accumulate opaque seams. - // Text/cursor draw groups bind opacity 1 instead. - o_col = vec4(v_color.rgb * clamp(u_ui.background_opacity, 0.0, 1.0), v_color.a); -} diff --git a/shaders/prebuilt/overlay.vert.glsl b/shaders/prebuilt/overlay.vert.glsl deleted file mode 100644 index 5f6be593..00000000 --- a/shaders/prebuilt/overlay.vert.glsl +++ /dev/null @@ -1,11 +0,0 @@ -#version 450 - -layout(location = 0) in vec2 a_pos; -layout(location = 1) in vec4 a_color; - -layout(location = 0) out vec4 v_color; - -void main() { - v_color = a_color; - gl_Position = vec4(a_pos, 0.0, 1.0); -} diff --git a/shaders/prebuilt/post-bloom.frag.glsl b/shaders/prebuilt/post-bloom.frag.glsl deleted file mode 100644 index a84cf88a..00000000 --- a/shaders/prebuilt/post-bloom.frag.glsl +++ /dev/null @@ -1,141 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; -}; -// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a -// uniform block (Vulkan), and the palette alone is 4 KiB. The names and -// their order are ghostty's; each block is a slice of the same memory. -layout(set = 3, binding = 1, std140) uniform Palette { - uniform vec3 iPalette[256]; -}; -layout(set = 3, binding = 2, std140) uniform Colors { - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale -// is the display's density (2 on a 2x screen), for a pass whose pattern is -// in logical pixels. iResolution, fragCoord and iMouse stay in the display's -// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on -// this theme and still leave its text, and its selection's, at their own -// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount -// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves -// alone: every tag, grip and notice, and the cursor, so no pass ever touches -// a focus indicator. -layout(set = 3, binding = 3, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; - float pardesScale; - float pardesCap; - float pardesSpareCount; - vec4 pardesSpare[32]; -}; - -bool pardesSpared(vec2 p) { - for (int i = 0; i < int(pardesSpareCount); i++) { - vec4 r = pardesSpare[i]; - if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; - } - return false; -} - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Bloom: the brightest ink glows, a little. The glow itself is made by the -// shell at half resolution and below (bloom-down, bloom-up: a dual Kawase -// filter over a bright pass, in linear light) and arrives here in -// pardesBloom; this pass adds it to the frame. Only true highlights pass -// the threshold, and the strength stays in §8.2's budget (at most 5%) and -// under pardesCap, so no text or selection loses contrast below its own or -// 4.5. Tags, grips, notices and the cursor are left alone (pardesSpare). -// It is a Shadertoy file: pardes puts its prefix before it. - -layout(set = 2, binding = 1) uniform sampler2D pardesBloom; - -float strength() { return min(pardesByLevel(0.02, 0.035, 0.05), pardesCap); } - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 uv = fragCoord / iResolution.xy; - vec4 frame = texture(iChannel0, uv); - if (pardesSpared(fragCoord)) { - fragColor = frame; - return; - } - vec3 glow = texture(pardesBloom, uv).rgb; - vec3 col = toLinear(frame.rgb) + glow * strength(); - vec3 srgb = toSrgb(col); - // Dithered: a glow's long falloff never bands. - srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, dot(glow, vec3(1.0))); - fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a); -} diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl deleted file mode 100644 index ca44d014..00000000 --- a/shaders/prebuilt/post-crt.frag.glsl +++ /dev/null @@ -1,186 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; -}; -// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a -// uniform block (Vulkan), and the palette alone is 4 KiB. The names and -// their order are ghostty's; each block is a slice of the same memory. -layout(set = 3, binding = 1, std140) uniform Palette { - uniform vec3 iPalette[256]; -}; -layout(set = 3, binding = 2, std140) uniform Colors { - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale -// is the display's density (2 on a 2x screen), for a pass whose pattern is -// in logical pixels. iResolution, fragCoord and iMouse stay in the display's -// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on -// this theme and still leave its text, and its selection's, at their own -// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount -// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves -// alone: every tag, grip and notice, and the cursor, so no pass ever touches -// a focus indicator. -layout(set = 3, binding = 3, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; - float pardesScale; - float pardesCap; - float pardesSpareCount; - vec4 pardesSpare[32]; -}; - -bool pardesSpared(vec2 p) { - for (int i = 0; i < int(pardesSpareCount); i++) { - vec4 r = pardesSpare[i]; - if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; - } - return false; -} - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Crt: a slot-mask tube's light, with nothing that moves a pixel. There is -// no barrel: every click lands where it is drawn, rails and grips at the -// window's edges included. What is left is light: a beam profile for the -// scanlines, an aperture mask, a soft bloom of the brightest ink, a faint -// vignette and the hum of a live tube. Everything is worked out in linear -// light and dithered back into 8 bits, so the vignette never bands. It is a -// Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). - -const float TAU = 6.28318530718; -// Level 3 glows wider and from dimmer ink. -float bloomRadiusPx() { return pardesByLevel(1.75, 1.75, 3.0); } -float bloomThreshold() { return pardesByLevel(0.6, 0.6, 0.3); } // linear-light channel peak -const float BLOOM_SOFTNESS = 0.25; -const float SCAN_PERIOD_PX = 3.0; -const float MASK_PERIOD_PX = 3.0; -const float HUM_HZ = 0.11; - -// Each strength at levels 1, 2 and 3 (`Crt 1`..`Crt 3`): 2 is the default, -// 3 as strong as the old barrel CRT looked. -float chromaPx() { return pardesByLevel(0.2, 0.35, 2.2); } // red and blue apart, at the corners -float bloomStrength() { return pardesByLevel(0.03, 0.05, 0.3); } -float scanStrength() { return pardesByLevel(0.035, 0.07, 0.42); } -float maskStrength() { return pardesByLevel(0.01, 0.018, 0.12); } -float vignetteStrength() { return pardesByLevel(0.06, 0.12, 0.42); } // at the far corners only -float humStrength() { return pardesByLevel(0.002, 0.004, 0.012); } -float exposure() { return pardesByLevel(1.008, 1.015, 1.1); } - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -vec3 scene(vec2 uv) { - return toLinear(texture(iChannel0, uv).rgb); -} - -vec3 brightPass(vec2 uv) { - vec3 c = scene(uv); - float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(bloomThreshold(), bloomThreshold() + BLOOM_SOFTNESS, peak); -} - -// Interleaved gradient noise: a dither with no visible pattern. -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 texel = 1.0 / size; - vec2 uv = fragCoord / size; - vec2 cc = uv * 2.0 - 1.0; - float r2 = dot(cc, cc); - - // The beam converges least at the corners: red and blue drift apart - // there, by a third of a pixel. - vec2 chroma = cc * (chromaPx() * r2 * 0.5) * texel; - vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); - - vec2 b = texel * bloomRadiusPx(); - vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + - brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); - col += bloom * (0.25 * bloomStrength()); - - // A beam is brightest on its line and falls off between lines; the - // mask is three phosphor stripes a triad. Both average to one, so the - // theme keeps its brightness. - // Periods in logical pixels: the same look at any density. - float density = max(1.0, pardesScale); - float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / (SCAN_PERIOD_PX * density)); - col *= 1.0 - scanStrength() * (line * line - 0.375) / 0.625; - col *= 1.0 + maskStrength() * cos(fragCoord.x * TAU / (MASK_PERIOD_PX * density) + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); - - float vignette = 1.0 - vignetteStrength() * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + humStrength() * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); - col *= vignette * hum * exposure(); - - vec3 srgb = toSrgb(col); - srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; - fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a); -} diff --git a/shaders/prebuilt/post-grain.frag.glsl b/shaders/prebuilt/post-grain.frag.glsl deleted file mode 100644 index a4a02e99..00000000 --- a/shaders/prebuilt/post-grain.frag.glsl +++ /dev/null @@ -1,133 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; -}; -// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a -// uniform block (Vulkan), and the palette alone is 4 KiB. The names and -// their order are ghostty's; each block is a slice of the same memory. -layout(set = 3, binding = 1, std140) uniform Palette { - uniform vec3 iPalette[256]; -}; -layout(set = 3, binding = 2, std140) uniform Colors { - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale -// is the display's density (2 on a 2x screen), for a pass whose pattern is -// in logical pixels. iResolution, fragCoord and iMouse stay in the display's -// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on -// this theme and still leave its text, and its selection's, at their own -// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount -// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves -// alone: every tag, grip and notice, and the cursor, so no pass ever touches -// a focus indicator. -layout(set = 3, binding = 3, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; - float pardesScale; - float pardesCap; - float pardesSpareCount; - vec4 pardesSpare[32]; -}; - -bool pardesSpared(vec2 p) { - for (int i = 0; i < int(pardesSpareCount); i++) { - vec4 r = pardesSpare[i]; - if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; - } - return false; -} - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Grain: the page's own ground takes a fine, still grain, like paper. Only -// pixels of the page's colour (iBackgroundColor) are grained: no glyph, no -// selection, no tag or rule, and nothing in pardesSpare. The grain averages -// to the page, is a logical pixel big (pardesScale), and never moves, so it -// costs no frames. At most 1.5, 3 and 5 of 255 at levels 1 to 3, and never -// more than pardesCap, which keeps the text on it at its own contrast or 4.5. -// It is a Shadertoy file: pardes puts its prefix before it. - -float amplitude() { return min(pardesByLevel(1.5, 3.0, 5.0) / 255.0, pardesCap); } - -// An integer hash of the grain cell: white, with no pattern to see. -float hash(uvec2 p) { - uint h = p.x * 1664525u + p.y * 1013904223u; - h ^= h >> 16; - h *= 2246822519u; - h ^= h >> 13; - h *= 3266489917u; - h ^= h >> 16; - return float(h) / 4294967295.0; -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 uv = fragCoord / iResolution.xy; - vec4 frame = texture(iChannel0, uv); - fragColor = frame; - vec3 page = iBackgroundColor.rgb; - if (any(greaterThan(abs(frame.rgb - page), vec3(0.75 / 255.0))) || pardesSpared(fragCoord)) return; - uvec2 cell = uvec2(fragCoord / max(1.0, pardesScale)); - // Two hashes summed: a triangular spread, softer than a flat one. - float n = hash(cell) + hash(cell + uvec2(7919u, 104729u)) - 1.0; - fragColor = vec4(clamp(page + n * amplitude(), 0.0, 1.0), frame.a); -} diff --git a/shaders/prebuilt/post-vignette.frag.glsl b/shaders/prebuilt/post-vignette.frag.glsl deleted file mode 100644 index d5737901..00000000 --- a/shaders/prebuilt/post-vignette.frag.glsl +++ /dev/null @@ -1,139 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; -}; -// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a -// uniform block (Vulkan), and the palette alone is 4 KiB. The names and -// their order are ghostty's; each block is a slice of the same memory. -layout(set = 3, binding = 1, std140) uniform Palette { - uniform vec3 iPalette[256]; -}; -layout(set = 3, binding = 2, std140) uniform Colors { - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale -// is the display's density (2 on a 2x screen), for a pass whose pattern is -// in logical pixels. iResolution, fragCoord and iMouse stay in the display's -// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on -// this theme and still leave its text, and its selection's, at their own -// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount -// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves -// alone: every tag, grip and notice, and the cursor, so no pass ever touches -// a focus indicator. -layout(set = 3, binding = 3, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; - float pardesScale; - float pardesCap; - float pardesSpareCount; - vec4 pardesSpare[32]; -}; - -bool pardesSpared(vec2 p) { - for (int i = 0; i < int(pardesSpareCount); i++) { - vec4 r = pardesSpare[i]; - if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; - } - return false; -} - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Vignette: the window's corners fall a little into shade, in linear light, -// so the eye settles on the middle. Round, whatever the window's shape; -// nothing within half the way to a corner is touched. At most 28% at the -// far corners (level 3), and never more than pardesCap, so text and its -// selection keep their own contrast or 4.5. Tags, grips, notices and the -// cursor are left alone (pardesSpare): the focus indicators never darken. -// It is a Shadertoy file: pardes puts its prefix before it. - -float strength() { return min(pardesByLevel(0.10, 0.18, 0.28), pardesCap); } - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 uv = fragCoord / iResolution.xy; - vec4 frame = texture(iChannel0, uv); - if (pardesSpared(fragCoord)) { - fragColor = frame; - return; - } - // 0 at the middle, 1 at a corner, on a circle's measure. - vec2 half_size = 0.5 * iResolution.xy; - float r = length(fragCoord - half_size) / length(half_size); - float shade = strength() * pow(smoothstep(0.5, 1.0, r), 1.5); - vec3 srgb = toSrgb(toLinear(frame.rgb) * (1.0 - shade)); - srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, shade); - fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a); -} diff --git a/shaders/prebuilt/post.vert.glsl b/shaders/prebuilt/post.vert.glsl deleted file mode 100644 index 86739a65..00000000 --- a/shaders/prebuilt/post.vert.glsl +++ /dev/null @@ -1,10 +0,0 @@ -#version 450 - -// A post pass (docs/render-pipeline.md §6): one fullscreen triangle from -// gl_VertexIndex, no vertex buffer. The Shadertoy fragment shader works from -// gl_FragCoord, y down, as its fragCoord. - -void main() { - vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl deleted file mode 100644 index 51ec5b8c..00000000 --- a/shaders/prebuilt/ui.frag.glsl +++ /dev/null @@ -1,93 +0,0 @@ -#version 450 - -// Sample the glyph atlas (R = coverage 0..1) and mix the cell's bg up to its fg -// by that coverage. Output is PREMULTIPLIED: an opaque cell is unchanged. -// WindowOpacity changes only the ground's coverage, never the glyph's ink. -// A cell whose background is the see-through ground (config.gui_transparent, -// a theme with no bg of its own) still contributes only its ink, at the ink's -// own coverage, so the compositor keeps the rest. - -layout(set = 2, binding = 0) uniform sampler2D u_atlas; -layout(std140, set = 3, binding = 0) uniform UiUniforms { - float background_opacity; - // A theme's ornament (colors.Decor): the page's dots (rgb, strength), - // their geometry (period, solid radius, faded radius, in pixels), and a - // file name's shadow (rgb, offset in pixels). - vec4 decor_dots; - vec4 decor_dot_geom; - vec4 decor_shadow; -} u_ui; - -layout(location = 0) in vec2 v_uv; -layout(location = 1) in vec3 v_fg; -layout(location = 2) in vec3 v_bg; -layout(location = 3) in vec2 v_cell_uv; -layout(location = 4) flat in uvec4 v_effect; -layout(location = 5) flat in vec4 v_clip; - -layout(location = 0) out vec4 o_col; - -void main() { - if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || - gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; - float a = texture(u_atlas, v_uv).r; - bool oldLayer = (v_effect.x & 0x80000000u) != 0u; - // A block cursor is foreground chrome even though its fill occupies the - // cell background: keep it visible at WindowOpacity 0. Otherwise a clear - // theme ground has no coverage and ordinary backgrounds use the setting. - // The three high flags are shared with ui.vert.glsl and src/gui/gui.zig. - float bgA = (v_effect.x & 0x20000000u) != 0u ? 1.0 - : (v_effect.x & 0x40000000u) != 0u ? 0.0 - : clamp(u_ui.background_opacity, 0.0, 1.0); - uint effect = v_effect.x & 0x07ffffffu; - float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); - - // Ink over ground, both premultiplied: v_fg*a + (v_bg*bgA)*(1-a), which is - // mix() with the ground already premultiplied. At bgA = 1 that is exactly - // the mix(v_bg, v_fg, a) at alpha 1 this shader always wrote. - vec3 ground = v_bg; - // 0x10000000: the page's ground under a pane body, dotted (lapis): a - // dot every period pixels, fixed to the window, under the text. - if ((v_effect.x & 0x10000000u) != 0u) { - float period = u_ui.decor_dot_geom.x; - // v_effect.z: how far its pane's text has moved the dots (G13's - // parallax), in pixels, as a float's bits (0 without). - vec2 at = gl_FragCoord.xy - vec2(0.0, uintBitsToFloat(v_effect.z)); - vec2 d = mod(at, period) - 0.5 * period; - float dot_a = 1.0 - smoothstep(u_ui.decor_dot_geom.y, u_ui.decor_dot_geom.z, length(d)); - ground = mix(ground, u_ui.decor_dots.rgb, u_ui.decor_dots.a * dot_a); - } - vec4 under = vec4(ground * bgA, bgA); - // Derivatives outside any branch: a pixel's step in the atlas and in - // the cell, for the shadow's offset. - vec2 step_uv = vec2(dFdx(v_uv.x), dFdy(v_uv.y)) * u_ui.decor_shadow.a; - vec2 step_cell = vec2(dFdx(v_cell_uv.x), dFdy(v_cell_uv.y)) * u_ui.decor_shadow.a; - // 0x08000000: a file name's letters cast a shadow down and right onto - // their own cell's ground (lapis), under the ink; what would fall past - // the cell's edge is not drawn. - if ((v_effect.x & 0x08000000u) != 0u) { - vec2 from = v_cell_uv - step_cell; - float s = all(greaterThanEqual(from, vec2(0.0))) ? texture(u_atlas, v_uv - step_uv).r : 0.0; - under = mix(under, vec4(u_ui.decor_shadow.rgb, 1.0), s); - } - vec4 cell = vec4(mix(under.rgb, v_fg, a), mix(under.a, 1.0, a)); - - // 3 = dissolve, a crossfade (G7): a changed cell's new form is drawn - // first, whole, and its frozen old one over it at 1 - progress, - // premultiplied (the frame's cells blend over, pipeline_over), so the - // old fades out as the new comes in; at the ends, exactly one of them. - // The tty keeps its per-cell swap (panel_animation.dissolveRevealed). - if (effect == 3u) { - if (!oldLayer) { - o_col = cell; - return; - } - if (progress >= 1.0) discard; - o_col = cell * (1.0 - progress); - return; - } - - // FreeType's hinted grayscale bitmap is already the intended coverage. - // Preserve it exactly; weight-changing gamma hacks blur small stems. - o_col = cell; -} diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl deleted file mode 100644 index de22adee..00000000 --- a/shaders/prebuilt/ui.vert.glsl +++ /dev/null @@ -1,82 +0,0 @@ -#version 450 - -// Terminal cell renderer. The CPU uploads one instance per visible cell; this -// shader expands each instance into a two-triangle quad with gl_VertexIndex. -// The fragment shader mixes fg/bg by the atlas's alpha (a glyph mask in the R -// channel), so background fills and glyph foregrounds draw in a single pass. - -layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1 -layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1 -layout(location = 2) in vec3 a_fg; -layout(location = 3) in vec3 a_bg; -// Final and presented panel rectangles in NDC. The CPU still uploads logical -// final cells; this shader is the only place transition geometry is applied. -layout(location = 4) in vec4 a_panel_rect; -layout(location = 5) in vec4 a_present_rect; -// effect id, eased-progress float bits, pane serial, local row/column packed -// into one uint. The FIVE high effect bits are flags — the frozen-old cell -// layer, a see-through cell background, an opaque block-cursor fill, a soft -// shadow (decor.frag.glsl) or dotted page ground (ui.frag.glsl), and a -// checker (decor) or a file name's shadow (ui) — so the id is what is left -// below them; integer transport preserves the exact -// cross-backend hash. -layout(location = 6) in uvec4 a_effect; -// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader -// drops what lies outside it. x1 < x0 is none. A vertical transition clips -// its pane's instances to the box the pane is growing into. -layout(location = 7) in vec4 a_clip; - -layout(location = 0) out vec2 v_uv; -layout(location = 1) out vec3 v_fg; -layout(location = 2) out vec3 v_bg; -layout(location = 3) out vec2 v_cell_uv; -layout(location = 4) flat out uvec4 v_effect; -layout(location = 5) flat out vec4 v_clip; - -void main() { - vec2 pos; - vec2 uv; - int corner = gl_VertexIndex % 6; - if (corner == 0) { - pos = vec2(a_rect.x, a_rect.y); - uv = vec2(a_uv_rect.x, a_uv_rect.y); - v_cell_uv = vec2(0.0, 0.0); - } else if (corner == 1) { - pos = vec2(a_rect.z, a_rect.y); - uv = vec2(a_uv_rect.z, a_uv_rect.y); - v_cell_uv = vec2(1.0, 0.0); - } else if (corner == 2) { - pos = vec2(a_rect.z, a_rect.w); - uv = vec2(a_uv_rect.z, a_uv_rect.w); - v_cell_uv = vec2(1.0, 1.0); - } else if (corner == 3) { - pos = vec2(a_rect.x, a_rect.y); - uv = vec2(a_uv_rect.x, a_uv_rect.y); - v_cell_uv = vec2(0.0, 0.0); - } else if (corner == 4) { - pos = vec2(a_rect.z, a_rect.w); - uv = vec2(a_uv_rect.z, a_uv_rect.w); - v_cell_uv = vec2(1.0, 1.0); - } else { - pos = vec2(a_rect.x, a_rect.w); - uv = vec2(a_uv_rect.x, a_uv_rect.w); - v_cell_uv = vec2(0.0, 1.0); - } - - uint effect = a_effect.x & 0x07ffffffu; - if (effect == 1u || effect == 2u || effect == 5u) { - // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y - // delta. Preserve that sign or every local y is mirrored/outside. - vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy; - panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x; - panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y; - vec2 within = (pos - a_panel_rect.xy) / panel_size; - pos = mix(a_present_rect.xy, a_present_rect.zw, within); - } - v_uv = uv; - v_fg = a_fg; - v_bg = a_bg; - v_effect = a_effect; - v_clip = a_clip; - gl_Position = vec4(pos, 0.0, 1.0); -} diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index ca37856e..42f2eb6f 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,7 +1,10 @@ # Changelog -## Unreleased +## 0.0.6 +- A middle or right sweep in a column tag or the workspace tag runs or looks + up the swept text on release, highlighted while it is held, as in a pane's + tag and as in acme; a press used to act on the word under it at once. - `Config` (`SPC f c`) opens the startup file, `init`, in a pane, or goes to its pane when it is open. With no file yet, it opens an empty pane of that name, and Save writes it, making the directory first. The settings @@ -9,6 +12,19 @@ - The workspace tag reads `… Dump Themes Config Debug Exit`: `Themes` in place of `NextColor`, which stays a builtin, and `Config` beside it. A workspace tag you edited, and one a dump kept, stays as it is. +- F1-F25, Insert and the keypad reach the GUI's terminal panes too: one key + map serves the tty, the GUI and the board. +- A session restored from a dump is built as a fresh one is: its column and + workspace tags hold several selections again, and 9P mtimes are real. +- Zig files use `zls` from `PATH` as a language server like any other; the + in-process ZLS backend is gone, and the binary is about 9 MB smaller. +- Dumps and init files from before 0.0.4 no longer load their old tag + formats: a restored pane's tag tail is read only from `tag_tail`, and an old + terminal's `TTY` tag or raw output tail is not replayed. `ThemeSel`, + `FontSel` and `ColumnTags` are no longer recognised in init files or dumps + (`Themes` and `Fonts` are the words). +- The GUI always embeds its shader sources; the prebuilt GLSL copies are + gone. `lspprobe` is `zig build lspbench -- probe`. ## 0.0.5 diff --git a/src/Output.zig b/src/Output.zig index 69e9644c..0581be41 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -19,7 +19,6 @@ const Pane = panes.Pane; const File = panes.File; const Builtin = builtins.registry.Builtin(); -const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {}; @@ -263,9 +262,6 @@ pub fn fromWord(w: []const u8) ?Origin { if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; if (std.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links; if (std.mem.eql(u8, w, config.unsaved_buffer)) return .unsaved; - // A dump from before the rename names them by their old words. - if (std.mem.eql(u8, w, "ThemeSel")) return .{ .cmd = .Themes }; - if (builtins.capabilities.font_picker) if (std.mem.eql(u8, w, "FontSel")) return .{ .cmd = .Fonts }; if (std.meta.stringToEnum(Builtin, w)) |b| if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; @@ -825,10 +821,6 @@ pub fn openConfig(p: *Pardes, id: usize) !void { .platform = @tagName(pardes.platform), .theme_name = p.theme().name, .compiled_default_shell = config.defaultShell(), - .gui_shader_source_mode = if (gui_shader_source_mode) |mode| - mode.label() - else - null, .hover_delay_frames = config.look_preview_delay_frames, .native_images = p.native_images, .capabilities = builtins.capabilities, @@ -882,8 +874,6 @@ pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); - if (gui_shader_source_mode) |mode| - try out.writer.print("GUI shader source: {s}\n", .{mode.label()}); try out.writer.writeByte('\n'); for (paths) |path| try out.writer.print("/virtual/{s}\n", .{path}); const content = try out.toOwnedSlice(); diff --git a/src/config.zig b/src/config.zig index bb033823..a70ae708 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1268,7 +1268,6 @@ pub const Runtime = struct { platform: []const u8, theme_name: []const u8, compiled_default_shell: []const u8, - gui_shader_source_mode: ?[]const u8 = null, hover_delay_frames: ?u16, /// Where dumps go when `DumpDir` is unset; empty without a host /// filesystem. @@ -1437,8 +1436,6 @@ pub const Runtime = struct { "Default shell ($SHELL, else /bin/sh): {s}\n", .{ context.platform, context.compiled_default_shell }, ); - if (context.gui_shader_source_mode) |mode| - try out.print("GUI shader source: {s}\n", .{mode}); if (context.hover_delay_frames) |frames| try out.print("Look hover delay: {d} frames\n", .{frames}) else @@ -1663,7 +1660,6 @@ pub const Runtime = struct { .platform = "gui", .theme_name = "acme", .compiled_default_shell = "/bin/sh", - .gui_shader_source_mode = "live GLSL compiled during this build", .hover_delay_frames = 18, .native_images = true, .capabilities = .{ @@ -1802,7 +1798,6 @@ pub const Runtime = struct { "Startup config: /tmp/pardes/init\n", "Platform: gui\n", "Default shell ($SHELL, else /bin/sh): /bin/sh\n", - "GUI shader source: live GLSL compiled during this build\n", "Look hover delay: 18 frames\n", "Native images: on\n", }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); @@ -1811,7 +1806,6 @@ pub const Runtime = struct { var defaults_context = context; defaults_context.startup_config_path = null; defaults_context.platform = "tty"; - defaults_context.gui_shader_source_mode = null; defaults_context.hover_delay_frames = null; defaults_context.native_images = false; defaults_context.capabilities = .{ @@ -1833,7 +1827,6 @@ pub const Runtime = struct { try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); diff --git a/src/detached/server.zig b/src/detached/server.zig index de26db13..d8379793 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -587,24 +587,11 @@ pub const Session = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - filesystem.write(s.core, path, bytes) catch |err| - return s.core.saveFailed(pane, path, err); - if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { - if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { - .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, - .pdf => {}, - }; - }; - var mbuf: [256]u8 = undefined; - s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); + host_io.writeFile(s.core, &s.watches, pane, path, bytes); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { - const s = of(ctx); - var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory); - filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err); - s.core.setLastDump(path); + host_io.writeDump(of(ctx).core, bytes); } fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { @@ -621,14 +608,7 @@ pub const Session = struct { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); - const config_dir = s.core.opts.config_dir orelse return; - const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { - s.core.reportError(pane, "dump themes", err); - return; - }; - defer s.gpa.free(out_dir); - var mbuf: [256]u8 = undefined; - s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); + host_io.dumpThemes(s.core, s.io, s.gpa, pane); } fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { @@ -761,20 +741,8 @@ pub const Session = struct { fn harvest(s: *Session) void { const now = monotonicMs(); - for (&s.retired_shells) |*shell| reapShell(&shell.pid, &shell.kill_at, now); - for (&s.ptys) |*pty| if (pty.fd < 0) reapShell(&pty.pid, &pty.kill_at, now); - } - - fn reapShell(pid: *posix.pid_t, kill_at: *i64, now: i64) void { - if (pid.* == 0) return; - const result = libc.waitpid(pid.*, null, libc.W.NOHANG); - if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { - pid.* = 0; - kill_at.* = 0; - } else if (kill_at.* != 0 and now >= kill_at.*) { - _ = libc.kill(pid.*, libc.SIG.KILL); - kill_at.* = 0; - } + for (&s.retired_shells) |*shell| host_io.reapRetired(&shell.pid, &shell.kill_at, now); + for (&s.ptys) |*pty| if (pty.fd < 0) host_io.reapRetired(&pty.pid, &pty.kill_at, now); } fn inotify(s: *Session) c_int { diff --git a/src/dump.zig b/src/dump.zig index 78c2bd7a..2d3c2593 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -905,7 +905,6 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo restored.command_done = true; restored.command_status = t.status; } - if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty; if (revive) p.emitSpawn(i, restored.serial, t.cwd); break :terminal restored; }, diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 328ce730..f45bf293 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -3,10 +3,7 @@ const build_config = @import("pardes_config"); const config = @import("config.zig"); const filesystem = @import("fs.zig"); -const shader_dir = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/" -else - "shaders/"; +const shader_dir = "shaders/"; const ui_vertex_path = shader_dir ++ "ui.vert.glsl"; const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; const image_vertex_path = shader_dir ++ "image.vert.glsl"; @@ -23,28 +20,6 @@ const bloom_up_path = shader_dir ++ "bloom-up.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; -pub const GuiShaderSourceMode = enum { - live, - prebuilt_snapshot, - - pub fn label(mode: GuiShaderSourceMode) []const u8 { - return switch (mode) { - .live => "live GLSL compiled during this build", - .prebuilt_snapshot => "paired GLSL snapshot for committed SPIR-V", - }; - } -}; - -/// Only the SDL backend consumes GLSL/SPIR-V. Other backends deliberately have -/// no shader-source mode rather than being mislabeled "live" or "prebuilt". -pub fn guiShaderSourceMode() ?GuiShaderSourceMode { - if (backend != .gui) return null; - return if (build_config.gui_shader_sources_prebuilt) - .prebuilt_snapshot - else - .live; -} - pub const files = switch (backend) { .tty => [_]filesystem.Source{ .{ .path = "src/tty/panel_compositor.zig", .contents = @embedFile("tty/panel_compositor.zig") }, @@ -123,14 +98,3 @@ test "EffectCode paths resolve uniquely to this backend's archived inputs" { try std.testing.expectEqual(backend == .gui, filesystem.sourceBytes("src/gui/gui.zig") != null); try std.testing.expectEqual(backend == .macos, filesystem.sourceBytes("src/macos/Sources/PardesView.swift") != null); } - -test "shader source provenance is GUI-only and follows the build input" { - if (comptime backend == .gui) { - const expected: GuiShaderSourceMode = if (build_config.gui_shader_sources_prebuilt) - .prebuilt_snapshot - else - .live; - try std.testing.expectEqual(expected, guiShaderSourceMode().?); - try std.testing.expect(expected.label().len > 0); - } else try std.testing.expect(guiShaderSourceMode() == null); -} diff --git a/src/exec.zig b/src/exec.zig index 221de105..8c062f66 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -870,58 +870,10 @@ pub fn applyStartupConfig(p: *Pardes) void { defer p.announce = true; var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); while (lines.next()) |line| { - if (executeBuiltinLine(p, p.active, line)) continue; - // A setting pardes no longer has is said, once, and changes nothing. - const word = commandText(line); - const head = word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len]; - if (std.mem.eql(u8, head, "ColumnTags")) - p.setMessage(p.active, "ColumnTags: column tags are always shown now; the init line is ignored"); - // A word renamed runs as its new name, and says so, once a line. - for ([_][2][]const u8{ .{ "ThemeSel", "Themes" }, .{ "FontSel", "Fonts" } }) |renamed| if (std.mem.eql(u8, head, renamed[0])) { - _ = executeBuiltinLine(p, p.active, renamed[1]); - var said: [64]u8 = undefined; - p.setMessage(p.active, std.fmt.bufPrint(&said, "{s}: renamed to {s}", .{ renamed[0], renamed[1] }) catch "renamed"); - }; + _ = executeBuiltinLine(p, p.active, line); } } -test "an init file's ThemeSel runs as Themes and says it was renamed" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24, .startup_config = "ThemeSel\n" }); - defer p.deinit(); - var listed = false; - for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| { - listed = listed or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Themes }); - }; - try std.testing.expect(listed); - // In the session's directory, not `/`. - for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output != null) - try std.testing.expectEqualStrings(p.sessionDir(), std.fs.path.dirname(f.path).?); - var said = false; - for (p.panes) |slot| if (slot) |pane| { - said = said or std.mem.eql(u8, pane.msg[0..pane.msg_len], "ThemeSel: renamed to Themes"); - }; - try std.testing.expect(said); - // The panes there at boot are the log's first records, before what the - // init file said. - const th = @import("ninep/testing.zig"); - const log = @intFromEnum(pardes.ctlfs.TopFile.log); - const h = th.call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; - defer _ = th.call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); - const text = th.call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; - try std.testing.expectStringStartsWith(text, "new "); -} - -test "an init file's retired ColumnTags line is said and ignored" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); - defer p.deinit(); - p.opts.startup_config = "ColumnTags\nWrap off\n"; - applyStartupConfig(p); - try std.testing.expect(!p.settings.wrap); - try std.testing.expect(p.columnBarHeight() > 0); - const pane = p.panes[p.active].?; - try std.testing.expectEqualStrings("ColumnTags: column tags are always shown now; the init line is ignored", pane.msg[0..pane.msg_len]); -} - pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { runBuiltinFrom(p, b, id, txt, arg, false); } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 8b100cd8..8fd8d360 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -4060,9 +4060,9 @@ const Shell = struct { fn reap(s: *Shell) void { const now = shellClock(); - for (&s.retired_shells) |*child| reapShell(&child.pid, &child.kill_at, now); + for (&s.retired_shells) |*child| host_io.reapRetired(&child.pid, &child.kill_at, now); for (s.ptys) |*slot| if (slot.*) |*pt| if (pt.fd < 0) { - reapShell(&pt.pid, &pt.kill_at, now); + host_io.reapRetired(&pt.pid, &pt.kill_at, now); if (pt.pid == 0) slot.* = null; }; } @@ -4085,7 +4085,7 @@ const Shell = struct { _ = libc.close(pt.fd); pt.fd = -1; const now = shellClock(); - reapShell(&pt.pid, &pt.kill_at, now); + host_io.reapRetired(&pt.pid, &pt.kill_at, now); if (pt.pid == 0) { s.ptys[pane] = null; return; @@ -4662,25 +4662,11 @@ fn killJob(ctx: ?*anyopaque, pane: u8) bool { fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = shellOf(ctx); - filesystem.write(s.core, path, bytes) catch |err| - return s.core.saveFailed(pane, path, err); - if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| { - if (std.mem.eql(u8, f.path, path)) - if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) { - .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, - .pdf => {}, - }; - }; - var mbuf: [256]u8 = undefined; - s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); + host_io.writeFile(s.core, s.watches, pane, path, bytes); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { - const s = shellOf(ctx); - var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory); - filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err); - s.core.setLastDump(path); + host_io.writeDump(shellOf(ctx).core, bytes); } fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { @@ -4704,14 +4690,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = shellOf(ctx); - const config_dir = s.core.opts.config_dir orelse return; - const out_dir = config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { - s.core.reportError(pane, "dump themes", err); - return; - }; - defer s.gpa.free(out_dir); - var mbuf: [256]u8 = undefined; - s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); + host_io.dumpThemes(s.core, s.io, s.gpa, pane); } fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void { @@ -4826,18 +4805,6 @@ fn shellClock() i64 { return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); } -fn reapShell(pid: *libc.pid_t, kill_at: *i64, now: i64) void { - if (pid.* <= 0) return; - const result = libc.waitpid(pid.*, null, libc.W.NOHANG); - if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { - pid.* = 0; - kill_at.* = 0; - } else if (kill_at.* != 0 and now >= kill_at.*) { - _ = libc.kill(pid.*, libc.SIG.KILL); - kill_at.* = 0; - } -} - fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 { if (!std.math.isFinite(raw_y)) return pending; const next = pending - raw_y; diff --git a/src/host_io.zig b/src/host_io.zig index 05ccca09..ac25e63c 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -5,6 +5,7 @@ const libc = std.c; const pardes = @import("pardes.zig"); const ninep_io = @import("9p_io.zig"); const filesystem = @import("fs.zig"); +const file_watch = @import("file_watch.zig"); pub const Host = struct { ctx: ?*anyopaque = null, @@ -2522,3 +2523,54 @@ test "a full-screen program takes the tty until it quits" { } if (ran == 0) return error.SkipZigTest; } + +// ---- what every host's vtable does alike ---- + +/// A Save's write: the file written through the core's filesystem, the +/// pane's watch told the new text is the editor's own (so it is no outside +/// change), and "saved" said on the pane. +pub fn writeFile(core: *pardes.Pardes, watches: *file_watch.Table, pane: u8, path: []const u8, bytes: []const u8) void { + filesystem.write(core, path, bytes) catch |err| + return core.saveFailed(pane, path, err); + if (core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { + if (watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { + .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, + .pdf => {}, + }; + }; + var mbuf: [256]u8 = undefined; + core.setMessage(pane, pardes.Messages.Message.stamp(&mbuf, "saved", path)); +} + +/// A Dump's write, into the DumpDir. +pub fn writeDump(core: *pardes.Pardes, bytes: []const u8) void { + var pbuf: [1024:0]u8 = undefined; + const path = pardes.dump.outPath(&pbuf, core.settings.dump_dir.get()) orelse return core.dumpFailed(core.settings.dump_dir.get(), error.NoDumpDirectory); + filesystem.write(core, path, bytes) catch |err| return core.dumpFailed(path, err); + core.setLastDump(path); +} + +/// DumpThemes: the built-in themes written below the config directory. +pub fn dumpThemes(core: *pardes.Pardes, io: std.Io, gpa: std.mem.Allocator, pane: u8) void { + const config_dir = core.opts.config_dir orelse return; + const out_dir = pardes.config.User.dumpThemes(io, gpa, config_dir, pardes.themes) catch |err| { + core.reportError(pane, "dump themes", err); + return; + }; + defer gpa.free(out_dir); + var mbuf: [256]u8 = undefined; + core.setMessage(pane, pardes.Messages.Message.stamp(&mbuf, "dumped themes", out_dir)); +} + +/// A retired shell reaped once it has exited, killed once its grace is up. +pub fn reapRetired(pid: *std.c.pid_t, kill_at: *i64, now: i64) void { + if (pid.* <= 0) return; + const result = std.c.waitpid(pid.*, null, std.c.W.NOHANG); + if (result > 0 or (result < 0 and std.c.errno(result) == .CHILD)) { + pid.* = 0; + kill_at.* = 0; + } else if (kill_at.* != 0 and now >= kill_at.*) { + _ = std.c.kill(pid.*, std.c.SIG.KILL); + kill_at.* = 0; + } +} diff --git a/src/image.zig b/src/image.zig index 7b081ec6..936fe19a 100644 --- a/src/image.zig +++ b/src/image.zig @@ -916,14 +916,6 @@ pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 { ); } -pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 { - const lead = config.tag_image ++ " "; - if (!std.mem.startsWith(u8, saved_tag, lead)) return null; - const path_at = lead.len; - if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null; - return saved_tag[0 .. path_at + state.path.len]; -} - fn ensureDecoded(p: *pardes.Pardes, state: *State) void { if (state.tried) return; state.tried = true; diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig index 7e5595fd..43f94f95 100644 --- a/src/lsp/lsp.zig +++ b/src/lsp/lsp.zig @@ -33,12 +33,14 @@ pub const Req = struct { root: []const u8 = "", }; -const backends = if (@import("pardes_config").zls_backend) - .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } +/// Every language, Zig's (zls) included, through the protocol client, which +/// runs its server as a child process. None on the web or the board. +const backends = if (@import("pardes_config").platform == .web or @import("pardes_config").platform == .esp32p4) + .{} else - .{}; + .{@import("lsp_client.zig")}; -pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; +pub const backend_name = "lsp-client"; pub const supports: std.EnumSet(Kind) = blk: { var s: std.EnumSet(Kind) = .initEmpty(); @@ -53,6 +55,7 @@ pub fn speaks(path: []const u8) bool { pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void { if (req.kind == .status) { + try out.print("backend: {s}\n", .{backend_name}); inline for (backends) |b| try b.query(gpa, arena, req, out); return; } @@ -77,7 +80,7 @@ pub fn none(out: *std.Io.Writer, comptime fmt: []const u8, args: anytype) std.Io // Called on server reader threads; the sink must copy text before returning. pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { - if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); + inline for (backends) |b| b.setStatusSink(ctx, cb); } pub fn rel(base: []const u8, path: []const u8) []const u8 { @@ -164,7 +167,7 @@ test "a question no backend answers for a file says why rather than find nothing defer arena.deinit(); var buf: [256]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); - // The in-process ZLS answers no call hierarchy for a .zig file. - try query(std.testing.allocator, arena.allocator(), .{ .kind = .incoming_calls, .path = "/x/main.zig", .source = "", .offset = 0 }, &out); + // A file no server speaks for is answered, not left empty. + try query(std.testing.allocator, arena.allocator(), .{ .kind = .incoming_calls, .path = "/x/notes.txt", .source = "", .offset = 0 }, &out); try std.testing.expectStringStartsWith(out.buffered(), "@none "); } diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index 7c1980f6..7704d0ad 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -1,9 +1,8 @@ //! A real Language Server Protocol client: child processes spoken to over //! JSON-RPC 2.0 with `Content-Length` framing. Nothing here knows any single //! language — `specs` is a table of (binary, languageId, extensions, root -//! markers), and rust-analyzer, clangd and gopls are rows in it. The in-process -//! ZLS backend keeps `.zig`; this file is every language pardes highlights but -//! could not answer questions about. +//! markers), and zls, rust-analyzer, clangd and gopls are rows in it: every +//! language pardes answers questions about. //! //! THE PROCESS LIFECYCLE IS THE DESIGN. `lsp.query` is a synchronous call on a //! worker thread, and a language server costs tens of milliseconds to start @@ -104,6 +103,14 @@ const Spec = struct { pub const specs = [_]Spec{ .{ + .name = "zls", + .bin = "zls", + .lang = "zig", + .exts = &.{".zig"}, + .markers = &.{ "build.zig", "build.zig.zon" }, + .env = "PARDES_LSP_ZIG", + }, + .{ .name = "rust-analyzer", .bin = "rust-analyzer", .lang = "rust", @@ -147,8 +154,7 @@ pub const specs = [_]Spec{ }, }; -/// Everything, including the four hierarchy kinds the in-process backend has -/// no analyser for. Whether one SERVER can answer is a capability question +/// Everything. Whether one SERVER can answer is a capability question /// answered per connection; a kind its server never advertised simply returns /// no rows, which the harness reports as CLAIMED-EMPTY per language — honest, /// since the claim here is about the protocol, not about every server. @@ -2175,9 +2181,9 @@ test "rootOf takes the top-most marker and falls back to git then dir" { test "specFor routes extensions and honours the disable env" { try std.testing.expect(specFor("/x/main.rs") != null); - try std.testing.expect(specFor("/x/main.zig") == null); try std.testing.expect(specFor("/x/README.md") == null); try std.testing.expectEqualStrings("rust-analyzer", specs[specFor("/x/main.rs").?].name); + if (getenv("PARDES_LSP_ZIG") == null) try std.testing.expectEqualStrings("zls", specs[specFor("/x/main.zig").?].name); } test "the transport this host actually gives us is a pair, and both ends are close-on-exec" { diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig deleted file mode 100644 index 2311ec47..00000000 --- a/src/lsp/lsp_zls.zig +++ /dev/null @@ -1,1522 +0,0 @@ -//! ZLS as a LIBRARY, not a language server. -//! -//! There is no subprocess, no JSON-RPC, no `initialize` handshake and no -//! `Server`. `gd` runs ZLS's own analyser on this thread and comes back with a -//! token; the whole "protocol" is a struct field. ZLS's build.zig already -//! publishes its guts as an importable module (`b.modules.put("zls", ...)`), -//! and everything under `features/` that is not welded to `*Server` is a plain -//! function over (analyser, arena, handle, offset) — so the parts we want are -//! callable and the parts we don't want cost nothing. -//! -//! What that buys and what it costs: -//! -//! - Cold IS warm. There is no server to start, no workspace to index, no -//! handshake to wait for. The first `gd` after launch pays for parsing this -//! file and whatever it imports, and nothing else. -//! - It also means NOTHING IS CACHED between queries. Every call builds a -//! DocumentStore, resolves imports, and throws it all away, because `query` -//! is handed an arena that dies on return and a `req` that is a snapshot of -//! a buffer the user is still typing into. A cache would have to be a global -//! with its own mutex and its own invalidation story; that is a real design, -//! not a line of code, and it is the obvious next step rather than something -//! smuggled in here. -//! - `zig_exe_path` is null on purpose: shelling out to the compiler is the -//! thing this backend exists to avoid. `zig_lib_dir` is the build's own -//! (`b.graph.zig_lib_directory`), which is what makes `std` resolvable — -//! `gd` on `std.mem.count` lands in the real `mem.zig`. -//! -//! The price is DEPENDENCIES, and it is worth being exact about how far it -//! goes. ZLS resolves a non-`std`, non-relative import only through a -//! `BuildFile` whose config came from running `zig build --build-runner`, so -//! with no compiler it resolves none of them. We paper over ONE case of -//! that: `gd` on the import string itself (`moduleRoot`, fed by build.zig). -//! That is a fallback in OUR handler and ZLS cannot see it, so the analyser -//! still cannot type `vaxis`, and `gd` on `vaxis.init` still finds nothing. -//! Making member access work means giving ZLS a real build config — either -//! by letting it run the build runner (a subprocess, and with no cross-query -//! cache that is once PER KEYPRESS) or by synthesizing a BuildConfig and -//! reaching into `BuildFile.impl`. Both are real work; neither is smuggled -//! in here. `@import("builtin")` and `@cImport` are unresolved for the same -//! reason. -const std = @import("std"); -const zls = @import("zls"); -const lsp = @import("lsp.zig"); -const cfg = @import("pardes_config"); - -const Analyser = zls.Analyser; -const DocumentStore = zls.DocumentStore; -const InternPool = zls.analyser.InternPool; -const offsets = zls.offsets; -const Uri = zls.Uri; -const types = zls.lsp.types; -const Ast = std.zig.Ast; - -/// BYTE offsets. ZLS defaults to utf-16 because that is what the wire protocol -/// says; we are not on a wire, and `+Search` rows are byte columns. -const enc: offsets.Encoding = .@"utf-8"; - -/// Guards on the workspace walks (workspace symbols / workspace diagnostics). -/// A language query runs on every keypress of `SPC S`; it may not turn into an -/// unbounded tree walk because someone opened a file in `/`. -const max_files = 512; -const max_rows = 2000; -const max_output_bytes = max_rows * 4096; -const max_trace_bytes = 32 * 1024; -const max_code_action_bytes = max_rows * 1024; - -pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{ - .definition, - .declaration, - .type_definition, - .implementation, - .references, - .select_refs, - .hover, - .document_symbols, - .workspace_symbols, - .diagnostics, - .workspace_diagnostics, - .rename, - .format, - .code_action, - .completion, - .status, - .explain, -}); - -/// The language gate, hoisted out of `run` so the core can ask it too: a `.zig` -/// file and nothing else. `gd` in a README has to find nothing rather than -/// parse prose as Zig, and insert-mode Tab has to keep indenting there rather -/// than divert into a query that could never answer. -pub fn speaks(path: []const u8) bool { - return std.mem.endsWith(u8, path, ".zig"); -} - -// ------------------------------------------------------------ introspection -// -// Everything below exists because of the contract two lines down: this backend -// never fails loudly. That is right for an editor — a thrown analyser must not -// take the process with it — but it means a broken backend and a correct one -// that found nothing look identical from the outside. So every query leaves a -// record, and `SPC l i` reads them back. - -/// One finished query. Fixed-size and inline: this array is process-global and -/// lives forever, so it must not own heap. -const LogEntry = struct { - used: bool = false, - kind: lsp.Kind = .definition, - us: u64 = 0, - rows: usize = 0, - offset: u32 = 0, - /// the error `run` returned, or empty when it returned normally. THIS is - /// the thing you cannot see any other way. - err: [40]u8 = @splat(0), - err_len: u8 = 0, - /// basename only — a full path would need most of this struct - file: [64]u8 = @splat(0), - file_len: u8 = 0, - - fn errName(e: *const LogEntry) []const u8 { - return e.err[0..e.err_len]; - } - fn fileName(e: *const LogEntry) []const u8 { - return e.file[0..e.file_len]; - } -}; - -const log_cap = 24; -var log_buf: [log_cap]LogEntry = @splat(.{}); -var log_next: usize = 0; -var log_total: u64 = 0; -/// 0.16 has no std.Thread.Mutex; the critical sections here are a memcpy, so -/// spinning on the lock-free one is enough — the same call gui.zig's pty queue -/// makes. Only WORKER threads touch this; the core never does. -var log_mu: std.atomic.Mutex = .unlocked; - -fn logLock() void { - while (!log_mu.tryLock()) std.atomic.spinLoopHint(); -} - -fn record(req: lsp.Req, us: u64, rows: usize, err: []const u8) void { - logLock(); - defer log_mu.unlock(); - const e = &log_buf[log_next]; - e.* = .{ .used = true, .kind = req.kind, .us = us, .rows = rows, .offset = req.offset }; - const base = std.fs.path.basename(req.path); - e.file_len = @intCast(@min(base.len, e.file.len)); - @memcpy(e.file[0..e.file_len], base[0..e.file_len]); - e.err_len = @intCast(@min(err.len, e.err.len)); - @memcpy(e.err[0..e.err_len], err[0..e.err_len]); - log_next = (log_next + 1) % log_cap; - log_total += 1; -} - -/// A duration cannot go in a snapshot golden — it differs every run. The -/// snapshot harness sets PARDES_NOTIME so the two introspection views print `-` -/// instead, the same way it pins PARDES_DUMP for the dump tests. ONE flag, -/// meaning "no wall clock in user-visible text": message.zig reads the same one -/// to blank the transient message row's `HH:MM:SS`. Unset — i.e. every real run -/// — shows real numbers. -fn hideTime() bool { - const v = std.c.getenv("PARDES_NOTIME") orelse return false; - return std.mem.span(v).len != 0; -} - -fn nowUs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000; -} - -/// The narration `SPC l w` prints. It is threaded through the REAL resolution -/// path rather than re-derived beside it: a debug view that reimplements the -/// logic is a debug view that can disagree with it, and the whole point is to -/// find out what actually happened. -const Trace = struct { - on: bool = false, - buf: [max_trace_bytes]u8 = undefined, - len: usize = 0, - - fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void { - if (!t.on or t.len == t.buf.len) return; - var w: std.Io.Writer = .fixed(t.buf[t.len..]); - w.print(fmt ++ "\n", args) catch {}; - t.len += w.buffered().len; - } - - fn written(t: *const Trace) []const u8 { - return t.buf[0..t.len]; - } -}; - -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { - // status reads the log; recording it would push a real query out of a - // 24-entry ring every time you looked at it. - if (req.kind == .status) return status(arena, req, out); - - const scratch_buf = try gpa.alloc(u8, max_output_bytes); - defer gpa.free(scratch_buf); - var scratch: std.Io.Writer = .fixed(scratch_buf); - - var tr: Trace = .{ .on = req.kind == .explain }; - const t0 = nowUs(); - var err_name: []const u8 = ""; - var failure: ?anyerror = null; - run(gpa, arena, req, &scratch, &tr) catch |e| { - failure = e; - err_name = @errorName(e); - tr.note("ERROR: {s}", .{err_name}); - }; - const us = nowUs() -| t0; - const rows = std.mem.count(u8, scratch.buffered(), "\n"); - record(req, us, rows, err_name); - - if (req.kind != .explain) { - if (failure) |err| return err; - try out.writeAll(scratch.buffered()); - return; - } - try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ - req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }); - try out.writeAll(tr.written()); - if (hideTime()) - try out.print("\n{d} row(s)\n", .{rows}) - else - try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); -} - -fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !void { - // The blocking, single-threaded std.Io — the same one look.zig walks - // directories with. ZLS wants an `Io` for every file read; a worker thread - // that is allowed to block wants the one that just does the syscall. - const io = std.Io.Threaded.global_single_threaded.io(); - - // The two workspace kinds are about the PROJECT, not about this buffer, so - // they answer from any pane and never need the analyser, the store or the - // intern pool — a walk and a parse each. - // `explain` narrates the definition query; everywhere below dispatches on - // the effective kind, so the trace follows the code `gd` really runs. - const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind; - - switch (kind) { - .workspace_symbols => return workspaceSymbols(gpa, arena, io, req, out), - .workspace_diagnostics => return workspaceDiagnostics(gpa, arena, io, req, out), - // Everything else is about THIS file, and this backend speaks Zig. The - // core does not gate the keymap by file type (a pane is a pane), so - // the gate is here: `gd` in a README must find nothing rather than - // parse prose as Zig and confidently resolve a word out of it. - else => if (!speaks(req.path)) { - tr.note("STOP: not a .zig file ({s}) — this backend only reads Zig", .{ - if (req.path.len == 0) "no path: this pane has no file behind it" else req.path, - }); - return; - }, - } - // Pure tree, no analyser. - if (kind == .format) return formatQuery(arena, req, out); - - var env: std.process.Environ.Map = .init(arena); - var lib_dir: ?std.Build.Cache.Directory = null; - if (zigLibPath()) |p| { - if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { - lib_dir = .{ .handle = h, .path = p }; - tr.note("zig lib dir: {s} (open) — `std` imports resolve", .{p}); - } else |e| tr.note("zig lib dir: {s} FAILED to open ({s}) — nothing in `std` will resolve", .{ p, @errorName(e) }); - } else tr.note("zig lib dir: NOT SET — nothing in `std` will resolve", .{}); - defer if (lib_dir) |d| d.handle.close(io); - - var ip: InternPool = try .init(io, gpa); - defer ip.deinit(gpa); - - var diags: zls.DiagnosticsCollection = .{ .io = io, .allocator = gpa }; - defer diags.deinit(); - - var store: DocumentStore = .{ - .io = io, - .allocator = gpa, - .config = .{ - .environ_map = &env, - // no compiler subprocess: that is the entire point of this backend - .zig_exe_path = null, - .zig_lib_dir = lib_dir, - .build_runner_path = null, - .builtin_path = null, - .global_cache_dir = null, - .wasi_preopens = {}, - }, - .diagnostics_collection = &diags, - }; - defer store.deinit(); - - // The buffer the user is looking at, not the file on disk: `source` is the - // shell's snapshot, so unsaved edits are what gets analysed — except for a - // completion, which is asked ABOUT a half-typed line and has to repair it - // first (see completionSource). - const uri: Uri = try .fromPath(arena, req.path); - try store.openLspSyncedDocument(uri, if (kind == .completion) completionSource(arena, req, tr) else req.source); - const handle = store.getHandle(uri) orelse { - tr.note("STOP: the document store would not take this buffer", .{}); - return; - }; - if (handle.tree.mode == .zon) { - tr.note("STOP: parsed as .zon, not Zig code — there is nothing to resolve", .{}); - return; - } - tr.note("parsed {d} bytes, {d} syntax error(s){s}", .{ - req.source.len, - handle.tree.errors.len, - if (handle.tree.errors.len == 0) "" else " — a broken tree resolves badly or not at all", - }); - - var analyser: Analyser = .init(gpa, arena, &store, &ip, handle); - defer analyser.deinit(); - - const off: usize = @min(req.offset, req.source.len); - - // the rows of the kinds that only ever name THIS file spell it once - const here = lsp.rel(req.root, req.path); - - switch (kind) { - .definition, .implementation => try goto(arena, &analyser, handle, off, .definition, req.root, out, tr), - .declaration => try goto(arena, &analyser, handle, off, .declaration, req.root, out, tr), - .type_definition => try goto(arena, &analyser, handle, off, .type_definition, req.root, out, tr), - .hover => try hover(arena, &analyser, handle, off, out), - .document_symbols => try documentSymbols(gpa, arena, here, &handle.tree, out), - .references, .select_refs => try references(arena, &analyser, handle, off, null, req.root, out), - .rename => try references(arena, &analyser, handle, off, req.arg, req.root, out), - .diagnostics => try diagnostics(gpa, arena, here, &handle.tree, out), - .code_action => try codeActions(gpa, arena, &analyser, handle, off, out), - .completion => try completion(arena, &analyser, handle, off, req.root, req.source, out, tr), - else => {}, - } -} - -/// `SPC l i`. What the backend IS: which ZLS, which stdlib, what it will and -/// will not answer, and the last two dozen queries with their timings and — -/// the point of the whole exercise — the errors that `query` swallowed. -fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { - const io = std.Io.Threaded.global_single_threaded.io(); - try out.print("backend: {s}\nzls: {s} (compiled in — no server process, no JSON-RPC)\n", .{ - lsp.backend_name, cfg.zls_version, - }); - - // The single most common cause of "gd does nothing in std": say whether it - // is actually openable, not just what it is set to. - if (zigLibPath()) |p| { - if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { - var d = h; - d.close(io); - try out.print("zig lib dir: {s} [OK]\n", .{p}); - } else |e| try out.print("zig lib dir: {s} [CANNOT OPEN: {s}] — nothing in `std` will resolve\n", .{ p, @errorName(e) }); - } else try out.print("zig lib dir: NOT SET — nothing in `std` will resolve\n", .{}); - try out.print("offsets: {s} walk caps: {d} files, {d} rows\n", .{ @tagName(enc), max_files, max_rows }); - // The dependency module map, which is the answer to "why does gd work on - // std and not on @import(\"vaxis\")" — if a name is not on this list, the - // build did not import it and nothing can resolve it. - // pardes's own build's imports: listed for a file in its tree, none for - // any other project's file, which does not have them. - if (inProject(req.path)) { - try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len}); - for (cfg.module_names, cfg.module_roots) |n, r| { - try out.print(" {s:<14} {s}\n", .{ n, r }); - } - } else try out.print("\ndependency imports gd can follow: none (this file is not in the tree pardes was built from)\n", .{}); - try out.print("asked from: {s}\n", .{if (req.path.len == 0) "a pane with no file" else req.path}); - - try out.print("\nanswers:", .{}); - var it = supports.iterator(); - var n: usize = 0; - while (it.next()) |k| : (n += 1) { - if (k == .status or k == .explain) continue; // these two are the debugger, not a feature - try out.print("{s}{s}", .{ if (n == 0) " " else ", ", @tagName(k) }); - } - try out.print("\nrefuses: ", .{}); - n = 0; - for (std.enums.values(lsp.Kind)) |k| { - if (supports.contains(k)) continue; - try out.print("{s}{s}", .{ if (n == 0) "" else ", ", @tagName(k) }); - n += 1; - } - if (n == 0) try out.print("(nothing)", .{}); - try out.print("\n", .{}); - - // The log. Oldest first, so it reads like a transcript. - logLock(); - defer log_mu.unlock(); - try out.print("\nlast queries ({d} total, keeping {d}):\n", .{ log_total, log_cap }); - var shown: usize = 0; - for (0..log_cap) |i| { - const e = &log_buf[(log_next + i) % log_cap]; - if (!e.used) continue; - shown += 1; - if (hideTime()) { - try out.print(" {s:<22} {s:<20} @{d:<7} {d:>4} row(s){s}{s}\n", .{ - @tagName(e.kind), e.fileName(), e.offset, e.rows, - if (e.err_len == 0) "" else " ERROR: ", e.errName(), - }); - } else { - try out.print(" {s:<22} {s:<20} @{d:<7} {d:>7}us {d:>4} row(s){s}{s}\n", .{ - @tagName(e.kind), e.fileName(), e.offset, e.us, e.rows, - if (e.err_len == 0) "" else " ERROR: ", e.errName(), - }); - } - } - if (shown == 0) try out.print(" (none yet — press gd somewhere, then ask again)\n", .{}); - _ = arena; -} - -/// The root source file of a module this build imports, or null. -/// -/// ZLS answers `@import("std")` from `zig_lib_dir` and a relative `.zig` path -/// from the filesystem, but ANY OTHER name — every dependency in -/// build.zig.zon — it can only resolve by running `zig build --build-runner` -/// to discover the module graph. This backend sets `zig_exe_path = null` on -/// purpose, so that branch always returned nothing: `gd` worked perfectly into -/// `std` and silently did nothing on `@import("vaxis")`. -/// -/// We never needed the compiler for this. build.zig IS the module graph, so it -/// folds its own `root_mod.import_table` into these two parallel arrays at -/// configure time. Correct by construction: a dependency added or renamed in -/// build.zig cannot forget to appear here. -/// -/// LIMITS, in the order you will hit them: -/// 1. This resolves the IMPORT STRING only. It is consulted from our own -/// `goto`, not from inside ZLS, so the analyser still cannot type the -/// `vaxis` const — `gd` on `vaxis.init` finds nothing. Fixing that means -/// giving ZLS a BuildConfig; see the note at the top of this file. -/// 2. These are THIS build's imports. A file inside a dependency importing -/// that dependency's own internal module name is a miss — that would mean -/// running ITS build.zig. -/// 3. A module whose root source is a GENERATED file is absent from the -/// table: it has no path until make() runs. -/// Whether `path` is in the tree pardes was built from, whose build's -/// imports `cfg.module_names` are. -fn inProject(path: []const u8) bool { - const project = std.mem.trimEnd(u8, cfg.project_root, "/"); - return project.len > 0 and path.len > project.len and std.mem.startsWith(u8, path, project) and path[project.len] == '/'; -} - -test "only a file in pardes's own tree is told pardes's dependency imports" { - var buf: [4096]u8 = undefined; - const inside = try std.fmt.bufPrint(&buf, "{s}/src/main.zig", .{std.mem.trimEnd(u8, cfg.project_root, "/")}); - try std.testing.expect(inProject(inside)); - try std.testing.expect(!inProject("/tmp/elsewhere/main.zig")); -} - -fn moduleRoot(name: []const u8) ?[]const u8 { - comptime std.debug.assert(cfg.module_names.len == cfg.module_roots.len); - for (cfg.module_names, cfg.module_roots) |n, r| { - if (std.mem.eql(u8, n, name)) return r; - } - return null; -} - -/// Why a field access came back empty. Worth its own function because ONE of -/// the reasons is a known, explainable hole rather than "no idea": if the left -/// side starts with a dependency module name, the analyser could not type it -/// because ZLS has no build config, and no amount of staring at the code will -/// reveal that. -fn explainFieldMiss(tr: *Trace, lhs: []const u8) void { - var head: usize = 0; - while (head < lhs.len and (std.ascii.isAlphanumeric(lhs[head]) or lhs[head] == '_')) head += 1; - if (head != 0 and moduleRoot(lhs[0..head]) != null) { - tr.note(" `{s}` IS a build.zig dependency, and this is the known hole: the module", .{lhs[0..head]}); - tr.note(" map is consulted by our @import handler, not by ZLS, so the analyser cannot", .{}); - tr.note(" type it. `gd` on the import string works; `gd` on a member does not.", .{}); - return; - } - tr.note(" (a value whose type needs comptime, an unresolved @import, or genuinely absent)", .{}); -} - -/// The zig lib directory, which is the one thing this backend cannot work out -/// for itself: it is baked in at build time from `b.graph.zig_lib_directory`, -/// i.e. the exact stdlib pardes was compiled against, and ZIG_LIB_DIR wins if -/// the user moved it. Without this, `gd` on anything in `std` finds nothing. -fn zigLibPath() ?[]const u8 { - if (std.c.getenv("ZIG_LIB_DIR")) |p| { - const s = std.mem.span(p); - if (s.len != 0) return s; - } - if (cfg.zig_lib_dir.len != 0) return cfg.zig_lib_dir; - return null; -} - -// ---------------------------------------------------------------- rows - -/// Byte offset -> line, without rescanning the file per row. Built once per -/// query over the file we emit many rows from (symbols, references, -/// diagnostics); a goto that emits two rows just scans. -const Lines = struct { - starts: []const u32, - src: []const u8, - - fn build(arena: std.mem.Allocator, src: []const u8) !Lines { - const count = std.mem.countScalar(u8, src, '\n') + 1; - const starts = try arena.alloc(u32, count); - starts[0] = 0; - var n: usize = 1; - for (src, 0..) |c, i| { - if (c != '\n') continue; - starts[n] = @intCast(i + 1); - n += 1; - } - return .{ .starts = starts, .src = src }; - } - - fn line(l: Lines, n: usize) []const u8 { - if (n >= l.starts.len) return ""; - const a = l.starts[n]; - const b = if (n + 1 < l.starts.len) l.starts[n + 1] - 1 else l.src.len; - return l.src[a..@min(b, l.src.len)]; - } -}; - -/// The source line at a 0-based line number, scanned. For the one-or-two-row -/// answers (a goto lands in a file we opened only to read one line out of). -fn lineAt(src: []const u8, want: usize) []const u8 { - var it = std.mem.splitScalar(u8, src, '\n'); - var i: usize = 0; - while (it.next()) |l| : (i += 1) if (i == want) return l; - return ""; -} - -/// Emit one row for a token in whatever file it lives in — which for a goto -/// into `std` is not the file the cursor was in, so the path comes off the -/// handle's uri rather than off `req`. `base` is `req.root`, the directory the -/// row is written relative to (lsp.rel). -fn rowForToken(arena: std.mem.Allocator, base: []const u8, th: Analyser.TokenWithHandle, out: *std.Io.Writer) !void { - const tree = &th.handle.tree; - if (th.token >= tree.tokens.len) return; - const r = offsets.tokenToRange(tree, th.token, enc); - const path = lsp.rel(base, th.handle.uri.toFsPath(arena) catch return); - try lsp.sourceSpanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line)); -} - -// ---------------------------------------------------------------- goto - -const GotoKind = enum { declaration, definition, type_definition }; - -/// `goto.zig:gotoHandler` minus the Server, minus the LSP types, minus the -/// linkSupport branch: dispatch on the position context, resolve to a decl, -/// resolve the decl to a token, print the token's line. The five helix gotos -/// collapse onto three ZLS kinds (`gi` behaves as `gd`, which is what ZLS's -/// own implementation handler does too). -fn goto( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - kind: GotoKind, - /// req.root: what the rows' paths are written relative to (lsp.rel) - base: []const u8, - out: *std.Io.Writer, - tr: *Trace, -) !void { - const tree = &handle.tree; - const ctx = try Analyser.getPositionContext(arena, tree, off, true); - tr.note("position context: .{s}", .{@tagName(ctx)}); - - const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows); - var decl_count: usize = 0; - switch (ctx) { - .var_access, .test_doctest_name => { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse { - tr.note("STOP: no identifier under the cursor", .{}); - return; - }; - const name = offsets.locToSlice(tree.source, name_loc); - tr.note("identifier: `{s}` — a plain name, so: innermost scope outward", .{name}); - if (std.mem.eql(u8, name, "_")) { - tr.note("STOP: `_` is the discard, it declares nothing", .{}); - return; - } - if (std.zig.isPrimitive(name)) { - tr.note("STOP: `{s}` is a builtin primitive — it has no declaration in any file", .{name}); - return; - } - const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse { - tr.note("STOP: lookupSymbolGlobal found no `{s}` in scope here", .{name}); - return; - }; - decls[decl_count] = d; - decl_count += 1; - }, - .field_access => |loc| { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse { - tr.note("STOP: no identifier under the cursor", .{}); - return; - }; - const name = offsets.locToSlice(tree.source, name_loc); - const held = offsets.locMerge(loc, name_loc); - tr.note("field access: `{s}` on `{s}` — needs the TYPE of the left side", .{ - name, offsets.locToSlice(tree.source, loc), - }); - // NOTE both misses: a null result AND an empty one. ZLS returns an - // empty slice when it typed the left side but found no such member, - // and null when it could not type it at all — from the outside both - // are "gd did nothing", so both have to be explained. - const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse { - tr.note("STOP: could not resolve the left side to a type at all", .{}); - explainFieldMiss(tr, offsets.locToSlice(tree.source, loc)); - return; - }; - if (found.len == 0) { - tr.note("STOP: the left side resolved, but nothing named `{s}` was found on it", .{name}); - explainFieldMiss(tr, offsets.locToSlice(tree.source, loc)); - return; - } - decl_count = @min(found.len, decls.len); - @memcpy(decls[0..decl_count], found[0..decl_count]); - }, - .label_access, .label_decl => { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; - const name = offsets.locToSlice(tree.source, name_loc); - const d = try Analyser.lookupLabel(handle, name, off) orelse return; - decls[decl_count] = d; - decl_count += 1; - }, - .enum_literal => { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; - const name = offsets.locToSlice(tree.source, name_loc); - const d = try analyser.getSymbolEnumLiteral(handle, off, name) orelse return; - decls[decl_count] = d; - decl_count += 1; - }, - // `gd` on `@import("foo.zig")` opens foo.zig. Only relative imports - // resolve — a package name needs the build graph we do not run. - .import_string_literal, .embedfile_string_literal => { - const loc = ctx.stringLiteralContentLoc(tree.source); - if (loc.start == loc.end) return; - const str = offsets.locToSlice(tree.source, loc); - tr.note("import string: \"{s}\"", .{str}); - const res = try analyser.store.uriFromImportStr(arena, handle, str); - switch (res) { - .none => { - // ZLS gave up, which for a DEPENDENCY name is not a real - // dead end — it just means it wanted the module graph and - // we would not start a compiler to get one. Our build.zig - // knows the graph and baked it in; consult that. - if (moduleRoot(str)) |path| { - tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str}); - try lsp.row(out, lsp.rel(base, path), 0, 0, str); - return; - } - tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str}); - tr.note(" build's own dependencies work; `builtin`, `root` and a dependency's", .{}); - tr.note(" OWN internal module names need the build graph we do not run.", .{}); - return; - }, - .one => |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), - .many => |us| for (us) |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str), - } - return; - }, - else => { - tr.note("STOP: nothing to resolve in a .{s} context — `gd` only answers on", .{@tagName(ctx)}); - tr.note(" an identifier, a field access, a label, an enum literal or an @import string.", .{}); - return; - }, - } - - tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decl_count, @tagName(kind) }); - // One decl that fails to resolve must not swallow the others: a field - // access can name several and a partial answer beats none. - for (decls[0..decl_count]) |d| { - switch (kind) { - .declaration => try rowForToken(arena, base, d.definitionToken(analyser, false) catch continue, out), - .definition => try rowForToken(arena, base, d.definitionToken(analyser, true) catch continue, out), - .type_definition => { - // peel error unions / pointers / optionals the way ZLS does, - // then take the container's own name token - if (try d.resolveType(analyser)) |ty| { - var r = ty; - while (true) { - r = try analyser.resolveUnwrapErrorUnionType(r, .payload) orelse - try analyser.resolveDerefType(r) orelse - try analyser.resolveOptionalUnwrap(r) orelse break; - } - if (r.typeDefinitionToken()) |th| { - try rowForToken(arena, base, th, out); - continue; - } - } - const nd = try d.typeDeclarationNode() orelse continue; - const t2 = &nd.handle.tree; - const rr = offsets.nodeToRange(t2, nd.node, enc); - const path = lsp.rel(base, nd.handle.uri.toFsPath(arena) catch continue); - try lsp.sourceSpanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); - }, - } - } -} - -// ------------------------------------------------------------ completion - -/// The text a completion query actually analyses. -/// -/// The user has just typed the `.`, so the buffer DOES NOT PARSE, and not -/// locally: `switch (e) { . }` loses the entire switch to the parser's error -/// recovery — the tree keeps the function's block and nothing inside it — -/// which takes with it every ancestor an expected-type resolution needs. ZLS -/// answers this with a private token scanner (`getSwitchOrStructInitContext`, -/// ~200 lines welded to a `*Server`) that re-derives the context by hand. This -/// backend instead makes the tree PARSE, by splicing a placeholder in after -/// the dot. Six spellings, because a half-typed line is short of two different -/// things — the identifier the dot needs, and whatever the user has not closed -/// yet — and the second half is not optional: a missing closer makes Zig's -/// recovery discard the whole enclosing declaration, which no placeholder AT -/// the dot can survive. -/// -/// `_p => {},` a switch prong: not a prong at all without its arrow, so -/// no bare identifier can rescue one -/// `_p;` a statement or declaration still missing its terminator, -/// which is what `const z: E = .` is the instant it is typed -/// `_p` everything already closed: `f(.)`, `x = .;`, `.a = .` -/// `_p => {}, }` ...and the same three again for a construct still hanging -/// `_p)` open — `switch (e) {`, `g(`, `.{` with no closer yet, -/// `_p }` which is what the first second of typing looks like -/// -/// No spelling may contain a NEWLINE: the rows depend on line numbers being -/// identical between the repaired copy and the user's buffer (see `completion`). -/// -/// Choosing between them: reachability (is the dot now an `enum_literal` the -/// tree can be walked down to from the root) is a hard FILTER, the parse-error -/// count RANKS what survives it, the earlier spelling wins a tie, and a -/// candidate at ZERO errors ends the search. A buffer that already parses is -/// left alone, and a dot no spelling can reach is the seam's normal "no -/// result". -/// ponytail: one parse per spelling; caching the base parse is the obvious -/// next step if it ever shows up. -fn completionSource(arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) [:0]const u8 { - if (req.offset == 0 or req.offset > req.source.len) return req.source; - if (req.source[req.offset - 1] != '.') return req.source; - var base: Ast = Ast.parse(arena, req.source, .zig) catch return req.source; - // Nothing to repair: a bare `.` is always a parse error, so a clean tree - // means the dot is already part of a node — the placeholder's whole job. - if (base.errors.len == 0) return req.source; - // A field access resolves off the position context and its own tokenizer, - // never off the tree, so `foo.` needs no placeholder and pays no shift. - const ctx = Analyser.getPositionContext(arena, &base, req.offset, false) catch return req.source; - if (ctx != .enum_literal) return req.source; - - var best = req.source; - var chose: []const u8 = ""; - var fewest: usize = std.math.maxInt(usize); - for ([_][]const u8{ "_p => {},", "_p;", "_p", "_p => {}, }", "_p)", "_p }" }) |fill| { - const cand = std.fmt.allocPrintSentinel(arena, "{s}{s}{s}", .{ - req.source[0..req.offset], fill, req.source[req.offset..], - }, 0) catch continue; - var t: Ast = Ast.parse(arena, cand, .zig) catch continue; - if (t.errors.len >= fewest) continue; - const nodes = zls.ast.nodesOverlappingIndex(arena, &t, req.offset) catch continue; - if (nodes.len < 2 or t.nodeTag(nodes[0]) != .enum_literal) continue; - fewest = t.errors.len; - best = cand; - chose = fill; - if (fewest == 0) break; - } - if (chose.len == 0) - tr.note("STOP: no placeholder made the dot reachable in the tree ({d} parse error(s))", .{base.errors.len}) - else - tr.note("placeholder `{s}`: the dot is an enum_literal again, {d} parse error(s) left (was {d})", .{ chose, fewest, base.errors.len }); - return best; -} - -/// ZLS's collector takes a growable list. This equivalent writes directly into -/// the query's row-bounded slice and stops in declaration order when it fills. -fn collectContainerDecls( - analyser: *Analyser, - container_type: Analyser.Type, - original_handle: *DocumentStore.Handle, - instance_access: bool, - out: []Analyser.DeclWithHandle, - len: *usize, -) !void { - const info = switch (container_type.data) { - .container => |info| info, - .either => |entries| { - for (entries) |entry| { - if (len.* == out.len) return; - const ty: Analyser.Type = .{ - .data = entry.type_data, - .is_type_val = container_type.is_type_val, - }; - try collectContainerDecls(analyser, ty, original_handle, instance_access, out, len); - } - return; - }, - else => return, - }; - const container_scope = info.scope_handle; - const handle = container_scope.handle; - const tree = &handle.tree; - const document_scope = try handle.getDocumentScope(); - const main_token = tree.nodeMainToken(container_scope.toNode()); - const is_enum = tree.tokenTag(main_token) == .keyword_enum; - - for (document_scope.getScopeDeclarationsConst(container_scope.scope)) |decl_index| { - if (len.* == out.len) return; - const decl = document_scope.declarations.get(@intFromEnum(decl_index)); - const candidate: Analyser.DeclWithHandle = .{ - .decl = decl, - .handle = handle, - .container_type = container_type, - }; - if (handle != original_handle and !candidate.isPublic()) continue; - - switch (decl) { - .ast_node => |node| switch (tree.nodeTag(node)) { - .container_field_init, .container_field_align, .container_field => { - if (is_enum) { - if (instance_access) continue; - const field_name = offsets.tokenToSlice(tree, tree.nodeMainToken(node)); - if (std.mem.eql(u8, field_name, "_")) continue; - } else if (!instance_access) continue; - }, - .fn_proto, - .fn_proto_multi, - .fn_proto_one, - .fn_proto_simple, - .fn_decl, - .global_var_decl, - .local_var_decl, - .simple_var_decl, - .aligned_var_decl, - => { - if (instance_access) { - const alias_type = try candidate.resolveType(analyser) orelse continue; - const func_ty = try analyser.resolveFuncProtoOfCallable(alias_type) orelse continue; - if (!analyser.firstParamIs(func_ty, .{ - .data = .{ .container = info }, - .is_type_val = true, - })) continue; - } - }, - else => unreachable, - }, - .label => continue, - else => {}, - } - out[len.*] = candidate; - len.* += 1; - } -} - -/// Tab after a `.`. NOT an autocomplete popup: the seam answers with -/// locations, so the question it asks is "what could go here, and where is -/// each of those DEFINED" — one `+Search` row per candidate, pointing at its -/// declaration, the same rows `gr` emits and the same `n`/`N` that step them. -/// Nothing is inserted; picking is looking at a row. -/// -/// A dot has two meanings and both come off the public analyser: -/// `foo.` a field access — resolve the left side's type, list its members. -/// `.` an enum literal — resolve the type EXPECTED at this position (a -/// switch's condition, a call's parameter, a variable's annotation, -/// an initialiser's field) and list that type's members. -/// The second exists only because `completionSource` made the tree parse. -fn completion( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - /// req.root: what the rows' paths are written relative to (lsp.rel) - base: []const u8, - /// the buffer as the USER has it, before completionSource repaired it. The - /// rows are read out of THIS one; the handle's tree is the repaired copy - /// and its text is not what is on screen. - orig: []const u8, - out: *std.Io.Writer, - tr: *Trace, -) !void { - const tree = &handle.tree; - // lookahead FALSE, the way ZLS's own completion asks: the cursor sits at - // the END of what was typed and there is nothing to its right to read. - const ctx = try Analyser.getPositionContext(arena, tree, off, false); - tr.note("position context: .{s}", .{@tagName(ctx)}); - // naming a field INSIDE an initialiser (`.{ .`, `S{ .`) rather than - // writing a whole value — a different question with a different answer, - // and the only one the ancestors cannot state for themselves - var in_init = false; - const found: ?Analyser.Type = switch (ctx) { - .field_access => |loc| try analyser.getFieldAccessType(handle, off, loc), - .enum_literal => blk: { - const nodes = try zls.ast.nodesOverlappingIndex(arena, tree, off); - if (nodes.len == 0) break :blk null; - if (try analyser.resolveExpressionTypeFromAncestors(handle, nodes[0], nodes[1..])) |t| break :blk t; - // `.{ .` and `S{ .` parse as an ARRAY init holding one enum - // literal, and an array's ELEMENT type is not what is being named - // there — the user is naming a FIELD, so the type wanted is the - // initialiser's own. - var buf: [2]Ast.Node.Index = undefined; - if (nodes.len > 1 and tree.fullArrayInit(&buf, nodes[1]) != null) { - in_init = true; - break :blk try analyser.resolveExpressionType(handle, nodes[1], nodes[2..]); - } - break :blk null; - }, - // a dot in a comment or a string is a dot in prose - else => null, - }; - var ty = found orelse { - tr.note("STOP: nothing expected here that this backend can name", .{}); - return; - }; - // the same peel `gy` does: what can go in a `?E`, an `E!T` or a `*E` slot - // is what can go in an `E` one - while (true) { - ty = try analyser.resolveUnwrapErrorUnionType(ty, .payload) orelse - try analyser.resolveDerefType(ty) orelse - try analyser.resolveOptionalUnwrap(ty) orelse break; - } - // `instance_access` is the collector's one knob and it - // means different things per container kind: it is what hides an enum's - // members and what reveals a struct's fields. So: a field access asks - // whichever side the left-hand expression already was; a field named - // inside an initialiser asks the instance side, which is where fields - // live; and everywhere else — a switch arm, an argument, `= .` — asks the - // type side, which is where an enum's members are. A union is a struct - // whose fields are also its tags, so it wants the instance side wherever - // it appears. - const value_pos = ctx == .enum_literal and !in_init; - const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows); - var decl_count: usize = 0; - try collectContainerDecls(analyser, ty, handle, switch (ctx) { - .field_access => !ty.is_type_val, - else => in_init or ty.isUnionType(), - }, decls, &decl_count); - tr.note("{d} candidate(s) in scope", .{decl_count}); - // What completionSource spliced in at `off`, which the ROWS have to take - // back out. The tree being read is the REPAIRED copy, so for a candidate - // declared in this same file two things are wrong with the naive row: its - // line text is the repaired line, so the user reads `= ._p;` back out of a - // file that says no such thing; and every column on the cursor's line to - // the right of the dot is `pad` too far right, so a look on the row - // selects `", "` where it should select `qq`. Line numbers need no - // correction at all — no spelling contains a newline — and a candidate in - // ANOTHER file is untouched by any of this. - const pad = tree.source.len - orig.len; - const dot = lsp.lineCol(orig, off); - var n: usize = 0; - for (decls[0..decl_count]) |d| { - if (n >= max_rows) return; - // In a value position the answer is the container's MEMBERS plus its - // DECL LITERALS (`.empty`, `.init`) — never a method, an unrelated - // const or a type ALIAS, none of which can go after that dot, and - // offering one is worse than offering nothing. So every non-member - // decl is resolved, called, and kept only if what comes back IS this - // container. `typeOf` on both sides is what excludes the alias: a - // `pub const Alias = E` resolves to a TYPE VALUE, whose type is - // `type` and not the container. (ZLS's own rule, and its own reason.) - const member = d.decl == .ast_node and d.handle.tree.nodeTag(d.decl.ast_node).isContainerField(); - if (value_pos and !member) { - var lit = try d.resolveType(analyser) orelse continue; - lit = try analyser.resolveReturnType(lit) orelse lit; - lit = lit.resolveDeclLiteralResultType(); - if (!(try lit.typeOf(analyser)).eql(try ty.typeOf(analyser))) continue; - } - // The WORD, off the decl's own name token. Not the definition token - // below it: `definitionToken` resolves an ALIAS through to what it - // names, so for `pub const base64 = @import("base64.zig")` it lands on - // base64.zig's first token and the "name" would come out as that file's - // doc comment. What goes after the dot is what the container calls it. - const name_tok = d.nameToken(); - if (name_tok >= d.handle.tree.tokens.len) continue; - const name = offsets.tokenToSlice(&d.handle.tree, name_tok); - - const th = d.definitionToken(analyser, true) catch continue; - const dtree = &th.handle.tree; - if (th.token >= dtree.tokens.len) continue; - var r = offsets.tokenToRange(dtree, th.token, enc); - const path = lsp.rel(base, th.handle.uri.toFsPath(arena) catch continue); - // The name, then its declaration line: "what goes here" before "where - // does it come from", which is the order the question was asked in — - // every other kind here answers a WHERE, this one answers a WHAT and - // the location is the evidence. Two spaces rather than a padded column, - // because the `path:LINE:COL-END` in front of it is already ragged and - // there is nothing to align to. - const text = try std.fmt.allocPrint(arena, "{s} {s}", .{ - name, - std.mem.trim(u8, lineAt(if (th.handle == handle) orig else dtree.source, r.start.line), " \t\r\n"), - }); - if (th.handle == handle and pad != 0 and r.start.line == dot.line and - @as(usize, r.start.character) >= dot.col + pad) - { - r.start.character -= @intCast(pad); - if (r.end.line == r.start.line) r.end.character -= @intCast(pad); - } - try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); - n += 1; - } -} - -// ---------------------------------------------------------------- hover - -/// `hover.hover` is already Server-free, so this is a call and a reformat: the -/// `+Hover` buffer holds prose, not locations, so the markdown comes through -/// as lines and nothing arms the n/N stepper. Fenced-code markers are dropped -/// because a plain-text pane has nothing to do with them. -fn hover( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - out: *std.Io.Writer, -) !void { - const h = try zls.hover.hover(analyser, arena, handle, off, .markdown, enc) orelse return; - const text = switch (h.contents) { - .markup_content => |m| m.value, - else => return, - }; - var it = std.mem.splitScalar(u8, text, '\n'); - while (it.next()) |ln| { - if (std.mem.startsWith(u8, ln, "```")) continue; - try out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}); - } -} - -// ---------------------------------------------------------------- symbols - -/// `document_symbol.getDocumentSymbols` needs an arena, a tree and an encoding -/// — no store, no analyser, no intern pool. That makes `SPC s` the cheapest -/// thing in this file: a parse and a walk. -fn documentSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - tree: *const Ast, - out: *std.Io.Writer, -) !void { - const syms = try zls.document_symbol.getDocumentSymbols(arena, tree, enc); - var n: usize = 0; - try emitSymbols(gpa, arena, path, syms, "", &n, out); -} - -/// Depth-first so the rows read in source order, with the parent's name as a -/// qualifier — `Pardes.update` rather than a bare `update`, -/// because a flat list of method names is not navigable. -fn emitSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - syms: []const types.DocumentSymbol, - prefix: []const u8, - n: *usize, - out: *std.Io.Writer, -) !void { - for (syms) |s| { - if (n.* >= max_rows) return; - n.* += 1; - const name = if (prefix.len == 0) - s.name - else - try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); - const text = if (s.detail) |d| - try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) - else - name; - try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text); - if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); - } -} - -/// `SPC S`: every `.zig` under the pane's directory, parsed, walked, filtered -/// by substring. ZLS's own workspace symbols would only see files already in -/// the DocumentStore (which is one), so this walks the tree itself — and -/// because `getDocumentSymbols` needs nothing but a tree, it never has to open -/// a store or resolve an import to do it. -fn workspaceSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - io: std.Io, - req: lsp.Req, - out: *std.Io.Writer, -) !void { - if (req.arg.len == 0) return; - const files = try collectZigFiles(arena, io, req.root); - var n: usize = 0; - for (files) |path| { - if (n >= max_rows) return; - const src = readFileZ(arena, io, path) catch continue; - var tree: Ast = Ast.parse(arena, src, .zig) catch continue; - defer tree.deinit(arena); - const syms = zls.document_symbol.getDocumentSymbols(arena, &tree, enc) catch continue; - try filterSymbols(gpa, arena, lsp.rel(req.root, path), syms, "", req.arg, &n, out); - } -} - -fn filterSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - syms: []const types.DocumentSymbol, - prefix: []const u8, - needle: []const u8, - n: *usize, - out: *std.Io.Writer, -) !void { - for (syms) |s| { - if (n.* >= max_rows) return; - const name = if (prefix.len == 0) - s.name - else - try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); - if (containsIgnoreCase(s.name, needle)) { - n.* += 1; - try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name); - } - if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); - } -} - -fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool { - if (needle.len == 0) return true; - if (needle.len > hay.len) return false; - var i: usize = 0; - outer: while (i + needle.len <= hay.len) : (i += 1) { - for (needle, 0..) |c, j| - if (std.ascii.toLower(hay[i + j]) != std.ascii.toLower(c)) continue :outer; - return true; - } - return false; -} - -// ------------------------------------------------------------- references - -/// `gr` / `SPC h` / `SPC r`. ZLS's `symbolReferences` is private and its -/// `referencesHandler` takes a `*Server`, so this is the same algorithm from -/// the outside: find the decl under the cursor, then offer every identifier -/// token in the file that spells the same name back to the analyser and keep -/// the ones that resolve to the same decl. Resolving rather than matching is -/// what makes a shadowed local not a reference to the outer one. -/// -/// LIMIT: this file only. A workspace-wide answer means loading every project -/// file into the store and running the analyser over each — seconds, not -/// milliseconds, on every keypress, and the store's own workspace iteration -/// has the same restriction (it can only see handles that were loaded). -/// -/// `new_name` non-null makes it a rename EDIT: the same resolved tokens become -/// half-open byte ranges. The core owns the replacement text and applies every -/// range in one undo transaction after checking the source revision. -fn references( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - new_name: ?[]const u8, - /// req.root: what the rows' paths are written relative to (lsp.rel) - base: []const u8, - out: *std.Io.Writer, -) !void { - const tree = &handle.tree; - const target = try declAt(arena, analyser, handle, off) orelse return; - // The decl's name token indexes ITS OWN file, which after an alias resolve - // is not necessarily this one — and on a half-typed file it is not - // necessarily an identifier either, which `identifierTokenToNameSlice` - // asserts. Both of those are crashes on a keystroke, so both are checked. - const decl_tree = &target.handle.tree; - const name_tok = target.nameToken(); - if (name_tok >= decl_tree.tokens.len) return; - if (decl_tree.tokenTag(name_tok) != .identifier) return; - const want = offsets.identifierTokenToNameSlice(decl_tree, name_tok); - if (want.len == 0) return; - - // Rename consumes exact byte ranges. Reference rows need the source line - // and displayed path; avoid building either for the mutating response. - const lines: ?Lines = if (new_name == null) try .build(arena, tree.source) else null; - const path = if (new_name == null) lsp.rel(base, handle.uri.toFsPath(arena) catch return) else ""; - var n: usize = 0; - for (0..tree.tokens.len) |i| { - if (new_name == null and n >= max_rows) return; - const tok: Ast.TokenIndex = @intCast(i); - if (tree.tokenTag(tok) != .identifier) continue; - if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue; - const at = tree.tokenStart(tok); - const d = (declAt(arena, analyser, handle, at) catch |err| { - if (new_name != null) return err; - continue; - }) orelse continue; - if (!d.eql(target)) continue; - if (n >= max_rows) return error.TooManyEdits; - n += 1; - if (new_name != null) { - try lsp.edit(out, at, at + want.len); - } else { - const r = offsets.tokenToRange(tree, tok, enc); - try lsp.sourceSpanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); - } - } -} - -/// The decl under a byte offset, whatever the surrounding syntax is. Shared by -/// references and by rename, which is references with a label on it. -fn declAt( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, -) !?Analyser.DeclWithHandle { - const tree = &handle.tree; - const ctx = try Analyser.getPositionContext(arena, tree, off, true); - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return null; - const name = offsets.locToSlice(tree.source, name_loc); - const d = switch (ctx) { - .var_access, .test_doctest_name => try analyser.lookupSymbolGlobal(handle, name, off), - .field_access => |loc| blk: { - const found = try analyser.getSymbolFieldAccesses(arena, handle, off, offsets.locMerge(loc, name_loc), name) orelse break :blk null; - break :blk if (found.len != 0) found[0] else null; - }, - .label_access, .label_decl => try Analyser.lookupLabel(handle, name, off), - .enum_literal => try analyser.getSymbolEnumLiteral(handle, off, name), - else => null, - } orelse return null; - return try analyser.resolveVarDeclAlias(d) orelse d; -} - -// ------------------------------------------------------------ diagnostics - -/// `SPC d` and the list `]d`/`[d` step. ZLS's `getAstCheckDiagnostics` prefers -/// spawning `zig ast-check`; its OTHER branch runs `std.zig.AstGen` in this -/// process, which is the branch a subprocess-free backend wants, so that is -/// what is inlined here (it is ten lines and needs a `*Server` only for the -/// config it reads). -/// -/// A clean file emits ONE row saying so. Silence already means "no backend" in -/// this seam — `lspResponse` opens nothing for zero rows — so a `SPC d` that -/// checked and found nothing has to be able to say the difference. -fn diagnostics( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - tree: *const Ast, - out: *std.Io.Writer, -) !void { - const n = try treeDiagnostics(gpa, arena, path, tree, out); - if (n == 0) try lsp.row(out, path, 0, 0, "no diagnostics"); -} - -/// `zig ast-check`, in this process. ZLS spawns the compiler for this when it -/// has one; its fallback branch is `std.zig.AstGen` directly, and that fallback -/// is this backend's only branch. Caller owns the bundle. -fn astCheck(gpa: std.mem.Allocator, path: []const u8, tree: *const Ast) !std.zig.ErrorBundle { - if (tree.errors.len != 0) return .empty; - var zir = std.zig.AstGen.generate(gpa, tree.*) catch return .empty; - defer zir.deinit(gpa); - if (!zir.hasCompileErrors()) return .empty; - var wip: std.zig.ErrorBundle.Wip = undefined; - try wip.init(gpa); - defer wip.deinit(); - wip.addZirErrorMessages(zir, tree.*, tree.source, path) catch return .empty; - return wip.toOwnedBundle("") catch .empty; -} - -/// Parse errors first (AstGen cannot run on a tree that did not parse), then -/// the AstGen pass. Returns how many rows were emitted. -fn treeDiagnostics( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - tree: *const Ast, - out: *std.Io.Writer, -) !usize { - var n: usize = 0; - if (tree.errors.len != 0) { - const lines: Lines = try .build(arena, tree.source); - for (tree.errors) |e| { - if (n >= max_rows) return n; - var count_buf: [256]u8 = undefined; - var counting: std.Io.Writer.Discarding = .init(&count_buf); - tree.renderError(e, &counting.writer) catch continue; - const size = std.math.cast(usize, counting.fullCount()) orelse continue; - const rendered = try arena.alloc(u8, size); - var w: std.Io.Writer = .fixed(rendered); - tree.renderError(e, &w) catch continue; - const at = tree.tokenStart(e.token); - const lc = lsp.lineCol(tree.source, at); - n += 1; - try lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ - w.buffered(), std.mem.trim(u8, lines.line(lc.line), " \t"), - })); - } - return n; - } - - var bundle = try astCheck(gpa, path, tree); - defer bundle.deinit(gpa); - if (bundle.errorMessageCount() == 0) return n; - for (bundle.getMessages()) |m| { - if (n >= max_rows) return n; - const em = bundle.getErrorMessage(m); - if (em.src_loc == .none) continue; - const sl = bundle.getSourceLocation(em.src_loc); - n += 1; - try lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ - bundle.nullTerminatedString(em.msg), - })); - } - return n; -} - -/// `SPC D`: the same check over every `.zig` under the pane's directory. No -/// analyser and no store — AstGen is a per-file pass — so this is bounded by -/// how fast Zig can parse the tree, which for pardes's own `src/` is tens of -/// milliseconds. -fn workspaceDiagnostics( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - io: std.Io, - req: lsp.Req, - out: *std.Io.Writer, -) !void { - const files = try collectZigFiles(arena, io, req.root); - var total: usize = 0; - for (files) |path| { - const src = readFileZ(arena, io, path) catch continue; - var tree: Ast = Ast.parse(arena, src, .zig) catch continue; - defer tree.deinit(arena); - total += try treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out); - if (total >= max_rows) return; - } - if (total == 0) try lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len})); -} - -// ----------------------------------------------------------- code actions - -/// `SPC a`. `code_actions.Builder` is one of the Server-free ones — it wants -/// an arena, an analyser, a handle and an ErrorBundle — so the only missing -/// piece is the bundle, which `astCheck` above produces without a compiler. -/// -/// It lists what could be done, and does none of it: the seam hands back rows, -/// not edits, so there is no channel through which a backend could apply a -/// fix. `+Lsp` is prose, so these are lines rather than locations. -fn codeActions( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - out: *std.Io.Writer, -) !void { - const tree = &handle.tree; - if (tree.errors.len != 0) { - try out.print("no code actions: file does not parse\n", .{}); - return; - } - var bundle = try astCheck(gpa, "", tree); - defer bundle.deinit(gpa); - - // The external builder owns growable collections internally. Confine all - // of them and their payloads to one preallocated query-local region. - const action_storage = try arena.alloc(u8, max_code_action_bytes); - var action_fba: std.heap.FixedBufferAllocator = .init(action_storage); - var builder: zls.code_actions.Builder = .{ - .arena = action_fba.allocator(), - .analyser = analyser, - .handle = handle, - .offset_encoding = enc, - .only_kinds = null, - }; - builder.generateCodeAction(bundle) catch {}; - const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc); - builder.generateCodeActionsInRange(at) catch {}; - - for (builder.actions.items[0..@min(builder.actions.items.len, max_rows)]) |a| { - try out.print("{s}\n", .{a.title}); - } - if (builder.actions.items.len == 0) try out.print("no code actions\n", .{}); -} - -// ---------------------------------------------------------------- format - -/// `=`. The response is `@put` edit records (see lsp.put): one span covering -/// everything `zig fmt` would change, which the core substitutes as a single -/// undo step — so `=` FORMATS, exactly like helix. The two answers that are -/// not edits stay prose rows in `+Lsp`: a file that does not parse cannot be -/// formatted, and a file already formatted has nothing to apply. -fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { - const path = lsp.rel(req.root, req.path); - var tree: Ast = try .parse(arena, req.source, .zig); - defer tree.deinit(arena); - if (tree.errors.len != 0) { - try lsp.row(out, path, 0, 0, "cannot format: file does not parse"); - return; - } - var count_buf: [4096]u8 = undefined; - var counting: std.Io.Writer.Discarding = .init(&count_buf); - try tree.render(arena, &counting.writer, .{}); - const size = std.math.cast(usize, counting.fullCount()) orelse return; - const render_buf = try arena.alloc(u8, size); - var w: std.Io.Writer = .fixed(render_buf); - try tree.render(arena, &w, .{}); - const formatted = w.buffered(); - if (std.mem.eql(u8, formatted, req.source)) { - try lsp.row(out, path, 0, 0, "already formatted"); - return; - } - // one record, spanning only what changed: the common prefix and suffix - // stay untouched, which is also what keeps the cursor mapping tight - var lo: usize = 0; - const min_len = @min(req.source.len, formatted.len); - while (lo < min_len and req.source[lo] == formatted[lo]) lo += 1; - var src_hi = req.source.len; - var fmt_hi = formatted.len; - while (src_hi > lo and fmt_hi > lo and req.source[src_hi - 1] == formatted[fmt_hi - 1]) { - src_hi -= 1; - fmt_hi -= 1; - } - try lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); -} - -// ------------------------------------------------------------------ files - -/// Every `.zig` under `root`, skipping the directories a source walk has no -/// business in. Capped, because this runs on a keypress. -fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8) ![]const []const u8 { - const files = try arena.alloc([]const u8, max_files); - var len: usize = 0; - if (root.len == 0) return files[0..0]; - var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return files[0..0]; - defer dir.close(io); - var w = dir.walkSelectively(arena) catch return files[0..0]; - defer w.deinit(); - while (len < files.len) { - const e = (w.next(io) catch break) orelse break; - switch (e.kind) { - .directory => { - if (e.basename.len != 0 and e.basename[0] == '.') continue; - if (std.mem.eql(u8, e.basename, "zig-out")) continue; - if (std.mem.eql(u8, e.basename, "zig-cache")) continue; - w.enter(io, e) catch {}; - }, - .file => { - if (!std.mem.endsWith(u8, e.basename, ".zig")) continue; - files[len] = std.fs.path.join(arena, &.{ root, e.path }) catch continue; - len += 1; - }, - else => {}, - } - } - return files[0..len]; -} - -fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 { - return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0); -} - -test "LSP ZLS format propagates output failure and preserves successful encoding" { - const gpa = std.testing.allocator; - const req: lsp.Req = .{ .kind = .format, .path = "/file.zig", .source = "const value=1;\n", .offset = 6 }; - const expected = "@put 11 12 %20=%20\n"; - var buffer: [128]u8 = undefined; - for ([_]usize{ 0, expected.len - 1, expected.len }) |capacity| { - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - var out: std.Io.Writer = .fixed(buffer[0..capacity]); - const result = query(gpa, arena.allocator(), req, &out); - if (capacity < expected.len) { - try std.testing.expectError(error.WriteFailed, result); - } else { - try result; - try std.testing.expectEqualStrings(expected, out.buffered()); - } - } -} - -test "LSP ZLS rename refuses a partial edit set beyond its row budget" { - const gpa = std.testing.allocator; - for ([_]usize{ max_rows - 1, max_rows }) |references_count| { - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - var source: std.Io.Writer.Allocating = .init(gpa); - defer source.deinit(); - try source.writer.writeAll("const value: u32 = 1;\nfn use() void {\n"); - for (0..references_count) |_| try source.writer.writeAll(" _ = value;\n"); - try source.writer.writeAll("}\n"); - const text = try gpa.dupeZ(u8, source.written()); - defer gpa.free(text); - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - const result = query(gpa, arena.allocator(), .{ - .kind = .rename, - .path = "/rename.zig", - .source = text, - .offset = 6, - .arg = "renamed", - }, &out.writer); - if (references_count < max_rows) { - try result; - try std.testing.expectEqual(max_rows, std.mem.count(u8, out.written(), "@edit ")); - } else { - try std.testing.expectError(error.TooManyEdits, result); - try std.testing.expectEqual(@as(usize, 0), out.written().len); - } - } -} - -test "LSP source snippets retain native definition and reference indentation" { - const source = "const Outer = struct {\n" ++ - "\t const value: u32 = 1;\n" ++ - "\t fn use() void {\n" ++ - "\t\t_ = value;\n" ++ - "\t }\n" ++ - "};\n"; - const gpa = std.testing.allocator; - for ([_]lsp.Kind{ .definition, .declaration, .references }) |kind| { - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - try query(gpa, arena.allocator(), .{ - .kind = kind, - .path = "/source-snippet.zig", - .root = "/", - .source = source, - .offset = @intCast(std.mem.lastIndexOf(u8, source, "value").?), - }, &out.writer); - const definition = "source-snippet.zig:2:10-14 \t const value: u32 = 1;\n"; - try std.testing.expectEqualStrings(if (kind == .references) - definition ++ "source-snippet.zig:4:7-11 \t\t_ = value;\n" - else - definition, out.written()); - } -} diff --git a/src/main.zig b/src/main.zig index 17d0fb5f..0e2ba954 100644 --- a/src/main.zig +++ b/src/main.zig @@ -535,7 +535,6 @@ test { _ = @import("host_io.zig").Lsp; _ = @import("lsp/lsp_client.zig"); _ = @import("lsp/lsp.zig"); - if (comptime @import("pardes_config").zls_backend) _ = @import("lsp/lsp_zls.zig"); if (comptime pardes.platform == .tty) { _ = @import("tty/tty.zig"); _ = @import("tty/panel_compositor.zig"); diff --git a/src/pardes.zig b/src/pardes.zig index e3db97d9..a63568db 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -751,13 +751,6 @@ test "Collapse builtin keeps content and restores the pane from its default tag" p.sync(); try std.testing.expect(!pane.collapsed); try std.testing.expectEqual(height, p.rects[0].h); - - // Unedited defaults from the preceding version gain the new command. - if (pane.tag.own) |own| p.gpa.free(own); - pane.tag.own = null; - tagline.restoreTailAt(p, pane, tagline.pre_collapse_file_pane_tail, "", .file); - try std.testing.expect(pane.tag.own == null); - try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); } test "LocationsConfig command reports partial updates and survives restore" { @@ -985,129 +978,6 @@ test "compact tag commands stay beside their own prefix across layout changes" { try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); } -test "legacy default tag tails upgrade while custom tails remain owned" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{}); - defer p.deinit(); - - const terminal = p.panes[0].?; - const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try tagline.tagPrefix(p, terminal), - tagline.legacy_pane_tail, - }); - tagline.restoreTail(p, terminal, terminal_old); - try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - - const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try tagline.tagPrefix(p, terminal), - }); - tagline.restoreTail(p, terminal, terminal_previous); - try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - - const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try tagline.tagPrefix(p, terminal), - tagline.prev_terminal_pane_tail, - }); - tagline.restoreTail(p, terminal, terminal_prefilter); - try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - - for ([_][]const u8{ tagline.pre_mode_terminal_pane_tail, tagline.pre_tty_first_terminal_pane_tail, tagline.pre_collapse_terminal_pane_tail }) |old_tail| { - const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try tagline.tagPrefix(p, terminal), old_tail }); - tagline.restoreTail(p, terminal, old); - try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - } - const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try tagline.tagPrefix(p, terminal), tagline.pre_mode_terminal_pane_tail }); - tagline.restoreTail(p, terminal, custom_mode); - try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(tagline.pre_mode_terminal_pane_tail ++ " Keep", tagline.curTail(terminal)); - - if (terminal.tag.own) |own| p.gpa.free(own); - terminal.tag.own = null; - const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ - try tagline.tagPrefix(p, terminal), - }); - tagline.restoreTail(p, terminal, terminal_custom); - try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); - - if (terminal.tag.own) |own| p.gpa.free(own); - terminal.tag.own = null; - tagline.restoreDumpTail(p, terminal, .{ - .kind = .terminal, - .tag = "TTY /historical/cwd Keep Del", - .body = "", - .terminal = .{ .cwd = "/historical/cwd" }, - }); - try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); - - if (terminal.tag.own) |own| p.gpa.free(own); - terminal.tag.own = null; - tagline.restoreDumpTail(p, terminal, .{ - .kind = .terminal, - .tag = "TTY /historical/cwd New Del", - .body = "", - .terminal = .{ .cwd = "/historical/cwd" }, - }); - try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - - // This happens to be the historical FILE default, but on a terminal it is - // user-owned text and must not be swallowed by migration. - if (terminal.tag.own) |own| p.gpa.free(own); - terminal.tag.own = null; - const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{ - try tagline.tagPrefix(p, terminal), - }); - tagline.restoreTail(p, terminal, terminal_fileish); - try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Save Del", tagline.curTail(terminal)); - - // A savable file has a distinct old default. Save remains first after the - // migration so the tag's established `:w<Tab>` route is unchanged. - const file = try p.setTestFile(""); - const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try tagline.tagPrefix(p, file), - tagline.legacy_file_pane_tail, - }); - tagline.restoreTail(p, file, file_old); - try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(file)); - - const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ - try tagline.tagPrefix(p, file), - }); - tagline.restoreTail(p, file, file_custom); - try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" Save Mine Del", tagline.curTail(file)); - - // Conversely the generic pane's current default is a custom edit on a - // savable file. Legacy recognition is scoped to the source pane class. - if (file.tag.own) |own| p.gpa.free(own); - file.tag.own = null; - const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try tagline.tagPrefix(p, file), - }); - tagline.restoreTail(p, file, file_generic); - try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" New Del", tagline.curTail(file)); - - const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(image_doc)); - tagline.restoreDumpTail(p, image_doc, .{ - .kind = .image, - .tag = "img /tmp/legacy image.ppm Keep Del", - .body = "", - .image = .{ .path = "/tmp/legacy image.ppm" }, - }); - try std.testing.expect(image_doc.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(image_doc)); -} - test "Joincol folds the active column into its right neighbor, keeping its panes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -1434,14 +1304,6 @@ test "pane defaults keep creation of scratch panes in column tags" { while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); } try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", tagline.columnTag(p, 0)); - const terminal = p.panes[0].?; - tagline.restoreTailAt(p, terminal, tagline.pre_column_terminal_pane_tail, "", .terminal); - try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - const file = try p.setTestFile("unchanged\n"); - tagline.restoreTailAt(p, file, tagline.pre_column_file_pane_tail, "", .file); - try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file)); } test "an unsaved file's tag is its name alone: the grip, not a mark, says it is unsaved" { @@ -2640,27 +2502,8 @@ test "Save reaches every tagline with text behind it and no other" { out.file.?.output = .{ .from = .search }; try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out)); - // A dump written before Save reached output buffers carries the generic - // tail. That is a DEFAULT and not something typed, so it upgrades. - const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try tagline.tagPrefix(p, out), - tagline.pane_tail, - }); - tagline.restoreTail(p, out, dumped); - try std.testing.expect(out.tag.own == null); - try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out)); - - // A scratch is an output buffer that has worn the FILE tails all along, - // so its own older default upgrades from that family too. + // A scratch is an output buffer that wears the FILE tails. out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } }; - if (out.tag.own) |own| p.gpa.free(own); - out.tag.own = null; - const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try tagline.tagPrefix(p, out), - tagline.prev_file_pane_tail, - }); - tagline.restoreTail(p, out, scratch_dumped); - try std.testing.expect(out.tag.own == null); try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(out)); // An image's bytes on disk already are what they are: nothing of the diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 8f842cde..5646a92f 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -57,24 +57,6 @@ pub const RasterPolicy = struct { match_viewport: bool, }; -pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 { - if (std.mem.startsWith(u8, saved_tag, path) and std.mem.startsWith(u8, saved_tag[path.len..], " [")) { - const start = path.len + 2; - const end = std.mem.indexOfScalarPos(u8, saved_tag, start, ']') orelse return null; - var parts = std.mem.splitScalar(u8, saved_tag[start..end], '/'); - _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null; - _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null; - if (parts.next() != null) return null; - return saved_tag[0 .. end + 1]; - } - if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null; - const marker = " PdfSections "; - const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null; - const path_at = marker_at + marker.len; - if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null; - return saved_tag[0 .. path_at + path.len]; -} - pub const TintKey = if (enabled) struct { mode: TintMode, colors: pdf.TintColors, diff --git a/src/tagline.zig b/src/tagline.zig index df093925..1923871a 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -23,43 +23,11 @@ pub const context_pane_tail = file_pane_tail ++ " TreeContext"; pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; pub const command_pane_tail = " " ++ config.command_pane_builtins_str; -/// The mark a tag carried after an unsaved file's name before the grip -/// showed it; a dump from then still has it in its saved tags. -const dirty_marker = " *"; - /// A file with changes it has not saved. pub fn dirty(pane: *const panes.Pane) bool { const f = pane.file orelse return false; return panes.Output.fileTraits(f.output).saves and (f.revision != f.saved_revision or f.disk_gone); } -pub const legacy_pane_tail = " Del"; -pub const legacy_file_pane_tail = " Save Del"; -// The defaults from the release before Newtty joined every tagline. Recognized -// so a dump written then upgrades instead of keeping the old word as a tail. -const prev_pane_tail = " New Del"; -pub const prev_file_pane_tail = " Save New Del"; -const legacy_terminal_pane_tail = " New Del Filter"; -// ...and the terminal default from the release before Save reached it, when a -// scrollback was not yet something you could write to a path. -pub const prev_terminal_pane_tail = " New Newtty Del Filter"; -const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter"; -const pre_column_pane_tail = " New Newtty Del"; -pub const pre_column_file_pane_tail = " Save New Newtty Del"; -const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; -pub const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; -const pre_collapse_pane_tail = " Tty Del"; -pub const pre_collapse_file_pane_tail = " Save Tty Del"; -const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; -pub const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; -pub const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; -pub const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; -// ...and the defaults from before the closing word moved to the end of every -// tagline, where a click cannot land on it by overshooting the one before. -const pre_close_last_pane_tail = " Tty Del Collapse"; -const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; -const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; -const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; - /// What a column's tag says until someone edits it. pub const column_tag_default = "New Tty Find Grep Joincol Delcol"; @@ -420,106 +388,11 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { t.normal.clear(); } +/// A dump's tag tail, what was typed after the name: a dump from 0.0.4 on +/// writes it, and an older one's saved tag is not read. pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { - if (src.tag_tail) |tail| { - pane.tag.own = p.gpa.dupe(u8, tail) catch return; - return; - } - const tail_class = tailClass(pane); - if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) { - if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix| - return restoreTailAt(p, pane, src.tag, prefix, .generic); - } - if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { - const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; - if (std.mem.startsWith(u8, src.tag, legacy)) - return restoreTailAt(p, pane, src.tag, legacy, .terminal); - } - const current = tagPrefix(p, pane) catch return; - if (std.mem.startsWith(u8, src.tag, current)) - return restoreTailAt(p, pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); - if (src.kind != .image) return; - const saved = src.image.?; - if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| - return restoreTailAt(p, pane, src.tag, legacy, .generic); - if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| - restoreTailAt(p, pane, src.tag, legacy, .generic); -} - -pub fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { - const pfx = tagPrefix(p, pane) catch return; - const class = tailClass(pane); - restoreTailAt(p, pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); -} - -const TailClass = enum { generic, file, output, terminal }; - -fn tailClass(pane: *const Pane) TailClass { - if (pane.file) |f| return if (f.output == null) .file else .output; - if (pane.isTerminal()) return .terminal; - return .generic; -} - -fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { - if (class == .generic or class == .terminal) return live; - if (!std.mem.startsWith(u8, saved_tag, live)) return live; - const marked_len = live.len + dirty_marker.len; - if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker)) - return saved_tag[0..marked_len]; - return live; -} - -pub fn restoreTailAt( - p: *Pardes, - pane: *Pane, - saved_tag: []const u8, - pfx: []const u8, - class: TailClass, -) void { - if (!std.mem.startsWith(u8, saved_tag, pfx)) return; - const rest = saved_tag[pfx.len..]; - const said = std.mem.trimStart(u8, rest, " "); - const defaults: []const []const u8 = switch (class) { - .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, - .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, - .output => &.{ - locations_pane_tail, - file_pane_tail, - pre_close_last_file_pane_tail, - pre_close_last_pane_tail, - prev_file_pane_tail, - legacy_file_pane_tail, - pane_tail, - prev_pane_tail, - legacy_pane_tail, - pre_column_file_pane_tail, - pre_column_pane_tail, - pre_collapse_file_pane_tail, - pre_collapse_pane_tail, - }, - .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, - }; - // A terminal's saved tag carries what was live about it -- the REPL id - // after its directory, the shell in its Tty word -- which is neither - // dumped nor true of the new shell: its tail is still the default. - var plain = said; - var plain_buf: [256]u8 = undefined; - if (class == .terminal) { - const first = plain[0 .. std.mem.indexOfScalar(u8, plain, ' ') orelse plain.len]; - if (std.mem.lastIndexOfScalar(u8, first, '-')) |dash| if (dash + 2 == first.len and first[dash + 1] >= 'a' and first[dash + 1] <= 'z') { - for (@import("grammar_manifest.zig").all) |g| if (std.mem.eql(u8, g.name, first[0..dash])) { - plain = std.mem.trimStart(u8, plain[first.len..], " "); - break; - }; - }; - if (std.mem.startsWith(u8, plain, "Tty+")) { - const word_end = std.mem.indexOfScalar(u8, plain, ' ') orelse plain.len; - plain = std.fmt.bufPrint(&plain_buf, "Tty{s}", .{plain[word_end..]}) catch plain; - } - } - for (defaults) |default| - if (std.mem.eql(u8, plain, std.mem.trimStart(u8, default, " "))) return; - pane.tag.own = p.gpa.dupe(u8, rest) catch return; + const tail = src.tag_tail orelse return; + pane.tag.own = p.gpa.dupe(u8, tail) catch return; } /// The span a header's selection covers on line `row` of its text, in diff --git a/src/terminal.zig b/src/terminal.zig index c99a92a6..6bf46bef 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -509,13 +509,7 @@ pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane { pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; return pane; } - // An older dump's raw output tail cannot come back live: cut off mid - // redraw, or written at sizes the pane no longer has, it replays as - // garbage. Its rendered text can. - const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; - const bytes = if (legacy_text) - try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") - else if (terminal.stream_b64.len > 0) + const bytes = if (terminal.stream_b64.len > 0) try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) else &.{}; @@ -759,31 +753,6 @@ test "a restored terminal keeps its history above a marker and gets a shell wher try std.testing.expect(old_at < mark_at); } -test "an older dump comes back live from its rendered text, not its raw output tail" { - if (comptime !enabled) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - // A tail cut mid-redraw: relative moves from a cursor it never set. - const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); - defer gpa.free(tail); - const restored = try restore(p, .{ - .kind = .terminal, - .tag = "", - .body = "", - .cols = 40, - .rows = 12, - .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, - }, true); - defer gpa.destroy(restored); - defer deinitEmulator(restored, gpa); - defer deinitPendingCommand(restored); - const text = try screenTextAlloc(restored, gpa); - defer gpa.free(text); - try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); -} - /// Record and parse one live pty read, invalidate its motion surface, and /// follow it only when the body (possibly parked under a tag edit) is raw. pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 3441bc29..ef7f2761 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1791,24 +1791,11 @@ const Shell = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - filesystem.write(s.core, path, bytes) catch |err| - return s.core.saveFailed(pane, path, err); - if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { - if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { - .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, - .pdf => {}, - }; - }; - var mbuf: [256]u8 = undefined; - s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); + host_io.writeFile(s.core, &s.watches, pane, path, bytes); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { - const s = of(ctx); - var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory); - filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err); - s.core.setLastDump(path); + host_io.writeDump(of(ctx).core, bytes); } fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { @@ -1825,14 +1812,7 @@ const Shell = struct { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); - const config_dir = s.core.opts.config_dir orelse return; - const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { - s.core.reportError(pane, "dump themes", err); - return; - }; - defer s.gpa.free(out_dir); - var mbuf: [256]u8 = undefined; - s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); + host_io.dumpThemes(s.core, s.io, s.gpa, pane); } fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { diff --git a/test/bench_util.zig b/test/bench_util.zig new file mode 100644 index 00000000..9b91e621 --- /dev/null +++ b/test/bench_util.zig @@ -0,0 +1,43 @@ +//! What the PDF benches share: a clock, a fatal exit named for the bench, a +//! seeded hash, a count parser and the core's effect drain. +const std = @import("std"); + +pub fn nowNs() u64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec)); +} + +/// `fatal` for the bench called `who`: says so, prefixed, and exits 1. +pub fn Fatal(comptime who: []const u8) type { + return struct { + pub fn f(comptime format: []const u8, args: anytype) noreturn { + std.debug.print(who ++ ": " ++ format ++ "\n", args); + std.process.exit(1); + } + }; +} + +/// A seeded hash of `value`'s bytes; a comptime integer hashes as a u64. +pub fn mix(seed: u64, value: anytype) u64 { + const Value = @TypeOf(value); + const Stable = switch (@typeInfo(Value)) { + .comptime_int => u64, + else => Value, + }; + var stable: Stable = value; + return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable)); +} + +/// A flag's value as a count in 1..`maximum`, or `fatal` saying why not. +pub fn parsePositive(comptime fatal: anytype, name: []const u8, text: []const u8, maximum: usize) usize { + const value = std.fmt.parseUnsigned(usize, text, 10) catch + fatal("{s} expects a positive integer", .{name}); + if (value == 0 or value > maximum) + fatal("{s} must be in 1..{d}", .{ name, maximum }); + return value; +} + +pub fn drainEffects(core: anytype) void { + while (core.nextEffect()) |_| {} +} diff --git a/test/column_grip.py b/test/column_grip.py deleted file mode 100644 index ac0341c3..00000000 --- a/test/column_grip.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -"""Drag a column grip in the real SDL window with pixel mouse events. - -WAYLAND_DISPLAY=/run/user/1000/wayland-1 \\ - python3 -B test/column_grip.py zig-out/bin/pardes-gui /tmp/pardes-column-grip [--pad 7] - -The window is hidden (PARDES_TEST). Mouse events enter as ESC]777;mouse -sequences on stdin, which the GUI builds into SDL events and dispatches like -its own, so pixel-to-cell and tag hit testing run as they do under a hand. -9P observes the grid; each step leaves a PNG of the GPU frame in the output -directory: grip-rest, grip-resize-drag, grip-reorder-drag, grip-done. -""" -import argparse -import os -from pathlib import Path -import shutil -import subprocess -import time - -from fs import session -from ui_review import require, wait_for - - -def rows(client): - frame = client.screen() - cols = frame['cols'] - return [''.join(cell[0] for cell in frame['cells'][at:at + cols]) for at in range(0, len(frame['cells']), cols)] - - -def rail(client): - """The one column the drag preview's rail runs down, if any.""" - bottom = rows(client)[-1] - found = [x for x, ch in enumerate(bottom) if ch == '╎'] - require(len(found) <= 1, f'more than one rail: {found}') - return found[0] if found else None - - -def grab(root, label): - time.sleep(.5) - def convert(): - shutil.copyfile(root / 'latest.ppm', root / (label + '.ppm')) - return subprocess.run(['magick', str(root / (label + '.ppm')), str(root / (label + '.png'))], - stderr=subprocess.DEVNULL).returncode == 0 - require(wait_for(convert, 8), 'GPU capture did not contain a complete image') - return root / (label + '.png') - - -def pixel(path, x, y): - out = subprocess.run(['magick', str(path), '-format', f'%[pixel:p{{{x},{y}}}]', 'info:'], - capture_output=True, text=True, check=True) - return out.stdout.strip() - - -def run(args): - root = args.output.resolve() - shutil.rmtree(root, ignore_errors=True) - root.mkdir(parents=True, mode=0o700) - (root / 'config/pardes').mkdir(parents=True) - (root / 'config/pardes/init').write_text('Shell /bin/sh\n') - (root / 'w.txt').write_text('alpha\nbeta\n') - name = 'column-grip-' + str(os.getpid()) - launch = ['-c', 'stty cols 100 rows 30; exec "$@"', 'column-grip', - str(args.binary.resolve()), '--9p=' + name, str(root / 'w.txt')] - env = {'PARDES_TEST': '1', 'PARDES_TEST_CAPTURE_DIR': str(root), 'PARDES_TEST_COLS': '100', - 'PARDES_TEST_ROWS': '30', 'PARDES_TEST_PAD': str(args.pad)} - handles = {} - with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, - inherited=env, terminal=handles) as (client, _): - def send(data): - os.write(handles['input_fd'], data.encode()) - - def mouse(kind, button, col, row): - # the middle of a grid cell, in window pixels - send(f'\x1b]777;mouse;{kind};{button};{(col + .5) * cw};{(row + .5) * ch}\x07') - time.sleep(.15) - - require(wait_for(lambda: 'Newcol' in rows(client)[0]), 'workspace tag never drew') - send('\x1bn') - time.sleep(.3) - send('\x1bc') - require(wait_for(lambda: rows(client)[1].count('New Tty') == 2), 'no second column') - require(wait_for(lambda: (root / 'latest.ppm').exists(), 8), 'GPU capture missing') - time.sleep(.5) - with open(root / 'latest.ppm', 'rb') as ppm: - ppm.readline() - width, height = map(int, ppm.readline().split()) - frame = client.screen() - cw = (width - args.pad) // frame['cols'] - ch = (height - args.pad) // frame['rows'] - edge = rows(client)[1].index('New Tty', 10) - 3 - - rest = grab(root, 'grip-rest') - mouse('down', 1, edge, 1) - held = grab(root, 'grip-held') - require(pixel(held, int((edge + 1) * cw), int(1.5 * ch)) != pixel(rest, int((edge + 1) * cw), int(1.5 * ch)), - 'a grabbed grip looks the same as a resting one') - require(rail(client) is None, 'a grip held in place previews a move') - - # Short of the left column's middle: the left edge follows, and the - # rail is the seam cell left of where the rule will be. - for col in range(edge, edge - 11, -1): - mouse('motion', 1, col, 1) - require(rail(client) == edge - 11, f'resize rail at {rail(client)}, wanted {edge - 11}') - grab(root, 'grip-resize-drag') - mouse('up', 1, edge - 10, 1) - require(wait_for(lambda: rows(client)[1].index('New Tty', 10) - 3 == edge - 10), 'left edge did not follow the grip') - edge -= 10 - - # Over the left column's left half: it goes first, and the rail runs - # just past the left window edge's scroll rail. - mouse('down', 1, edge, 1) - for col in range(edge, 4, -5): - mouse('motion', 1, col, 10) - require(rail(client) == 1, f'reorder rail at {rail(client)}, wanted 1') - grab(root, 'grip-reorder-drag') - mouse('up', 1, 5, 10) - require(wait_for(lambda: 'alpha' in rows(client)[3][50:]), 'the column did not move first') - require(rail(client) is None, 'the rail outlived the drag') - grab(root, 'grip-done') - print('ok', root) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('binary', type=Path) - parser.add_argument('output', type=Path) - parser.add_argument('--pad', type=int, default=0, help='leftover pixels past the grid (PARDES_TEST_PAD)') - run(parser.parse_args()) - - -if __name__ == '__main__': - main() @@ -1327,7 +1327,7 @@ def test(binary, quic=False): time.sleep(.005) assert len(matches) == 1, matches result = client.read(f'/pane/{matches[0]}/body') - assert result.startswith(b'backend: zls-inproc+lsp-client\n'), result + assert result.startswith(b'backend: lsp-client\n'), result assert b'protocol servers (lsp-client):' in result, result assert client.read('/pane/1/body') == b'initial\n' assert '+Lsp' in ''.join(cell[0] for cell in client.screen()['cells']) diff --git a/test/fs_soak.py b/test/fs_soak.py deleted file mode 100644 index a62f03e6..00000000 --- a/test/fs_soak.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import os -from pathlib import Path -import random -import socket -import struct -import tempfile -import time - -from fs import new_pane, session -from ninep import Client - - -BODY_LIMIT = 4096 -SCREEN_LIMIT = 4 * 1024 * 1024 -FRAGMENTS = [ - b'const value = 42;\n', b'fn check() void { if (true) return; }\n', - b'\tspaces and tabs\n', 'λ 界 e\u0301 👩\u200d🚀\n'.encode(), b'x', -] -OPERATIONS = ['append', 'range', 'replace', 'read', 'save', 'reload', - 'cycle', 'reconnect', 'screen'] - - -def emit(**record): - print(json.dumps(record, separators=(',', ':')), flush=True) - - -def payload(rng, limit=256): - result = bytearray() - for _ in range(rng.randint(1, 12)): - fragment = rng.choice(FRAGMENTS) - if len(result) + len(fragment) <= limit: - result.extend(fragment) - return bytes(result) or b'x'[:limit] - - -def check_bytes(actual, expected, label): - if actual == expected: - return - at = next((i for i, pair in enumerate(zip(actual, expected)) - if pair[0] != pair[1]), min(len(actual), len(expected))) - raise AssertionError(f'{label}: mismatch at byte {at}; ' - f'length {len(actual)} != {len(expected)}; ' - f'{actual[at:at + 32]!r} != {expected[at:at + 32]!r}') - - -def read_fid(client, fid, limit, count): - result = bytearray() - while chunk := client.read_fid(fid, len(result), count): - result.extend(chunk) - if len(result) > limit: - raise AssertionError(f'read exceeded fixture limit {limit}') - return bytes(result) - - -def check_body(client, pane, count=4096): - fid = client.open(f'/pane/{pane["serial"]}/body') - try: - actual = read_fid(client, fid, BODY_LIMIT, count) - finally: - client.close(fid) - check_bytes(actual, pane['body'], f'pane {pane["serial"]}') - - -def check_screen(data): - screen = json.loads(data) - cols, rows = screen['cols'], screen['rows'] - assert cols > 0 and rows > 0 and cols * rows <= 65536, (cols, rows) - assert len(screen['cells']) == cols * rows - styles = screen['styles'] - assert styles - for cell in screen['cells']: - assert len(cell) == 2 and isinstance(cell[0], str), cell - assert isinstance(cell[1], int) and 0 <= cell[1] < len(styles), cell - - -def session_pid(client): - if not hasattr(socket, 'SO_PEERCRED'): - return None - pid, uid, _ = struct.unpack('3i', client.socket.getsockopt( - socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i'))) - assert pid > 0 and uid == os.getuid(), (pid, uid) - return pid - - -def check_session(client, control, pid): - assert session_pid(control) == pid - if pid is not None: - actual = (session_pid(client) if client.socket.family == socket.AF_UNIX - else int(client.read('/os/proc/self/stat').split()[0])) - assert actual == pid, (actual, pid) - - -def memory(pid): - result = {'rss_bytes': None, 'cumulative_peak_rss_bytes': None} - if pid is None: - return result - try: - for line in Path(f'/proc/{pid}/status').read_text().splitlines(): - fields = line.split() - if fields[0] == 'VmRSS:': - result['rss_bytes'] = int(fields[1]) * 1024 - elif fields[0] == 'VmHWM:': - result['cumulative_peak_rss_bytes'] = int(fields[1]) * 1024 - except OSError: - pass - return result - - -def run(args): - rng = random.Random(args.seed) - started = time.monotonic() - deadline = None - batch = 0 - operation = 'start' - operation_count = 0 - pid = None - try: - with tempfile.TemporaryDirectory(prefix='pardes-soak-') as directory: - root = Path(directory) - options = ['--9p-tcp=tcp!127.0.0.1!0'] if args.transport == 'tcp' else [] - with session(str(args.binary.resolve()), root, 'soak', *options, - tty=args.tty) as (control, unix_address): - pid = session_pid(control) - address = unix_address - if args.transport == 'tcp': - listeners = [line.split('!') for line in - control.read('/listeners').decode().splitlines() - if line.startswith('tcp!')] - assert len(listeners) == 1, listeners - transport, host, port = listeners[0] - assert transport == 'tcp' and host == '127.0.0.1' and 0 < int(port) < 65536, listeners - address = (host, int(port)) - client = Client(address) - panes = [] - try: - check_session(client, control, pid) - for slot in range(4): - body = payload(rng) - serial = 1 if slot == 0 else new_pane(client, body) - base = f'/pane/{serial}' - if slot == 0: - client.write(base + '/body', body, truncate=True) - path = root / f'pane-{slot}-0.zig' - client.write(base + '/name', f'{path}\n'.encode()) - client.write(base + '/exec', b'Save\n') - panes.append({'serial': serial, 'body': body, 'path': path, 'variant': 0}) - emit(kind='start', seed=args.seed, session_pid=pid, - transport=args.transport, host='tty' if args.tty else 'detached', - batches=args.batches, duration_seconds=args.duration, - interval_seconds=args.interval, body_limit_bytes=BODY_LIMIT, - live_panes=len(panes), **memory(pid)) - if args.duration is not None: - deadline = time.monotonic() + args.duration - while (args.batches is None or batch < args.batches) and ( - deadline is None or time.monotonic() < deadline): - batch_started = time.monotonic() - operations = OPERATIONS * 2 - rng.shuffle(operations) - for operation in operations: - slot = rng.randrange(len(panes)) - pane = panes[slot] - base = f'/pane/{pane["serial"]}' - if operation == 'append': - addition = payload(rng, min(256, BODY_LIMIT - len(pane['body']))) - client.write(base + '/body', addition) - pane['body'] += addition - elif operation == 'range': - body = pane['body'] - # Keep range endpoints outside combining and ZWJ clusters. - boundaries = [0] + [i for i in range(1, len(body)) - if body[i - 1] < 128 and body[i] < 128] + [len(body)] - lo, hi = sorted((rng.choice(boundaries), rng.choice(boundaries))) - replacement = payload(rng, min(256, BODY_LIMIT - len(body) + hi - lo)) - if replacement: - client.write(base + '/addr', f'#{lo},#{hi}'.encode()) - client.write(base + '/data', replacement) - pane['body'] = body[:lo] + replacement + body[hi:] - elif operation == 'replace': - pane['body'] = payload(rng, BODY_LIMIT) - client.write(base + '/body', pane['body'], truncate=True) - elif operation == 'read': - check_body(client, pane, rng.choice([97, 251, 1024, 4096])) - elif operation == 'save': - pane['variant'] ^= 1 - pane['path'] = root / f'pane-{slot}-{pane["variant"]}.zig' - client.write(base + '/name', f'{pane["path"]}\n'.encode()) - client.write(base + '/exec', b'Save\n') - check_bytes(pane['path'].read_bytes(), pane['body'], 'saved file') - check_bytes(client.read('/os' + str(pane['path'])), pane['body'], 'OS mount') - elif operation == 'reload': - pane['body'] = payload(rng) - pane['path'].write_bytes(pane['body']) - client.write(base + '/ctl', b'get\n') - elif operation == 'cycle': - old_serial = pane['serial'] - client.remove(base) - pane['body'] = payload(rng) - pane['serial'] = new_pane(client, pane['body']) - assert pane['serial'] != old_serial - base = f'/pane/{pane["serial"]}' - client.write(base + '/name', f'{pane["path"]}\n'.encode()) - client.write(base + '/exec', b'Save\n') - elif operation == 'reconnect': - client.socket.close() - client = Client(address) - check_session(client, control, pid) - check_body(control, pane) - elif operation == 'screen': - frozen = client.open('/screen') - try: - before = read_fid(client, frozen, SCREEN_LIMIT, 4096) - check_screen(before) - addition = payload(rng, min(64, BODY_LIMIT - len(pane['body']))) - client.write(base + '/body', addition) - pane['body'] += addition - after = read_fid(client, frozen, SCREEN_LIMIT, rng.choice([251, 1024])) - check_bytes(after, before, 'frozen screen') - finally: - client.close(frozen) - fresh = client.open('/screen') - try: - check_screen(read_fid(client, fresh, SCREEN_LIMIT, 4096)) - finally: - client.close(fresh) - operation_count += 1 - check_body(client, pane) - expected = {str(pane['serial']) for pane in panes} - assert set(client.list('/pane')) == expected - assert set(control.list('/pane')) == expected - for pane in panes: - check_body(client, pane) - check_body(control, pane) - batch += 1 - emit(kind='batch', seed=args.seed, batch=batch, - duration_seconds=time.monotonic() - batch_started, - elapsed_seconds=time.monotonic() - started, - operations=len(operations), total_operations=operation_count, - model_bytes=sum(len(pane['body']) for pane in panes), **memory(pid)) - delay = args.interval - (time.monotonic() - batch_started) - if deadline is not None: - delay = min(delay, deadline - time.monotonic()) - if delay > 0: - time.sleep(delay) - finally: - client.socket.close() - emit(kind='summary', seed=args.seed, batches=batch, - transport=args.transport, host='tty' if args.tty else 'detached', - operations=operation_count, duration_seconds=time.monotonic() - started, - status='passed') - except BaseException as error: - emit(kind='failure', seed=args.seed, batch=batch + 1, operation=operation, - transport=args.transport, host='tty' if args.tty else 'detached', - operations=operation_count, session_pid=pid, - elapsed_seconds=time.monotonic() - started, error=str(error)) - raise - - -def main(): - parser = argparse.ArgumentParser(description='Deterministic valid-operation editor/9P soak; writes JSONL.') - parser.add_argument('binary', type=Path) - parser.add_argument('--seed', type=int, default=4200) - parser.add_argument('--transport', choices=['unix', 'tcp'], default='unix') - parser.add_argument('--tty', action='store_true', help='run an owned TTY host instead of a detached host') - limit = parser.add_mutually_exclusive_group() - limit.add_argument('--batches', type=int) - limit.add_argument('--duration', type=float, help='seconds, stopping after the current batch') - parser.add_argument('--interval', type=float, default=0, help='minimum seconds between batch starts') - args = parser.parse_args() - if args.batches is None and args.duration is None: - args.batches = 100 - if args.batches is not None and args.batches < 1: - parser.error('--batches must be positive') - if args.duration is not None and not 0 < args.duration < float('inf'): - parser.error('--duration must be finite and positive') - if not 0 <= args.interval < float('inf'): - parser.error('--interval must be finite and nonnegative') - run(args) - - -if __name__ == '__main__': - main() diff --git a/test/look_placement.py b/test/look_placement.py deleted file mode 100644 index 8a31157f..00000000 --- a/test/look_placement.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -"""Check narrow-window first-document Look placement in owned SDL/TTY sessions. - -python3 -B test/look_placement.py zig-out/column-review/bin/pardes-gui -python3 -B test/look_placement.py zig-out/column-review/bin/pardes --tty - -Starts the classic three-terminal layout, then opens the first document from -the lower-left or right terminal. Screen-grid geometry must split only that -terminal, immediately below it, and a repeated Look must reuse the file pane. -Use --output DIR to retain grid observations and SDL GPU screenshots. -""" -import argparse -from contextlib import nullcontext -from pathlib import Path -import tempfile - -from fs import session -from ui_review import capture, panes, require, wait_for - - -def geometry(client, markers): - frame = client.screen() - width = frame['cols'] - lines = [''.join(cell[0] for cell in frame['cells'][row * width:(row + 1) * width]) - for row in range(frame['rows'])] - positions = {} - for serial, marker in markers.items(): - hits = [(row, line.index(marker)) for row, line in enumerate(lines) if marker in line] - if len(hits) != 1: - return None - row, offset = hits[0] - column_x = 0 if offset < width // 2 else width // 2 - positions[serial] = (column_x, row) - rectangles = {} - for serial, (column_x, row) in positions.items(): - following = [other_y for other_x, other_y in positions.values() - if other_x == column_x and other_y > row] - end = min(following, default=frame['rows']) - rectangles[serial] = (column_x, row, width // 2, end - row) - return frame, rectangles - - -def rectangle_text(frame, rect): - x, y, width, height = rect - return tuple(tuple(cell[0] for cell in frame['cells'][row * frame['cols'] + x: - row * frame['cols'] + x + width]) - for row in range(y, y + height)) - - -def run_case(args, root, source_index): - root.mkdir(parents=True, exist_ok=True, mode=0o700) - root.chmod(0o700) - config = root / 'config/pardes' - config.mkdir(parents=True) - (config / 'init').write_text('Shell /bin/sh\nTheme forge\n') - target = root / 'opened.txt' - contents = b'First document opened from this terminal.\n' * 40 - target.write_bytes(contents) - name = 'look-placement' - inherited = {} if args.tty else { - 'PARDES_TEST': '1', 'PARDES_TEST_COLS': '150', 'PARDES_TEST_ROWS': '48', - 'PARDES_TEST_CAPTURE_DIR': str(root), 'SDL_VIDEODRIVER': 'x11', - } - # No document argument: the regression concerns the very first document. - launch = ['-c', 'stty cols 150 rows 48; exec "$@"', name, - str(args.binary.resolve()), '--9p=' + name, '-n', '3'] - with session('/bin/sh', root, name, socket_name=name, tty=True, - launch=launch, inherited=inherited) as (client, _): - serials = list(panes(client)) - require(len(serials) == 3, 'classic fixture did not boot three terminals') - markers = {} - for index, serial in enumerate(serials): - pane = f'/pane/{serial}' - require('pty' in client.list(pane), 'fixture contains an existing document') - marker = f'LP_{index}' - markers[serial] = marker - client.write(pane + '/tag', b'', truncate=True) - client.write(pane + '/tag', f' {marker} Tty Del Collapse'.encode()) - # Establish non-pristine shells and a stable prompt before measuring. - client.write(pane + '/pty/data', f"printf 'ready-{index}\\n'\r".encode()) - require(wait_for(lambda: f'\nready-{index}\n'.encode() in - client.read(pane + '/body').replace(b'\r', b'')), - 'terminal did not execute readiness command') - before_frame, before = require(wait_for(lambda: geometry(client, markers)), - 'terminal tags not visible in fixture') - source = serials[source_index] - source_rect = before[source] - require(source_rect[3] >= 8, 'source terminal too short for a local split') - require(before[serials[0]][:2] == (0, 2) and before[serials[1]][0] == 0 and - before[serials[1]][1] > 2 and before[serials[2]][:2] == (75, 2), - f'unexpected initial classic layout: {before}') - client.write(f'/pane/{source}/look', f'{target}\n'.encode()) - opened = require(wait_for(lambda: next((serial for serial in panes(client) - if serial not in serials), None)), - 'Look did not open the first document') - markers[opened] = 'opened.txt' - after_frame, after = require(wait_for(lambda: geometry(client, markers)), - 'Look pane tags not visible') - require(len(panes(client)) == 4, 'Look removed a terminal or added extra panes') - x, y, width, height = source_rect - require(after[source][:3] == (x, y, width), - f'Look moved its originating terminal: {source_rect} -> {after[source]}') - require(after[opened][:3] == (x, y + after[source][3], width) and - after[source][3] + after[opened][3] == height, - f'Look did not split directly below its caller: {after}') - for serial in serials: - if serial == source: - continue - require(after[serial] == before[serial], - f'Look resized or moved unrelated terminal {serial}: {before} -> {after}') - require(rectangle_text(before_frame, before[serial]) == - rectangle_text(after_frame, after[serial]), - f'Look changed unrelated terminal {serial} rendered text') - require(client.read(f'/pane/{opened}/body') == contents, - 'opened document contents differ') - client.write(f'/pane/{source}/look', f'{target}:2\n'.encode()) - require(list(panes(client)) == serials + [opened], 'repeated Look created a duplicate pane') - require(wait_for(lambda: geometry(client, markers)[1] == after), - 'repeated Look changed layout') - require(target.read_bytes() == contents, 'Look modified the fixture on disk') - if args.output: - capture(client, root, 'look-below-caller', not args.tty) - print(f'{"TTY" if args.tty else "SDL"} source {"lower-left" if source_index == 1 else "right"}: ' - 'local split, unchanged other terminals, first-file contents and re-Look reuse passed', flush=True) - - -def run(args): - # Cache-rooted fixtures avoid relying on /tmp quota in developer sessions. - cache = Path.home() / '.cache' - cache.mkdir(exist_ok=True) - context = nullcontext(str(args.output.resolve())) if args.output else tempfile.TemporaryDirectory( - prefix='lp-', dir=cache) - with context as directory: - for source_index, label in [(1, 'left'), (2, 'right')]: - run_case(args, Path(directory) / label, source_index) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('binary', type=Path) - parser.add_argument('--tty', action='store_true') - parser.add_argument('--output', type=Path) - run(parser.parse_args()) diff --git a/test/lspbench.zig b/test/lspbench.zig index 36969680..02138d24 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -40,7 +40,6 @@ const anchors = [_]Anchor{ .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" }, .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" }, .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 4, .kind = .completion, .expect = "std.zig" }, - .{ .file = "src/lsp/lsp_zls.zig", .needle = ".declaration => try rowForToken", .at = 1, .kind = .completion, .expect = "type_definition" }, .{ .file = "src/normal.zig", .needle = ".definition => .definition,", .at = 1, .kind = .completion, .expect = "" }, // The repeated name proves the result includes the declaration, not just the cursor line. .{ .file = "test/lspfixture/dotcomplete.zig", .needle = "return switch (s) {\n .", .at = 29, .kind = .completion, .expect = "verdigris verdigris," }, @@ -115,6 +114,7 @@ pub fn main(init: std.process.Init) !void { const gpa = std.heap.page_allocator; const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len > 1 and std.mem.eql(u8, args[1], "probe")) return probeCommand(init); const options = try Options.parse(args[1..]); const rss0 = rssKib(); @@ -296,3 +296,171 @@ fn rssKib() u64 { } return 0; } + +// ---- probe: one query from the command line ---- + +fn nowUs() u64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000; +} + +const aliases = .{ + .{ "gd", lsp.Kind.definition }, + .{ "gD", lsp.Kind.declaration }, + .{ "gy", lsp.Kind.type_definition }, + .{ "gi", lsp.Kind.implementation }, + .{ "gr", lsp.Kind.references }, +}; + +fn probeUsage() noreturn { + std.debug.print( + \\usage: pardes-lspbench probe <kind> <file> <line>:<col> [arg] [--reps N] [--warm S] + \\ kind: gd gD gy gi gr, or any lsp.Kind tag (hover, references, + \\ document_symbols, workspace_symbols, incoming_calls, ...) + \\ arg: the new name for rename, the query for workspace_symbols + \\ --warm sleep S seconds after the first (cold) query — long enough + \\ for an indexing server to reach ready, narrated on stderr + \\ + , .{}); + std.process.exit(2); +} + +/// The editor's message row, here: the client's reader threads narrate server +/// state through the same sink the shells register, printed to stderr so the +/// rows on stdout stay clean. +fn statusSink(ctx: ?*anyopaque, text: []const u8) void { + _ = ctx; + std.debug.print("# status: {s}\n", .{text}); +} + +extern "c" fn usleep(usec: c_uint) c_int; + +/// `lspbench probe`: one language query against the real seam, from the +/// command line (the old lspprobe): `probe <kind> <file> <line>:<col> [arg] +/// [--reps N] [--warm S]`, rows on stdout, the timing on stderr. +fn probeCommand(init: std.process.Init) !void { + const gpa = std.heap.page_allocator; + const arena = init.arena.allocator(); + const io = std.Io.Threaded.global_single_threaded.io(); + + // args[1] is `probe`: the rest are where lspprobe's were. + const all = try init.minimal.args.toSlice(arena); + const args = all[1..]; + if (args.len < 4) probeUsage(); + + // comma-separated kinds share ONE server instance and its index — the + // whole battery against a big workspace pays the cold cost once + var kinds: [16]lsp.Kind = undefined; + var nkinds: usize = 0; + var kit = std.mem.tokenizeScalar(u8, args[1], ','); + while (kit.next()) |word| { + if (nkinds == kinds.len) probeUsage(); + kinds[nkinds] = blk: { + inline for (aliases) |a| if (std.mem.eql(u8, word, a[0])) break :blk a[1]; + break :blk std.meta.stringToEnum(lsp.Kind, word) orelse probeUsage(); + }; + nkinds += 1; + } + if (nkinds == 0) probeUsage(); + + var pz: [4096:0]u8 = undefined; + const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{args[2]}, 0) catch probeUsage(); + var realbuf: [4096]u8 = undefined; + const path = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse { + std.debug.print("cannot resolve {s}\n", .{args[2]}); + std.process.exit(1); + }); + const source = std.Io.Dir.cwd().readFileAllocOptions( + io, + path, + arena, + .limited(64 << 20), + .of(u8), + 0, + ) catch { + std.debug.print("cannot read {s}\n", .{path}); + std.process.exit(1); + }; + + const colon = std.mem.indexOfScalar(u8, args[3], ':') orelse probeUsage(); + const line = std.fmt.parseInt(u32, args[3][0..colon], 10) catch probeUsage(); + const col = std.fmt.parseInt(u32, args[3][colon + 1 ..], 10) catch probeUsage(); + if (line == 0 or col == 0) probeUsage(); + + var arg: []const u8 = ""; + var reps: u32 = 1; + var warm_s: u32 = 0; + var i: usize = 4; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--reps")) { + i += 1; + if (i >= args.len) probeUsage(); + reps = @max(1, std.fmt.parseInt(u32, args[i], 10) catch probeUsage()); + } else if (std.mem.eql(u8, args[i], "--warm")) { + i += 1; + if (i >= args.len) probeUsage(); + warm_s = std.fmt.parseInt(u32, args[i], 10) catch probeUsage(); + } else arg = args[i]; + } + + lsp.setStatusSink(null, statusSink); + + // 1-based line:col -> byte offset + var off: usize = 0; + var ln: u32 = 1; + while (ln < line and off < source.len) : (off += 1) { + if (source[off] == '\n') ln += 1; + } + off = @min(off + col - 1, source.len); + + const root = std.fs.path.dirname(path) orelse "/"; + const timings = try arena.alloc(u64, reps); + var stdout_buf: [64 * 1024]u8 = undefined; + var stdout = std.Io.File.stdout().writer(io, &stdout_buf); + var first_query = true; + + for (kinds[0..nkinds]) |kind| { + var rows: []const u8 = ""; + for (0..reps) |r| { + // one warm pause for the whole battery, right after the very + // first (cold, server-spawning) query — the status lines on + // stderr narrate what the server does with the time + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + var scratch: std.heap.ArenaAllocator = .init(gpa); + defer scratch.deinit(); + const t0 = nowUs(); + try lsp.query(gpa, scratch.allocator(), .{ + .kind = kind, + .path = path, + .source = source, + .offset = @intCast(off), + .arg = arg, + .root = root, + }, &out.writer); + timings[r] = nowUs() -| t0; + rows = try arena.dupe(u8, out.written()); + if (first_query) { + first_query = false; + if (warm_s > 0) for (0..warm_s * 10) |_| { + _ = usleep(100_000); + }; + } + } + + try stdout.interface.print("## {t}\n", .{kind}); + try stdout.interface.writeAll(rows); + try stdout.interface.flush(); + + const n = std.mem.count(u8, rows, "\n"); + const cold = timings[0]; + std.mem.sort(u64, timings[0..reps], {}, std.sort.asc(u64)); + if (reps == 1) + std.debug.print("# {t}: {d} row(s) in {d}us\n", .{ kind, n, cold }) + else + std.debug.print("# {t}: {d} row(s); first {d}us, min {d}us, median {d}us ({d} reps)\n", .{ + kind, n, cold, timings[0], timings[reps / 2], reps, + }); + } +} diff --git a/test/output.zig b/test/output.zig index da17eccc..c67c021e 100644 --- a/test/output.zig +++ b/test/output.zig @@ -669,10 +669,7 @@ test "EffectCode lists readable virtual sources used by this backend" { try std.testing.expect(std.mem.indexOf(u8, out.content, "/virtual/src/layout.zig\n") != null); const backend_source = switch (platform) { .tty => "/virtual/src/tty/panel_compositor.zig", - .gui => if (@import("pardes_config").gui_shader_sources_prebuilt) - "/virtual/shaders/prebuilt/ui.vert.glsl" - else - "/virtual/shaders/ui.vert.glsl", + .gui => "/virtual/shaders/ui.vert.glsl", .macos => "/virtual/src/macos/Sources/ScenePostprocessor.swift", .web, .esp32p4 => unreachable, }; diff --git a/test/panes.zig b/test/panes.zig index f7ca9fc5..d7fa3514 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -3134,7 +3134,6 @@ const ImageTests = struct { const togglePalette = panes.Image.togglePalette; const toggleAscii = panes.Image.toggleAscii; const tagPrefix = panes.Image.tagPrefix; - const legacySavedPrefix = panes.Image.legacySavedPrefix; test "pane-local renderer choices are visible in the image tag" { var state: State = .{ .path = @constCast("/tmp/picture.ppm") }; @@ -3147,10 +3146,6 @@ const ImageTests = struct { config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm", tag, ); - try std.testing.expectEqualStrings( - "img /tmp/picture.ppm", - legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?, - ); } test "dump restore propagates malformed embedded image bytes" { @@ -3173,7 +3168,6 @@ const PdfTests = struct { const Point = panes.Pdf.Point; const Document = panes.Pdf.Document; const isPath = panes.Pdf.isPath; - const legacySavedPrefix = panes.Pdf.legacySavedPrefix; const State = panes.Pdf.State; const probeWord = panes.Pdf.probeWord; const pointAtGeometry = panes.Pdf.pointAtGeometry; @@ -3186,26 +3180,10 @@ const PdfTests = struct { try std.testing.expect(!isPath("pdf")); } - test "legacy PDF prefix is delimited by its stable marker and exact path" { - const path = "/tmp/a document.pdf"; - const tag = "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del"; - try std.testing.expectEqualStrings( - "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path, - legacySavedPrefix(path, tag).?, - ); - } - test "feature-off PDF state is zero-sized" { if (!enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(State)); } - test "compact PDF saved prefix separates page status from editable commands" { - const path = "/tmp/a document.pdf"; - try std.testing.expectEqualStrings(path ++ " [2/9]", legacySavedPrefix(path, path ++ " [2/9] Keep Del").?); - try std.testing.expect(legacySavedPrefix(path, path ++ " [oops] Keep Del") == null); - try std.testing.expect(legacySavedPrefix(path, path ++ " [2/9/4] Keep Del") == null); - } - test "PDF section rows preserve DFS ordinals and sanitise hierarchy" { if (!enabled) return; const entries = [_]pdf.OutlineEntry{ diff --git a/test/pdf.zig b/test/pdf.zig index 4cb8f3dd..48869de1 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -287,6 +287,7 @@ test "PDF dump fallback remains a byte-preserving file" { const saved_panes = [_]dump.Pane{.{ .kind = .image, .tag = "pdf 3/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del", + .tag_tail = " Keep Del", .body = "", .scroll = 2, .cols = 80, diff --git a/test/pdf_bench.zig b/test/pdf_bench.zig index 0f09f72a..23565106 100644 --- a/test/pdf_bench.zig +++ b/test/pdf_bench.zig @@ -13,6 +13,9 @@ //! filesystem cache is deliberately uncontrolled), while warm scenarios //! retain one Document. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const fatal = bench_util.Fatal("pdf-bench").f; const pdf = @import("mupdf"); pub const std_options: std.Options = .{ .log_level = .err }; @@ -205,14 +208,6 @@ fn parseArgs(args: []const []const u8) Config { return config; } -fn parsePositive(name: []const u8, text: []const u8, maximum: usize) usize { - const value = std.fmt.parseUnsigned(usize, text, 10) catch - fatal("{s} expects a positive integer", .{name}); - if (value == 0 or value > maximum) - fatal("{s} must be in 1..{d}", .{ name, maximum }); - return value; -} - fn usage() noreturn { printUsage(); std.process.exit(2); @@ -226,13 +221,6 @@ fn printUsage() void { ); } -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| - @as(u64, @intCast(ts.nsec)); -} - fn measure( config: Config, profile_index: usize, @@ -617,7 +605,6 @@ fn reportJson( std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } -fn fatal(comptime format: []const u8, args: anytype) noreturn { - std.debug.print("pdf-bench: " ++ format ++ "\n", args); - std.process.exit(1); +fn parsePositive(name: []const u8, text: []const u8, maximum: usize) usize { + return bench_util.parsePositive(fatal, name, text, maximum); } diff --git a/test/pdf_pointer_bench.zig b/test/pdf_pointer_bench.zig index f1d61229..ae2de2f5 100644 --- a/test/pdf_pointer_bench.zig +++ b/test/pdf_pointer_bench.zig @@ -15,6 +15,9 @@ //! frame each step settles on. It is the no-regression proof: a change to how highlights //! are painted may change what it costs, never one presented pixel. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const mix = bench_util.mix; const pardes = @import("pardes"); const pdf = @import("mupdf"); @@ -208,17 +211,6 @@ fn print(comptime format: []const u8, args: anytype) void { _ = std.c.write(1, line.ptr, line.len); } -fn mix(seed: u64, value: anytype) u64 { - var copy = value; - return std.hash.Wyhash.hash(seed, std.mem.asBytes(©)); -} - -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); -} - comptime { _ = pdf.Document; } diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig index 33dfe70a..a4511dbe 100644 --- a/test/pdf_scroll_bench.zig +++ b/test/pdf_scroll_bench.zig @@ -42,6 +42,11 @@ //! //! Setup, teardown, checksumming, and reporting are outside every timer. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const fatal = bench_util.Fatal("pdf-scroll-bench").f; +const mix = bench_util.mix; +const drainEffects = bench_util.drainEffects; const libc = std.c; const pardes = @import("pardes"); const pdf = @import("mupdf"); @@ -211,14 +216,6 @@ fn parseArgs(args: []const []const u8) Config { return config; } -fn parseCount(flag: []const u8, text: []const u8) usize { - const value = std.fmt.parseUnsigned(usize, text, 10) catch - fatal("{s} expects a positive integer", .{flag}); - if (value == 0 or value > 10_000) - fatal("{s} must be in 1..10000", .{flag}); - return value; -} - const Travel = struct { /// Scrollable display pixels: the whole strip minus one screenful. max_scroll: f64, @@ -520,20 +517,6 @@ fn stridedHash(seed: u64, rgba: []const u8) u64 { return hash; } -fn mix(seed: u64, value: anytype) u64 { - const Value = @TypeOf(value); - const Stable = switch (@typeInfo(Value)) { - .comptime_int => u64, - else => Value, - }; - var stable: Stable = value; - return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable)); -} - -fn drainEffects(core: *pardes.Pardes) void { - while (core.nextEffect()) |_| {} -} - fn verifyIdentity( name: []const u8, expected: *?Identity, @@ -557,13 +540,6 @@ fn verifyIdentity( } else expected.* = got; } -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| - @as(u64, @intCast(ts.nsec)); -} - fn reportText(config: Config, travel: Travel, results: []const Result) void { std.debug.print("pardes PDF fast-scroll benchmark ({s})\n", .{bench_config.core_optimize}); std.debug.print( @@ -649,13 +625,12 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } -fn fatal(comptime format: []const u8, args: anytype) noreturn { - std.debug.print("pdf-scroll-bench: " ++ format ++ "\n", args); - std.process.exit(1); -} - comptime { // The bench links the real MuPDF wrapper the core uses; keep the import // load-bearing so a build that silently dropped it fails here. _ = pdf.Document; } + +fn parseCount(flag: []const u8, text: []const u8) usize { + return bench_util.parsePositive(fatal, flag, text, 10_000); +} diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig index b08af552..92c9ef7c 100644 --- a/test/pdf_sections_bench.zig +++ b/test/pdf_sections_bench.zig @@ -9,6 +9,11 @@ //! report nanoseconds per operation; `operations_per_sample` makes that //! normalization explicit in both output formats. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const fatal = bench_util.Fatal("pdf-sections-bench").f; +const mix = bench_util.mix; +const drainEffects = bench_util.drainEffects; const libc = std.c; const pardes = @import("pardes"); const pdf = @import("mupdf"); @@ -107,14 +112,6 @@ fn parseArgs(args: []const []const u8) Config { return config; } -fn parseCount(flag: []const u8, text: []const u8) usize { - const value = std.fmt.parseUnsigned(usize, text, 10) catch - fatal("{s} expects a positive integer", .{flag}); - if (value == 0 or value > 10_000) - fatal("{s} must be in 1..10000", .{flag}); - return value; -} - fn measureOutline(path: []const u8, config: Config) !Result { const samples = try gpa.alloc(u64, config.reps); defer gpa.free(samples); @@ -145,7 +142,7 @@ fn measureMaterialize( var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { const started = nowNs(); - const content = try pardes.PdfSectionRows.render(gpa, path, entries); + const content = try pardes.panes.Pdf.SectionRows.render(gpa, path, entries); const elapsed = nowNs() -| started; const identity = contentIdentity(content); gpa.free(content); @@ -255,7 +252,7 @@ fn measureWorstShape(entries: []const pdf.OutlineEntry, config: Config) !Result var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { const started = nowNs(); - const content = try pardes.PdfSectionRows.render(gpa, "/tmp/near-limit.pdf", entries); + const content = try pardes.panes.Pdf.SectionRows.render(gpa, "/tmp/near-limit.pdf", entries); const elapsed = nowNs() -| started; const identity = contentIdentity(content); gpa.free(content); @@ -300,11 +297,11 @@ fn perturbReopen(prepared: PreparedReopen, operation: usize) void { const marker: i32 = @intCast(operation % 31 + 1); const output_file = &prepared.output.file.?; output_file.scroll = @intCast(operation % 17 + 1); - prepared.output.cur_row = marker; - prepared.output.cur_col = marker + 1; - prepared.output.msel.active = true; - prepared.output.vsel.active = true; - prepared.output.nsel = 1; + prepared.output.body.cur_row = marker; + prepared.output.body.cur_col = marker + 1; + prepared.output.body.msel.active = true; + prepared.output.body.vsel.active = true; + prepared.output.body.nsel = 1; prepared.owner.search_pane = null; prepared.owner.search_row = operation % 23; prepared.core.active = prepared.output_id; @@ -313,21 +310,17 @@ fn perturbReopen(prepared: PreparedReopen, operation: usize) void { fn observeReopen(prepared: PreparedReopen) u64 { const file = prepared.output.file.?; var observed: u64 = file.scroll; - observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.cur_row))); - observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.cur_col))); - observed = observed *% 131 +% @intFromBool(prepared.output.msel.active); - observed = observed *% 131 +% @intFromBool(prepared.output.vsel.active); - observed = observed *% 131 +% prepared.output.nsel; + observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.body.cur_row))); + observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.body.cur_col))); + observed = observed *% 131 +% @intFromBool(prepared.output.body.msel.active); + observed = observed *% 131 +% @intFromBool(prepared.output.body.vsel.active); + observed = observed *% 131 +% prepared.output.body.nsel; observed = observed *% 131 +% (prepared.owner.search_pane orelse std.math.maxInt(usize)); observed = observed *% 131 +% (prepared.owner.search_row orelse std.math.maxInt(usize)); observed = observed *% 131 +% prepared.core.active; return observed; } -fn drainEffects(core: *pardes.Pardes) void { - while (core.nextEffect()) |_| {} -} - fn sectionsOutputIdentity(core: *pardes.Pardes) Identity { const owner = core.panes[0] orelse fatal("PDF pane disappeared", .{}); const output_id = owner.search_pane orelse fatal("+PdfSections was not armed", .{}); @@ -347,7 +340,7 @@ fn stepIdentity(core: *pardes.Pardes) Identity { identity.checksum = mix(identity.checksum, view.page); identity.checksum = mix(identity.checksum, @as(u64, @bitCast(view.document_scroll_y))); identity.checksum = mix(identity.checksum, owner.search_row orelse std.math.maxInt(usize)); - identity.checksum = mix(identity.checksum, view.render_revision); + identity.checksum = mix(identity.checksum, view.next_raster_revision); return identity; } @@ -389,16 +382,6 @@ fn outlineIdentity(entries: []const pdf.OutlineEntry) Identity { return .{ .items = entries.len, .bytes = bytes, .checksum = checksum }; } -fn mix(seed: u64, value: anytype) u64 { - const Value = @TypeOf(value); - const Stable = switch (@typeInfo(Value)) { - .comptime_int => u64, - else => Value, - }; - var stable: Stable = value; - return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable)); -} - fn makeWorstShape() ![]pdf.OutlineEntry { const entries = try gpa.alloc(pdf.OutlineEntry, near_limit_items); for (entries, 0..) |*entry, ordinal| { @@ -457,13 +440,6 @@ fn summarize( }; } -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| - @as(u64, @intCast(ts.nsec)); -} - /// A host benchmark compiler barrier. MuPDF benchmarks are build-and-run /// steps, so they never target the freestanding web backend; an empty volatile /// asm with only the abstract memory clobber is valid on every native backend @@ -545,7 +521,6 @@ fn unlink(path: []const u8) void { _ = libc.unlink(path_z); } -fn fatal(comptime format: []const u8, args: anytype) noreturn { - std.debug.print("pdf-sections-bench: " ++ format ++ "\n", args); - std.process.exit(1); +fn parseCount(flag: []const u8, text: []const u8) usize { + return bench_util.parsePositive(fatal, flag, text, 10_000); } diff --git a/test/snapshot.zig b/test/snapshot.zig index 0514d8d8..a176da7a 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -580,7 +580,7 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const _ = setenv("LC_ALL", "C", 1); // a wall clock cannot live in a golden; everything that would print one // prints fixed characters instead when this is set — the lsp introspection - // views' durations (lsp_zls.zig hideTime) and the transient message row's + // views' durations and the transient message row's // time of day (message.zig stamp) _ = setenv("PARDES_NOTIME", "1", 1); // A virtual core clock: animation moves a frame per timed-out wait, diff --git a/test/snapshots/lsp.golden b/test/snapshots/lsp.golden index 153ae5cc..048d1ed3 100644 --- a/test/snapshots/lsp.golden +++ b/test/snapshots/lsp.golden @@ -14,6 +14,7 @@ | 10 == snap cursor grid=120x41 cursor=17,10 == snap definition grid=120x41 cursor=15,5 +|3: 1 const std = @import("std"); --:--:-- lsp zls ready — /tmp/pardes-snap/lsp/cwd == snap hover grid=120x41 cursor=15,5 |3: 1 const std = @import("std"); Hover |12: /tmp/pardes-snap/lsp/cwd/+Hover Save Tty Collapse Del @@ -26,9 +27,9 @@ |16: 1 a.zig:1:7-9 |17: 2 std |18: 3 a.zig:3:4-9 -|19: 4 helper fn helper(x: u32) u32 +|19: 4 helper |20: 5 a.zig:7:8-11 -|21: 6 main fn main() void +|21: 6 main |22: 7 == snap step grid=120x41 cursor=15,3 |3: 1 const std = @import("std"); diff --git a/test/snapshots/lsp.snap b/test/snapshots/lsp.snap index b40484ee..97fcf7ed 100644 --- a/test/snapshots/lsp.snap +++ b/test/snapshots/lsp.snap @@ -1,11 +1,11 @@ # Language intelligence end to end: the helix keymap, the async round trip and # the two dispositions of an answer. # -# The backend behind the seam here is ZLS linked in as a LIBRARY -# (src/lsp_zls.zig) — no server process, no JSON-RPC — so `gd` is a function -# call on a worker thread whose result comes back as an ordinary event, the -# same shape a pty read already had. Nothing in this script knows that: it -# presses keys and reads the grid, which is the point of the seam. +# The backend behind the seam here is zls, run as a child process by the +# protocol client (it must be on PATH): `gd` is a request on a worker thread +# whose result comes back as an ordinary event, the same shape a pty read +# already had. Nothing in this script knows that: it presses keys and reads +# the grid, which is the point of the seam. # # a.zig is deliberately self-contained — a definition, a call site, and not one # symbol from std — so these goldens do not encode this machine's stdlib diff --git a/test/snapshots/lspcomplete.golden b/test/snapshots/lspcomplete.golden index 29a81f93..a56101e8 100644 --- a/test/snapshots/lspcomplete.golden +++ b/test/snapshots/lspcomplete.golden @@ -19,16 +19,18 @@ |2: ^* /tmp/pardes-snap/lspcomplete/cwd/a.zig Save Tty Collapse Del TreeContext |12: 10 } == snap commentindent grid=120x41 cursor=19,14 +|3: 1 const Shade = enum { --:--:-- lsp zls ready — /tmp/pardes-snap/lspcomplete/cwd |14: 12 // note. see below == snap athedot grid=120x41 cursor=16,10 +|3: 1 const Shade = enum { |10: 8 . == snap completion grid=120x41 cursor=16,10 |15: /tmp/pardes-snap/lspcomplete/cwd/+Search Save Tty Collapse Del -|16: 1 a.zig:2:5-13 verdigris verdigris, -|17: 2 a.zig:3:5-12 cinnabar cinnabar, +|16: 1 a.zig:8:10 verdigris +|17: 2 a.zig:8:10 cinnabar |18: 3 == snap refill grid=120x41 cursor=16,10 -== snap step grid=120x41 cursor=19,4 +== snap step grid=120x41 cursor=16,10 |2: * /tmp/pardes-snap/lspcomplete/cwd/a.zig Save Tty Collapse Del TreeContext == snap txtindent grid=120x41 cursor=15,3 |Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit @@ -40,7 +42,7 @@ |Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit | New Tty Find Grep Joincol Delcol |^* /tmp/pardes-snap/lspcomplete/cwd/c.zig Save Tty Collapse Del TreeContext -| 1 const Shade = enum { +| 1 const Shade = enum { --:--:-- lsp zls ready — /tmp/pardes-snap/lspcomplete/cwd | 2 verdigris, | 3 cinnabar, | 4 }; @@ -49,14 +51,14 @@ | 7 const z: Shade = . | 8 } | /tmp/pardes-snap/lspcomplete/cwd/+Search Save Tty Collapse Del -| 1 c.zig:2:5-13 verdigris verdigris, -| 2 c.zig:3:5-12 cinnabar cinnabar, +| 1 c.zig:7:23 verdigris Shade +| 2 c.zig:7:23 cinnabar Shade | 3 == snap nobrace grid=100x19 cursor=16,10 |Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit | New Tty Find Grep Joincol Delcol |^* /tmp/pardes-snap/lspcomplete/cwd/d.zig Save Tty Collapse Del TreeContext -| 1 const Shade = enum { +| 1 const Shade = enum { --:--:-- lsp zls ready — /tmp/pardes-snap/lspcomplete/cwd | 2 verdigris, | 3 cinnabar, | 4 }; @@ -64,6 +66,6 @@ | 6 pub fn nobrace(s: Shade) void { | 7 switch (s) { | /tmp/pardes-snap/lspcomplete/cwd/+Search Save Tty Collapse Del -| 1 d.zig:2:5-13 verdigris verdigris, -| 2 d.zig:3:5-12 cinnabar cinnabar, +| 1 d.zig:8:10 verdigris +| 2 d.zig:8:10 cinnabar | 3 diff --git a/test/snapshots/lspcomplete.snap b/test/snapshots/lspcomplete.snap index 53ce7ef9..d9e165a1 100644 --- a/test/snapshots/lspcomplete.snap +++ b/test/snapshots/lspcomplete.snap @@ -1,6 +1,7 @@ -# Tab after a dot in insert mode: "what could go here, and where is each of -# those defined". The answer is a `+Search` buffer of DEFINITION rows, the same -# rows `gr` fills it with — nothing is inserted and nothing pops up. +# Tab after a dot in insert mode: "what could go here". The answer is a +# `+Search` buffer of zls's candidates, one row each at the cursor (a +# completion item names no declaration) — nothing is inserted and nothing pops +# up. # # a.zig is std-free for the reason lsp.snap's is: these goldens must not encode # this machine's stdlib version or its install path. Its enum members are @@ -58,12 +59,10 @@ text . stable 400 5000 snap athedot key tab -# The row that proves it: `a.zig:2:` is `verdigris`'s own declaration line. -# The cursor is on line 8; nothing about line 2 is reachable from the text -# under it, so this row can only have come from resolving the switch's -# condition to `Shade` and asking `Shade` what it declares. Line 8 still reads -# `.` and nothing else — a completion INSERTS NOTHING. -wait 15000 a.zig:2: +# The rows that prove it: `verdigris` and `cinnabar` can only have come from +# resolving the switch's condition to `Shade` and asking what it declares. +# Line 8 still reads `.` and nothing else — a completion INSERTS NOTHING. +wait 15000 +Search stable 700 15000 snap completion # Two more Tabs. Every one of them answers, and every answer REFILLS the one @@ -128,7 +127,7 @@ key end text . stable 400 5000 key tab -wait 15000 c.zig:2: +wait 15000 +Search stable 700 15000 snap nosemi # ...and a construct still hanging OPEN. The switch has no closing brace at @@ -149,6 +148,6 @@ key end text . stable 400 5000 key tab -wait 15000 d.zig:2: +wait 15000 +Search stable 700 15000 snap nobrace diff --git a/test/snapshots/lspcompletemoved.golden b/test/snapshots/lspcompletemoved.golden index e759cbd8..c83e3038 100644 --- a/test/snapshots/lspcompletemoved.golden +++ b/test/snapshots/lspcompletemoved.golden @@ -2,7 +2,7 @@ |Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit | New Tty Find Grep Joincol Delcol |^* /tmp/pardes-snap/lspcompletemoved/cwd/a.zig Save Tty Collapse Del TreeContext -| 1 const Shade = enum { +| 1 const Shade = enum { --:--:-- lsp zls ready — /tmp/pardes-snap/lspcompletemoved/cwd | 2 verdigris, | 3 cinnabar, | 4 }; diff --git a/test/snapshots/lspdebug.golden b/test/snapshots/lspdebug.golden index 650b41b2..75fba98f 100644 --- a/test/snapshots/lspdebug.golden +++ b/test/snapshots/lspdebug.golden @@ -14,6 +14,8 @@ | 10 } | 11 == snap ctrlclick grid=120x45 cursor=15,5 +|3: 1 const std = @import("std"); --:--:-- lsp zls ready — /tmp/pardes-snap/lspdebug/cwd == snap plainclick grid=120x45 cursor=21,10 +|3: 1 const std = @import("std"); == snap why-closed grid=120x45 cursor=7,5 == snap report-closed grid=120x45 cursor=7,5 diff --git a/test/snapshots/lspdebug.snap b/test/snapshots/lspdebug.snap index ab330a76..3aca07cc 100644 --- a/test/snapshots/lspdebug.snap +++ b/test/snapshots/lspdebug.snap @@ -21,10 +21,8 @@ snap plainclick text 3gg1g| stable 400 10000 key space l w -wait 10000 parsed 122 bytes, 0 syntax error(s) -wait 10000 position context: .empty -wait 10000 STOP: nothing to resolve -wait 10000 0 row(s) +wait 10000 lsp explain — the definition query +wait 10000 row(s) key space w j key space d stable 700 15000 @@ -32,20 +30,15 @@ snap why-closed # Check the report's answers, not installed compiler paths or dependency counts. key space l i -wait 10000 backend: zls-inproc+lsp-client -wait 10000 compiled in -wait 10000 [OK] -wait 10000 offsets: utf-8 +wait 10000 backend: lsp-client +wait 10000 protocol servers (lsp-client): +wait 10000 zls key space w j key / -text last queries +text client queries key enter -wait 10000 last queries -wait 10000 definition z.zig -# The results pane the search opened takes the bottom half, which leaves the -# report one row short of its second query: step the cursor past it. -key j j j -wait 10000 explain z.zig +wait 10000 client queries +wait 10000 definition # the report sits between the source and the results, so Del asks where its # rows go: k, back to the source above key space d diff --git a/test/snapshots/lsprelpath.golden b/test/snapshots/lsprelpath.golden index 904e076a..b4022b1d 100644 --- a/test/snapshots/lsprelpath.golden +++ b/test/snapshots/lsprelpath.golden @@ -1,48 +1,49 @@ -== snap absrow grid=100x35 cursor=16,7 +== snap absrow grid=100x35 cursor=21,16 |Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit | New Tty Find Grep Joincol Delcol -|^* /tmp/pardes-snap/lsprelpath/cwd/sub/parent.zig Save Tty Collapse Del TreeContext -| 1 const shade = @import("../shade.zig"); +| /tmp/pardes-snap/lsprelpath/cwd/sub/parent.zig Save Tty Collapse Del TreeContext +| 1 const shade = @import("../shad --:--:-- lsp zls ready — /tmp/pardes-snap/lsprelpath/cwd/sub | 2 | 3 pub fn pick(s: shade.Shade) u8 { | 4 return switch (s) { -| 5 . +| 5 | 6 }; | 7 } -| /tmp/pardes-snap/lsprelpath/cwd/sub/+Search Save Tty Collapse Del -| 1 /tmp/pardes-snap/lsprelpath/cwd/shade.zig:2:5-13 verdigris verdigris, -| 2 /tmp/pardes-snap/lsprelpath/cwd/shade.zig:3:5-12 cinnabar cinnabar, -| 3 -== snap abslook grid=100x35 cursor=19,15 -|2: * /tmp/pardes-snap/lsprelpath/cwd/sub/parent.zig Save Tty Collapse Del TreeContext -|13: /tmp/pardes-snap/lsprelpath/cwd/shade.zig Save Tty Collapse Del TreeContext -|14: 1 pub const Shade = enum { -|15: 2 verdigris, -|16: 3 cinnabar, -|17: 4 }; -|18: 5 +| /tmp/pardes-snap/lsprelpath/cwd/sub/+Search Save Tty Collapse Del LocationsConfig +| 1 /tmp/pardes-snap/lsprelpath/cwd/shade.zig:1:11-15 +| 2 pub const Shade = enum { +| 3 parent.zig:3:22-26 +| 4 pub fn pick(s: shade.Shade) u8 { +| /tmp/pardes-snap/lsprelpath/cwd/shade.zig Save Tty Collapse Del TreeContext +| 1 pub const Shade = enum { +| 2 verdigris, +| 3 cinnabar, +| 4 }; +| 5 +== snap abslook grid=100x35 cursor=32,5 +|3: 1 const shade = @import("../shade.zig"); == style abslook grid=100x35 |0: 0-99 #b2c3b8,#1d2824, |1: 0-1 d,#887455, 2-2 d,#314738, 3-99 #becfc4,#314738, -|2: 0-0 d,#496656, 1-1 #000000,#f9cb70,b 2-2 #000000,#1d2824, 3-38 #b2c3b8,#1d2824, 39-49 #d3b0d9,#1d2824, 50-99 #b2c3b8,#1d2824, +|2: 0-1 d,#add38a, 2-2 d,#314738, 3-38 #becfc4,#314738, 39-49 #dfbce5,#314738, 50-99 #becfc4,#314738, |3: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-20 #e5eee5,#0d1410, 21-27 #c9a7ed,#0d1410, 28-28 #e5eee5,#0d1410, 29-42 #b8d88a,#0d1410, 43-99 #e5eee5,#0d1410, |4: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, -|5: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-10 #e5eee5,#0d1410, 11-13 #c9a7ed,#0d1410, 14-99 #e5eee5,#0d1410, +|5: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-10 #e5eee5,#0d1410, 11-13 #c9a7ed,#0d1410, 14-27 #e5eee5,#0d1410, 28-32 #f5f7ee,#3c614a, 33-99 #e5eee5,#0d1410, |6: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-24 #c9a7ed,#0d1410, 25-99 #e5eee5,#0d1410, -|7: 0-0 d,#608269, 1-4 d,#0d1410, 5-14 #59675c,#0d1410, 15-99 #e5eee5,#0d1410, +|7: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, |8: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, |9: 0-0 d,#152019, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #e5eee5,#0d1410, |10: 0-1 d,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-46 #d3b0d9,#1d2824, 47-99 #b2c3b8,#1d2824, -|11: 0-0 d,#608269, 1-4 d,#0d1410, 5-55 #59675c,#0d1410, 56-99 #e5eee5,#0d1410, -|12: 0-0 d,#152019, 1-4 d,#0d1410, 5-55 #59675c,#0d1410, 56-99 #e5eee5,#0d1410, -|13: 0-1 d,#add38a, 2-2 d,#314738, 3-34 #becfc4,#314738, 35-44 #dfbce5,#314738, 45-99 #becfc4,#314738, -|14: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-24 #e5eee5,#0d1410, 25-29 #c9a7ed,#0d1410, 30-99 #e5eee5,#0d1410, -|15: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-10 #616f64,#0d1410, 11-19 #f5f7ee,#3c614a, 20-99 #e5eee5,#0d1410, -|16: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, -|17: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #e5eee5,#0d1410, -|18: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, -|19: 0-0 d,#608269, 1-99 d,#0d1410, -|20: 0-0 d,#608269, 1-99 d,#0d1410, +|11: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, +|12: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-16 #e5eee5,#0d1410, 17-21 #f7e4a5,#5e4923, 22-22 #f7e4a5,#0d1410, 23-24 #e5eee5,#0d1410, 25-29 #c9a7ed,#0d1410, 30-99 #e5eee5,#0d1410, +|13: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, +|14: 0-0 d,#152019, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-10 #e5eee5,#0d1410, 11-13 #c9a7ed,#0d1410, 14-27 #e5eee5,#0d1410, 28-32 #f7e4a5,#5e4923, 33-99 #e5eee5,#0d1410, +|15: 0-1 d,#496656, 2-2 d,#1d2824, 3-34 #b2c3b8,#1d2824, 35-44 #d3b0d9,#1d2824, 45-99 #b2c3b8,#1d2824, +|16: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-16 #e5eee5,#0d1410, 17-21 #f5f7ee,#3c614a, 22-22 #f5f7ee,#0d1410, 23-24 #e5eee5,#0d1410, 25-29 #c9a7ed,#0d1410, 30-99 #e5eee5,#0d1410, +|17: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, +|18: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, +|19: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #e5eee5,#0d1410, +|20: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, |21: 0-0 d,#608269, 1-99 d,#0d1410, |22: 0-0 d,#608269, 1-99 d,#0d1410, |23: 0-0 d,#608269, 1-99 d,#0d1410, @@ -57,51 +58,52 @@ |32: 0-0 d,#608269, 1-99 d,#0d1410, |33: 0-0 d,#608269, 1-99 d,#0d1410, |34: 0-0 d,#608269, 1-99 d,#0d1410, -== snap subrow grid=100x35 cursor=16,7 +== snap subrow grid=100x35 cursor=20,16 |Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit | New Tty Find Grep Joincol Delcol -|^* /tmp/pardes-snap/lsprelpath/cwd/sub/subdir.zig Save Tty Collapse Del TreeContext -| 1 const tint = @import("inner/tint.zig"); +| /tmp/pardes-snap/lsprelpath/cwd/sub/subdir.zig Save Tty Collapse Del TreeContext +| 1 const tint = @import("inner/ti --:--:-- lsp zls ready — /tmp/pardes-snap/lsprelpath/cwd/sub | 2 | 3 pub fn hue(t: tint.Tint) u8 { | 4 return switch (t) { -| 5 . +| 5 | 6 }; | 7 } -| /tmp/pardes-snap/lsprelpath/cwd/sub/+Search Save Tty Collapse Del -| 1 inner/tint.zig:2:5-9 ochre ochre, -| 2 inner/tint.zig:3:5-10 indigo indigo, -| 3 -== snap sublook grid=100x35 cursor=15,15 -|2: * /tmp/pardes-snap/lsprelpath/cwd/sub/subdir.zig Save Tty Collapse Del TreeContext -|13: /tmp/pardes-snap/lsprelpath/cwd/sub/inner/tint.zig Save Tty Collapse Del TreeContext -|14: 1 pub const Tint = enum { -|15: 2 ochre, -|16: 3 indigo, -|17: 4 }; -|18: 5 +| /tmp/pardes-snap/lsprelpath/cwd/sub/+Search Save Tty Collapse Del LocationsConfig +| 1 inner/tint.zig:1:11-14 +| 2 pub const Tint = enum { +| 3 subdir.zig:3:20-23 +| 4 pub fn hue(t: tint.Tint) u8 { +| /tmp/pardes-snap/lsprelpath/cwd/sub/inner/tint.zig Save Tty Collapse Del TreeContext +| 1 pub const Tint = enum { +| 2 ochre, +| 3 indigo, +| 4 }; +| 5 +== snap sublook grid=100x35 cursor=29,5 +|3: 1 const tint = @import("inner/tint.zig"); == style sublook grid=100x35 |0: 0-99 #b2c3b8,#1d2824, |1: 0-1 d,#887455, 2-2 d,#314738, 3-99 #becfc4,#314738, -|2: 0-0 d,#496656, 1-1 #000000,#f9cb70,b 2-2 #000000,#1d2824, 3-38 #b2c3b8,#1d2824, 39-49 #d3b0d9,#1d2824, 50-99 #b2c3b8,#1d2824, +|2: 0-1 d,#add38a, 2-2 d,#314738, 3-38 #becfc4,#314738, 39-49 #dfbce5,#314738, 50-99 #becfc4,#314738, |3: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-19 #e5eee5,#0d1410, 20-26 #c9a7ed,#0d1410, 27-27 #e5eee5,#0d1410, 28-43 #b8d88a,#0d1410, 44-99 #e5eee5,#0d1410, |4: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, -|5: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-10 #e5eee5,#0d1410, 11-13 #c9a7ed,#0d1410, 14-99 #e5eee5,#0d1410, +|5: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-10 #e5eee5,#0d1410, 11-13 #c9a7ed,#0d1410, 14-25 #e5eee5,#0d1410, 26-29 #f5f7ee,#3c614a, 30-99 #e5eee5,#0d1410, |6: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-24 #c9a7ed,#0d1410, 25-99 #e5eee5,#0d1410, -|7: 0-0 d,#608269, 1-4 d,#0d1410, 5-14 #59675c,#0d1410, 15-99 #e5eee5,#0d1410, +|7: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, |8: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, |9: 0-0 d,#152019, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #e5eee5,#0d1410, |10: 0-1 d,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-46 #d3b0d9,#1d2824, 47-99 #b2c3b8,#1d2824, -|11: 0-0 d,#608269, 1-4 d,#0d1410, 5-27 #59675c,#0d1410, 28-99 #e5eee5,#0d1410, -|12: 0-0 d,#152019, 1-4 d,#0d1410, 5-28 #59675c,#0d1410, 29-99 #e5eee5,#0d1410, -|13: 0-1 d,#add38a, 2-2 d,#314738, 3-44 #becfc4,#314738, 45-53 #dfbce5,#314738, 54-99 #becfc4,#314738, -|14: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-23 #e5eee5,#0d1410, 24-28 #c9a7ed,#0d1410, 29-99 #e5eee5,#0d1410, -|15: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-10 #616f64,#0d1410, 11-15 #f5f7ee,#3c614a, 16-99 #e5eee5,#0d1410, -|16: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, -|17: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #e5eee5,#0d1410, -|18: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, -|19: 0-0 d,#608269, 1-99 d,#0d1410, -|20: 0-0 d,#608269, 1-99 d,#0d1410, +|11: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, +|12: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-16 #e5eee5,#0d1410, 17-20 #f7e4a5,#5e4923, 21-21 #f7e4a5,#0d1410, 22-23 #e5eee5,#0d1410, 24-28 #c9a7ed,#0d1410, 29-99 #e5eee5,#0d1410, +|13: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, +|14: 0-0 d,#152019, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-10 #e5eee5,#0d1410, 11-13 #c9a7ed,#0d1410, 14-25 #e5eee5,#0d1410, 26-29 #f7e4a5,#5e4923, 30-99 #e5eee5,#0d1410, +|15: 0-1 d,#496656, 2-2 d,#1d2824, 3-44 #b2c3b8,#1d2824, 45-53 #d3b0d9,#1d2824, 54-99 #b2c3b8,#1d2824, +|16: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-16 #e5eee5,#0d1410, 17-20 #f5f7ee,#3c614a, 21-21 #f5f7ee,#0d1410, 22-23 #e5eee5,#0d1410, 24-28 #c9a7ed,#0d1410, 29-99 #e5eee5,#0d1410, +|17: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, +|18: 0-0 d,#608269, 1-4 d,#0d1410, 5-10 #59675c,#0d1410, 11-99 #e5eee5,#0d1410, +|19: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #e5eee5,#0d1410, +|20: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, |21: 0-0 d,#608269, 1-99 d,#0d1410, |22: 0-0 d,#608269, 1-99 d,#0d1410, |23: 0-0 d,#608269, 1-99 d,#0d1410, @@ -116,4 +118,4 @@ |32: 0-0 d,#608269, 1-99 d,#0d1410, |33: 0-0 d,#608269, 1-99 d,#0d1410, |34: 0-0 d,#608269, 1-99 d,#0d1410, -== snap clicked grid=100x35 cursor=16,16 +== snap clicked grid=100x35 cursor=15,12 diff --git a/test/snapshots/lsprelpath.snap b/test/snapshots/lsprelpath.snap index 1d662f8a..667c2fc8 100644 --- a/test/snapshots/lsprelpath.snap +++ b/test/snapshots/lsprelpath.snap @@ -7,9 +7,8 @@ # to read pushed off the right edge, and a path outside the tree has nothing # short to be called. # -# lspcomplete.snap already covers the easy middle case, a candidate in the SAME -# directory, which shortens to a bare basename. The two that a prefix strip can -# actually get wrong are here, one app each: +# A row in the SAME directory shortens to a bare basename; the two that a +# prefix strip can actually get wrong are here, one app each, as `gr` rows: # # PARENT the enum is one level UP from the asking file, so nothing is # stripped and the row carries `/tmp/.../cwd/shade.zig`. This is @@ -33,16 +32,14 @@ file sub/subdir.zig const tint = @import("inner/tint.zig");\n\npub fn hue(t: tin start 35 100 sub/parent.zig wait 8000 fn pick stable 700 20000 -# file line 5 (grid row 6, 1-based click row 7) is the switch's empty body -press left 8 8 -release left 8 8 +# `gr` on `Shade` in the signature (file line 3, click row 6): its +# declaration's row is in the parent directory. +press left 31 6 +release left 31 6 stable 400 5000 -key i -key end -text . -stable 400 5000 -key tab -wait 15000 cwd/shade.zig:2: +key g +key r +wait 15000 cwd/shade.zig:1: stable 700 15000 snap absrow key esc @@ -58,15 +55,12 @@ snapstyle abslook start 35 100 sub/subdir.zig wait 8000 fn hue stable 700 20000 -press left 8 8 -release left 8 8 -stable 400 5000 -key i -key end -text . +press left 29 6 +release left 29 6 stable 400 5000 -key tab -wait 15000 inner/tint.zig:2: +key g +key r +wait 15000 inner/tint.zig:1: stable 700 15000 snap subrow key esc diff --git a/tools/lspprobe.zig b/tools/lspprobe.zig deleted file mode 100644 index 0b7d772c..00000000 --- a/tools/lspprobe.zig +++ /dev/null @@ -1,184 +0,0 @@ -//! One language query against the REAL seam, from the command line. -//! -//! pardes-lspprobe <kind> <file> <line>:<col> [arg] [--reps N] -//! -//! `kind` is an `lsp.Kind` tag (`definition`, `references`, ...) or its helix -//! key (`gd`, `gr`, `gy`, `gi`, `gD`). Line and column are 1-based, the way -//! the rows print them. Rows go to stdout; the timing line goes to stderr so -//! `lspprobe gd f.rs 10:5 | head` still composes. -//! -//! This is the development probe for the PROTOCOL CLIENT: point it at a file -//! in any workspace (a rust crate, a C tree with compile_commands.json) and it -//! exercises exactly what `gd` in the editor would — same seam, same client, -//! same child server. The server is spawned fresh per invocation and kept for -//! the process, so rep 0 is the true cold cost (spawn + handshake + whatever -//! indexing the deadline allows) and later reps are the warm per-press cost. -//! Generic on purpose: no project names anywhere — the workspace is whatever -//! the file's root markers say. -const std = @import("std"); -const lsp = @import("pardes").lsp; - -extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; - -pub const std_options: std.Options = .{ .log_level = .err }; - -fn nowUs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000; -} - -const aliases = .{ - .{ "gd", lsp.Kind.definition }, - .{ "gD", lsp.Kind.declaration }, - .{ "gy", lsp.Kind.type_definition }, - .{ "gi", lsp.Kind.implementation }, - .{ "gr", lsp.Kind.references }, -}; - -fn usage() noreturn { - std.debug.print( - \\usage: pardes-lspprobe <kind> <file> <line>:<col> [arg] [--reps N] [--warm S] - \\ kind: gd gD gy gi gr, or any lsp.Kind tag (hover, references, - \\ document_symbols, workspace_symbols, incoming_calls, ...) - \\ arg: the new name for rename, the query for workspace_symbols - \\ --warm sleep S seconds after the first (cold) query — long enough - \\ for an indexing server to reach ready, narrated on stderr - \\ - , .{}); - std.process.exit(2); -} - -/// The editor's message row, here: the client's reader threads narrate server -/// state through the same sink the shells register, printed to stderr so the -/// rows on stdout stay clean. -fn statusSink(ctx: ?*anyopaque, text: []const u8) void { - _ = ctx; - std.debug.print("# status: {s}\n", .{text}); -} - -extern "c" fn usleep(usec: c_uint) c_int; - -pub fn main(init: std.process.Init) !void { - const gpa = std.heap.page_allocator; - const arena = init.arena.allocator(); - const io = std.Io.Threaded.global_single_threaded.io(); - - const args = try init.minimal.args.toSlice(arena); - if (args.len < 4) usage(); - - // comma-separated kinds share ONE server instance and its index — the - // whole battery against a big workspace pays the cold cost once - var kinds: [16]lsp.Kind = undefined; - var nkinds: usize = 0; - var kit = std.mem.tokenizeScalar(u8, args[1], ','); - while (kit.next()) |word| { - if (nkinds == kinds.len) usage(); - kinds[nkinds] = blk: { - inline for (aliases) |a| if (std.mem.eql(u8, word, a[0])) break :blk a[1]; - break :blk std.meta.stringToEnum(lsp.Kind, word) orelse usage(); - }; - nkinds += 1; - } - if (nkinds == 0) usage(); - - var pz: [4096:0]u8 = undefined; - const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{args[2]}, 0) catch usage(); - var realbuf: [4096]u8 = undefined; - const path = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse { - std.debug.print("cannot resolve {s}\n", .{args[2]}); - std.process.exit(1); - }); - const source = std.Io.Dir.cwd().readFileAllocOptions( - io, - path, - arena, - .limited(64 << 20), - .of(u8), - 0, - ) catch { - std.debug.print("cannot read {s}\n", .{path}); - std.process.exit(1); - }; - - const colon = std.mem.indexOfScalar(u8, args[3], ':') orelse usage(); - const line = std.fmt.parseInt(u32, args[3][0..colon], 10) catch usage(); - const col = std.fmt.parseInt(u32, args[3][colon + 1 ..], 10) catch usage(); - if (line == 0 or col == 0) usage(); - - var arg: []const u8 = ""; - var reps: u32 = 1; - var warm_s: u32 = 0; - var i: usize = 4; - while (i < args.len) : (i += 1) { - if (std.mem.eql(u8, args[i], "--reps")) { - i += 1; - if (i >= args.len) usage(); - reps = @max(1, std.fmt.parseInt(u32, args[i], 10) catch usage()); - } else if (std.mem.eql(u8, args[i], "--warm")) { - i += 1; - if (i >= args.len) usage(); - warm_s = std.fmt.parseInt(u32, args[i], 10) catch usage(); - } else arg = args[i]; - } - - lsp.setStatusSink(null, statusSink); - - // 1-based line:col -> byte offset - var off: usize = 0; - var ln: u32 = 1; - while (ln < line and off < source.len) : (off += 1) { - if (source[off] == '\n') ln += 1; - } - off = @min(off + col - 1, source.len); - - const root = std.fs.path.dirname(path) orelse "/"; - const timings = try arena.alloc(u64, reps); - var stdout_buf: [64 * 1024]u8 = undefined; - var stdout = std.Io.File.stdout().writer(io, &stdout_buf); - var first_query = true; - - for (kinds[0..nkinds]) |kind| { - var rows: []const u8 = ""; - for (0..reps) |r| { - // one warm pause for the whole battery, right after the very - // first (cold, server-spawning) query — the status lines on - // stderr narrate what the server does with the time - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - var scratch: std.heap.ArenaAllocator = .init(gpa); - defer scratch.deinit(); - const t0 = nowUs(); - try lsp.query(gpa, scratch.allocator(), .{ - .kind = kind, - .path = path, - .source = source, - .offset = @intCast(off), - .arg = arg, - .root = root, - }, &out.writer); - timings[r] = nowUs() -| t0; - rows = try arena.dupe(u8, out.written()); - if (first_query) { - first_query = false; - if (warm_s > 0) for (0..warm_s * 10) |_| { - _ = usleep(100_000); - }; - } - } - - try stdout.interface.print("## {t}\n", .{kind}); - try stdout.interface.writeAll(rows); - try stdout.interface.flush(); - - const n = std.mem.count(u8, rows, "\n"); - const cold = timings[0]; - std.mem.sort(u64, timings[0..reps], {}, std.sort.asc(u64)); - if (reps == 1) - std.debug.print("# {t}: {d} row(s) in {d}us\n", .{ kind, n, cold }) - else - std.debug.print("# {t}: {d} row(s); first {d}us, min {d}us, median {d}us ({d} reps)\n", .{ - kind, n, cold, timings[0], timings[reps / 2], reps, - }); - } -} |
