use smol_str::SmolStr;
use crate::index::ModuleIndex;
use crate::linter::diagnostic::Diagnostic;
use crate::linter::rules::matchers::in_signature_position;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::resolve::{Namespace, PackageSource, Resolution, resolve_submodule};
use crate::semantic::{BindingKind, QualifiedRead, SemanticModel};
use crate::syntax::{SyntaxKind, SyntaxNode};
pub struct NonPublicAccess;
const MODULE_IMPLICIT: &[&str] = &["eval", "include"];
impl Rule for NonPublicAccess {
fn id(&self) -> &'static str {
"non-public-access"
}
fn default_enabled(&self) -> bool {
false
}
fn description(&self) -> &'static str {
"Flag a qualified read of a name the target module neither `export`s \
nor declares `public`. Those two statements are how a Julia module \
says what its API is — `public` (1.11) declares intent without \
attaching the name to a `using` — so everything else is internal and \
free to change in a patch release. Only a qualifier that really names \
a module is checked: a local, a parameter, a name an item list bound \
(`import Foo: Bar`), and any plain field access are values, not \
modules. Two kinds of module are left alone: one that declares no \
public API at all, whose whole surface is reached qualified by design, \
and one that re-exports with `Reexport.jl`, whose `@reexport using \
Bar` is a macro call the index cannot follow. So is the package under \
development, whose own internals its files may use. A Base/Core \
export reaches through any module, since every module but a \
`baremodule` implicitly does `using Base` — `Threads.ReentrantLock` \
is Base's type — and so do the `eval` and `include` every module \
binds for itself. Definition sites are not reads: `Base.show(io, x) = \
…` extends a function and `Foo.bar = 1` writes one, and code inside a \
quote or a macro call is exempt as everywhere else. Off by default: \
the rule needs the harvested library that only project context \
provides, and it reports what a module *declared*, so a package whose \
documented interface is qualified and unexported is reported too. No \
fix — the caller cannot make someone else's name public."
}
fn examples(&self) -> &'static [Example] {
&[
Example {
caption: "`summarysize` is part of Base's public API; \
`unwrap_unionall` is not:",
source: "Base.summarysize(x)\nBase.unwrap_unionall(T)\n",
},
Example {
caption: "Macros are checked in their own namespace:",
source: "Base.@_inline_meta\n",
},
]
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
if !ctx.trusts_resolution() {
return;
}
for read in ctx.model.qualified_reads() {
if let Some(diagnostic) = self.finding(ctx, read) {
sink.push(diagnostic);
}
}
}
}
impl NonPublicAccess {
fn finding(&self, ctx: &RuleContext<'_>, read: &QualifiedRead) -> Option<Diagnostic> {
let (member, qualifier) = read.path.split_last()?;
if qualifier.is_empty() || MODULE_IMPLICIT.contains(&member.as_str()) {
return None;
}
let scan = ctx.file_scan();
if scan.in_quote(read.range) || (!read.is_macro && scan.in_macro_call(read.range)) {
return None;
}
if is_definition_target(ctx.root, read) {
return None;
}
let resolution = ctx.resolution.as_ref()?;
let path = module_path(ctx, read, qualifier)?;
let head = path.first()?;
if resolution
.workspace
.as_ref()
.is_some_and(|(pkg, _)| pkg.name == head.as_str())
{
return None;
}
let package = resolution.packages.package(head)?;
let rest: Vec<&str> = path[1..].iter().map(SmolStr::as_str).collect();
let module = resolve_submodule(&package.root, &rest)?;
if !declares_public_api(module) || reexports_through_a_macro(module) {
return None;
}
if module.exports.iter().any(|e| e.name == member.as_str()) {
return None;
}
if !module.bare && system_exports(resolution.packages, member) {
return None;
}
let written = qualifier
.iter()
.map(SmolStr::as_str)
.collect::<Vec<_>>()
.join(".");
Some(Diagnostic::new(
self.id(),
read.range,
format!("`{written}` does not export `{member}` or declare it `public`"),
))
}
}
fn module_path(
ctx: &RuleContext<'_>,
read: &QualifiedRead,
qualifier: &[SmolStr],
) -> Option<Vec<SmolStr>> {
let root = &qualifier[0];
let resolver = ctx.resolver()?;
let mut path = match resolver.resolve(root, read.range.start(), Namespace::Value) {
Resolution::Binding(id) => {
if ctx.model.binding(id).kind != BindingKind::Import {
return None;
}
loaded_module_path(ctx.model, root)?
}
Resolution::Workspace { .. } => return None,
Resolution::System { module, .. } if module != *root => vec![module, root.clone()],
_ => vec![root.clone()],
};
path.extend(qualifier[1..].iter().cloned());
Some(path)
}
fn loaded_module_path(model: &SemanticModel, name: &SmolStr) -> Option<Vec<SmolStr>> {
model.module_loads().iter().find_map(|load| {
if load.items.is_some() || load.path.leading_dots != 0 {
return None;
}
let bound = load
.alias
.as_ref()
.or_else(|| load.path.components.last())?;
(bound == name).then(|| load.path.components.clone())
})
}
fn declares_public_api(module: &ModuleIndex) -> bool {
module.exports.iter().any(|e| e.name != module.name)
}
fn reexports_through_a_macro(module: &ModuleIndex) -> bool {
let named_reexport = |name: &str| matches!(name, "Reexport" | "@reexport");
module
.imported_names
.iter()
.any(|name| named_reexport(name))
|| module
.usings
.iter()
.any(|using| using.components.last().is_some_and(|c| named_reexport(c)))
}
fn system_exports(packages: &dyn PackageSource, member: &str) -> bool {
["Base", "Core"].iter().any(|name| {
packages
.package(name)
.is_some_and(|pkg| pkg.root.exports.iter().any(|e| e.name == member))
})
}
fn is_definition_target(root: &SyntaxNode, read: &QualifiedRead) -> bool {
let Some(node) = root.covering_element(read.range).into_node() else {
return false;
};
let Some(parent) = node.parent() else {
return false;
};
let is_first = parent.children().next().is_some_and(|first| first == node);
match parent.kind() {
SyntaxKind::ASSIGNMENT_EXPR => is_first,
SyntaxKind::CALL_EXPR => is_first && in_signature_position(&parent),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::harvest_tree;
use crate::index::model::{ModuleIndex, PackageIndex};
use crate::linter::rules::ResolutionContext;
use crate::semantic::SemanticModel;
use std::collections::BTreeMap;
use std::sync::Arc;
type Library = BTreeMap<String, Arc<PackageIndex>>;
fn pkg(name: &str, src: &str) -> Arc<PackageIndex> {
let parsed = crate::parser::parse(src);
assert!(parsed.diagnostics.is_empty(), "fixture must parse clean");
let mut root = ModuleIndex {
name: name.to_string(),
..harvest_tree(&parsed.cst)
};
root.add_self_name_exports();
Arc::new(PackageIndex {
name: name.to_string(),
root,
members: Vec::new(),
member_modules: Default::default(),
diagnostics: Vec::new(),
})
}
fn library(packages: &[(&str, &str)]) -> Library {
let mut lib = Library::from([
("Base".to_string(), pkg("Base", "export length\n")),
("Core".to_string(), pkg("Core", "")),
]);
for (name, src) in packages {
lib.insert((*name).to_string(), pkg(name, src));
}
lib
}
const FOO: &str = "export make\npublic peek\nmake() = 1\npeek() = 2\ntweak() = 3\n";
fn findings_in(src: &str, lib: &Library, workspace: Option<Arc<PackageIndex>>) -> Vec<String> {
let parsed = crate::parser::parse(src);
assert!(parsed.diagnostics.is_empty(), "fixture must parse clean");
let model = SemanticModel::build(&parsed.cst);
let ctx =
RuleContext::new(None, &parsed.cst, &model).with_resolution(Some(ResolutionContext {
packages: lib,
workspace: workspace.map(|pkg| (pkg, Vec::new())),
declared_deps: None,
}));
let mut sink = Vec::new();
NonPublicAccess.check_file(&ctx, &mut sink);
sink.into_iter().map(|d| d.message.body).collect()
}
fn findings(src: &str, extra: &[(&str, &str)]) -> Vec<String> {
let mut packages = vec![("Foo", FOO)];
packages.extend_from_slice(extra);
findings_in(src, &library(&packages), None)
}
fn count(src: &str) -> usize {
findings(src, &[]).len()
}
fn flagged(qualifier: &str, member: &str) -> String {
format!("`{qualifier}` does not export `{member}` or declare it `public`")
}
#[test]
fn an_internal_member_is_flagged() {
assert_eq!(
findings("using Foo\nFoo.tweak()\n", &[]),
vec![flagged("Foo", "tweak")]
);
}
#[test]
fn exported_and_public_members_are_silent() {
assert_eq!(
findings("using Foo\nFoo.make()\nFoo.peek()\nFoo.tweak()\n", &[]),
vec![flagged("Foo", "tweak")]
);
}
#[test]
fn a_member_read_as_a_value_is_flagged_too() {
assert_eq!(
findings("using Foo\nf = Foo.tweak\n", &[]),
vec![flagged("Foo", "tweak")]
);
}
#[test]
fn a_module_that_declares_nothing_public_is_left_alone() {
let quiet = [("Quiet", "read(x) = 1\nwrite(x) = 2\n")];
assert_eq!(
findings("using Quiet\nQuiet.read(x)\n", &quiet),
Vec::<String>::new()
);
}
#[test]
fn a_module_that_re_exports_through_a_macro_is_left_alone() {
let re = [(
"Re",
"using Reexport\n@reexport using Other\nexport own\nown() = 1\n",
)];
assert_eq!(
findings("using Re\nRe.alpha(x)\n", &re),
Vec::<String>::new()
);
}
#[test]
fn a_base_name_reached_through_another_module_is_exempt() {
assert_eq!(count("using Foo\nFoo.length(x)\n"), 0);
}
#[test]
fn the_names_every_module_binds_implicitly_are_exempt() {
assert_eq!(count("using Foo\nFoo.eval(ex)\n"), 0);
assert_eq!(count("using Foo\nFoo.include(\"x.jl\")\n"), 0);
}
#[test]
fn macros_are_checked_in_their_own_namespace() {
let pkgs = [(
"Mac",
"export @shout\nmacro shout(x)\n x\nend\nmacro whisper(x)\n x\nend\n",
)];
assert_eq!(
findings("using Mac\nMac.@shout 1\nMac.@whisper 1\n", &pkgs),
vec![flagged("Mac", "@whisper")]
);
}
#[test]
fn a_submodule_path_resolves() {
let pkgs = [(
"Nest",
"export Sub\nmodule Sub\nexport ok\nok() = 1\nhidden() = 2\nend\n",
)];
assert_eq!(
findings("using Nest\nNest.Sub.ok()\nNest.Sub.hidden()\n", &pkgs),
vec![flagged("Nest.Sub", "hidden")]
);
}
#[test]
fn an_alias_and_a_dotted_load_resolve_to_the_module() {
assert_eq!(
findings("import Foo as F\nF.tweak()\n", &[]),
vec![flagged("F", "tweak")]
);
let pkgs = [(
"Outer",
"module Inner\nexport ok\nok() = 1\nsecret() = 2\nend\n",
)];
assert_eq!(
findings("using Outer.Inner\nInner.secret()\n", &pkgs),
vec![flagged("Inner", "secret")]
);
}
#[test]
fn base_and_core_resolve_without_a_load() {
assert_eq!(
findings_in("Base.internal_thing()\n", &library(&[]), None),
vec![flagged("Base", "internal_thing")]
);
assert_eq!(
findings_in("Base.length(x)\n", &library(&[]), None),
Vec::<String>::new()
);
}
#[test]
fn a_definition_or_an_assignment_target_is_not_a_read() {
assert_eq!(count("using Foo\nFoo.tweak(x) = 1\n"), 0);
assert_eq!(count("using Foo\nfunction Foo.tweak(x)\n 1\nend\n"), 0);
assert_eq!(count("using Foo\nFoo.tweak = 1\n"), 0);
assert_eq!(
findings("using Foo\nFoo.make(x) = Foo.tweak(x)\n", &[]),
vec![flagged("Foo", "tweak")]
);
}
#[test]
fn a_field_access_on_a_value_is_not_a_module_access() {
assert_eq!(count("Foo = load()\nFoo.tweak\n"), 0);
assert_eq!(count("g(Foo) = Foo.tweak\n"), 0);
assert_eq!(
count("function g()\n Foo = load()\n Foo.tweak\nend\n"),
0
);
}
#[test]
fn an_item_list_binds_names_not_modules() {
let pkgs = [("Bar", "export ok\nok() = 1\nhidden() = 2\n")];
assert_eq!(
findings("import Foo: Bar\nBar.hidden\n", &pkgs),
Vec::<String>::new()
);
}
#[test]
fn a_relative_load_names_a_module_inside_the_package() {
assert_eq!(count("using .Foo\nFoo.tweak()\n"), 0);
}
#[test]
fn an_unharvested_module_is_left_alone() {
assert_eq!(count("import Mystery\nMystery.thing()\n"), 0);
}
#[test]
fn the_package_under_development_may_use_its_own_internals() {
let ws = pkg("Foo", FOO);
assert_eq!(
findings_in("Foo.tweak()\n", &library(&[("Foo", FOO)]), Some(ws)),
Vec::<String>::new()
);
}
#[test]
fn quoted_code_and_macro_calls_are_skipped() {
assert_eq!(count("using Foo\nex = :(Foo.tweak())\n"), 0);
assert_eq!(count("using Foo\nquote\n Foo.tweak()\nend\n"), 0);
assert_eq!(count("using Foo\n@assert Foo.tweak()\n"), 0);
}
#[test]
fn eval_and_an_unfollowable_include_bail_the_file() {
assert_eq!(count("using Foo\neval(ex)\nFoo.tweak()\n"), 0);
assert_eq!(count("using Foo\ninclude(\"other.jl\")\nFoo.tweak()\n"), 0);
}
#[test]
fn the_span_covers_the_whole_qualified_chain() {
let src = "using Foo\nx = Foo.tweak(1)\n";
let parsed = crate::parser::parse(src);
let model = SemanticModel::build(&parsed.cst);
let lib = library(&[("Foo", FOO)]);
let ctx =
RuleContext::new(None, &parsed.cst, &model).with_resolution(Some(ResolutionContext {
packages: &lib,
workspace: None,
declared_deps: None,
}));
let mut sink = Vec::new();
NonPublicAccess.check_file(&ctx, &mut sink);
assert_eq!(sink.len(), 1);
assert_eq!(&src[sink[0].range], "Foo.tweak");
}
#[test]
fn no_resolution_context_is_silent() {
let parsed = crate::parser::parse("using Foo\nFoo.tweak()\n");
let model = SemanticModel::build(&parsed.cst);
let ctx = RuleContext::new(None, &parsed.cst, &model);
let mut sink = Vec::new();
NonPublicAccess.check_file(&ctx, &mut sink);
assert!(sink.is_empty());
}
}