summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 04:20:20 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit628aa40f13e9bbd313b51ab625f193110aad8dd0 (patch)
treee60902495e308505959361d8719c382401b88295 /src
parent0a15af8d98771180e32402e68ea844f9f377cefb (diff)
downloadpardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.tar.gz
pardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.zip
Tab after a dot in insert mode lists what could go there
Diffstat (limited to 'src')
-rw-r--r--src/lsp/lsp.zig19
-rw-r--r--src/lsp/lsp_zls.zig237
-rw-r--r--src/output_pane.zig8
-rw-r--r--src/pardes.zig122
4 files changed, 370 insertions, 16 deletions
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index b8356724..0ce70869 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -51,6 +51,12 @@ pub const Kind = enum {
format,
/// SPC h
select_refs,
+ /// Tab in insert mode, with a `.` immediately before the cursor. NOT an
+ /// autocomplete popup — the seam returns locations, so this answers "what
+ /// could go here, and where is each of those DEFINED": one row per
+ /// candidate, pointing at its declaration, in the same `+Search` buffer
+ /// `gr` fills. Nothing is inserted.
+ completion,
// The two introspection kinds. A backend that answers nothing is
// indistinguishable from a backend that is broken, so these exist to tell
@@ -160,6 +166,9 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *s
/// base tree shipped with.
const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig") else struct {
pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.Io.Writer) void {}
+ pub fn speaks(_: []const u8) bool {
+ return false;
+ }
pub const supports: std.EnumSet(Kind) = .initEmpty();
};
@@ -169,5 +178,15 @@ const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig")
/// does not have shows up immediately in the harness's matrix.
pub const supports: std.EnumSet(Kind) = backend.supports;
+/// Does the backend read this file's LANGUAGE at all? `supports` answers what
+/// a backend can do; this answers what it can do it TO, and it exists for the
+/// one key that must not be eaten when the answer is no: insert-mode Tab
+/// diverts to `completion` after a `.`, so in a README — or in any pane the
+/// backend would refuse — it has to indent instead. The core asks rather than
+/// knowing, so the list of extensions stays the backend's business.
+pub fn speaks(path: []const u8) bool {
+ return backend.speaks(path);
+}
+
/// Name shown by the harness and in `SPC ?`. Each implementation renames it.
pub const backend_name = "zls-inproc";
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index 02b72f10..eeacb5e3 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -76,10 +76,19 @@ pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{
.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
@@ -233,7 +242,7 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
// 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 (!std.mem.endsWith(u8, req.path, ".zig")) {
+ 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,
});
@@ -277,9 +286,11 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
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.
+ // 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, req.source);
+ 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;
@@ -309,6 +320,7 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
.rename => try references(arena, &analyser, handle, off, req.arg, out),
.diagnostics => try diagnostics(gpa, arena, req.path, &handle.tree, out),
.code_action => try codeActions(gpa, arena, &analyser, handle, off, out),
+ .completion => try completion(arena, &analyser, handle, off, req.source, out, tr),
else => {},
}
}
@@ -640,6 +652,225 @@ fn goto(
}
}
+// ------------------------------------------------------------ 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`).
+///
+/// Picking between them takes TWO signals, and getting that wrong was the first
+/// version's bug twice over.
+///
+/// The parse-error count alone does not work. A repair that supplies the
+/// identifier without closing the outer construct merely TIES with the broken
+/// buffer — measured, `const z: E = .` is one error either way — so demanding
+/// a strict improvement threw the repair away in exactly the cases it was
+/// written for, and the feature only worked where the syntax was already
+/// finished. Accepting ties does not save it either: for an unclosed
+/// `switch (e) {` the bare identifier and the arrow BOTH tie at one error and
+/// only the arrow produces a switch node, so the count cannot say which helped.
+///
+/// Reachability alone does not work either. "Is the dot now an `enum_literal`
+/// the tree can be walked down to from the root" is exactly the question the
+/// repair asks, and an orphaned node answers it correctly — but several
+/// spellings can answer yes while leaving trees of very different quality, and
+/// taking the first loses candidates the cleanest spelling would have found.
+///
+/// So: reachability is a hard FILTER, the error count RANKS what survives it,
+/// the earlier spelling wins a tie, and a candidate that reaches ZERO errors
+/// ends the search because nothing can beat it. That last clause is why six
+/// spellings cost less than the three that came before them: a switch arm —
+/// the case this feature exists for — is answered by the first candidate, and
+/// only a buffer nothing repairs pays for all six. 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;
+}
+
+/// 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,
+ /// 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 collectDeclarationsOfContainer'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;
+ var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
+ try analyser.collectDeclarationsOfContainer(ty, handle, switch (ctx) {
+ .field_access => !ty.is_type_val,
+ else => in_init or ty.isUnionType(),
+ }, &decls);
+ tr.note("{d} candidate(s) in scope", .{decls.items.len});
+ // 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.items) |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;
+ }
+ 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 = th.handle.uri.toFsPath(arena) catch continue;
+ if (th.handle != handle) {
+ lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(dtree.source, r.start.line));
+ } else {
+ if (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);
+ }
+ lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(orig, r.start.line));
+ }
+ n += 1;
+ }
+}
+
// ---------------------------------------------------------------- hover
/// `hover.hover` is already Server-free, so this is a call and a reformat: the
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 49af86af..bce3d503 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -166,7 +166,13 @@ pub fn traits(o: Origin) Traits {
.steps = true,
.jumps = true,
},
- .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs => .{
+ // completion lists WHAT COULD GO HERE, one row per candidate's
+ // definition. It does not jump on a single row where the gotos do:
+ // a goto answers a question whose answer is a place, so landing
+ // there IS the answer, whereas the question here is "what can I
+ // write", and being teleported into the one candidate's
+ // declaration instead of being shown it is not that.
+ .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{
.name = config.search_buffer,
.steps = true,
},
diff --git a/src/pardes.zig b/src/pardes.zig
index 2ea52e15..0dba0abf 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4195,8 +4195,13 @@ pub const Pardes = struct {
/// `arg` rides along only so the buffer the answer opens can record what
/// was asked (a rename's new name, a symbol query) — the query itself has
/// it in the effect already.
+ /// `row`/`col` are where the cursor was when the question was asked. Only
+ /// `completion` reads them, and only to undo itself: Tab diverted instead
+ /// of indenting, so an empty answer has to put the indent back — but only
+ /// if the cursor has not moved since, or four spaces appear under someone
+ /// who kept typing.
lsp_seq: u32 = 0,
- lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128) } = null,
+ lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128), row: i32 = 0, col: i32 = 0 } = null,
/// One current shell-filter request. A newer submit frees and supersedes
/// it; old worker answers then fail the id check. The request itself owns
@@ -7445,7 +7450,14 @@ pub const Pardes = struct {
.col = @intCast(@max(0, pane.cur_col)),
}) else 0;
p.lsp_seq +%= 1;
- p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id, .arg = .from(arg) };
+ p.lsp_wait = .{
+ .id = p.lsp_seq,
+ .kind = kind,
+ .pane = id,
+ .arg = .from(arg),
+ .row = pane.cur_row,
+ .col = pane.cur_col,
+ };
p.emit(.{ .lsp = .{
.id = p.lsp_seq,
.kind = kind,
@@ -7466,7 +7478,20 @@ pub const Pardes = struct {
if (w.id != id) return; // superseded by a newer press, or the pane died
p.lsp_wait = null;
const pane = p.panes[w.pane] orelse return;
- if (rows.len == 0) return;
+ if (rows.len == 0) {
+ // No rows is a legal answer everywhere except here. Tab DIVERTED
+ // instead of indenting, so an empty answer would eat the keystroke
+ // — a dot in a comment, a dot in a string, a half-typed line
+ // nothing can be made of — and a Tab that silently does nothing is
+ // worse than not having the feature. So the indent happens now,
+ // late, on the condition that nothing has moved: same pane, still
+ // in insert, one cursor, and the cursor still on the cell the Tab
+ // was pressed at. Anyone who kept typing during the query gets
+ // nothing rather than four spaces landing 300ms behind their hands.
+ if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and
+ pane.cur_row == w.row and pane.cur_col == w.col) p.insertTab(pane);
+ return;
+ }
const from: output_pane.Origin = .{ .query = w.kind };
const nrows = std.mem.count(u8, rows, "\n");
@@ -7484,6 +7509,44 @@ pub const Pardes = struct {
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const content = p.gpa.dupe(u8, rows) catch return;
+ // The same KIND asked again REFILLS the buffer it already opened, the
+ // rule runSearch has always had. It was missing here and that was
+ // survivable while every language query was a deliberate press: `gr`
+ // twice left two identical lists and you closed one. Tab after a dot
+ // is an ordinary typing keystroke, which turns the same bug fatal —
+ // measured, twenty Tabs stacked fifteen byte-identical `+Search` panes
+ // under the file, crushed it to one visible line, and from the
+ // sixteenth on freeSlot returned null and the key was eaten for the
+ // rest of the session with nothing said. One code path, so every kind
+ // that lands in a results buffer gets the fix.
+ //
+ // Unlike runSearch this does NOT key on the ARGUMENT. A search is
+ // identified by its pattern; a language query is asked about a
+ // different symbol every time with the same (usually empty) arg, so
+ // the arg cannot tell two lists apart and the KIND is the natural
+ // unit: a second `gr` replaces the first list rather than growing a
+ // stack of them.
+ for (p.panes, 0..) |slot, i| {
+ if (i == w.pane) continue;
+ const rp = slot orelse continue;
+ const rf = if (rp.file) |*f| f else continue;
+ const o = rf.output orelse continue;
+ if (!std.meta.eql(o.from, from)) continue;
+ if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
+ output_pane.setArg(&rf.output.?, w.arg.slice());
+ // a refill that changes nothing keeps its place (runSearch's rule,
+ // and the same reason): re-asking about a symbol you are already
+ // stepping must not throw the list back to the top
+ const same = std.mem.eql(u8, rf.content, content);
+ file_pane.setContent(p, rf, content);
+ if (!same) rf.scroll = 0;
+ p.active = w.pane;
+ if (output_pane.traits(from).steps) {
+ pane.search_pane = i;
+ pane.search_row = null;
+ }
+ return;
+ }
const free = p.freeSlot() orelse {
p.gpa.free(content);
return;
@@ -7941,20 +8004,55 @@ pub const Pardes = struct {
pane.cur_pinned = true;
},
Key.tab => {
- // helix insert_tab with a Spaces indent style: spaces to
- // the next tab stop (smart-tab machinery skipped)
- const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
- const spaces = " ";
- const new = modal.insertAt(p.gpa, text, c, spaces[0..pad]) catch return;
- p.setEditText(pane, new);
- pane.cur_col += @intCast(pad);
- pane.cur_pinned = true;
- pane.ensureCursorVisible();
+ // Tab straight after a `.` asks the language backend what
+ // could go there — an output buffer of DEFINITIONS, one row
+ // per candidate, not an autocomplete popup and not an
+ // insertion. Only where an answer is possible: a terminal, an
+ // output buffer or a file the backend does not speak still
+ // indents, because a Tab that silently does nothing is worse
+ // than not having the feature. The extension list stays the
+ // backend's (lsp.speaks); this only asks. (An answer that
+ // comes back EMPTY indents too, late — see lspResponse.)
+ //
+ // Never with several cursors. A language query is a
+ // per-KEYSTROKE action inside a per-SELECTION replay, so
+ // multiOnce would stop the replay dead: the other cursors
+ // would neither ask nor indent and the whole multicursor would
+ // collapse on a Tab. Every other insert key applies to all of
+ // them, and so does this one — by indenting.
+ const ln = modal.lineSlice(text, c.row);
+ if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: {
+ const f = pane.file orelse break :dot;
+ if (f.output != null or !lsp.speaks(f.path)) break :dot;
+ return p.lspRequest(p.active, .completion, "");
+ }
+ p.insertTab(pane);
},
else => {},
}
}
+ /// helix insert_tab with a Spaces indent style: spaces to the next tab
+ /// stop (smart-tab machinery skipped). It is a function rather than the
+ /// five lines it used to be inside the Tab prong because Tab after a `.`
+ /// asks the language backend FIRST and indents only if the answer comes
+ /// back empty — which happens on another turn of the loop entirely, so
+ /// lspResponse needs to be able to press the same key.
+ fn insertTab(p: *Pardes, pane: *Pane) void {
+ const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return;
+ const c: modal.Cursor = .{
+ .row = @intCast(@max(0, pane.cur_row - eb.row0)),
+ .col = @intCast(@max(0, pane.cur_col)),
+ };
+ const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
+ const spaces = " ";
+ const new = modal.insertAt(p.gpa, eb.text, c, spaces[0..pad]) catch return;
+ p.setEditText(pane, new);
+ pane.cur_col += @intCast(pad);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+
const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
/// a range's two cells, normalized to document order