Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/lint/rules/import/no-restricted-paths.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@ Each zone has these keys:

If `from` is a path, each `except` is a path relative to `from` and must be inside it. If `from` is a glob, each `except` must be a glob too, and it needs to match the file's absolute path, like `**/types.js`.

A folder name like `[id]` or `{a,b}` is glob syntax in `target` and `from`, from every directory you run `bun lint` in. So `./src/[id]` doesn't match the folder `src/[id]`. To name that folder, write a glob with the bracket in a set: `./src/[[]id]/**`.

Reported:

{/* file "src/server/db.js" */}
```js src/server/db.js
export function query() {}
```

{/* bad {"filename": "src/[id]/page.js", "root": true, "options": [{"zones": [{"target": "./src/[[]id]/**", "from": "./src/server"}]}]} */}
```js src/[id]/page.js
import { query } from "../server/db.js";
```

Not reported:

{/* file "src/server/db.js" */}
```js src/server/db.js
export function query() {}
```

{/* good {"filename": "src/[id]/page.js", "root": true, "options": [{"zones": [{"target": "./src/[id]", "from": "./src/server"}]}]} */}
```js src/[id]/page.js
import { query } from "../server/db.js";
```

With this zone, `src/client/app.js` can import `../server/types.js` and nothing else from `src/server`:

