From 61955d0a40766d68c8fc30fd10608d7a903986ab Mon Sep 17 00:00:00 2001 From: Jade Lovelace Date: Sun, 27 Jul 2025 22:51:24 -0700 Subject: [PATCH] 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 --- doc/manual/rl-next/hyperlinks-in-attrsets.md | 16 ++++++ lix/libexpr/attr-set.hh | 1 + lix/libexpr/print.cc | 31 ++++++++--- lix/libutil/terminal.cc | 54 ++++++++++++++++++++ lix/libutil/terminal.hh | 19 +++++++ tests/unit/libexpr/value/print.cc | 24 +++++++++ tests/unit/libutil/terminal.cc | 20 ++++++++ 7 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 doc/manual/rl-next/hyperlinks-in-attrsets.md diff --git a/doc/manual/rl-next/hyperlinks-in-attrsets.md b/doc/manual/rl-next/hyperlinks-in-attrsets.md new file mode 100644 index 000000000..a711d5059 --- /dev/null +++ b/doc/manual/rl-next/hyperlinks-in-attrsets.md @@ -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 '' lib.licenses.mit +{ deprecated = false; free = true; fullName = "MIT License"; redistributable = true; shortName = "mit"; spdxId = "MIT"; url = "https://spdx.org/licenses/MIT.html"; } +``` diff --git a/lix/libexpr/attr-set.hh b/lix/libexpr/attr-set.hh index 8bdb13c5e..a1fd95677 100644 --- a/lix/libexpr/attr-set.hh +++ b/lix/libexpr/attr-set.hh @@ -159,6 +159,7 @@ public: Value & alloc(std::string_view name, PosIdx pos = noPos); + [[nodiscard("must use created bindings")]] Bindings * finish() { bindings->sort(); diff --git a/lix/libexpr/print.cc b/lix/libexpr/print.cc index fd3303869..20eaf03f4 100644 --- a/lix/libexpr/print.cc +++ b/lix/libexpr/print.cc @@ -2,6 +2,7 @@ #include #include #include +#include #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 AttrPair; +typedef std::pair AttrPair; struct ImportantFirstAttrNameCmp { @@ -105,7 +107,7 @@ struct ImportantFirstAttrNameCmp }; typedef std::set ValuesSeen; -typedef std::vector> AttrVec; +typedef std::vector> 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::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(&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++; diff --git a/lix/libutil/terminal.cc b/lix/libutil/terminal.cc index 2d825ad96..162dc3a23 100644 --- a/lix/libutil/terminal.cc +++ b/lix/libutil/terminal.cc @@ -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 #include +#include namespace nix { @@ -198,4 +201,55 @@ std::pair 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 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; +} } diff --git a/lix/libutil/terminal.hh b/lix/libutil/terminal.hh index 28c96c780..867941e5f 100644 --- a/lix/libutil/terminal.hh +++ b/lix/libutil/terminal.hh @@ -2,6 +2,7 @@ ///@file #include +#include #include namespace nix { @@ -69,4 +70,22 @@ void updateWindowSize(); */ std::pair 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 lineNumber = std::nullopt); } diff --git a/tests/unit/libexpr/value/print.cc b/tests/unit/libexpr/value/print.cc index 0e777c8d0..4d3ae6271 100644 --- a/tests/unit/libexpr/value/print.cc +++ b/tests/unit/libexpr/value/print.cc @@ -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 diff --git a/tests/unit/libutil/terminal.cc b/tests/unit/libutil/terminal.cc index 694f665ca..6c181775e 100644 --- a/tests/unit/libutil/terminal.cc +++ b/tests/unit/libutil/terminal.cc @@ -1,5 +1,6 @@ #include "lix/libutil/terminal.hh" #include +#include 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"); +} }