Merge: Extend nitpm (formerly picnit) to support package versions and dependencies
authorJean Privat <jean@pryen.org>
Fri, 6 Apr 2018 02:01:35 +0000 (22:01 -0400)
committerJean Privat <jean@pryen.org>
Fri, 6 Apr 2018 02:01:35 +0000 (22:01 -0400)
Rename picnit to nitpm and intro 4 main new features.

### Install package versions

Install specific versions of a package using the following command:

~~~
nitpm install gamnit=0.5
~~~

The version string (the `0.5` in the above example) must be a Git branch or tag, it will be used when cloning the package locally. The package will be downloaded to `~/.local/lib/nit/gamnit=0.5/`, allowing multiple versions of the same package to be installed concurrently.

### Dependencies in package.ini

Packages should now declare dependencies to other nitpm packages in the `package.ini` at the `import` key:

~~~
[package]
name=my_package
import=hello_nitpm, gamnit=0.5
~~~

The dependencies can then be installed automatically with `nitpm install` from the root of the package.

Nit tools read the local `package.ini` to redirect imports of `gamnit` inside this package to this specific version. So for `my_package` described above, all references to `gamnit` will use the implementation `gamnit=0.5`.

### Recursive installation

nitpm installs dependencies recursively, so if `gamnit` requires `glesv2`, after an explicit command to install `gamnit` nitpm will also install `glesv2`. This implementation is minimal, it could be improved by precalculating all dependencies and asking for confirmation.

### Customizable install directory

You can now use the env var `NITPM_PATH` to set the path where libraries are installed. This will override the default path at `~/.local/lib/nit/`.

### Others

* `nitpm uninstall` can uninstall many packages at once, it is safer and it accepts the -f option to skip the confirmation.
* `nitpm list` lists packages in alphabetical order.

Pull-Request: #2622
Reviewed-by: Romain Chanoir <romain.chanoir@viacesi.fr>
Reviewed-by: Jean Privat <jean@pryen.org>

20 files changed:
share/man/nitpm.md [new file with mode: 0644]
share/man/picnit.md [deleted file]
src/Makefile
src/loader.nit
src/nitpm.nit [moved from src/picnit.nit with 54% similarity]
src/nitpm_shared.nit [new file with mode: 0644]
src/picnit_shared.nit [deleted file]
tests/niti.skip
tests/nitpm.args [new file with mode: 0644]
tests/nitvm.skip
tests/picnit.args [deleted file]
tests/sav/nitpm.res [new file with mode: 0644]
tests/sav/nitpm_args2.res [new file with mode: 0644]
tests/sav/nitpm_args3.res [moved from tests/sav/picnit_args4.res with 100% similarity]
tests/sav/nitpm_args5.res [new file with mode: 0644]
tests/sav/nitpm_args8.res [new file with mode: 0644]
tests/sav/picnit.res [deleted file]
tests/sav/picnit_args2.res [deleted file]
tests/sav/picnit_args3.res [deleted file]
tests/sav/picnit_args5.res [deleted file]

