Repository: NixOS/nix (C++)

Issue:
`nix profile`s upgrade and remove have counter-intuitive argument
Using upgrade and remove is counter intuitive. The following example shows what a user will likely presume to work:

```console
$ nix profile install github:cachix/devenv/latest
$ nix profile upgrade github:cachix/devenv/latest
warning: 'github:cachix/devenv/latest' does not match any packages
warning: Use 'nix profile list' to see the current profile.
$ nix profile remove devenv
warning: 'devenv' does not match any packages
warning: Use 'nix profile list' to see the current profile.
```

- The installable used in `install` is not applicable to `remove` nor `upgrade`.
- The output shows warnings, but are actually errors. Nothing has changed to the profile.
- `nix profile list` is suggested, but it isn't clear that you need to pick the _index_ from that list.
- The name of the installable (`devenv`) is not applicable to `remove` nor `upgrade`. Ideally the `NAME` is deferred from the flake patterns (`github:OWNER/NAME/VERSION`, `github:OWNER/REPO/VERSION#NAME`, `nixpkgs#NAME`, `github:OWNER/NAME/VERSION#default`).


Discussion: To ease my own pain here, I wrote a little interactive fzf wrapper script around the `upgrade` and `remove` subcommands:
https://github.com/edrex/nixcfg/blob/main/cmd/nix-profile
This issue has been mentioned on **NixOS Discourse**. There might be relevant details there:

https://discourse.nixos.org/t/flakes-as-a-unified-format-for-profiles/29476/1




