libexpr: hyperlink attr names to their definition locations

Concept: what if you could, in your fancy terminal, in the year of our
lord 2025, just click on the attrs you're looking at to go to where
they're defined. Currently we only expose this info as
builtins.unsafeGetAttrPos, which is inconvenient as it's not
discoverable to users.

By putting it in this more visible yet invisible spot, it's more likely
to be more useful to more people.

In the current state, this is not the most useful ever due to stuff like
https://github.com/neovim/neovim/discussions/35097. However, it can be
expanded by perhaps adding something like the url format setting ripgrep
has.

Change-Id: I3947f97d5c2056d59099af468d7b855486438227
This commit is contained in:
Jade Lovelace
2025-08-20 20:55:54 +00:00
committed by jade
parent c82af241f5
commit 61955d0a40
7 changed files with 157 additions and 8 deletions
@@ -0,0 +1,16 @@
---
synopsis: Add hyperlinks in attr set printing
issues: []
cls: [3790]
category: Features
credits: [jade]
---
The attribute set printer, such as is seen in `nix repl` or in type errors, now prints hyperlinks on each attribute name to its definition site if it is known.
Example: all of the attributes shown here are hyperlinks to the exact definition site of the attribute in question:
```
$ nix eval -f '<nixpkgs>' lib.licenses.mit
{ deprecated = false; free = true; fullName = "MIT License"; redistributable = true; shortName = "mit"; spdxId = "MIT"; url = "https://spdx.org/licenses/MIT.html"; }
```
+1
View File
@@ -159,6 +159,7 @@ public:
Value & alloc(std::string_view name, PosIdx pos = noPos);
[[nodiscard("must use created bindings")]]
Bindings * finish()
{
bindings->sort();
+23 -8
View File
@@ -2,6 +2,7 @@
#include <span>
#include <unordered_set>
#include <sstream>
#include <variant>
#include "lix/libutil/escape-string.hh"
#include "lix/libexpr/print.hh"
@@ -10,6 +11,7 @@
#include "lix/libutil/signals.hh"
#include "lix/libexpr/eval.hh"
#include "lix/libutil/print-elided.hh"
#include "lix/libutil/source-path.hh"
#include "lix/libutil/terminal.hh"
namespace nix {
@@ -90,7 +92,7 @@ bool isImportantAttrName(const std::string& attrName)
return attrName == "type" || attrName == "_type";
}
typedef std::pair<std::string, Value *> AttrPair;
typedef std::pair<std::string, Attr const *> AttrPair;
struct ImportantFirstAttrNameCmp
{
@@ -105,7 +107,7 @@ struct ImportantFirstAttrNameCmp
};
typedef std::set<const void *> ValuesSeen;
typedef std::vector<std::pair<std::string, Value *>> AttrVec;
typedef std::vector<std::pair<std::string, Attr const *>> AttrVec;
class Printer
{
@@ -259,18 +261,18 @@ private:
}
auto item = v[0].second;
if (!item) {
if (!item->value) {
return true;
}
if (options.force) {
// The item is going to be forced during printing anyway, but we need its type now.
state.forceValue(*item, noPos);
state.forceValue(*item->value, noPos);
}
// Pretty-print single-item attrsets only if they contain nested
// structures.
auto itemType = item->type();
auto itemType = item->value->type();
return itemType == nList || itemType == nAttrs;
}
@@ -286,7 +288,7 @@ private:
AttrVec sorted;
for (auto & i : *v.attrs)
sorted.emplace_back(state.ctx.symbols[i.name], i.value);
sorted.emplace_back(state.ctx.symbols[i.name], &i);
if (options.maxAttrs == std::numeric_limits<size_t>::max())
std::sort(sorted.begin(), sorted.end());
@@ -304,9 +306,22 @@ private:
break;
}
printAttributeName(output, i.first);
std::ostringstream name;
printAttributeName(name, i.first);
auto pos = state.ctx.positions[i.second->pos];
if (auto path = std::get_if<CheckedSourcePath>(&pos.origin);
path && options.ansiColors)
{
output << makeHyperlink(
name.str(), makeHyperlinkLocalPath(path->to_string(), pos.line)
);
} else {
output << name.str();
}
output << " = ";
print(*i.second, depth + 1);
print(*i.second->value, depth + 1);
output << ";";
attrsPrinted++;
printedHere++;
+54
View File
@@ -1,9 +1,12 @@
#include "lix/libutil/terminal.hh"
#include "fmt.hh"
#include "lix/libutil/environment-variables.hh"
#include "lix/libutil/sync.hh"
#include "url.hh"
#include <sys/ioctl.h>
#include <unistd.h>
#include <limits.h>
namespace nix {
@@ -198,4 +201,55 @@ std::pair<unsigned short, unsigned short> getWindowSize()
return *windowSize.lock();
}
std::string makeHyperlink(std::string_view linkText, std::string_view target)
{
// 700 is arbitrarily chosen as a length limit as it's where screen breaks
// according to https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#length-limits
if (target.empty() || target.length() > 700) {
return std::string{linkText};
}
#define OSC "\e]"
#define ST "\e\\"
return fmt(OSC "8;;%s" ST "%s" OSC "8;;" ST, target, linkText);
#undef OSC
#undef ST
}
std::string makeHyperlinkLocalPath(std::string_view path, std::optional<unsigned> lineNumber)
{
// File paths in OSC 8 are required to have the hostname in them per the
// spec.
static std::string theHostname = []() -> std::string {
// According to POSIX if the hostname is too long, there is no guarantee of
// null termination so let's make sure there's always one.
char theHostname_[_POSIX_HOST_NAME_MAX + 1] = {};
int err = gethostname(theHostname_, sizeof(theHostname_) - 1);
// Who knows why getting the hostname would fail, but it is fallible!
if (err < 0) {
return "localhost";
} else {
return theHostname_;
}
}();
if (!path.starts_with('/')) {
// Problematic to have non absolute paths
return "";
}
auto content = percentEncode(path, "/");
// XXX(jade): these schemes are not standardized and even the file link
// line number has no guarantee to work (and in fact theoretically is
// supported in kitty but in practice is mostly ignored).
// https://github.com/BurntSushi/ripgrep/blob/bf63fe8f258afc09bae6caa48f0ae35eaf115005/crates/printer/src/hyperlink_aliases.rs#L4-L22
auto result = fmt("file://%s%s", theHostname, content);
if (lineNumber.has_value()) {
result += fmt("#%d", *lineNumber);
}
return result;
}
}
+19
View File
@@ -2,6 +2,7 @@
///@file
#include <limits>
#include <optional>
#include <string>
namespace nix {
@@ -69,4 +70,22 @@ void updateWindowSize();
*/
std::pair<unsigned short, unsigned short> getWindowSize();
/**
* Makes a terminal hyperlink using OSC 8.
*
* If the link target is too long (700 bytes is the current limit), the link is
* skipped and the link text is emitted as-is. This limits the maximum amount
* of context required to a manageable amount that doesn't break any terminals.
*
* See: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
*
* @see makeHyperlinkLocalPath
*/
std::string makeHyperlink(std::string_view linkText, std::string_view target);
/**
* Creates an OSC 8 compliant `file://` path for a given filesystem path.
*/
std::string
makeHyperlinkLocalPath(std::string_view path, std::optional<unsigned> lineNumber = std::nullopt);
}
+24
View File
@@ -1,3 +1,6 @@
#include "lix/libutil/canon-path.hh"
#include "lix/libutil/source-path.hh"
#include "lix/libutil/terminal.hh"
#include "tests/libexpr.hh"
#include "lix/libexpr/value.hh"
@@ -774,4 +777,25 @@ TEST_F(ValuePrintingTests, ansiColorsListElided)
});
}
TEST_F(ValuePrintingTests, osc8InAttrSets)
{
const auto arbitrarySource = SourcePath(CanonPath("/dev/null")).unsafeIntoChecked();
auto origin = evaluator.positions.addOrigin(Pos::Origin(arbitrarySource), 0);
auto pos = evaluator.positions.add(origin, 0);
BindingsBuilder builder = evaluator.buildBindings(1);
auto vZero = Value{NewValueAs::integer, NixInt{0}};
builder.insert(evaluator.symbols.create("x"), &vZero, pos);
auto vAttrs = Value{NewValueAs::attrs, builder.finish()};
auto hyperlink = makeHyperlink("x", makeHyperlinkLocalPath("/dev/null", 1));
test(
vAttrs,
"{ " + hyperlink + " = " ANSI_CYAN "0" ANSI_NORMAL "; }",
PrintOptions{.ansiColors = true}
);
}
} // namespace nix
+20
View File
@@ -1,5 +1,6 @@
#include "lix/libutil/terminal.hh"
#include <gtest/gtest.h>
#include <regex>
namespace nix {
@@ -176,4 +177,23 @@ TEST(filterANSIEscapes, controlChars) {
EXPECT_EQ(filterANSIEscapes("foo\v\n\fbar", false, 8), "foo\v\n\fba");
}
TEST(makeHyperlink, works)
{
auto big = std::string(701, 'A');
EXPECT_EQ(makeHyperlink(big, "meow"), "\e]8;;meow\e\\" + big + "\e]8;;\e\\");
EXPECT_EQ(makeHyperlink("meow", big), "meow");
}
TEST(makeHyperlinkLocalPath, works)
{
// NOLINTNEXTLINE(lix-foreign-exceptions): its a test lol
auto regex = std::regex{R""(^file://([^/]+)/(.*)$)""};
std::smatch match;
auto output = makeHyperlinkLocalPath("/a/b/ c", 4);
ASSERT_TRUE(std::regex_match(output, match, regex));
// Hostname has a value
ASSERT_GT(match[1].length(), 0);
ASSERT_EQ(match[2].str(), "a/b/%20c#4");
}
}