diff --git a/share/man/nitpm.md b/share/man/nitpm.md
new file mode 100644 (file)
index 0000000..b0f222f
--- /dev/null
@@ -0,0 +1,63 @@
+# NAME
+
+nitpm - Nit package manager
+
+# SYNOPSIS
+
+nitpm [--help] [--verbose] <command> [<args>]
+
+# OPTIONS
+
+### `-h`, `--help`
+Show help message.
+
+### `-v`, `--verbose`
+Print more information.
+
+# COMMANDS
+
+### `install`
+Install packages by name, Git repository address or from the local package.ini.
+
+       # Search and install package by name, e.g. hello_nitpm:
+       nitpm install hello_nitpm
+
+       # Install package from a Git repository:
+       nitpm install https://gitlab.com/xymus/hello_nitpm.git
+
+       # Search and install a specific branch or tag of a package, e.g. 1.0:
+       nitpm install nitpm_test_versions=1.0
+
+       # Install all packages declared in the local package.ini file:
+       nitpm install
+
+To support the last command, the package.ini file should list the required packages after `[package]` on an `import=` line:
+
+       [package]
+       name=my_package
+       import=nitpm_test_versions=1.0,gamnit
+
+### `list`
+List installed packages.
+
+       nitpm list
+
+### `upgrade`
+Upgrade a package.
+
+       nitpm upgrade hello_nitpm
+
+### `uninstall`
+Uninstall packages.
+
+       nitpm uninstall hello_nitpm
+
+### `help`
+Show general help message or the help for a command.
+
+       nitpm help
+       nitpm help install
+
+# SEE ALSO
+
+The Nit language documentation and the source code of its tools and libraries may be downloaded from <http://nitlanguage.org>
diff --git a/share/man/picnit.md b/share/man/picnit.md
deleted file mode 100644 (file)
index 6b353d1..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-# NAME
-
-picnit - Nit package manager
-
-# SYNOPSIS
-
-picnit [--help] [--verbose] <command> [<args>]
-
-# OPTIONS
-
-### `-h`, `--help`
-
-Show the help message.
-
-### `-v`, `--verbose`
-
-Print more information, may be useful for debugging.
-
-# COMMANDS
-
-### install
-
-Install a package by searching for its name or directly from a Git repository URL.
-
-       picnit install hello_picnit
-       picnit install https://gitlab.com/xymus/hello_picnit.git
-
-### list
-
-List installed packages.
-
-       picnit list
-
-### upgrade
-
-Upgrade a package.
-
-       picnit upgrade hello_picnit
-
-### uninstall
-
-Uninstall a package.
-
-       picnit uninstall hello_picnit
-
-### help
-
-Show general help message or the help for a command.
-
-       picnit help
-       picnit help install
-
-# SEE ALSO
-
-The Nit language documentation and the source code of its tools and libraries may be downloaded from <http://nitlanguage.org>
index 96ac02e..8d3b867 100644 (file)
@@ -16,7 +16,7 @@
 
 NITCOPT=--semi-global
 OLDNITCOPT=--semi-global
-OBJS=nitc nitpick nit nitls nitunit picnit nitx nitlight nitserial nitrestful
+OBJS=nitc nitpick nit nitls nitunit nitpm nitx nitlight nitserial nitrestful
 SRCS=$(patsubst %,%.nit,$(OBJS))
 BINS=$(patsubst %,../bin/%,$(OBJS))
 MOREOBJS=nitdoc nitweb nitcatalog nitmetrics nitpretty nitweb
index f18d7be..8bdfcb2 100644 (file)
@@ -39,7 +39,7 @@ module loader
 
 import modelbuilder_base
 import ini
-import picnit_shared
+import nitpm_shared
 
 redef class ToolContext
        # Option --path
@@ -66,9 +66,9 @@ redef class ModelBuilder
                # Setup the paths value
                paths.append(toolcontext.opt_path.value)
 
-               # Packages managed by picnit, only use when not testing with tests.sh
+               # Packages managed by nitpm, only use when not testing with tests.sh
                if "NIT_TESTING_TESTS_SH".environ != "true" then
-                       paths.add picnit_lib_dir
+                       paths.add nitpm_lib_dir
                end
 
                var path_env = "NIT_PATH".environ
@@ -255,6 +255,11 @@ redef class ModelBuilder
                        end
                end
 
+               if mgroup != null then
+                       var alias = mgroup.mpackage.import_alias(name)
+                       if alias != null then name = alias
+               end
+
                var loc = null
                if anode != null then loc = anode.hot_location
                var candidate = search_module_in_paths(loc, name, lookpaths)
@@ -287,6 +292,11 @@ redef class ModelBuilder
        # If found, the module is returned.
        private fun search_module_in_paths(location: nullable Location, name: String, lookpaths: Collection[String]): nullable MModule
        do
+               var name_no_version
+               if name.has('=') then
+                       name_no_version = name.split('=').first
+               else name_no_version = name
+
                var res = new ArraySet[MModule]
                for dirname in lookpaths do
                        # Try a single module file
@@ -296,7 +306,7 @@ redef class ModelBuilder
                        var g = identify_group((dirname/name).simplify_path)
                        if g != null then
                                scan_group(g)
