Merge "repl: tab-complete quoted attribute names" into main

This commit is contained in:
Rebecca Turner 2024-12-11 03:27:15 +00:00 committed by Gerrit Code Review
commit 06996718c3
4 changed files with 129 additions and 50 deletions

View file

@ -70,6 +70,10 @@ horrors:
iFreilicht:
github: iFreilicht
ian-h-chamberlain:
forgejo: ian-h-chamberlain
github: ian-h-chamberlain
isabelroses:
forgejo: isabelroses
github: isabelroses

View file

@ -0,0 +1,10 @@
---
synopsis: "`nix repl` correctly tab-completes attribute names that require quotes"
cls: [1783]
credits: [ian-h-chamberlain]
category: Improvements
---
The REPL (`nix repl`) now includes quotes as part of attribute names while completing with `<TAB>`,
if necessary. For example, attribute names like `"hello@example.com"` or `"hello world"` would
be suggested without quotes, resulting in invalid syntax.

View file

@ -18,6 +18,7 @@ extern "C" {
}
#include "lix/libutil/finally.hh"
#include "lix/libutil/strings.hh"
#include "lix/libcmd/repl-interacter.hh"
namespace nix {
@ -30,52 +31,14 @@ void sigintHandler(int signo)
{
g_signal_received = signo;
}
};
static detail::ReplCompleterMixin * curRepl; // ugly
static char * completionCallback(char * s, int * match)
/**
* @return a null-terminated list of completions as expected by `el_print_columns`
*/
char ** copyCompletions(const StringSet& possible)
{
auto possible = curRepl->completePrefix(s);
if (possible.size() == 1) {
*match = 1;
auto * res = strdup(possible.begin()->c_str() + strlen(s));
if (!res)
throw Error("allocation failure");
return res;
} else if (possible.size() > 1) {
auto checkAllHaveSameAt = [&](size_t pos) {
auto & first = *possible.begin();
for (auto & p : possible) {
if (p.size() <= pos || p[pos] != first[pos])
return false;
}
return true;
};
size_t start = strlen(s);
size_t len = 0;
while (checkAllHaveSameAt(start + len))
++len;
if (len > 0) {
*match = 1;
auto * res = strdup(std::string(*possible.begin(), start, len).c_str());
if (!res)
throw Error("allocation failure");
return res;
}
}
*match = 0;
return nullptr;
}
static int listPossibleCallback(char * s, char *** avp)
{
auto possible = curRepl->completePrefix(s);
if (possible.size() > (INT_MAX / sizeof(char *)))
throw Error("too many completions");
int ac = 0;
char ** vp = nullptr;
@ -90,15 +53,95 @@ static int listPossibleCallback(char * s, char *** avp)
}
return p;
};
vp = check(static_cast<char **>(malloc(possible.size() * sizeof(char *))));
for (auto & p : possible)
vp[ac++] = check(strdup(p.c_str()));
*avp = vp;
return vp;
}
return ac;
// Instead of using the readline-provided prefix, do our own tokenization
// to avoid the default behavior of treating dots/quotes as word boundaries.
// See the definition of SEPS for what it treats as a boundary:
// https://github.com/troglobit/editline/blob/caf4b3c0ce3b0785791198b11de6f3134e9f05d8/src/editline.c
std::string getLastTokenBeforeCursor()
{
std::string_view line{rl_line_buffer, static_cast<size_t>(rl_point)};
auto tokens = tokenizeString<std::vector<std::string>>(
line,
// Same as editline's SEPS, except for double and single quotes:
"#$&()*:;<=>?[\\]^`{|}~\n\t "
);
if (tokens.empty()) {
return "";
}
return tokens.back();
}
// Sometimes inserting text or listing possible completions has a side effect
// of hiding the text after the cursor (even though it remains in the buffer).
// This helper just refreshes the display while keeping the cursor in place.
//
// Inserting text also sometimes moves the whole buffer down one line, usually
// if the cursor is inside a quoted attr name. I'm not sure why (vs unquoted)
// but it still seems to work pretty well and is just a visual artifact.
el_status_t redisplay()
{
int cursorPos = rl_point;
rl_refresh_line(0, 0);
rl_point = cursorPos;
return (rl_point == rl_end) ? CSstay : CSmove;
}
};
static el_status_t doCompletion() {
auto s = getLastTokenBeforeCursor();
auto possible = curRepl->completePrefix(s);
if (possible.empty()) {
return el_ring_bell();
}
if (possible.size() == 1) {
const auto completion = *possible.cbegin();
if (completion.size() > s.size()) {
rl_insert_text(completion.c_str() + s.size());
return redisplay();
}
return el_ring_bell();
}
auto checkAllHaveSameAt = [&](size_t pos) {
auto & first = *possible.begin();
for (auto & p : possible) {
if (p.size() <= pos || p[pos] != first[pos]) {
return false;
}
}
return true;
};
size_t start = s.size();
size_t len = 0;
while (checkAllHaveSameAt(start + len)) {
++len;
}
if (len > 0) {
auto commonPrefix = possible.begin()->substr(start, len);
rl_insert_text(commonPrefix.c_str());
el_ring_bell();
return redisplay();
}
char** columns = copyCompletions(possible);
el_print_columns(possible.size(), columns);
return redisplay();
}
ReadlineLikeInteracter::Guard ReadlineLikeInteracter::init(detail::ReplCompleterMixin * repl)
@ -115,8 +158,10 @@ ReadlineLikeInteracter::Guard ReadlineLikeInteracter::init(detail::ReplCompleter
auto oldRepl = curRepl;
curRepl = repl;
Guard restoreRepl([oldRepl] { curRepl = oldRepl; });
rl_set_complete_func(completionCallback);
rl_set_list_possib_func(listPossibleCallback);
// editline does its own escaping of completions, so we rebind tab
// to our own completion function to skip that and do nix escaping
// instead of shell escaping.
el_bind_key(CTL('I'), doCompletion);
return restoreRepl;
}