```toml bunfig.toml
Expand Down
2 changes: 1 addition & 1 deletion docs/lint/rules/reports.json
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@
"import/no-nodejs-modules": {"0":[[1,1,1,21,"Do not import Node.js builtin module \"fs\""],[2,1,2,34,"Do not import Node.js builtin module \"node:path\""],[4,12,4,25,"Do not import Node.js builtin module \"os\""]],"3":[[1,1,1,29,"Do not import Node.js builtin module \"path\""]]},
"import/no-relative-packages": {"0":[[1,24,1,41,"Relative import from another package is not allowed. Use `@acme/ui/button.js` instead of `../ui/button.js`"]]},
"import/no-relative-parent-imports": {"0":[[1,25,1,36,"Relative imports from parent directories are not allowed. Please either pass what you're importing through at runtime (dependency injection), move `home.js` to same directory as `../api.js` or consider making `../api.js` a package."]]},
"import/no-restricted-paths": {"0":[[1,23,1,40,"Unexpected path \"../server/db.js\" imported in restricted zone."]]},
"import/no-restricted-paths": {"0":[[1,23,1,40,"Unexpected path \"../server/db.js\" imported in restricted zone."]],"2":[[1,23,1,40,"Unexpected path \"../server/db.js\" imported in restricted zone."]]},
"import/no-self-import": {"0":[[1,1,1,35,"Module imports itself."]],"1":[[1,21,1,33,"Module imports itself."]]},
"import/no-unassigned-import": {"0":[[1,1,1,32,"Imported module should be assigned"],[2,1,2,25,"Imported module should be assigned"],[3,1,3,30,"Imported module should be assigned"]],"3":[[1,1,1,32,"Imported module should be assigned"]]},
"import/no-unresolved": {"0":[[1,28,1,47,"Unable to resolve path to module './utils/fromat.js'."],[2,21,2,54,"Unable to resolve path to module 'a-package-that-is-not-installed'."]]},
Expand Down
7 changes: 6 additions & 1 deletion src/codegen/lint-rule-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,12 @@ export async function lintExamples(dir: string, examples: Example[], byPage: Map
const nameOf = (it: Example) => `${it.page.replaceAll("/", "__")}__${it.line}`;
// Those in another language are below an ESLint config of their own.
const languagesOf = (it: Example) => byPage.get(it.page)!.languages;
const directoryOf = (it: Example) => join(dir, ...(languagesOf(it).length ? ["languages"] : []), nameOf(it));
// A `root` example has options that are paths, so it runs below a directory with a name that is a pattern.
const above = (it: Example) => [
...(languagesOf(it).length ? ["languages"] : []),
...(it.root ? ["[p]{a,b}+(c)"] : []),
];
const directoryOf = (it: Example) => join(dir, ...above(it), nameOf(it));
const put = (path: string, content: string) => (
mkdirSync(dirname(path), { recursive: true }),
writeFileSync(path, content)
Expand Down
10 changes: 10 additions & 0 deletions src/lint/driver/for_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ pub fn run_path_tests(args: &[&[u8]]) -> Option<Vec<u8>> {
b"namespaced" => paths::namespaced(a),
b"normalize" => paths::normalize(a),
b"join" => paths::join_normalized(a, b),
// `split_joined` with each join that a directory and an option or a `tsconfig.json` entry go through.
b"split_resolved" | b"split_joined" | b"split_included" => {
let (joined, separators) = match *function {
b"split_resolved" => (paths::resolve_as(style, a, b), style.separators()),
b"split_joined" => (paths::join_normalized(a, b), style.separators()),
_ => (bun_sema::resolve::join(a, b), &b"/\\"[..]),
};
let (directory, below) = paths::split_joined(a, &[b], separators, &joined);
[directory, b" + ", below].concat()
}
_ => return None,
});
out.push(b'\n');
Expand Down
25 changes: 25 additions & 0 deletions src/lint/glob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,31 @@ impl Minimatch {
}
}

/// A pattern for the paths below `directory`, which is compared byte by byte: a name like `[app]` is no class there.
pub struct Under<M> {
directory: Box<[u8]>,
below: M,
}

impl<M> Under<M> {
/// `directory` and the text of `below` are the two parts of [`paths::split_joined`].
pub fn new(directory: &[u8], below: M) -> Under<M> {
Under {
directory: directory.into(),
below,
}
}

/// The pattern and the part of `path` that it is for, if `path` starts with the directory.
pub fn below<'p>(&self, path: &'p [u8]) -> Option<(&M, &'p [u8])> {
// Without a directory, `strip_prefix` is a call that compares no bytes.
match self.directory.is_empty() {
true => Some((&self.below, path)),
false => Some((&self.below, path.strip_prefix(&self.directory[..])?)),
}
}
}

/// Converts `1..3` from inside braces the way picomatch does. It builds the regex class `[1-3]` from the sorted parts.
/// If that class is invalid, the text matches literally, without the braces.
/// Returns `None` unless the parts are letters, digits and `-`.
Expand Down
126 changes: 88 additions & 38 deletions src/lint/graph/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@
use bun_core::strings;
use bun_glob::{BunGlobWalker, Pattern};
use bun_lint::ast::File;
use bun_lint::glob::Under;
use bun_lint::language::{LanguageOptions, Parser};
use bun_lint::modules::{
Declaration, Flavor, Import, ListFiles, Listed, Lookup, MakeRecord, ModuleId, Modules, Reader,
Record, Request, RequestKind, ResolveBy, Resolved, requests_of,
};
use bun_lint::paths::{is_absolute, relative};
use bun_lint::paths::{is_absolute, relative, split_joined};
use bun_sema::atom::Interner;
use bun_sema::bind::{BindOptions, Recycled, bind_for_lint_in};
use bun_sema::config::{
Expand Down Expand Up @@ -146,7 +147,7 @@ struct Caches<'h> {
resolvers: ShardedMap<Vec<u8>, ProjectResolver<'h>>,
/// Maps a directory to the `tsconfig.json` path for its files. With [`Store::tsconfig`], the key is a file.
configs: ShardedMap<Vec<u8>, Vec<u8>>,
/// Caches [`Graph::scopes_in`] and [`Graph::scopes_of`].
/// Caches [`Graph::scopes_in`], [`Graph::scopes_of`] and, after a NUL, [`Graph::scopes_of_typescript_resolver`].
scopes: ShardedMap<Vec<u8>, Vec<Scope>>,
/// Caches [`Graph::configs_of_typescript_resolver`], keyed by the directory and the projects, and
/// [`Graph::listed_projects`], keyed by the projects.
Expand Down Expand Up @@ -226,6 +227,32 @@ fn with_file<R>(
/// Calls the function with every index below the count, on any number of threads.
pub type Parallel<'p> = &'p dyn Fn(usize, &(dyn Fn(usize) + Sync));

/// How the directory of a `tsconfig.json` is read in an `include` or `exclude` entry that is joined to it.
#[derive(Copy, Clone)]
enum Directory {
/// As a part of the pattern, like oxc-resolver: in a directory named `[x]` oxlint applies no `include` pattern.
Pattern,
/// As a path, whatever its name is, like eslint-import-resolver-typescript. The rest of an entry stays a pattern.
Path,
}

/// An `include` or `exclude` entry of a `tsconfig.json`.
struct Entry {
pattern: Under<Pattern>,
/// The entry's path, if the pattern doesn't match it. oxc-resolver compares the two before it matches.
path: Option<Box<[u8]>>,
/// The pattern ends in a `*`, in which case it only matches JavaScript and TypeScript files.
is_for_scripts: bool,
}

impl Entry {
fn has(&self, path: &[u8]) -> bool {
let below = self.pattern.below(path);
below.is_some_and(|(pattern, rest)| pattern.matches(rest))
|| self.path.as_deref() == Some(path)
}
}

/// The set of files a `tsconfig.json` applies to, as decided by oxc-resolver, which oxlint uses. TypeScript decides
/// this differently.
struct Scope {
Expand All @@ -234,10 +261,8 @@ struct Scope {
/// `baseUrl`, or else the config's directory.
base: Vec<u8>,
files: Vec<Vec<u8>>,
/// Absolute patterns. The flag is true if the pattern ends in a `*`, in which case it only matches JavaScript and
/// TypeScript files.
include: Vec<(Pattern, bool)>,
exclude: Vec<Pattern>,
include: Vec<Entry>,
exclude: Vec<Entry>,
allows_js: bool,
}

Expand All @@ -251,32 +276,49 @@ impl Scope {
allows_js: false,
};

fn new(project: &Project) -> Scope {
fn new(project: &Project, reads: Directory) -> Scope {
let directory = directory_of(&project.config_path);
let absolute = |relative_path: &[u8]| match relative_path.strip_prefix(b"${configDir}") {
Some(rest) => join(directory, rest.strip_prefix(b"/").unwrap_or(rest)),
None => join(directory, relative_path),
};
let pattern = |config_glob: &Vec<u8>| {
let mut path = absolute(config_glob);
let entry = |config_glob: &[u8], reads: Directory| {
let path = absolute(config_glob);
let written = config_glob
.strip_prefix(b"${configDir}")
.unwrap_or(config_glob);
let (directory, below) = match reads {
Directory::Pattern => (&b""[..], &path[..]),
// `join` takes both for separators on every system.
Directory::Path => split_joined(directory, &[written], b"/\\", &path),
};
// Only these make a pattern that doesn't match its own text.
let is_own_match = !strings::contains_any(below, b"[{\\");
// A last segment without a `.` or a wildcard is treated as a directory.
let last = &path[directory_of(&path).len()..];
if !strings::contains_any(last, b".*?") {
path.extend_from_slice(b"/**/*");
let below = match strings::contains_any(last, b".*?") {
true => Cow::Borrowed(below),
false => Cow::Owned([below, b"/**/*"].concat()),
};
Entry {
pattern: Under::new(
directory,
Pattern::new(&bun_glob::pattern::of_fast_glob(&below)),
),
is_for_scripts: below.ends_with(b"*"),
path: (!is_own_match).then(|| path[..].into()),
}
(
Pattern::new(&bun_glob::pattern::of_fast_glob(&path)),
path.ends_with(b"*"),
)
};
let patterns =
|config_globs: &[Vec<u8>]| config_globs.iter().map(pattern).collect::<Vec<_>>();
let entries = |config_globs: &[Vec<u8>]| {
let entries = config_globs.iter().map(|it| entry(it, reads));
entries.collect::<Vec<_>>()
};
let raw_config = &project.written;
let everything = [b"**/*".to_vec()];
let include = match (&raw_config.include, &raw_config.files) {
(Some(include), _) => &include[..],
(None, Some(_)) => &[],
(None, None) => &everything[..],
(Some(include), _) => entries(include),
(None, Some(_)) => Vec::new(),
// Everything in the directory, which oxc-resolver compares as a path too.
(None, None) => vec![entry(b"**/*", Directory::Path)],
};
let allow_js = (project.raw_compiler_options.iter()).find(|it| it.0 == b"allowJs");
let base_url = (project.raw_compiler_options.iter()).find(|it| it.0 == b"baseUrl");
Expand All @@ -288,10 +330,8 @@ impl Scope {
files: (raw_config.files.iter().flatten())
.map(|it| absolute(it))
.collect(),
include: patterns(include),
exclude: (patterns(raw_config.exclude.as_deref().unwrap_or_default()).into_iter())
.map(|it| it.0)
.collect(),
include,
exclude: entries(raw_config.exclude.as_deref().unwrap_or_default()),
allows_js: allow_js.is_some_and(|it| it.1 == Json::Bool(true)),
}
}
Expand All @@ -307,8 +347,8 @@ impl Scope {
return false;
}
let is_script = is_js || matches!(extension, b"ts" | b"tsx" | b"mts" | b"cts");
let mut include = self.include.iter().filter(|it| is_script || !it.1);
include.any(|it| it.0.matches(path)) && !self.exclude.iter().any(|it| it.matches(path))
let mut include = (self.include.iter()).filter(|it| is_script || !it.is_for_scripts);
include.any(|it| it.has(path)) && !self.exclude.iter().any(|it| it.has(path))
}
}

Expand Down Expand Up @@ -409,19 +449,31 @@ impl<'h> Graph<'h> {
fn scopes_in(&self, directory: &[u8]) -> &[Scope] {
match self.caches().scopes.get_ref(directory) {
Some(scopes) => scopes,
None => self.load_scopes(directory, &join(directory, b"tsconfig.json")),
None => {
let config = join(directory, b"tsconfig.json");
self.load_scopes(directory, &config, Directory::Pattern)
}
}
}

/// The same for the config at `config`, whatever its file name.
fn scopes_of(&self, config: &[u8]) -> &[Scope] {
match self.caches().scopes.get_ref(config) {
Some(scopes) => scopes,
None => self.load_scopes(config, config),
None => self.load_scopes(config, config, Directory::Pattern),
}
}

/// The same with [`Directory::Path`], for eslint-import-resolver-typescript.
fn scopes_of_typescript_resolver(&self, config: &[u8]) -> &[Scope] {
let key = [b"\0", config].concat();
match self.caches().scopes.get_ref(&key[..]) {
Some(scopes) => scopes,
None => self.load_scopes(&key, config, Directory::Path),
}
}

fn load_scopes(&self, key: &[u8], config: &[u8]) -> &[Scope] {
fn load_scopes(&self, key: &[u8], config: &[u8], reads: Directory) -> &[Scope] {
let caches = self.caches();
let _loading = self.loading.lock();
if let Some(scopes) = caches.scopes.get_ref(key) {
Expand All @@ -430,15 +482,15 @@ impl<'h> Graph<'h> {
let disk = WithoutListings(self.store.disk());
let load = |config: &[u8]| {
let project = load_overriding(&disk, &self.store.session, config, &|_| Vec::new());
project.ok().map(|it| Scope::new(&it))
project.ok().map(|it| Scope::new(&it, reads))
};
let project = (disk.is_file(config))
.then(|| load_overriding(&disk, &self.store.session, config, &|_| Vec::new()).ok());
let scopes = project.flatten().map_or_else(Vec::new, |project| {
let referenced = project.references.iter();
let referenced =
referenced.map(|it| load(&resolve_config_file_name_of_project_reference(&it.path)));
let own = Some(Scope::new(&project));
let own = Some(Scope::new(&project, reads));
let all: Option<Vec<Scope>> = referenced.chain([own]).collect();
all.unwrap_or_else(|| vec![Scope::BROKEN])
});
Expand Down Expand Up @@ -513,8 +565,8 @@ impl<'h> Graph<'h> {
};
bun_lint::utils::sort::sort_by_key(&mut listed, distance);
listed.dedup();
let applies_to_file =
|it: &Vec<u8>| self.scopes_of(it).last().is_some_and(|own| own.has(from));
let scopes_of = |config: &Vec<u8>| self.scopes_of_typescript_resolver(config);
let applies_to_file = |it: &Vec<u8>| scopes_of(it).last().is_some_and(|own| own.has(from));
let chosen = match listed.len() {
0 if self.store.uses_nearest_tsconfig => {
let mut above = bun_lint::paths::ancestors(directory);
Expand All @@ -525,6 +577,7 @@ impl<'h> Graph<'h> {
}
0 => self.config_in(&self.store.cwd).into_iter().collect(),
1 => listed,
// The closest project that includes the file, alone, as the resolver does once it has resolved an import.
_ => match listed.iter().position(applies_to_file) {
Some(at) => vec![listed.swap_remove(at)],
None => listed,
Comment thread
robobun marked this conversation as resolved.
Resolved
Expand All @@ -533,10 +586,7 @@ impl<'h> Graph<'h> {
let mut configs = Vec::new();
for config in chosen {
// `references: "auto"`
let referenced = self
.scopes_of(&config)
.split_last()
.map_or(&[][..], |it| it.1);
let referenced = scopes_of(&config).split_last().map_or(&[][..], |it| it.1);
let contains_directory = |it: &&Scope| {
let rest = directory.strip_prefix(&it.base[..]);
!it.config.is_empty() && rest.is_some_and(|it| matches!(it, [] | [b'/', ..]))
Expand Down
2 changes: 1 addition & 1 deletion src/lint/js_plugin/eslint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ impl Host<'_> {

/// Calls ESLint's `Linter.verify`. `run` is JSON with what all files of a config file share: `from`, `basePath`,
/// `allowInlineConfig`, `onlyErrors`, `aboutLayout`, and for [`Configuration::whole`] `file`, `ignores` and `added`.
/// `reviveOverride` in `worker/eslint.js` turns each object in `added` into one that every ESLint 9 and 10 accepts.
/// `reviveOverride` in `worker/eslint.js` turns each object in `added` into one that the installed ESLint accepts.
pub fn lint_with_eslint(
&self,
configuration: &Configuration,
Expand Down
Loading
Loading