-                               res.add_all g.mmodules_by_name(name)
+                               res.add_all g.mmodules_by_name(name_no_version)
                        end
                end
                if res.is_empty then return null
@@ -826,7 +836,7 @@ redef class ModelBuilder
        # Resolve the module identification for a given `AModuleName`.
        #
        # This method handles qualified names as used in `AModuleName`.
-       fun seach_module_by_amodule_name(n_name: AModuleName, mgroup: nullable MGroup): nullable MModule
+       fun search_module_by_amodule_name(n_name: AModuleName, mgroup: nullable MGroup): nullable MModule
        do
                var mod_name = n_name.n_id.text
 
@@ -860,6 +870,13 @@ redef class ModelBuilder
                # If no module yet, then assume that the first element of the path
                # Is to be searched in the path.
                var root_name = n_name.n_path.first.text
+
+               # Search for an alias in required external packages
+               if mgroup != null then
+                       var alias = mgroup.mpackage.import_alias(root_name)
+                       if alias != null then root_name = alias
+               end
+
                var roots = search_group_in_paths(root_name, paths)
                if roots.is_empty then
                        error(n_name, "Error: cannot find `{root_name}`. Tried: {paths.join(", ")}.")
@@ -889,7 +906,7 @@ redef class ModelBuilder
        # Basically it check that `bar::foo` matches `bar/foo.nit` and `bar/baz/foo.nit`
        # but not `baz/foo.nit` nor `foo/bar.nit`
        #
-       # Is used by `seach_module_by_amodule_name` to validate qualified names.
+       # Is used by `search_module_by_amodule_name` to validate qualified names.
        private fun match_amodulename(n_name: AModuleName, m: MModule): Bool
        do
                var g: nullable MGroup = m.mgroup
@@ -925,7 +942,7 @@ redef class ModelBuilder
                        end
 
                        # Load the imported module
-                       var sup = seach_module_by_amodule_name(aimport.n_name, mmodule.mgroup)
+                       var sup = search_module_by_amodule_name(aimport.n_name, mmodule.mgroup)
                        if sup == null then
                                mmodule.is_broken = true
                                nmodule.mmodule = null # invalidate the module
@@ -979,7 +996,7 @@ redef class ModelBuilder
                        var atconditionals = aimport.get_annotations("conditional")
                        if atconditionals.is_empty then continue
 
-                       var suppath = seach_module_by_amodule_name(aimport.n_name, mmodule.mgroup)
+                       var suppath = search_module_by_amodule_name(aimport.n_name, mmodule.mgroup)
                        if suppath == null then continue # skip error
 
                        for atconditional in atconditionals do
@@ -1183,6 +1200,31 @@ redef class MPackage
                end
                return true
        end
+
+       # Get the name to search for, for a `root_name` declared as `import` in `ini`
+       fun import_alias(root_name: String): nullable String
+       do
+               var map = import_aliases_parsed
+               if map == null then return null
+
+               var val = map.get_or_null(root_name)
+               if val == null then return null
+
+               return val.dir_name
+       end
+
+       private var import_aliases_parsed: nullable Map[String, ExternalPackage] is lazy do
+               var ini = ini
+               if ini == null then return null
+
+               var import_line = ini["package.import"]
+               if import_line == null then return null
+
+               var map = import_line.parse_import
+               if map.is_empty then return null
+
+               return map
+       end
 end
 
 redef class MGroup
similarity index 54%
rename from src/picnit.nit
rename to src/nitpm.nit
index f5e28c8..df2aaa5 100644 (file)
 # limitations under the License.
 
 # Nit package manager command line interface
-module picnit
+module nitpm
 
 import opts
 import prompt
 import ini
 import curl
 
-import picnit_shared
-
-redef class Text
-
-       # Does `self` look like a package name?
-       #
-       # ~~~
-       # assert "gamnit".is_package_name
-       # assert "n1t".is_package_name
-       # assert not ".".is_package_name
-       # assert not "./gamnit".is_package_name
-       # assert not "https://github.com/nitlang/nit.git".is_package_name
-       # assert not "git://github.com/nitlang/nit".is_package_name
-       # assert not "git@gitlab.com:xymus/gamnit.git".is_package_name
-       # assert not "4it".is_package_name
-       # ~~~
-       private fun is_package_name: Bool
-       do
-               if is_empty then return false
-               if not chars.first.is_alpha then return false
-
-               for c in chars do
-                       if not (c.is_alphanumeric or c == '_') then return false
-               end
-
-               return true
-       end
+import nitpm_shared
 
