From c754b5e4ac9a9ed1bff9e2726b8888f7cef68e44 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 15 Apr 2026 16:22:21 +0300 Subject: [PATCH] builder: add a "base url" to v.mod to resolve module names (fixes #24223) --- doc/docs.md | 4 ++++ vlib/v/builder/base_url_test.v | 16 +++++++++++++++ vlib/v/builder/builder.v | 36 +++++++++++++++++++++++---------- vlib/v/builder/builder_test.v | 36 +++++++++++++++++++++++++++++++++ vlib/v/builder/compile.v | 18 +++++++++++++++-- vlib/v/util/module.v | 37 +++++++++++++++++++++++++++++----- vlib/v/vmod/encoder.v | 4 ++++ vlib/v/vmod/encoder_test.v | 1 + vlib/v/vmod/parser.v | 4 ++++ vlib/v/vmod/parser_test.v | 7 +++++++ vlib/v/vmod/vmod.v | 19 +++++++++++++++++ 11 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 vlib/v/builder/base_url_test.v diff --git a/doc/docs.md b/doc/docs.md index d1110ebe0..e29b442ef 100644 --- a/doc/docs.md +++ b/doc/docs.md @@ -6119,6 +6119,7 @@ Package are up to date. ```v ignore Module { name: 'mypackage' + base_url: 'src' description: 'My nice package.' version: '0.0.1' license: 'MIT' @@ -6126,6 +6127,9 @@ Package are up to date. } ``` + `base_url` is optional. When set, V resolves the package sources relative to + that folder, next to the `v.mod` file. + Minimal file structure: ``` v.mod diff --git a/vlib/v/builder/base_url_test.v b/vlib/v/builder/base_url_test.v new file mode 100644 index 000000000..cb5e2dd0a --- /dev/null +++ b/vlib/v/builder/base_url_test.v @@ -0,0 +1,16 @@ +module builder + +import os + +fn test_find_module_path_from_vmod_root_honors_base_url() { + root := os.join_path(os.vtmp_dir(), 'v_builder_base_url_${os.getpid()}') + os.rmdir_all(root) or {} + defer { + os.rmdir_all(root) or {} + } + os.mkdir_all(os.join_path(root, 'source', 'feature'))! + os.write_file(os.join_path(root, 'v.mod'), + "Module {\n\tname: 'example.pkg'\n\tbase_url: 'source'\n}\n")! + assert find_module_path_from_vmod_root(root, 'example.pkg.feature')! == os.join_path(root, + 'source', 'feature') +} diff --git a/vlib/v/builder/builder.v b/vlib/v/builder/builder.v index 5d1951c68..4c23f267f 100644 --- a/vlib/v/builder/builder.v +++ b/vlib/v/builder/builder.v @@ -560,15 +560,15 @@ pub fn (b &Builder) v_files_from_dir(dir string) []string { } mut res := b.pref.should_compile_filtered_files(dir, files) if res.len == 0 { - // Perhaps the .v files are stored in /src/ ? - src_path := os.join_path(dir, 'src') - if os.is_dir(src_path) { + // Perhaps the .v files are stored in a custom source root? + source_root := source_root_from_vmod_root(dir) or { os.join_path(dir, 'src') } + if source_root != dir && os.is_dir(source_root) { if b.pref.is_verbose { - println('v_files_from_dir ("${src_path}") (/src/)') + println('v_files_from_dir ("${source_root}") (v.mod source root)') } - files = os.ls(src_path) or { panic(err) } - source_dir = os.real_path(src_path) - res = b.pref.should_compile_filtered_files(src_path, files) + files = os.ls(source_root) or { panic(err) } + source_dir = os.real_path(source_root) + res = b.pref.should_compile_filtered_files(source_root, files) } } return b.with_same_module_subdir_files(source_dir, res) @@ -686,19 +686,32 @@ pub fn module_path(mod string) string { return mod.replace('.', os.path_separator) } -fn find_module_path_from_vmod_root(vmod_root string, mod string) !string { +fn manifest_from_vmod_root(vmod_root string) !vmod.Manifest { vmod_path := os.join_path(vmod_root, 'v.mod') if !os.is_file(vmod_path) { return error('module not found') } - manifest := vmod.from_file(vmod_path) or { return error('module not found') } + return vmod.from_file(vmod_path) or { return error('module not found') } +} + +fn source_root_from_vmod_root(vmod_root string) !string { + manifest := manifest_from_vmod_root(vmod_root)! + return manifest.source_root(vmod_root) +} + +fn lookup_source_root_from_vmod_root(vmod_root string) string { + return source_root_from_vmod_root(vmod_root) or { os.join_path(vmod_root, 'src') } +} + +fn find_module_path_from_vmod_root(vmod_root string, mod string) !string { + manifest := manifest_from_vmod_root(vmod_root)! tail_path := mod_tail_after_vmod_name(mod, manifest.name) or { return error('module not found') } if tail_path == '' { return error('module not found') } - try_path := os.join_path(vmod_root, 'src', tail_path) + try_path := os.join_path(manifest.source_root(vmod_root), tail_path) if os.is_dir(try_path) { return try_path } @@ -721,7 +734,8 @@ fn find_module_path_from_search_root(search_path string, mod string) !string { continue } submodule_path := mod_parts[i..].join(os.path_separator) - src_try_path := os.join_path(candidate_root, 'src', submodule_path) + source_root := lookup_source_root_from_vmod_root(candidate_root) + src_try_path := os.join_path(source_root, submodule_path) if os.is_dir(src_try_path) { return src_try_path } diff --git a/vlib/v/builder/builder_test.v b/vlib/v/builder/builder_test.v index 8d9074d7b..8341279c0 100644 --- a/vlib/v/builder/builder_test.v +++ b/vlib/v/builder/builder_test.v @@ -144,6 +144,42 @@ fn main() { assert run_v_ok('${os.quoted_path(vexe)} run ${os.quoted_path(main_file)}').trim_space() == 'database' } +fn test_run_custom_base_url_uses_project_root_lookup() { + os.chdir(test_path)! + project_dir := os.join_path(test_path, 'run_base_url_project') + defer { + os.chdir(test_path) or {} + } + os.mkdir_all(os.join_path(project_dir, 'source', 'foo'))! + os.mkdir_all(os.join_path(project_dir, 'source', 'modules', 'dep'))! + os.write_file(os.join_path(project_dir, 'v.mod'), + "Module {\n\tname: 'run_base_url_project'\n\tbase_url: 'source'\n\tdescription: ''\n\tversion: ''\n\tlicense: ''\n\tdependencies: []\n}\n")! + os.write_file(os.join_path(project_dir, 'source', 'main.v'), 'module main +import foo +import dep + +fn main() { + println(foo.name() + "+" + dep.name()) +} +')! + os.write_file(os.join_path(project_dir, 'source', 'foo', 'foo.v'), 'module foo + +pub fn name() string { + return "foo" +} +')! + os.write_file(os.join_path(project_dir, 'source', 'modules', 'dep', 'dep.v'), 'module dep + +pub fn name() string { + return "dep" +} +')! + os.chdir(project_dir)! + assert run_v_ok('${os.quoted_path(vexe)} run .').trim_space() == 'foo+dep' + assert run_v_ok('${os.quoted_path(vexe)} run source').trim_space() == 'foo+dep' + assert run_v_ok('${os.quoted_path(vexe)} run ./source').trim_space() == 'foo+dep' +} + fn test_thirdparty_object_build_with_multiline_cflags() { mut env := os.environ() existing_cflags := if 'CFLAGS' in env { env['CFLAGS'] } else { '' } diff --git a/vlib/v/builder/compile.v b/vlib/v/builder/compile.v index 363661784..0c66f880b 100644 --- a/vlib/v/builder/compile.v +++ b/vlib/v/builder/compile.v @@ -251,6 +251,12 @@ pub fn (mut v Builder) set_module_lookup_paths() { if os.exists(os.join_path(lookup_root, 'src/modules')) { v.module_search_paths << os.join_path(lookup_root, 'src/modules') } + if source_root := source_root_from_vmod_root(lookup_root) { + source_modules := os.join_path(source_root, 'modules') + if source_modules !in v.module_search_paths && os.exists(source_modules) { + v.module_search_paths << source_modules + } + } if os.exists(os.join_path(lookup_root, 'modules')) { v.module_search_paths << os.join_path(lookup_root, 'modules') } @@ -263,6 +269,15 @@ pub fn (mut v Builder) set_module_lookup_paths() { } fn (v &Builder) module_lookup_root() string { + mut mcache := vmod.get_cache() + vmod_file_location := mcache.get_by_folder(v.compiled_dir) + if vmod_file_location.vmod_file != '' && vmod_file_location.vmod_folder != v.compiled_dir { + if source_root := source_root_from_vmod_root(vmod_file_location.vmod_folder) { + if os.real_path(source_root) == v.compiled_dir { + return vmod_file_location.vmod_folder + } + } + } if os.file_name(v.compiled_dir) != 'src' { return v.compiled_dir } @@ -270,8 +285,7 @@ fn (v &Builder) module_lookup_root() string { if project_dir == v.compiled_dir { return v.compiled_dir } - mut mcache := vmod.get_cache() - if mcache.get_by_folder(v.compiled_dir).vmod_folder == project_dir { + if vmod_file_location.vmod_folder == project_dir { return project_dir } if os.real_path(os.getwd()) == project_dir { diff --git a/vlib/v/util/module.v b/vlib/v/util/module.v index 9ef0d66c9..c28cb7b84 100644 --- a/vlib/v/util/module.v +++ b/vlib/v/util/module.v @@ -201,14 +201,32 @@ fn pref_path_to_source_root(pref_ &pref.Preferences) string { real_pref_path_dir := os.real_path(pref_path_dir) files := os.ls(real_pref_path_dir) or { return real_pref_path_dir } if pref_.should_compile_filtered_files(real_pref_path_dir, files).len == 0 { - src_path := os.join_path(real_pref_path_dir, 'src') - if os.is_dir(src_path) { - return src_path + source_root := source_root_from_vmod_root(real_pref_path_dir) or { + os.join_path(real_pref_path_dir, 'src') + } + if source_root != real_pref_path_dir && os.is_dir(source_root) { + return source_root } } return real_pref_path_dir } +fn source_root_from_vmod_root(vmod_root string) !string { + vmod_path := os.join_path(vmod_root, 'v.mod') + if !os.is_file(vmod_path) { + return error('module not found') + } + manifest := vmod.from_file(vmod_path) or { return error('module not found') } + return manifest.source_root(vmod_root) +} + +fn configured_base_parts(manifest vmod.Manifest) []string { + if manifest.base_url == '' { + return []string{} + } + return os.norm_path(manifest.base_url).split(os.path_separator).filter(it.len > 0 && it != '.') +} + fn normalize_src_based_mod_name(mod_full_name string, path string) string { real_path := os.real_path(path) mut mcache := vmod.get_cache() @@ -222,7 +240,16 @@ fn normalize_src_based_mod_name(mod_full_name string, path string) string { } rel_path := real_path.all_after(vmod_prefix) rel_parts := rel_path.split(os.path_separator) - if rel_parts.len == 0 || rel_parts[0] != 'src' { + mut base_parts := []string{} + manifest := vmod.from_file(vmod_file_location.vmod_file) or { vmod.Manifest{} } + base_parts = configured_base_parts(manifest) + if base_parts.len == 0 { + if rel_parts.len == 0 || rel_parts[0] != 'src' { + return mod_full_name + } + base_parts = ['src'] + } + if rel_parts.len < base_parts.len || rel_parts[..base_parts.len] != base_parts { return mod_full_name } full_parts := mod_full_name.split('.') @@ -230,6 +257,6 @@ fn normalize_src_based_mod_name(mod_full_name string, path string) string { return mod_full_name } mut normalized_parts := full_parts[..full_parts.len - rel_parts.len].clone() - normalized_parts << rel_parts[1..] + normalized_parts << rel_parts[base_parts.len..] return normalized_parts.join('.') } diff --git a/vlib/v/vmod/encoder.v b/vlib/v/vmod/encoder.v index 4f27277fe..f01cbae42 100644 --- a/vlib/v/vmod/encoder.v +++ b/vlib/v/vmod/encoder.v @@ -34,6 +34,10 @@ pub fn encode(manifest Manifest) string { b.writeln('Module {') b.write_string('\tname: ') b.writeln(quote(manifest.name)) + if manifest.base_url != '' { + b.write_string('\tbase_url: ') + b.writeln(quote(manifest.base_url)) + } b.write_string('\tdescription: ') b.writeln(quote(manifest.description)) b.write_string('\tversion: ') diff --git a/vlib/v/vmod/encoder_test.v b/vlib/v/vmod/encoder_test.v index 45ccd85e5..4fe4c11c4 100644 --- a/vlib/v/vmod/encoder_test.v +++ b/vlib/v/vmod/encoder_test.v @@ -46,6 +46,7 @@ fn test_encode_vmod_with_multiple_deps() { const mf_with_extra_fields = "Module { name: 'V' + base_url: 'src' description: 'The V programming language.' version: '0.4.10' license: 'MIT' diff --git a/vlib/v/vmod/parser.v b/vlib/v/vmod/parser.v index df64eb04c..1781189c4 100644 --- a/vlib/v/vmod/parser.v +++ b/vlib/v/vmod/parser.v @@ -22,6 +22,7 @@ enum TokenKind { pub struct Manifest { pub mut: name string + base_url string description string version string license string @@ -222,6 +223,9 @@ fn (mut p Parser) parse() !Manifest { 'name' { mn.name = field_value } + 'base_url' { + mn.base_url = field_value + } 'version' { mn.version = field_value } diff --git a/vlib/v/vmod/parser_test.v b/vlib/v/vmod/parser_test.v index cae77387c..b86fe4865 100644 --- a/vlib/v/vmod/parser_test.v +++ b/vlib/v/vmod/parser_test.v @@ -17,6 +17,7 @@ fn test_ok() { ok_source.replace('\n', '\r\n '), ok_source.replace('\n', '\n ')] { content := vmod.decode(s)! assert content.name == 'V' + assert content.base_url == '' assert content.description == 'The V programming language.' assert content.version == '0.7.7' assert content.license == 'MIT' @@ -26,6 +27,7 @@ fn test_ok() { } e := vmod.decode('Module{}')! assert e.name == '' + assert e.base_url == '' assert e.description == '' assert e.version == '' assert e.license == '' @@ -42,6 +44,11 @@ fn test_invalid_start() { assert false } +fn test_base_url() { + content := vmod.decode("Module {\n\tname: 'V'\n\tbase_url: 'source'\n}")! + assert content.base_url == 'source' +} + fn test_invalid_end() { vmod.decode('\nModule{\n \nname: ${quote}zzzz}') or { assert err.msg() == 'vmod: invalid token ${quote}eof${quote}, at line 4' diff --git a/vlib/v/vmod/vmod.v b/vlib/v/vmod/vmod.v index 5e9e74e78..670dbd348 100644 --- a/vlib/v/vmod/vmod.v +++ b/vlib/v/vmod/vmod.v @@ -11,6 +11,25 @@ pub fn get_cache() &ModFileCacher { return private_file_cacher } +// resolved_base_url returns the source folder configured by `base_url`, +// resolved relative to the folder containing the `v.mod` file. +pub fn (manifest Manifest) resolved_base_url(vmod_root string) string { + if manifest.base_url == '' { + return '' + } + return os.norm_path(os.join_path(vmod_root, manifest.base_url)) +} + +// source_root returns the folder where sources are looked up under a `v.mod`. +// When `base_url` is missing, it preserves the existing `src/` fallback. +pub fn (manifest Manifest) source_root(vmod_root string) string { + base_url := manifest.resolved_base_url(vmod_root) + if base_url != '' { + return base_url + } + return os.norm_path(os.join_path(vmod_root, 'src')) +} + // This file provides a caching mechanism for seeking quickly whether a // given folder has a v.mod file in it or in any of its parent folders. // -- 2.39.5