Fix (patch):
diff --git a/doc/manual/rl-next/nix-profile-names.md b/doc/manual/rl-next/nix-profile-names.md
new file mode 100644
index 00000000000..f5953bd725a
--- /dev/null
+++ b/doc/manual/rl-next/nix-profile-names.md
@@ -0,0 +1,6 @@
+---
+synopsis: "`nix profile` now allows referring to elements by human-readable name"
+prs: 8678
+---
+
+[`nix profile`](@docroot@/command-ref/new-cli/nix3-profile.md) now uses names to refer to installed packages when running [`list`](@docroot@/command-ref/new-cli/nix3-profile-list.md), [`remove`](@docroot@/command-ref/new-cli/nix3-profile-remove.md) or [`upgrade`](@docroot@/command-ref/new-cli/nix3-profile-upgrade.md) as opposed to indices. Indices are deprecated and will be removed in a future version.
diff --git a/src/libexpr/flake/flakeref.cc b/src/libexpr/flake/flakeref.cc
index 8b0eb74601e..86a0982f3f0 100644
--- a/src/libexpr/flake/flakeref.cc
+++ b/src/libexpr/flake/flakeref.cc
@@ -190,7 +190,7 @@ std::optional<std::pair<FlakeRef, std::string>> parseFlakeIdRef(
 
     static std::regex flakeRegex(
         "((" + flakeIdRegexS + ")(?:/(?:" + refAndOrRevRegex + "))?)"
-        + "(?:#(" + queryRegex + "))?",
+        + "(?:#(" + fragmentRegex + "))?",
         std::regex::ECMAScript);
 
     if (std::regex_match(url, match, flakeRegex)) {
diff --git a/src/libutil/url-name.cc b/src/libutil/url-name.cc
new file mode 100644
index 00000000000..7e51aa2e119
--- /dev/null
+++ b/src/libutil/url-name.cc
@@ -0,0 +1,48 @@
+#include "url-name.hh"
+#include <regex>
+#include <iostream>
+
+namespace nix {
+
+static const std::string attributeNamePattern("[a-z0-9_-]+");
+static const std::regex lastAttributeRegex("(?:" + attributeNamePattern + "\\.)*(?!default)(" + attributeNamePattern +")(\\^.*)?");
+static const std::string pathSegmentPattern("[a-zA-Z0-9_-]+");
+static const std::regex lastPathSegmentRegex(".*/(" + pathSegmentPattern +")");
+static const std::regex secondPathSegmentRegex("(?:" + pathSegmentPattern + ")/(" + pathSegmentPattern +")(?:/.*)?");
+static const std::regex gitProviderRegex("github|gitlab|sourcehut");
+static const std::regex gitSchemeRegex("git($|\\+.*)");
+static const std::regex defaultOutputRegex(".*\\.default($|\\^.*)");
+
+std::optional<std::string> getNameFromURL(const ParsedURL & url)
+{
+    std::smatch match;
+
+    /* If there is a dir= argument, use its value */
+    if (url.query.count("dir") > 0)
+        return url.query.at("dir");
+
+    /* If the fragment isn't a "default" and contains two attribute elements, use the last one */
+    if (std::regex_match(url.fragment, match, lastAttributeRegex))
+        return match.str(1);
+
+    /* If this is a github/gitlab/sourcehut flake, use the repo name */
+    if (std::regex_match(url.scheme, gitProviderRegex) && std::regex_match(url.path, match, secondPathSegmentRegex))
+        return match.str(1);
+
+    /* If it is a regular git flake, use the directory name */
+    if (std::regex_match(url.scheme, gitSchemeRegex) && std::regex_match(url.path, match, lastPathSegmentRegex))
+        return match.str(1);
+
+    /* If everything failed but there is a non-default fragment, use it in full */
+    if (!url.fragment.empty() && !std::regex_match(url.fragment, defaultOutputRegex))
+        return url.fragment;
+
+    /* If there is no fragment, take the last element of the path */
+    if (std::regex_match(url.path, match, lastPathSegmentRegex))
+        return match.str(1);
+
+    /* If even that didn't work, the URL does not contain enough info to determine a useful name */
+    return {};
+}
+
+}
diff --git a/src/libutil/url-name.hh b/src/libutil/url-name.hh
new file mode 100644
index 00000000000..6f32754d268
--- /dev/null
+++ b/src/libutil/url-name.hh
@@ -0,0 +1,20 @@
+#include "url.hh"
+#include "url-parts.hh"
+#include "util.hh"
+#include "split.hh"
+
+namespace nix {
+
+/**
+ * Try to extract a reasonably unique and meaningful, human-readable
+ * name of a flake output from a parsed URL.
+ * When nullopt is returned, the callsite should use information available
+ * to it outside of the URL to determine a useful name.
+ * This is a heuristic approach intended for user interfaces.
+ * @return nullopt if the extracted name is not useful to identify a
+ * flake output, for example because it is empty or "default".
+ * Otherwise returns the extracted name.
+ */
+std::optional<std::string> getNameFromURL(const ParsedURL & url);
+
+}
diff --git a/src/libutil/url-parts.hh b/src/libutil/url-parts.hh
index 07bc8d0cde9..870deb9daf6 100644
--- a/src/libutil/url-parts.hh
+++ b/src/libutil/url-parts.hh
@@ -19,6 +19,7 @@ const static std::string userRegex = "(?:(?:" + unreservedRegex + "|" + pctEncod
 const static std::string authorityRegex = "(?:" + userRegex + "@)?" + hostRegex + "(?::[0-9]+)?";
 const static std::string pcharRegex = "(?:" + unreservedRegex + "|" + pctEncoded + "|" + subdelimsRegex + "|[:@])";
 const static std::string queryRegex = "(?:" + pcharRegex + "|[/? \"])*";
+const static std::string fragmentRegex = "(?:" + pcharRegex + "|[/? \"^])*";
 const static std::string segmentRegex = "(?:" + pcharRegex + "*)";
 const static std::string absPathRegex = "(?:(?:/" + segmentRegex + ")*/?)";
 const static std::string pathRegex = "(?:" + segmentRegex + "(?:/" + segmentRegex + ")*/?)";
diff --git a/src/libutil/url.cc b/src/libutil/url.cc
index 152c06d8e9f..c6561441d93 100644
--- a/src/libutil/url.cc
+++ b/src/libutil/url.cc
@@ -16,7 +16,7 @@ ParsedURL parseURL(const std::string & url)
         "((" + schemeNameRegex + "):"
         + "(?:(?://(" + authorityRegex + ")(" + absPathRegex + "))|(/?" + pathRegex + ")))"
         + "(?:\\?(" + queryRegex + "))?"
-        + "(?:#(" + queryRegex + "))?",
+        + "(?:#(" + fragmentRegex + "))?",
         std::regex::ECMAScript);
 
     std::smatch match;
diff --git a/src/nix/profile-list.md b/src/nix/profile-list.md
index 5d7fcc0ecc7..facfdf0d6d4 100644
--- a/src/nix/profile-list.md
+++ b/src/nix/profile-list.md
@@ -6,12 +6,14 @@ R""(
 
   ```console
   # nix profile list
+  Name:               gdb
   Index:              0
   Flake attribute:    legacyPackages.x86_64-linux.gdb
   Original flake URL: flake:nixpkgs
   Locked flake URL:   github:NixOS/nixpkgs/7b38b03d76ab71bdc8dc325e3f6338d984cc35ca
   Store paths:        /nix/store/indzcw5wvlhx6vwk7k4iq29q15chvr3d-gdb-11.1
 
+  Name:               blender-bin
   Index:              1
   Flake attribute:    packages.x86_64-linux.default
   Original flake URL: flake:blender-bin
@@ -26,7 +28,7 @@ R""(
   # nix build github:edolstra/nix-warez/91f2ffee657bf834e4475865ae336e2379282d34?dir=blender#packages.x86_64-linux.default
   ```
 
-  will build the package with index 1 shown above.
+  will build the package `blender-bin` shown above.
 
 # Description
 
@@ -34,10 +36,14 @@ This command shows what packages are currently installed in a
 profile. For each installed package, it shows the following
 information:
 
-* `Index`: An integer that can be used to unambiguously identify the
+* `Name`: A unique name used to unambiguously identify the
   package in invocations of `nix profile remove` and `nix profile
   upgrade`.
 
+* `Index`: An integer that can be used to unambiguously identify the
+  package in invocations of `nix profile remove` and `nix profile upgrade`.
+  (*Deprecated, will be removed in a future version in favor of `Name`.*)
+
 * `Flake attribute`: The flake output attribute path that provides the
   package (e.g. `packages.x86_64-linux.hello`).
 
diff --git a/src/nix/profile-remove.md b/src/nix/profile-remove.md
index ba85441d801..c994b79bdca 100644
--- a/src/nix/profile-remove.md
+++ b/src/nix/profile-remove.md
@@ -2,16 +2,17 @@ R""(
 
 # Examples
 
-* Remove a package by position:
+* Remove a package by name:
 
   ```console
-  # nix profile remove 3
+  # nix profile remove hello
   ```
 
-* Remove a package by attribute path:
+* Remove a package by index
+  *(deprecated, will be removed in a future version)*:
 
   ```console
-  # nix profile remove packages.x86_64-linux.hello
+  # nix profile remove 3
   ```
 
 * Remove all packages:
diff --git a/src/nix/profile-upgrade.md b/src/nix/profile-upgrade.md
index 39cca428b0a..47103edfc24 100644
--- a/src/nix/profile-upgrade.md
+++ b/src/nix/profile-upgrade.md
@@ -9,18 +9,16 @@ R""(
   # nix profile upgrade '.*'
   ```
 
-* Upgrade a specific package:
+* Upgrade a specific package by name:
 
   ```console
-  # nix profile upgrade packages.x86_64-linux.hello
+  # nix profile upgrade hello
   ```
 
-* Upgrade a specific profile element by number:
+* Upgrade a specific package by index
+  *(deprecated, will be removed in a future version)*:
 
   ```console
-  # nix profile list
-  0 flake:nixpkgs#legacyPackages.x86_64-linux.spotify …
-
   # nix profile upgrade 0
   ```
 
diff --git a/src/nix/profile.cc b/src/nix/profile.cc
index 9d9492da99c..1d89815e2e2 100644
--- a/src/nix/profile.cc
+++ b/src/nix/profile.cc
@@ -10,6 +10,8 @@
 #include "../nix-env/user-env.hh"
 #include "profiles.hh"
 #include "names.hh"
+#include "url.hh"
+#include "url-name.hh"
 
 #include <nlohmann/json.hpp>
 #include <regex>
@@ -43,6 +45,7 @@ const int defaultPriority = 5;
 struct ProfileElement
 {
     StorePathSet storePaths;
+    std::string name;
     std::optional<ProfileElementSource> source;
     bool active = true;
     int priority = defaultPriority;
@@ -116,6 +119,8 @@ struct ProfileManifest
 
         if (pathExists(manifestPath)) {
             auto json = nlohmann::json::parse(readFile(manifestPath));
+            /* Keep track of already found names to allow preventing duplicates. */
+            std::set<std::string> foundNames;
 
             auto version = json.value("version", 0);
             std::string sUrl;
@@ -149,6 +154,25 @@ struct ProfileManifest
                         e["outputs"].get<ExtendedOutputsSpec>()
                     };
                 }
+
+                std::string nameCandidate = element.identifier();
+                if (e.contains("name")) {
+                    nameCandidate = e["name"];
+                }
+                else if (element.source) {
+                    auto url = parseURL(element.source->to_string());
+                    auto name = getNameFromURL(url);
+                    if (name)
+                        nameCandidate = *name;
+                }
+
+                auto finalName = nameCandidate;
+                for (int i = 1; foundNames.contains(finalName); ++i) {
+                    finalName = nameCandidate + std::to_string(i);
+                }
+                element.name = finalName;
+                foundNames.insert(element.name);
+
                 elements.emplace_back(std::move(element));
             }
         }
@@ -163,6 +187,7 @@ struct ProfileManifest
             for (auto & drvInfo : drvInfos) {
                 ProfileElement element;
                 element.storePaths = {drvInfo.queryOutPath()};
+                element.name = element.identifier();
                 elements.emplace_back(std::move(element));
             }
         }
@@ -451,15 +476,25 @@ class MixProfileElementMatchers : virtual Args
     {
         std::vector<Matcher> res;
 
+        auto anyIndexMatchers = false;
+
         for (auto & s : _matchers) {
-            if (auto n = string2Int<size_t>(s))
+            if (auto n = string2Int<size_t>(s)) {
                 res.push_back(*n);
+                anyIndexMatchers = true;
+            }
             else if (store->isStorePath(s))
                 res.push_back(s);
             else
                 res.push_back(RegexPattern{s,std::regex(s, std::regex::extended | std::regex::icase)});
         }
 
+        if (anyIndexMatchers) {
+            warn("Indices are deprecated and will be removed in a future version!\n"
+                 "         Refer to packages by their `Name` as printed by `nix profile list`.\n"
+                 "         See https://github.com/NixOS/nix/issues/9171 for more information.");
+        }
+
         return res;
     }
 
@@ -471,8 +506,7 @@ class MixProfileElementMatchers : virtual Args
             } else if (auto path = std::get_if<Path>(&matcher)) {
                 if (element.storePaths.count(store.parseStorePath(*path))) return true;
             } else if (auto regex = std::get_if<RegexPattern>(&matcher)) {
-                if (element.source
-                    && std::regex_match(element.source->attrPath, regex->reg))
+                if (std::regex_match(element.name, regex->reg))
                     return true;
             }
         }
@@ -556,62 +590,83 @@ struct CmdProfileUpgrade : virtual SourceExprCommand, MixDefaultProfile, MixProf
         Installables installables;
         std::vector<size_t> indices;
 
+        auto matchedCount = 0;
         auto upgradedCount = 0;
 
         for (size_t i = 0; i < manifest.elements.size(); ++i) {
             auto & element(manifest.elements[i]);
-            if (element.source
-                && !element.source->originalRef.input.isLocked()
-                && matches(*store, element, i, matchers))
-            {
-                upgradedCount++;
-
-                Activity act(*logger, lvlChatty, actUnknown,
-                    fmt("checking '%s' for updates", element.source->attrPath));
-
-                auto installable = make_ref<InstallableFlake>(
-                    this,
-                    getEvalState(),
-                    FlakeRef(element.source->originalRef),
-                    "",
-                    element.source->outputs,
-                    Strings{element.source->attrPath},
-                    Strings{},
-                    lockFlags);
-
-                auto derivedPaths = installable->toDerivedPaths();
-                if (derivedPaths.empty()) continue;
-                auto * infop = dynamic_cast<ExtraPathInfoFlake *>(&*derivedPaths[0].info);
-                // `InstallableFlake` should use `ExtraPathInfoFlake`.
-                assert(infop);
-                auto & info = *infop;
+            if (!matches(*store, element, i, matchers)) {
+                continue;
+            }
 
-                if (element.source->lockedRef == info.flake.lockedRef) continue;
+            matchedCount++;
 
-                printInfo("upgrading '%s' from flake '%s' to '%s'",
-                    element.source->attrPath, element.source->lockedRef, info.flake.lockedRef);
+            if (!element.source) {
+                warn(
+                    "Found package '%s', but it was not installed from a flake, so it can't be checked for upgrades!",
+                    element.identifier()
+                );
+                continue;
+            }
+            if (element.source->originalRef.input.isLocked()) {
+                warn(
+                    "Found package '%s', but it was installed from a locked flake reference so it can't be upgraded!",
+                    element.identifier()
+                );
+                continue;
+            }
 
-                element.source = ProfileElementSource {
-                    .originalRef = installable->flakeRef,
-                    .lockedRef = info.flake.lockedRef,
-                    .attrPath = info.value.attrPath,
-                    .outputs = installable->extendedOutputsSpec,
-                };
+            upgradedCount++;
+
+            Activity act(*logger, lvlChatty, actUnknown,
+                fmt("checking '%s' for updates", element.source->attrPath));
+
+            auto installable = make_ref<InstallableFlake>(
+                this,
+                getEvalState(),
+                FlakeRef(element.source->originalRef),
+                "",
+                element.source->outputs,
+                Strings{element.source->attrPath},
+                Strings{},
+                lockFlags);
+
+            auto derivedPaths = installable->toDerivedPaths();
+            if (derivedPaths.empty()) continue;
+            auto * infop = dynamic_cast<ExtraPathInfoFlake *>(&*derivedPaths[0].info);
+            // `InstallableFlake` should use `ExtraPathInfoFlake`.
+            assert(infop);
+            auto & info = *infop;
+
+            if (element.source->lockedRef == info.flake.lockedRef) continue;
+
+            printInfo("upgrading '%s' from flake '%s' to '%s'",
+                element.source->attrPath, element.source->lockedRef, info.flake.lockedRef);
+
+            element.source = ProfileElementSource {
+                .originalRef = installable->flakeRef,
+                .lockedRef = info.flake.lockedRef,
+                .attrPath = info.value.attrPath,
+                .outputs = installable->extendedOutputsSpec,
+            };
 
-                installables.push_back(installable);
-                indices.push_back(i);
-            }
+            installables.push_back(installable);
+            indices.push_back(i);
         }
 
         if (upgradedCount == 0) {
-            for (auto & matcher : matchers) {
-                if (const size_t * index = std::get_if<size_t>(&matcher)){
-                    warn("'%d' is not a valid index", *index);
-                } else if (const Path * path = std::get_if<Path>(&matcher)){
-                    warn("'%s' does not match any paths", *path);
-                } else if (const RegexPattern * regex = std::get_if<RegexPattern>(&matcher)){
-                    warn("'%s' does not match any packages", regex->pattern);
+            if (matchedCount == 0) {
+                for (auto & matcher : matchers) {
+                    if (const size_t * index = std::get_if<size_t>(&matcher)){
+                        warn("'%d' is not a valid index", *index);
+                    } else if (const Path * path = std::get_if<Path>(&matcher)){
+                        warn("'%s' does not match any paths", *path);
+                    } else if (const RegexPattern * regex = std::get_if<RegexPattern>(&matcher)){
+                        warn("'%s' does not match any packages", regex->pattern);
+                    }
                 }
+            } else {
+                warn("Found some packages but none of them could be upgraded.");
             }
             warn ("Use 'nix profile list' to see the current profile.");
         }
@@ -657,9 +712,10 @@ struct CmdProfileList : virtual EvalCommand, virtual StoreCommand, MixDefaultPro
             for (size_t i = 0; i < manifest.elements.size(); ++i) {
                 auto & element(manifest.elements[i]);
                 if (i) logger->cout("");
-                logger->cout("Index:              " ANSI_BOLD "%s" ANSI_NORMAL "%s",
-                    i,
+                logger->cout("Name:               " ANSI_BOLD "%s" ANSI_NORMAL "%s",
+                    element.name,
                     element.active ? "" : " " ANSI_RED "(inactive)" ANSI_NORMAL);
+                logger->cout("Index:              %s", i);
                 if (element.source) {
                     logger->cout("Flake attribute:    %s%s", element.source->attrPath, element.source->outputs.to_string());
                     logger->cout("Original flake URL: %s", element.source->originalRef.to_string());