-       # Get package name from the Git address `self`
-       #
-       # Return `null` on failure.
-       #
-       # ~~~
-       # assert "https://github.com/nitlang/nit.git".git_name == "nit"
-       # assert "git://github.com/nitlang/nit".git_name == "nit"
-       # assert "gamnit".git_name == "gamnit"
-       # assert "///".git_name == null
-       # assert "file:///".git_name == "file:"
-       # ~~~
-       private fun git_name: nullable String
-       do
-               var parts = split("/")
-               for part in parts.reverse_iterator do
-                       if not part.is_empty then
-                               return part.strip_extension(".git")
-                       end
-               end
-
-               return null
-       end
-end
-
-# Command line action, passed after `picnit`
+# Command line action, passed after `nitpm`
 abstract class Command
 
        # Short name of the command, specified in the command line
@@ -105,24 +55,60 @@ class CommandInstall
        super Command
 
        redef fun name do return "install"
-       redef fun usage do return "picnit install [package or git-repository]"
-       redef fun description do return "Install a package by its name or from a git-repository"
+       redef fun usage do return "nitpm install [package0[=version] [package1 ...]]"
+       redef fun description do return "Install packages by name, Git repository address or from the local package.ini"
+
+       # Packages installed in this run (identified by the full path)
+       private var installed = new Array[String]
 
        redef fun apply(args)
        do
-               if args.length != 1 then
-                       print_local_help
-                       exit 1
+               if args.not_empty then
+                       # Install each package
+                       for arg in args do
+                               # Parse each arg as an import string, with versions and commas
+                               install_packages arg
+                       end
+               else
+                       # Install packages from local package.ini
+                       var ini_path = "package.ini"
+                       if not ini_path.file_exists then
+                               print_error "Local `package.ini` not found."
+                               print_local_help
+                               exit 1
+                       end
+
+                       var ini = new ConfigTree(ini_path)
+                       var import_line = ini["package.import"]
+                       if import_line == null then
+                               print_error "The local `package.ini` declares no external dependencies."
+                               exit 0
+                               abort
+                       end
+
+                       install_packages import_line
+               end
+       end
+
+       # Install packages defined by the `import_line`
+       private fun install_packages(import_line: String)
+       do
+               var imports = import_line.parse_import
+               for name, ext_package in imports do
+                       install_package(ext_package.id, ext_package.version)
                end
+       end
 
-               var package_id = args.first
+       # Install the `package_id` at `version`
+       private fun install_package(package_id: String, version: nullable String)
+       do
                if package_id.is_package_name then
                        # Ask a centralized server
                        # TODO choose a future safe URL
                        # TODO customizable server list
                        # TODO parse ini file in memory
 
-                       var url = "https://xymus.net/picnit/{package_id}.ini"
+                       var url = "https://xymus.net/nitpm/{package_id}.ini"
                        var ini_path = "/tmp/{package_id}.ini"
 
                        if verbose then print "Looking for a package description at '{url}'"
@@ -138,7 +124,7 @@ class CommandInstall
 
                        assert response isa CurlFileResponseSuccess
                        if response.status_code == 404 then
-                               print_error "Package not found by the server"
+                               print_error "Package '{package_id}' not found on the server"
                                exit 1
                        else if response.status_code != 200 then
                                print_error "Server side error: {response.status_code}"
@@ -158,12 +144,12 @@ class CommandInstall
                                abort
                        end
 
-                       install_from_git(git_repo, package_id)
+                       install_from_git(git_repo, package_id, version)
                else
                        var name = package_id.git_name
                        if name != null and name != "." and not name.is_empty then
                                name = name.to_lower
-                               install_from_git(package_id, name)
+                               install_from_git(package_id, name, version)
                        else
                                print_error "Failed to infer the package name"
                                exit 1
@@ -171,30 +157,49 @@ class CommandInstall
                end
        end
 
