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"); +} }