From f6e41da47be431a5e9b2d83c13eb3b594c13c3fa Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 26 Feb 2026 10:37:14 +0300 Subject: [PATCH] vpm: add commands to link or unlink V projects (fixes #24386) --- cmd/tools/vcomplete.v | 2 + cmd/tools/vpm/link.v | 138 +++++++++++++++++++++++++++++++++++++ cmd/tools/vpm/link_test.v | 74 ++++++++++++++++++++ cmd/tools/vpm/vpm.v | 10 ++- cmd/v/v.v | 7 +- vlib/v/help/default.txt | 7 +- vlib/v/help/vpm/link.txt | 10 +++ vlib/v/help/vpm/unlink.txt | 10 +++ vlib/v/help/vpm/vpm.txt | 6 +- 9 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 cmd/tools/vpm/link.v create mode 100644 cmd/tools/vpm/link_test.v create mode 100644 vlib/v/help/vpm/link.txt create mode 100644 vlib/v/help/vpm/unlink.txt diff --git a/cmd/tools/vcomplete.v b/cmd/tools/vcomplete.v index 68ede1c66..c2dcf691d 100644 --- a/cmd/tools/vcomplete.v +++ b/cmd/tools/vcomplete.v @@ -126,11 +126,13 @@ const auto_complete_commands = [ 'self', 'search', 'install', + 'link', 'update', 'upgrade', 'outdated', 'list', 'remove', + 'unlink', 'vlib-docs', 'get', 'version', diff --git a/cmd/tools/vpm/link.v b/cmd/tools/vpm/link.v new file mode 100644 index 000000000..551b91857 --- /dev/null +++ b/cmd/tools/vpm/link.v @@ -0,0 +1,138 @@ +module main + +import os +import v.help +import v.vmod + +struct LinkedProject { + name string + project_dir string + link_path string +} + +fn vpm_link(query []string) { + if settings.is_help { + help.print_and_exit('link') + } + ensure_no_query_for_project_command('link', query) + ensure_vmodules_dir_exist() + + project := get_project_for_linking() or { + vpm_error(err.msg()) + exit(1) + } + if project.project_dir == os.real_path(project.link_path) { + println('Module `${project.name}` is already available in `${fmt_mod_path(project.link_path)}`.') + return + } + if os.exists(project.link_path) || os.is_link(project.link_path) { + if os.is_link(project.link_path) { + if os.real_path(project.link_path) == project.project_dir { + println('Module `${project.name}` is already linked in `${fmt_mod_path(project.link_path)}`.') + return + } + vpm_error('`${project.name}` is already linked at `${fmt_mod_path(project.link_path)}`.', + details: 'Run `v unlink` first to replace it.' + ) + exit(1) + } + vpm_error('`${project.name}` already exists at `${fmt_mod_path(project.link_path)}`.') + exit(1) + } + parent_dir := os.dir(project.link_path) + os.mkdir_all(parent_dir) or { + vpm_error('failed to create `${fmt_mod_path(parent_dir)}`.', details: err.msg()) + exit(1) + } + os.symlink(project.project_dir, project.link_path) or { + vpm_error('failed to link `${project.name}`.', details: err.msg()) + exit(1) + } + println('Linked `${project.name}` to `${fmt_mod_path(project.link_path)}`.') +} + +fn vpm_unlink(query []string) { + if settings.is_help { + help.print_and_exit('unlink') + } + ensure_no_query_for_project_command('unlink', query) + + project := get_project_for_linking() or { + vpm_error(err.msg()) + exit(1) + } + if !os.exists(project.link_path) && !os.is_link(project.link_path) { + println('Module `${project.name}` is not linked in `${fmt_mod_path(project.link_path)}`.') + return + } + if !os.is_link(project.link_path) { + vpm_error('`${project.name}` at `${fmt_mod_path(project.link_path)}` is not a symlink.') + exit(1) + } + remove_symlink(project.link_path) or { + vpm_error('failed to unlink `${project.name}`.', details: err.msg()) + exit(1) + } + cleanup_empty_link_parent_dirs(project.link_path) + println('Unlinked `${project.name}` from `${fmt_mod_path(project.link_path)}`.') +} + +fn ensure_no_query_for_project_command(command string, query []string) { + if query.len == 0 { + return + } + vpm_error('`${command}` does not accept package names.', + details: 'Run `v ${command}` from inside the project directory.' + ) + exit(2) +} + +fn get_project_for_linking() !LinkedProject { + wrkdir := os.getwd() + mut mcache := vmod.get_cache() + vmod_location := mcache.get_by_folder(wrkdir) + if vmod_location.vmod_file == '' { + return error('no `v.mod` file found in `${wrkdir}` or its parent directories.') + } + manifest := vmod.from_file(vmod_location.vmod_file)! + if manifest.name.trim_space() == '' { + return error('`${vmod_location.vmod_file}` is missing the `name` field.') + } + mod_path := normalize_mod_path(manifest.name.replace('.', os.path_separator)) + vmodules_path := if os.is_dir(settings.vmodules_path) { + os.real_path(settings.vmodules_path) + } else { + settings.vmodules_path + } + return LinkedProject{ + name: manifest.name + project_dir: os.real_path(vmod_location.vmod_folder) + link_path: os.join_path(vmodules_path, mod_path) + } +} + +fn remove_symlink(path string) ! { + os.rm(path) or { + $if windows { + os.rmdir(path)! + } $else { + return err + } + } +} + +fn cleanup_empty_link_parent_dirs(link_path string) { + vmodules_path := if os.is_dir(settings.vmodules_path) { + os.real_path(settings.vmodules_path) + } else { + settings.vmodules_path + } + mut parent := os.dir(link_path) + for parent != vmodules_path && parent != os.dir(parent) { + if !os.is_dir(parent) || !os.is_dir_empty(parent) { + break + } + os.rmdir(parent) or { break } + parent = os.dir(parent) + } +} diff --git a/cmd/tools/vpm/link_test.v b/cmd/tools/vpm/link_test.v new file mode 100644 index 000000000..f3b1b51e7 --- /dev/null +++ b/cmd/tools/vpm/link_test.v @@ -0,0 +1,74 @@ +import os +import rand +import test_utils + +const vexe = os.quoted_path(@VEXE) +const test_path = os.join_path(os.vtmp_dir(), 'vpm_link_test_${rand.ulid()}') + +fn testsuite_begin() { + test_utils.set_test_env(test_path) + os.mkdir_all(test_path) or {} +} + +fn testsuite_end() { + os.rmdir_all(test_path) or {} +} + +fn test_link_and_unlink_current_project() { + module_name := 'author.coollib' + project_path := os.join_path(test_path, 'project') + write_vmod(project_path, module_name) or { + assert false, err.msg() + return + } + project_subdir := os.join_path(project_path, 'src') + os.mkdir_all(project_subdir) or { + assert false, err.msg() + return + } + link_path := os.join_path(test_path, 'author', 'coollib') + + link_res := os.execute('cd ${os.quoted_path(project_subdir)} && ${vexe} link') + if link_res.exit_code != 0 && is_symlink_privilege_error(link_res.output) { + eprintln('Skipping symlink test due to missing privileges.') + return + } + assert link_res.exit_code == 0, link_res.output + assert os.is_link(link_path), 'expected `${link_path}` to be a symlink' + assert os.real_path(link_path) == os.real_path(project_path) + assert link_res.output.contains('Linked `${module_name}`'), link_res.output + + link_again_res := os.execute('cd ${os.quoted_path(project_path)} && ${vexe} link') + assert link_again_res.exit_code == 0, link_again_res.output + assert link_again_res.output.contains('already linked') + || link_again_res.output.contains('already available'), link_again_res.output + + unlink_res := os.execute('cd ${os.quoted_path(project_subdir)} && ${vexe} unlink') + assert unlink_res.exit_code == 0, unlink_res.output + assert !os.exists(link_path) && !os.is_link(link_path) + assert !os.exists(os.join_path(test_path, 'author')) + assert unlink_res.output.contains('Unlinked `${module_name}`'), unlink_res.output +} + +fn test_link_without_vmod() { + path := os.join_path(test_path, 'no_manifest') + os.mkdir_all(path) or { + assert false, err.msg() + return + } + res := os.execute('cd ${os.quoted_path(path)} && ${vexe} link') + assert res.exit_code == 1, res.output + assert res.output.contains('no `v.mod` file found'), res.output +} + +fn write_vmod(path string, module_name string) ! { + os.mkdir_all(path)! + vmod_path := os.join_path(path, 'v.mod') + vmod_contents := "Module {\n\tname: '${module_name}'\n\tdescription: ''\n\tversion: '0.0.0'\n\tlicense: 'MIT'\n\tdependencies: []\n}\n" + os.write_file(vmod_path, vmod_contents)! +} + +fn is_symlink_privilege_error(output string) bool { + lower := output.to_lower() + return lower.contains('required privilege is not held') || lower.contains('symbolic link') +} diff --git a/cmd/tools/vpm/vpm.v b/cmd/tools/vpm/vpm.v index deeb12df3..8ca9cc082 100644 --- a/cmd/tools/vpm/vpm.v +++ b/cmd/tools/vpm/vpm.v @@ -12,8 +12,8 @@ import v.vmod const settings = init_settings() const default_vpm_server_urls = ['https://vpm.vlang.io', 'https://vpm.url4e.com'] const vpm_server_urls = rand.shuffle_clone(default_vpm_server_urls) or { [] } // ensure that all queries are distributed fairly -const valid_vpm_commands = ['help', 'search', 'install', 'update', 'upgrade', 'outdated', 'list', - 'remove', 'show'] +const valid_vpm_commands = ['help', 'search', 'install', 'link', 'update', 'upgrade', 'outdated', + 'list', 'remove', 'show', 'unlink'] const excluded_dirs = ['.cache', 'vlib'] fn main() { @@ -44,6 +44,9 @@ fn main() { 'install' { vpm_install(query) } + 'link' { + vpm_link(query) + } 'update' { vpm_update(query) } @@ -62,6 +65,9 @@ fn main() { 'show' { vpm_show(query) } + 'unlink' { + vpm_unlink(query) + } else { // Unreachable in regular usage. V will catch unknown commands beforehand. vpm_error('unknown command "${vpm_command}"') diff --git a/cmd/v/v.v b/cmd/v/v.v index 9c432f14f..e34303069 100644 --- a/cmd/v/v.v +++ b/cmd/v/v.v @@ -145,7 +145,8 @@ fn main() { util.launch_tool(prefs.is_verbose, 'vcreate', os.args[1..]) return } - 'install', 'list', 'outdated', 'remove', 'search', 'show', 'update', 'upgrade' { + 'install', 'link', 'list', 'outdated', 'remove', 'search', 'show', 'unlink', + 'update', 'upgrade' { util.launch_tool(prefs.is_verbose, 'vpm', os.args[1..]) return } @@ -178,8 +179,8 @@ fn main() { } other_commands := ['run', 'crun', 'build', 'build-module', 'help', 'version', 'new', 'init', - 'install', 'list', 'outdated', 'remove', 'search', 'show', 'update', 'upgrade', 'vlib-docs', - 'interpret', 'translate'] + 'install', 'link', 'list', 'outdated', 'remove', 'search', 'show', 'unlink', 'update', + 'upgrade', 'vlib-docs', 'interpret', 'translate'] mut all_commands := []string{} all_commands << external_tools all_commands << other_commands diff --git a/vlib/v/help/default.txt b/vlib/v/help/default.txt index 202a5b7c8..218a4f0f2 100644 --- a/vlib/v/help/default.txt +++ b/vlib/v/help/default.txt @@ -68,8 +68,11 @@ V supports the following commands: * Package Management Utilities: install Install a module from VPM. + link Symlink the current project into VMODULES. remove Remove a module that was installed from VPM. search Search for a module from VPM. + unlink Remove the symlink for the current project + from VMODULES. update Update an installed module from VPM. upgrade Upgrade all the outdated modules. list List all installed modules. @@ -83,5 +86,5 @@ Use "v help other" to see less frequently used commands. Use "v help topics" to see a list of all known help topics. Note: Help is required to write more help topics. -Only build, new, init, doc, fmt, vet, run, test, watch, search, install, -remove, update, bin2v, check-md are properly documented currently. +Only build, new, init, doc, fmt, vet, run, test, watch, search, install, +link, remove, unlink, update, bin2v, check-md are properly documented currently. diff --git a/vlib/v/help/vpm/link.txt b/vlib/v/help/vpm/link.txt new file mode 100644 index 000000000..28f4ca004 --- /dev/null +++ b/vlib/v/help/vpm/link.txt @@ -0,0 +1,10 @@ +Symlink the current project into VMODULES. + +Usage: + v link + +The command looks for `v.mod` in the current directory and its parent directories. +The `name` field in `v.mod` determines the destination inside VMODULES. + +Options: + --help, -h Prints the help menu. diff --git a/vlib/v/help/vpm/unlink.txt b/vlib/v/help/vpm/unlink.txt new file mode 100644 index 000000000..e1ffe8372 --- /dev/null +++ b/vlib/v/help/vpm/unlink.txt @@ -0,0 +1,10 @@ +Remove the current project's symlink from VMODULES. + +Usage: + v unlink + +The command looks for `v.mod` in the current directory and its parent directories. +The `name` field in `v.mod` determines which symlink is removed from VMODULES. + +Options: + --help, -h Prints the help menu. diff --git a/vlib/v/help/vpm/vpm.txt b/vlib/v/help/vpm/vpm.txt index c740ca84e..b9b86d867 100644 --- a/vlib/v/help/vpm/vpm.txt +++ b/vlib/v/help/vpm/vpm.txt @@ -2,6 +2,8 @@ Package Management Utilities: install Installs each PACKAGE. + link Symlink the current project into VMODULES. + list List all installed packages. outdated List all installed modules that need updates. @@ -12,6 +14,8 @@ Package Management Utilities: show Display information about a module on vpm. + unlink Remove the current project's symlink from VMODULES. + update Updates each PACKAGE. - upgrade Upgrade all outdated modules. \ No newline at end of file + upgrade Upgrade all outdated modules. -- 2.39.5