-       private fun install_from_git(git_repo, name: String)
+       private fun install_from_git(git_repo, name: String, version: nullable String)
        do
                check_git
 
-               var target_dir = picnit_lib_dir / name
-               if target_dir.file_exists then
-                       print_error "Already installed"
-                       exit 1
+               var target_dir = nitpm_lib_dir / name
+               if version != null then target_dir += "=" + version
+               if installed.has(target_dir) then
+                       # Ignore packages installed in this run
+                       return
                end
+               installed.add target_dir
 
-               var cmd = "git clone {git_repo.escape_to_sh} {target_dir.escape_to_sh}"
-               if verbose then print "+ {cmd}"
+               if target_dir.file_exists then
+                       # Warn about packages previously installed,
+                       # install dependencies anyway in case of a previous error.
+                       print_error "Package '{name}' is already installed"
+               else
+                       # Actually install it
+                       var cmd_branch = ""
+                       if version != null then cmd_branch = "--branch '{version}'"
 
-               if "NIT_TESTING".environ == "true" then
-                       # Silence git output when testing
-                       cmd += " 2> /dev/null"
-               end
+                       var cmd = "git clone --depth 1 {cmd_branch} {git_repo.escape_to_sh} {target_dir.escape_to_sh}"
+                       if verbose then print "+ {cmd}"
 
-               var proc = new Process("sh", "-c", cmd)
-               proc.wait
+                       if "NIT_TESTING".environ == "true" then
+                               # Silence git output when testing
+                               cmd += " 2> /dev/null"
+                       end
 
-               if proc.status != 0 then
-                       print_error "Install failed"
-                       exit 1
+                       var proc = new Process("sh", "-c", cmd)
+                       proc.wait
+
+                       if proc.status != 0 then
+                               print_error "Install of '{name}' failed"
+                               exit 1
+                       end
+               end
+
+               # Recursive install
+               var ini = new ConfigTree(target_dir/"package.ini")
+               var import_line = ini["package.import"]
+               if import_line != null then
+                       install_packages import_line
                end
        end
 end
@@ -204,7 +209,7 @@ class CommandUpgrade
        super Command
 
        redef fun name do return "upgrade"
-       redef fun usage do return "picnit upgrade <package>"
+       redef fun usage do return "nitpm upgrade <package>"
        redef fun description do return "Upgrade a package"
 
        redef fun apply(args)
@@ -215,7 +220,7 @@ class CommandUpgrade
                end
 
                var name = args.first
-               var target_dir = picnit_lib_dir / name
+               var target_dir = nitpm_lib_dir / name
 
                if not target_dir.file_exists or not target_dir.to_path.is_dir then
                        print_error "Package not found"
@@ -242,39 +247,58 @@ class CommandUninstall
        super Command
 
        redef fun name do return "uninstall"
-       redef fun usage do return "picnit uninstall <package>"
-       redef fun description do return "Uninstall a package"
+       redef fun usage do return "nitpm uninstall [-f] <package0>[=version] [package1 ...]"
+       redef fun description do return "Uninstall packages"
 
        redef fun apply(args)
        do
-               if args.length != 1 then
+               var opt_force = "-f"
+               var force = args.has(opt_force)
+               if force then args.remove(opt_force)
+
+               if args.is_empty then
                        print_local_help
                        exit 1
                end
 
-               var name = args.first
-               var target_dir = picnit_lib_dir / name
+               for name in args do
 
-               if not target_dir.file_exists or not target_dir.to_path.is_dir then
-                       print_error "Package not found"
-                       exit 1
-               end
+                       var clean_nitpm_lib_dir = nitpm_lib_dir.simplify_path
+                       var target_dir = clean_nitpm_lib_dir / name
+
+                       # Check validity of the package to delete
+                       target_dir = target_dir.simplify_path
+                       var within_dir = target_dir.has_prefix(clean_nitpm_lib_dir + "/") and
+                               target_dir.length > clean_nitpm_lib_dir.length + 1
+                       var valid_name = name.length > 0 and name.chars.first.is_lower
+                       if not valid_name or not within_dir then
+                               print_error "Package name '{name}' is invalid"
+                               continue
+                       end
 