View file

@ -12,6 +12,7 @@
#include "lix/libutil/ansicolor.hh"
#include "lix/libmain/shared.hh"
#include "lix/libutil/escape-string.hh"
#include "lix/libexpr/eval.hh"
#include "lix/libexpr/eval-cache.hh"
#include "lix/libexpr/eval-inline.hh"
@ -350,7 +351,7 @@ ReplExitStatus NixRepl::mainLoop()
}
}
StringSet NixRepl::completePrefix(const std::string & prefix)
StringSet NixRepl::completePrefix(const std::string &prefix)
{
StringSet completions;
@ -415,6 +416,22 @@ StringSet NixRepl::completePrefix(const std::string & prefix)
i++;
}
} else {
// To handle cases like `foo."bar.`, walk back the cursor
// to the previous dot if there are an odd number of quotes.
auto quoteCount =
std::count_if(cur.begin(), cur.begin() + dot, [](char c) { return c == '"'; });
if (quoteCount % 2 != 0) {
// Find the last quote before the dot
auto prevQuote = cur.rfind('"', dot - 1);
if (prevQuote != std::string::npos) {
// And the previous dot prior to that quote
auto prevDot = cur.rfind('.', prevQuote);
if (prevDot != std::string::npos) {
dot = prevDot;
}
}
}
/* Temporarily disable the debugger, to avoid re-entering readline. */
auto debug = std::move(evaluator.debug);
Finally restoreDebug([&]() { evaluator.debug = std::move(debug); });
@ -431,7 +448,10 @@ StringSet NixRepl::completePrefix(const std::string & prefix)
state.forceAttrs(v, noPos, "while evaluating an attrset for the purpose of completion (this error should not be displayed; file an issue?)");
for (auto & i : *v.attrs) {
std::string_view name = evaluator.symbols[i.name];
std::ostringstream output;
printAttributeName(output, evaluator.symbols[i.name]);
std::string name = output.str();
if (name.substr(0, cur2.size()) != cur2) continue;
completions.insert(concatStrings(prev, expr, ".", name));
}