diff --git a/R/info.r b/R/info.r index 93c76ced..da70e441 100644 --- a/R/info.r +++ b/R/info.r @@ -41,8 +41,8 @@ pkg_info = function (spec) { fmt('') } -is_absolute = function (spec) { - spec$prefix[1L] %in% c('.', '..') +is_local = function (spec) { + ! is.null(spec$prefix) && spec$prefix[1L] %in% c('.', '..') } find_mod = function (spec, caller) { @@ -50,7 +50,7 @@ find_mod = function (spec, caller) { } `find_mod.box$mod_spec` = function (spec, caller) { - if (is_absolute(spec)) find_local_mod(spec, caller) else find_global_mod(spec, caller) + if (is_local(spec)) find_local_mod(spec, caller) else find_global_mod(spec, caller) } `find_mod.box$pkg_spec` = function (spec, caller) { @@ -98,7 +98,8 @@ find_in_path = function (spec, base_paths) { } mod_file_candidates = function (spec, base_paths) { - mod_path_prefix = merge_path(spec$prefix) + # Fallback for unscoped, global module names. + mod_path_prefix = merge_path(spec$prefix) %||% '.' ext = c('.r', '.R') simple_mod = file.path(mod_path_prefix, paste0(spec$name, ext)) nested_mod = file.path(mod_path_prefix, spec$name, paste0('__init__', ext)) diff --git a/R/spec.r b/R/spec.r index 5f24c452..7dcbd8c5 100644 --- a/R/spec.r +++ b/R/spec.r @@ -4,7 +4,8 @@ # spec → pkg_name (“[” attach_spec “]”)? !“/” / # mod # pkg_name → name -# mod → mod_prefix “/” mod_name (“[” attach_spec “]”)? +# mod → mod_prefix “/” mod_name (“[” attach_spec “]”)? / +# “mod” “(” mod_prefix “)” (“[” attach_spec “]”)? # mod_prefix → name !“/” / # mod_prefix “/” name # mod_name → name @@ -127,28 +128,32 @@ spec_name = function (spec) { parse_spec_impl = function (expr) { if (is.name(expr)) { if (identical(expr, quote(.))) { - list(prefix = '.', name = '__init__') + list(mod = list(prefix = '.', name = '__init__')) } else if (identical(expr, quote(..))) { - list(prefix = '..', name = '.') + list(mod = list(prefix = '..', name = '.')) } else { c(parse_pkg_name(expr), list(attach = NULL)) } } else if (is.call(expr)) { if (identical(expr[[1L]], quote(`[`))) { - if (is.name((name = expr[[2L]]))) { + name = expr[[2L]] + if (is.name((name))) { if (identical(name, quote(.))) { - c(list(prefix = '.', name = '__init__'), parse_attach_spec(expr)) + c(list(mod = list(prefix = '.', name = '__init__')), parse_attach_spec(expr)) } else if (identical(name, quote(..))) { - # parse_mod(bquote(../.(`[[<-`(expr, 2L, quote(.))))) - c(list(prefix = '..', name = '.'), parse_attach_spec(expr)) + c(list(mod = list(prefix = '..', name = '.')), parse_attach_spec(expr)) } else { c(parse_pkg_name(name), parse_attach_spec(expr)) } + } else if (is.call(name) && identical(name[[1L]], quote(mod))) { + c(parse_bare_mod(name[[2L]]), parse_attach_spec(expr)) } else { throw('expected a name in {expr;"}, got {describe_token(name)}') } } else if (identical(expr[[1L]], quote(`/`))) { parse_mod(expr) + } else if (identical(expr[[1L]], quote(mod))) { + c(parse_bare_mod(expr[[2L]]), list(attach = NULL)) } else { throw('expected {"/";"} in {expr;"}, got {expr[[1L]];"}') } @@ -196,6 +201,36 @@ parse_mod = function (expr) { } } +parse_bare_mod = function (expr) { + if (is.call(expr)) { + if (identical(expr[[1L]], quote(`/`))) { + prefix = parse_mod_prefix(expr[[2L]]) + mod = expr[[3L]] + + # Runs of `..` from the start are valid; `..` in the middle is not: + + if (any(diff(which(c(TRUE, prefix$prefix == '..'))) > 1L)) { + # At least one gap in the sequence of `..` from the start + throw('{"..";"} can only be used as a prefix') + } + + if ('.' %in% prefix$prefix[-1L]) { + throw('{".";"} can only be used as a prefix') + } + + if (is.name(mod)) { + list(mod = c(name = parse_name(mod), prefix)) + } else { + throw('expected mod name in {expr;"}, got {describe_token(mod)}') + } + } else { + throw('expected mod name in {expr;"}, got {describe_token(expr[[1L]])}') + } + } else if (is.name(expr)) { + list(mod = list(name = parse_name(expr), prefix = NULL)) + } +} + parse_mod_prefix = function (expr) { if (is.name(expr)) { list(prefix = deparse1(expr)) @@ -280,5 +315,9 @@ parse_name = function (expr) { } describe_token = function (expr) { - fmt('{class(expr)} literal {expr;"}') + if (is.name(expr) || is.call(expr)) { + fmt('{expr;"}') + } else { + fmt('{class(expr)} literal {expr;"}') + } } diff --git a/tests/testthat/helper-mod-spec.r b/tests/testthat/helper-mod-spec.r new file mode 100644 index 00000000..7d6d315c --- /dev/null +++ b/tests/testthat/helper-mod-spec.r @@ -0,0 +1,33 @@ +test_use = function (...) { + call = match.call() + parse_spec(call[[2L]], names(call)[[2L]] %||% '') +} + +expect_identical_spec = function (object, expected) { + act = quasi_label(rlang::enquo(object), NULL, arg = 'object') + exp = quasi_label(rlang::enquo(expected), NULL, arg = 'expected') + + ident = identical(act$val, exp$val) + msg = if (ident) { + '' + } else { + act_str = capture.output(str(unclass(act$val))) + exp_str = capture.output(str(unclass(exp$val))) + paste0(paste(' ', act_str, collapse = '\n'), '\n≠\n', paste(' ', exp_str, collapse = '\n')) + } + + expect( + ident, + sprintf('%s not identical to %s:\n%s', act$lab, exp$lab, msg), + info = NULL + ) + invisible(act$val) +} + +is_mod_spec = function (x) { + inherits(x, 'box$mod_spec') +} + +is_pkg_spec = function (x) { + inherits(x, 'box$pkg_spec') +} diff --git a/tests/testthat/test-find.r b/tests/testthat/test-find.r index 5f8fdd31..07eaca6d 100644 --- a/tests/testthat/test-find.r +++ b/tests/testthat/test-find.r @@ -98,3 +98,17 @@ test_that('modules are found during Shiny startup', { script_path = rscript('support/run-shiny.r') expect_paths_equal(script_path, 'support/shiny-app') }) + +test_that('unscoped modules can be found', { + spec = parse_spec(quote(mod(a)), '') + candidates = mod_file_candidates(spec, 'x') + expect_setequal( + unlist(candidates), + c('x/./a.r', 'x/./a.R', 'x/./a/__init__.r', 'x/./a/__init__.R') + ) + + old_opts = options(box.path = file.path(getOption('box.path'), 'mod')) + on.exit(options(old_opts)) + + expect_error(find_mod(spec, environment()), NA) +}) diff --git a/tests/testthat/test-parse-mod-spec.r b/tests/testthat/test-parse-mod-spec.r index b701ee18..44f8cdfb 100644 --- a/tests/testthat/test-parse-mod-spec.r +++ b/tests/testthat/test-parse-mod-spec.r @@ -1,18 +1,5 @@ context('spec parser') -test_use = function (...) { - call = match.call() - parse_spec(call[[2L]], names(call)[[2L]] %||% '') -} - -is_mod_spec = function (x) { - inherits(x, 'box$mod_spec') -} - -is_pkg_spec = function (x) { - inherits(x, 'box$pkg_spec') -} - test_that('modules without attaching can be parsed', { m = test_use(foo/bar) expect_true(is_mod_spec(m)) @@ -271,3 +258,61 @@ test_that('aliases need a name', { expect_box_error(test_use(foo/bar[alias =, y]), 'alias without a name provided in attach list') expect_box_error(test_use(foo/bar[x, alias =]), 'alias without a name provided in attach list') }) + +test_that('module names can be disambiguated explicitly', { + # Helper to generate a mod spec for a global, prefix-less module *without* + # relying on the disambiguation syntax; since that is what we want to test + # here, so we can’t rely on it working correctly. + fake_global_mod = function (...) { + call = match.call() + call[[1L]] = quote(test_use) + x = eval.parent(call) + x['prefix'] = list(NULL) + x + } + + # Test the helper. + fake = fake_global_mod(foo/bar) + expect_identical( + fake, + structure( + list( + name = 'bar', + prefix = NULL, + attach = NULL, + alias = 'bar', + explicit = FALSE + ), + class = c('box$mod_spec', 'box$spec') + ) + ) + + m = test_use(mod(foo/bar)) + expect_true(is_mod_spec(m)) + expect_identical_spec(m, test_use(foo/bar)) + + n = test_use(mod(foo)) + expect_identical_spec(n, fake_global_mod(x/foo)) + + expect_identical_spec(test_use(foo = mod(foo)), fake_global_mod(foo = x/foo)) + + o = test_use(mod(foo)[bar]) + expect_true(is_mod_spec(o)) + expect_null(o$prefix) + expect_equal(o$name, 'foo') + expect_equal(o$attach, c(bar = 'bar')) + + p = test_use(mod(foo/bar)[baz]) + expect_true(is_mod_spec(p)) + expect_equal(p$prefix, 'foo') + expect_equal(p$name, 'bar') + expect_equal(p$attach, c(baz = 'baz')) + + expect_box_error(test_use(mod(foo[bar]))) + expect_box_error(test_use(mod(foo/bar[baz]))) + + expect_error(test_use(mod(./foo)), NA) + expect_error(test_use(mod(../../foo)), NA) + expect_box_error(test_use(mod(foo/./bar))) + expect_box_error(test_use(mod(foo/../bar))) +})