-               # Ask confirmation
-               var response = prompt("Delete {target_dir.escape_to_sh}? [Y/n] ")
-               var accept = response != null and
-                       (response.to_lower == "y" or response.to_lower == "yes" or response == "")
-               if not accept then return
+                       if not target_dir.file_exists or not target_dir.to_path.is_dir then
+                               print_error "Package not found"
+                               exit 1
+                       end
 
-               var cmd = "rm -rf {target_dir.escape_to_sh}"
-               if verbose then print "+ {cmd}"
+                       # Ask confirmation
+                       if not force then
+                               var response = prompt("Delete {target_dir.escape_to_sh}? [Y/n] ")
+                               var accept = response != null and
+                                       (response.to_lower == "y" or response.to_lower == "yes" or response == "")
+                               if not accept then return
+                       end
 
-               var proc = new Process("sh", "-c", cmd)
-               proc.wait
+                       var cmd = "rm -rf {target_dir.escape_to_sh}"
+                       if verbose then print "+ {cmd}"
 
-               if proc.status != 0 then
-                       print_error "Uninstall failed"
-                       exit 1
+                       var proc = new Process("sh", "-c", cmd)
+                       proc.wait
+
+                       if proc.status != 0 then
+                               print_error "Uninstall failed"
+                               exit 1
+                       end
                end
        end
 end
@@ -284,23 +308,36 @@ class CommandList
        super Command
 
        redef fun name do return "list"
-       redef fun usage do return "picnit list"
+       redef fun usage do return "nitpm list"
        redef fun description do return "List installed packages"
 
        redef fun apply(args)
        do
-               var files = picnit_lib_dir.files
+               var files = nitpm_lib_dir.files
+               var name_to_desc = new Map[String, nullable String]
+               var max_name_len = 0
+
+               # Collect package info
                for file in files do
-                       var ini_path = picnit_lib_dir / file / "package.ini"
+                       var ini_path = nitpm_lib_dir / file / "package.ini"
                        if verbose then print "- Reading ini file at {ini_path}"
                        var ini = new ConfigTree(ini_path)
                        var tags = ini["package.tags"]
 
-                       if tags != null then
-                               print "{file.justify(15, 0.0)} {tags}"
-                       else
-                               print file
-                       end
+                       name_to_desc[file] = tags
+                       max_name_len = max_name_len.max(file.length)
+               end
+
+               # Sort in alphabetical order
+               var sorted_names = name_to_desc.keys.to_a
+               alpha_comparator.sort sorted_names
+
+               # Print with clear columns
+               for name in sorted_names do
+                       var col0 = name.justify(max_name_len+1, 0.0)
+                       var col1 = name_to_desc[name] or else ""
+                       var line = col0 + col1
+                       print line.trim
                end
        end
 end
@@ -310,7 +347,7 @@ class CommandHelp
        super Command
 
        redef fun name do return "help"
-       redef fun usage do return "picnit help [command]"
+       redef fun usage do return "nitpm help [command]"
        redef fun description do return "Show general help message or the help for a command"
 
        redef fun apply(args)
@@ -334,10 +371,10 @@ redef class Sys
        var opts = new OptionContext
 
        # Help option
-       var opt_help = new OptionBool("Show this help message", "--help", "-h")
+       var opt_help = new OptionBool("Show help message", "-h", "--help")
 
        # Verbose mode option
-       var opt_verbose = new OptionBool("Print more information", "--verbose", "-v")
+       var opt_verbose = new OptionBool("Print more information", "-v", "--verbose")
        private fun verbose: Bool do return opt_verbose.value
 
        # All command line actions, mapped to their short `name`
@@ -350,17 +387,17 @@ redef class Sys
        private var command_help = new CommandHelp(commands)
 end
 
-redef fun picnit_lib_dir
+redef fun nitpm_lib_dir
 do
        if "NIT_TESTING".environ == "true" then
-               return "/tmp/picnit-test-" + "NIT_TESTING_ID".environ
+               return "/tmp/nitpm-test-" + "NIT_TESTING_ID".environ
        else return super
 end
 
 # Print the general help message
 private fun print_help
 do
