Skip to content

Commit bc71c45

Browse files
committed
Update docs + relax import rules for private visibility
1 parent 0cfa0a5 commit bc71c45

File tree

3 files changed

+177
-17
lines changed

3 files changed

+177
-17
lines changed

crates/biome_js_analyze/src/lint/correctness/no_private_imports.rs

+163-17
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use schemars::JsonSchema;
1515

1616
use crate::services::dependency_graph::ResolvedImports;
1717

18+
const INDEX_BASENAMES: &[&str] = &["index", "mod"];
19+
1820
declare_lint_rule! {
1921
/// Restricts imports of private exports.
2022
///
@@ -24,21 +26,64 @@ declare_lint_rule! {
2426
/// this makes it hard to enforce module boundaries, or to prevent importing
2527
/// things that were only exported for test purposes, for instance.
2628
///
27-
/// This rule recognizes the JSDoc annotations `@public`, `@package`, and
29+
/// This rule recognizes the JSDoc tags `@public`, `@package`, and
2830
/// `@private` so that you are free to set the visibility of exports.
29-
/// Exports without annotation have a default visibility of **public**, but
30-
/// this can be configured.
31+
/// Exports without tag have a default visibility of **public**, but this
32+
/// can be configured.
33+
///
34+
/// The `@access` tag is also supported if it's used with one of the values
35+
/// `public`, `package`, or `private`.
36+
///
37+
/// ## Public visibility
38+
///
39+
/// Public visibility is the default and means there are no restrictions for
40+
/// importing a given symbol. In other words, without this rule, all
41+
/// exported symbols are implicitly public.
42+
///
43+
/// ## Package visibility
44+
///
45+
/// Within the context of this rule, _package visibility_ means that a
46+
/// symbol is visible within the same "package", which means that any module
47+
/// that resides in the same folder, or one of its subfolders, is allowed to
48+
/// import the symbol. Modules that only share a common folder higher up in
49+
/// the hierarchy are not allowed to import the symbol.
50+
///
51+
/// For a visual explanation, see
52+
/// [this illustration](https://github.com/uhyo/eslint-plugin-import-access?tab=readme-ov-file#what).
53+
///
54+
/// ## Private visibility
55+
///
56+
/// Private visibility means that a symbol may not be imported. This may
57+
/// sound backwards: Why export a symbol at all if you intend for it to be
58+
/// private?
59+
///
60+
/// But to understand the usefulness of `@private`, we should consider that
61+
/// this rule doesn't treat modules and files as one and the same thing.
62+
/// While files are indeed modules, folders are considered modules too, with
63+
/// their files and subfolders being submodules. Therefore, symbols exported
64+
/// as `@private` from an index file, such as `index.js`, can _still_ be
65+
/// imported from other submodules in that same module.
3166
///
32-
/// By enabling this rule, all exported symbols, such as types, functions
33-
/// or other things that may be exported, are considered to be "package
34-
/// private". This means that modules that reside in the same directory, as
35-
/// well as submodules of those "sibling" modules, are allowed to import
36-
/// them, while any other modules that are further away in the file system
37-
/// are restricted from importing them. A symbol's visibility may be
38-
/// extended by re-exporting from an index file.
67+
/// :::note
68+
/// For the sake of compatibility with conventions used with Deno, modules
69+
/// named `mod.js`/`mod.ts` are considered index files too.
70+
/// :::
3971
///
40-
/// Notes:
72+
/// Another reason why private visibility may still be useful is that it
73+
/// allows you to choose specific exceptions. For example, using
74+
/// [overrides](https://biomejs.dev/reference/configuration/#overrides), you
75+
/// may want to disable this rule in all files with a `.test.js` extension.
76+
/// This way, symbols marked private cannot be imported from anywhere except
77+
/// test files.
4178
///
79+
/// ## Known Limitations
80+
///
81+
/// * This rule currently only looks at the JSDoc comments that are attached
82+
/// to the _`export` statement_ nearest to the symbol's definition. If the
83+
/// symbol isn't exported in the same statement as in which it is defined,
84+
/// the visibility as specified in `export` statement is used, not that of
85+
/// the symbol definition. Re-exports cannot override the visibility from
86+
/// the original `export`.
4287
/// * This rule only applies to imports for JavaScript and TypeScript
4388
/// files. Imports for resources such as images or CSS files are exempted
4489
/// regardless of the default visibility setting.
@@ -96,7 +141,6 @@ declare_lint_rule! {
96141
/// // import from the index file of a parent module:
97142
/// import { subPrivateVariable } from "../index.js";
98143
/// ```
99-
///
100144
pub NoPrivateImports {
101145
version: "next",
102146
name: "noPrivateImports",
@@ -113,7 +157,7 @@ declare_lint_rule! {
113157
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
114158
#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
115159
pub struct NoPrivateImportsOptions {
116-
/// The default visibility to assume for symbols without annotation.
160+
/// The default visibility to assume for symbols without visibility tag.
117161
///
118162
/// Default: **public**.
119163
pub default_visibility: Visibility,
@@ -251,12 +295,17 @@ struct GetRestrictedImportOptions<'a> {
251295
/// Dependency data of the target module we're importing from.
252296
target_data: ModuleDependencyData,
253297

254-
/// The visibility to assume for symbols without explicit visibility
255-
/// annotation.
298+
/// The visibility to assume for symbols without explicit visibility tag.
256299
default_visibility: Visibility,
257300
}
258301

259302
impl GetRestrictedImportOptions<'_> {
303+
/// Returns whether [Self::target_path] is within the same module as
304+
/// [Self::self_path].
305+
fn target_path_is_in_same_module(&self) -> bool {
306+
target_path_is_in_same_module_as_self_path(self.target_path, self.self_path)
307+
}
308+
260309
/// Returns whether [Self::target_path] is within the same package as
261310
/// [Self::self_path].
262311
fn target_path_is_in_same_package(&self) -> bool {
@@ -368,7 +417,7 @@ fn get_restricted_import_visibility(
368417

369418
let is_restricted = match visibility {
370419
Visibility::Public => false,
371-
Visibility::Private => true,
420+
Visibility::Private => !options.target_path_is_in_same_module(),
372421
Visibility::Package => !options.target_path_is_in_same_package(),
373422
};
374423

@@ -387,13 +436,41 @@ fn parse_visibility(jsdoc_comment: &str) -> Option<Visibility> {
387436
})
388437
}
389438

439+
/// Returns whether `target_path` is within the same module as `self_path`.
440+
#[inline]
441+
fn target_path_is_in_same_module_as_self_path(
442+
target_path: &Utf8Path,
443+
self_path: &Utf8Path,
444+
) -> bool {
445+
if !target_path
446+
.file_stem()
447+
.is_some_and(|stem| INDEX_BASENAMES.contains(&stem))
448+
{
449+
return false;
450+
}
451+
452+
let Some(target_parent) = target_path.parent() else {
453+
// If we cannot navigate further up from the target path, it means the
454+
// target is in the root, which means everything else is in the same
455+
// module as it.
456+
return true;
457+
};
458+
459+
self_path
460+
.ancestors()
461+
.any(|ancestor| ancestor == target_parent)
462+
}
463+
390464
/// Returns whether `target_path` is within the same package as `self_path`.
391465
#[inline]
392466
fn target_path_is_in_same_package_as_self_path(
393467
target_path: &Utf8Path,
394468
self_path: &Utf8Path,
395469
) -> bool {
396-
let target_path = if target_path.file_stem().is_some_and(|stem| stem == "index") {
470+
let target_path = if target_path
471+
.file_stem()
472+
.is_some_and(|stem| INDEX_BASENAMES.contains(&stem))
473+
{
397474
target_path.parent().unwrap_or(Utf8Path::new("."))
398475
} else {
399476
target_path
@@ -415,8 +492,77 @@ fn target_path_is_in_same_package_as_self_path(
415492
mod tests {
416493
use super::*;
417494

495+
#[test]
496+
fn test_target_path_is_in_same_module_as_self_path() {
497+
assert!(target_path_is_in_same_module_as_self_path(
498+
Utf8Path::new("index.js"),
499+
Utf8Path::new("self.js")
500+
));
501+
assert!(target_path_is_in_same_module_as_self_path(
502+
Utf8Path::new("index.js"),
503+
Utf8Path::new("nested/self.js")
504+
));
505+
assert!(target_path_is_in_same_module_as_self_path(
506+
Utf8Path::new("./index.js"),
507+
Utf8Path::new("./nested/self.js")
508+
));
509+
assert!(target_path_is_in_same_module_as_self_path(
510+
Utf8Path::new("./nested/index.js"),
511+
Utf8Path::new("./nested/nested/self.js")
512+
));
513+
514+
assert!(!target_path_is_in_same_module_as_self_path(
515+
Utf8Path::new("target.js"),
516+
Utf8Path::new("self.js")
517+
));
518+
assert!(!target_path_is_in_same_module_as_self_path(
519+
Utf8Path::new("target.js"),
520+
Utf8Path::new("nested/self.js")
521+
));
522+
assert!(!target_path_is_in_same_module_as_self_path(
523+
Utf8Path::new("./target.js"),
524+
Utf8Path::new("./nested/self.js")
525+
));
526+
assert!(!target_path_is_in_same_module_as_self_path(
527+
Utf8Path::new("target/index.js"),
528+
Utf8Path::new("self.js")
529+
));
530+
assert!(!target_path_is_in_same_module_as_self_path(
531+
Utf8Path::new("target/index.js"),
532+
Utf8Path::new("nested/self.js")
533+
));
534+
assert!(!target_path_is_in_same_module_as_self_path(
535+
Utf8Path::new("target/private.js"),
536+
Utf8Path::new("self.js")
537+
));
538+
assert!(!target_path_is_in_same_module_as_self_path(
539+
Utf8Path::new("target/private.js"),
540+
Utf8Path::new("nested/self.js")
541+
));
542+
assert!(!target_path_is_in_same_module_as_self_path(
543+
Utf8Path::new("./target/private.js"),
544+
Utf8Path::new("./self.js")
545+
));
546+
}
547+
418548
#[test]
419549
fn test_target_path_is_in_same_package_as_self_path() {
550+
assert!(target_path_is_in_same_package_as_self_path(
551+
Utf8Path::new("index.js"),
552+
Utf8Path::new("self.js")
553+
));
554+
assert!(target_path_is_in_same_package_as_self_path(
555+
Utf8Path::new("index.js"),
556+
Utf8Path::new("nested/self.js")
557+
));
558+
assert!(target_path_is_in_same_package_as_self_path(
559+
Utf8Path::new("./index.js"),
560+
Utf8Path::new("./nested/self.js")
561+
));
562+
assert!(target_path_is_in_same_package_as_self_path(
563+
Utf8Path::new("./nested/index.js"),
564+
Utf8Path::new("./nested/nested/self.js")
565+
));
420566
assert!(target_path_is_in_same_package_as_self_path(
421567
Utf8Path::new("target.js"),
422568
Utf8Path::new("self.js")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Importing private symbols is allowed only when importing from an index file
2+
// in the same folder or one of its parents:
3+
import { fooPrivateVariable } from "./index";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: importPrivate.js
4+
---
5+
# Input
6+
```js
7+
// Importing private symbols is allowed only when importing from an index file
8+
// in the same folder or one of its parents:
9+
import { fooPrivateVariable } from "./index";
10+
11+
```

0 commit comments

Comments
 (0)