-       print "usage: picnit <command> [options]"
+       print "usage: nitpm <command> [options]"
        print ""
 
        print "commands:"
diff --git a/src/nitpm_shared.nit b/src/nitpm_shared.nit
new file mode 100644 (file)
index 0000000..2a93c17
--- /dev/null
@@ -0,0 +1,137 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Services related to the Nit package manager
+module nitpm_shared
+
+# Folder where are downloaded nitpm packages
+fun nitpm_lib_dir: String
+do
+       var dir = "NITPM_PATH".environ
+       if not dir.is_empty then return dir
+
+       return "HOME".environ / ".local/lib/nit/"
+end
+
+redef class Text
+
+       # Does `self` look like a package name?
+       #
+       # ~~~
+       # assert "gamnit".is_package_name
+       # assert "n1t".is_package_name
+       # assert not ".".is_package_name
+       # assert not "./gamnit".is_package_name
+       # assert not "https://github.com/nitlang/nit.git".is_package_name
+       # assert not "git://github.com/nitlang/nit".is_package_name
+       # assert not "git@gitlab.com:xymus/gamnit.git".is_package_name
+       # assert not "4it".is_package_name
+       # ~~~
+       fun is_package_name: Bool
+       do
+               if is_empty then return false
+               if not chars.first.is_alpha then return false
+
+               for c in chars do
+                       if not (c.is_alphanumeric or c == '_') then return false
+               end
+
+               return true
+       end
+
+       # Get package name from the Git address `self`
+       #
+       # Return `null` on failure.
+       #
+       # ~~~
+       # assert "https://github.com/nitlang/nit.git".git_name == "nit"
+       # assert "git://github.com/nitlang/nit".git_name == "nit"
+       # assert "gamnit".git_name == "gamnit"
+       # assert "///".git_name == null
+       # assert "file:///".git_name == "file:"
+       # ~~~
+       fun git_name: nullable String
+       do
+               var parts = split("/")
+               for part in parts.reverse_iterator do
+                       if not part.is_empty then
+                               return part.strip_extension(".git")
+                       end
+               end
+
+               return null
+       end
+
+       # Parse the external package declaration, as declared in package.ini
+       #
+       # Return a map of `ExternalPackage` organized by the short package name,
+       # as used in imports from Nit code.
+       fun parse_import: Map[String, ExternalPackage]
+       do
+               var res = new Map[String, ExternalPackage]
+               var ids = self.split(",")
+               for id in ids do
+                       id = id.chomp
+                       if id.is_empty then continue
+
+                       # Check version suffix (e.g. gamnit=1.0)
+                       var match = id.search_last("=")
+                       var package_name
+                       var version = null
+                       if match != null then
+                               # There's a version suffix
+                               package_name = id.substring(0, match.from)
+                               version = id.substring_from(match.after)
+                               id = package_name
+                       else
+                               package_name = id
+                       end
+
+                       # Extract a package name from a Git address
+                       if not package_name.is_package_name then
+                               # Assume it's a Git repository
+                               var git_name = package_name.git_name
+                               if git_name == null then
+                                       # Invalid name
+                                       # TODO report error only when used by the parser
+                                       continue
+                               end
+                               package_name = git_name
+                       end
+
+                       res[package_name] = new ExternalPackage(id, package_name, version)
+               end
+               return res
+       end
+end
+
+# Reference to a nitpm package
+class ExternalPackage
+
+       # Package identifier (name or Git address), without the version
+       var id: String
+
+       # Standard Nit package name, as used in importations from Nit
+       var name: String
+
+       # Version string of the package
+       var version: nullable String
+
+       # Expected folder name for this package
+       var dir_name: String is lazy do
+               var version = version
+               if version == null then return name
+               return name + "=" + version
+       end
+end
diff --git a/src/picnit_shared.nit b/src/picnit_shared.nit
deleted file mode 100644 (file)
index 07d44cd..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-# This file is part of NIT ( http://www.nitlanguage.org ).
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# Services related to the Nit package manager
-module picnit_shared
-
-# Folder where are downloaded picnit packages
-fun picnit_lib_dir: String do do return "HOME".environ / ".local/lib/nit/"
index b3a9b20..7a451c4 100644 (file)
@@ -43,3 +43,4 @@ test_rubix_cube
 test_rubix_visual
 test_csv
 repeating_key_xor_solve
+nitpm
diff --git a/tests/nitpm.args b/tests/nitpm.args
new file mode 100644 (file)
index 0000000..3d57c9e
--- /dev/null
@@ -0,0 +1,8 @@
+install https://gitlab.com/xymus/hello_nitpm.git
+install hello_nitpm
+upgrade hello_nitpm
+install nitpm_test_versions=1.0 nitpm_test_versions=stable nitpm_test_imports
+list
+uninstall -f nitpm_test_versions=1.0 nitpm_test_versions=2.0 nitpm_test_versions=stable nitpm_test_imports nitpm_test_cycle hello_nitpm
+list
+uninstall . .. 0invalid _invalid a/../
index 08a50fd..d346e2c 100644 (file)
@@ -46,3 +46,4 @@ repeating_key_xor_solve
 test_explain_assert
 base_notnull_lit_alt2
 assertions
+nitpm
diff --git a/tests/picnit.args b/tests/picnit.args
deleted file mode 100644 (file)
index 35da5ee..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-install https://gitlab.com/xymus/hello_picnit.git
-install hello_picnit
-list
-upgrade hello_picnit
diff --git a/tests/sav/nitpm.res b/tests/sav/nitpm.res
new file mode 100644 (file)
index 0000000..c10cc0f
--- /dev/null
@@ -0,0 +1,12 @@
+usage: nitpm <command> [options]
+
+commands:
+  install     Install packages by name, Git repository address or from the local package.ini
+  list        List installed packages
+  upgrade     Upgrade a package
+  uninstall   Uninstall packages
+  help        Show general help message or the help for a command
+
+options:
+  -h, --help      Show help message
+  -v, --verbose   Print more information
diff --git a/tests/sav/nitpm_args2.res b/tests/sav/nitpm_args2.res
new file mode 100644 (file)
index 0000000..a9f808e
--- /dev/null
@@ -0,0 +1 @@
+Package 'hello_nitpm' is already installed
diff --git a/tests/sav/nitpm_args5.res b/tests/sav/nitpm_args5.res
new file mode 100644 (file)
index 0000000..822769d
--- /dev/null
@@ -0,0 +1,6 @@
+hello_nitpm                example
+nitpm_test_cycle
+nitpm_test_imports
+nitpm_test_versions=1.0
+nitpm_test_versions=2.0
+nitpm_test_versions=stable
diff --git a/tests/sav/nitpm_args8.res b/tests/sav/nitpm_args8.res
new file mode 100644 (file)
index 0000000..dc13ee1
--- /dev/null
@@ -0,0 +1,5 @@
+Package name '.' is invalid
+Package name '..' is invalid
+Package name '0invalid' is invalid
+Package name '_invalid' is invalid
+Package name 'a/../' is invalid
diff --git a/tests/sav/picnit.res b/tests/sav/picnit.res
deleted file mode 100644 (file)
index 952c1c1..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-usage: picnit <command> [options]
-
-commands:
-  install     Install a package by its name or from a git-repository
-  list        List installed packages
-  upgrade     Upgrade a package
-  uninstall   Uninstall a package
-  help        Show general help message or the help for a command
-
-options:
-  --help, -h      Show this help message
-  --verbose, -v   Print more information
diff --git a/tests/sav/picnit_args2.res b/tests/sav/picnit_args2.res
deleted file mode 100644 (file)
index 8fba0b7..0000000
+++ /dev/null
@@ -1 +0,0 @@
-Already installed
diff --git a/tests/sav/picnit_args3.res b/tests/sav/picnit_args3.res
deleted file mode 100644 (file)
index 81eb1a2..0000000
+++ /dev/null
@@ -1 +0,0 @@
-hello_picnit    example
diff --git a/tests/sav/picnit_args5.res b/tests/sav/picnit_args5.res
deleted file mode 100644 (file)
index 2a7bfed..0000000
+++ /dev/null
@@ -1 +0,0 @@
-Already up-to-date.