Skip to content
Merged
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
28 changes: 28 additions & 0 deletions .changeset/based-bears-brawl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@biomejs/biome": minor
---

Biome's resolver now supports `baseUrl` if specified in `tsconfig.json`.

#### Example

Given the following file structure:

**`tsconfig.json`**
```json
{
"compilerOptions": {
"baseUrl": "./src",
}
}
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a paragraph that explains the example below? That's the format that we try with the docs. E.g. "Given the following file structure ..., when importing a "foo" from index.ts, Biome will automatically pick up src/foo.ts"

**`src/foo.ts`**
```ts
export function foo() {}
```

In this scenario, `import { foo } from "foo";` should work regardless of the
location of the file containing the `import` statement.

Fixes [#6432](https://github.com/biomejs/biome/issues/6432).
9 changes: 6 additions & 3 deletions crates/biome_package/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use biome_fs::FileSystem;
use camino::Utf8Path;
pub use license::generated::*;
pub use node_js_package::{
Dependencies, NodeJsPackage, PackageJson, PackageType, TsConfigJson, Version,
CompilerOptions, Dependencies, NodeJsPackage, PackageJson, PackageType, TsConfigJson, Version,
};

use std::any::TypeId;
Expand All @@ -30,7 +30,10 @@ pub trait Manifest: Debug + Sized {
type Language: Language;

/// Loads the manifest of the package from the root node.
fn deserialize_manifest(root: &LanguageRoot<Self::Language>) -> Deserialized<Self>;
fn deserialize_manifest(
root: &LanguageRoot<Self::Language>,
path: &Utf8Path,
) -> Deserialized<Self>;

/// Reads the manifest from the given `path`.
fn read_manifest(fs: &dyn FileSystem, path: &Utf8Path) -> Deserialized<Self>;
Expand All @@ -41,7 +44,7 @@ pub trait Package {
type Manifest: Manifest;

/// Inserts a manifest into the package, taking care of deserialization.
fn insert_serialized_manifest(&mut self, root: &PackageRoot<Self>);
fn insert_serialized_manifest(&mut self, root: &PackageRoot<Self>, path: &Utf8Path);

fn manifest(&self) -> Option<&Self::Manifest> {
None
Expand Down
19 changes: 14 additions & 5 deletions crates/biome_package/src/node_js_package/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
mod package_json;
mod tsconfig_json;

use camino::Utf8Path;
pub use package_json::{Dependencies, PackageJson, PackageType, Version};
pub use tsconfig_json::TsConfigJson;
pub use tsconfig_json::{CompilerOptions, TsConfigJson};

use biome_rowan::Language;

Expand All @@ -22,8 +23,12 @@ pub struct NodeJsPackage {
}

impl NodeJsPackage {
pub fn insert_serialized_tsconfig(&mut self, content: &ProjectLanguageRoot<TsConfigJson>) {
let tsconfig = TsConfigJson::deserialize_manifest(content);
pub fn insert_serialized_tsconfig(
&mut self,
content: &ProjectLanguageRoot<TsConfigJson>,
path: &Utf8Path,
) {
let tsconfig = TsConfigJson::deserialize_manifest(content, path);
let (tsconfig, deserialize_diagnostics) = tsconfig.consume();
self.tsconfig = Some(tsconfig.unwrap_or_default());
self.diagnostics = deserialize_diagnostics
Expand All @@ -46,8 +51,12 @@ pub(crate) type ProjectLanguageRoot<M> = <<M as Manifest>::Language as Language>
impl Package for NodeJsPackage {
type Manifest = PackageJson;

fn insert_serialized_manifest(&mut self, content: &ProjectLanguageRoot<Self::Manifest>) {
let deserialized = Self::Manifest::deserialize_manifest(content);
fn insert_serialized_manifest(
&mut self,
content: &ProjectLanguageRoot<Self::Manifest>,
path: &Utf8Path,
) {
let deserialized = Self::Manifest::deserialize_manifest(content, path);
let (manifest, diagnostics) = deserialized.consume();
self.manifest = manifest;
self.diagnostics = diagnostics
Expand Down
5 changes: 4 additions & 1 deletion crates/biome_package/src/node_js_package/package_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ impl PackageJson {
impl Manifest for PackageJson {
type Language = JsonLanguage;

fn deserialize_manifest(root: &LanguageRoot<Self::Language>) -> Deserialized<Self> {
fn deserialize_manifest(
root: &LanguageRoot<Self::Language>,
_path: &Utf8Path,
) -> Deserialized<Self> {
deserialize_from_json_ast::<Self>(root, "")
}

Expand Down
59 changes: 43 additions & 16 deletions crates/biome_package/src/node_js_package/tsconfig_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,23 @@ pub struct TsConfigJson {
impl Manifest for TsConfigJson {
type Language = JsonLanguage;

fn deserialize_manifest(root: &LanguageRoot<Self::Language>) -> Deserialized<Self> {
deserialize_from_json_ast::<Self>(root, "")
fn deserialize_manifest(
root: &LanguageRoot<Self::Language>,
path: &Utf8Path,
) -> Deserialized<Self> {
let deserialized = deserialize_from_json_ast::<Self>(root, "");
let (mut tsconfig, errors) = deserialized.consume();
if let Some(manifest) = tsconfig.as_mut() {
manifest.initialise_paths(path);
}

Deserialized::new(tsconfig, errors)
}

fn read_manifest(fs: &dyn biome_fs::FileSystem, path: &Utf8Path) -> Deserialized<Self> {
match fs.read_file_from_path(path) {
Ok(content) => {
let (manifest, errors) = Self::parse(true, path, &content);
let (manifest, errors) = Self::parse(path, &content);
Deserialized::new(Some(manifest), errors)
}
Err(error) => Deserialized::new(None, vec![Error::from(error)]),
Expand All @@ -55,7 +64,7 @@ impl Manifest for TsConfigJson {
}

impl TsConfigJson {
fn parse(root: bool, path: &Utf8Path, json: &str) -> (Self, Vec<Error>) {
fn parse(path: &Utf8Path, json: &str) -> (Self, Vec<Error>) {
let (tsconfig, diagnostics) = deserialize_from_json_str(
json,
JsonParserOptions::default()
Expand All @@ -66,34 +75,52 @@ impl TsConfigJson {
.consume();

let mut tsconfig: Self = tsconfig.unwrap_or_default();
tsconfig.root = root;
tsconfig.path = path.to_path_buf();
tsconfig.initialise_paths(path);

(tsconfig, diagnostics)
}

/// Initialises the paths stored in the manifest.
///
/// `path` must be an absolute path to the `tsconfig.json` file itself.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does it mean to be absolute to a file? Can we add a debug_assert! that checks it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means the path is absolute, i.e. not relative.

Added a debug_assert!.

fn initialise_paths(&mut self, path: &Utf8Path) {
// Some tests that use UNIX paths are not recognised as absolute on
// Windows...
#[cfg(not(target_os = "windows"))]
debug_assert!(path.is_absolute());

self.root = true; // For now we only support root configs.

self.path = path.to_path_buf();
let directory = path.parent();
if let Some(base_url) = tsconfig.compiler_options.base_url {
tsconfig.compiler_options.base_url =
if let Some(base_url) = self.compiler_options.base_url.as_ref() {
self.compiler_options.base_url =
directory.map(|dir| normalize_path(&dir.join(base_url)));
}
if tsconfig.compiler_options.paths.is_some() {
tsconfig.compiler_options.paths_base =
tsconfig.compiler_options.base_url.as_ref().map_or_else(
|| directory.map_or_else(Default::default, Utf8Path::to_path_buf),
Clone::clone,
);
if self.compiler_options.paths.is_some() {
self.compiler_options.paths_base = self.compiler_options.base_url.as_ref().map_or_else(
|| directory.map_or_else(Default::default, Utf8Path::to_path_buf),
Clone::clone,
);
}
(tsconfig, diagnostics)
}
}

#[derive(Clone, Debug, Default, Deserializable)]
pub struct CompilerOptions {
/// https://www.typescriptlang.org/tsconfig/#baseUrl
///
/// The base URL is normalised to an absolute path after parsing.
pub base_url: Option<Utf8PathBuf>,

Comment on lines +111 to 115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Ensure JSON key maps to base_url (baseUrl) during deserialisation

Unless the derive macro auto-maps camelCase to snake_case, base_url won’t deserialize from baseUrl. Adding an explicit rename is cheap insurance.

Apply this diff:

     /// https://www.typescriptlang.org/tsconfig/#baseUrl
     ///
     /// The base URL is normalised to an absolute path after parsing.
-    pub base_url: Option<Utf8PathBuf>,
+    #[deserializable(rename = "baseUrl")]
+    pub base_url: Option<Utf8PathBuf>,

To confirm whether auto-mapping is already handled, run:


🏁 Script executed:

#!/bin/bash
# Inspect how camelCase keys are handled elsewhere and if baseUrl mapping exists
rg -n -C2 --type=rust 'Deserializable.*rename' 
rg -n -C3 --type=rust '\bcompiler_options\b' 
rg -n -C2 --type=rust '"baseUrl"|base_url'

Length of output: 9285


I want to double-check how TsConfigJson is derived and whether there’s a rename_all = "camelCase" in place.


🏁 Script executed:

#!/bin/bash
# Inspect TsConfigJson derive and serde attributes
rg -n '#\[derive' crates/biome_package/src/node_js_package/tsconfig_json.rs
rg -n 'rename_all' -C2 --type=rust crates/biome_package/src/node_js_package/tsconfig_json.rs
rg -n 'serde' -C2 --type=rust crates/biome_package/src/node_js_package/tsconfig_json.rs

Length of output: 458


Let’s peek at the top of tsconfig_json.rs to see which derive and import paths Deserializable comes from:


🏁 Script executed:

#!/bin/bash
sed -n '1,30p' crates/biome_package/src/node_js_package/tsconfig_json.rs

Length of output: 1081


Explicitly rename base_url to match JSON baseUrl

The TsConfigJson derive has no rename_all, so base_url will map to "base_url", not "baseUrl". Add an explicit rename:

     /// The base URL is normalised to an absolute path after parsing.
-    pub base_url: Option<Utf8PathBuf>,
+    #[deserializable(rename = "baseUrl")]
+    pub base_url: Option<Utf8PathBuf>,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// https://www.typescriptlang.org/tsconfig/#baseUrl
///
/// The base URL is normalised to an absolute path after parsing.
pub base_url: Option<Utf8PathBuf>,
/// https://www.typescriptlang.org/tsconfig/#baseUrl
///
/// The base URL is normalised to an absolute path after parsing.
#[deserializable(rename = "baseUrl")]
pub base_url: Option<Utf8PathBuf>,
🤖 Prompt for AI Agents
In crates/biome_package/src/node_js_package/tsconfig_json.rs around lines 89 to
93, the struct field `base_url` will serialize/deserialize as "base_url" because
the derive has no rename_all; add an explicit serde rename to match the
TypeScript tsconfig key by annotating the field with #[serde(rename =
"baseUrl")] so it maps to "baseUrl" for both serialization and deserialization.

/// Path aliases.
pub paths: Option<CompilerOptionsPathsMap>,

/// The actual base from where path aliases are resolved.
///
/// The base URL is normalised to an absolute path.
#[deserializable(skip)]
paths_base: Utf8PathBuf,
pub paths_base: Utf8PathBuf,

/// See: https://www.typescriptlang.org/tsconfig/#typeRoots
#[deserializable(rename = "typeRoots")]
Expand Down
56 changes: 41 additions & 15 deletions crates/biome_package/tests/manifest_spec_tests.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
use biome_diagnostics::{DiagnosticExt, print_diagnostic_to_string};
use biome_json_parser::{JsonParserOptions, parse_json};
use biome_package::{NodeJsPackage, Package};
use std::ffi::OsStr;
use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
use std::fs::read_to_string;
use std::path::Path;

mod manifest {
tests_macros::gen_tests! {"tests/manifest/invalid/*.{json}", crate::run_invalid_manifests, "module"}
Expand All @@ -15,16 +14,16 @@ mod tsconfig {
}

fn run_invalid_manifests(input: &'static str, _: &str, _: &str, _: &str) {
let input_file = Path::new(input);
let file_name = input_file.file_name().and_then(OsStr::to_str).unwrap();
let input_file = Utf8Path::new(input);
let file_name = input_file.file_name().unwrap();
let input_code = read_to_string(input_file)
.unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}"));

let mut package = NodeJsPackage::default();
match input_file.extension().map(OsStr::as_encoded_bytes) {
match input_file.extension().map(str::as_bytes) {
Some(b"json") => {
let parsed = parse_json(input_code.as_str(), JsonParserOptions::default());
package.insert_serialized_manifest(&parsed.tree());
package.insert_serialized_manifest(&parsed.tree(), input_file);
}
_ => {
panic!("Extension not supported");
Expand Down Expand Up @@ -69,19 +68,19 @@ fn run_invalid_manifests(input: &'static str, _: &str, _: &str, _: &str) {
}

fn run_invalid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) {
let input_file = Path::new(input);
let file_name = input_file.file_name().and_then(OsStr::to_str).unwrap();
let input_file = Utf8Path::new(input);
let file_name = input_file.file_name().unwrap();
let input_code = read_to_string(input_file)
.unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}"));

let mut project = NodeJsPackage::default();
match input_file.extension().map(OsStr::as_encoded_bytes) {
match input_file.extension().map(str::as_bytes) {
Some(b"json") => {
let parsed = parse_json(
input_code.as_str(),
JsonParserOptions::default().with_allow_comments(),
);
project.insert_serialized_tsconfig(&parsed.tree());
project.insert_serialized_tsconfig(&parsed.tree(), input_file);
}
_ => {
panic!("Extension not supported");
Expand Down Expand Up @@ -126,19 +125,19 @@ fn run_invalid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) {
}

fn run_valid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) {
let input_file = Path::new(input);
let file_name = input_file.file_name().and_then(OsStr::to_str).unwrap();
let input_file = Utf8Path::new(input);
let file_name = input_file.file_name().unwrap();
let input_code = read_to_string(input_file)
.unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}"));

let mut project = NodeJsPackage::default();
match input_file.extension().map(OsStr::as_encoded_bytes) {
match input_file.extension().map(str::as_bytes) {
Some(b"json") => {
let parsed = parse_json(
input_code.as_str(),
JsonParserOptions::default().with_allow_comments(),
);
project.insert_serialized_tsconfig(&parsed.tree());
project.insert_serialized_tsconfig(&parsed.tree(), input_file);
}
_ => {
panic!("Extension not supported");
Expand All @@ -154,11 +153,38 @@ fn run_valid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) {

let mut snapshot_result = String::new();

let strip_prefix = |path: &mut Utf8PathBuf| {
if path.to_string().is_empty() {
return;
}

assert!(path.is_absolute());
let mut stripped_path = Utf8PathBuf::from("<PREFIX>");
let mut past_prefix = false;
for component in path.components() {
if past_prefix {
stripped_path.push(component);
} else if component == Utf8Component::Normal("tests") {
past_prefix = true;
}
}
*path = stripped_path;
};

let mut tsconfig = project.tsconfig.unwrap();
strip_prefix(&mut tsconfig.path);
strip_prefix(&mut tsconfig.compiler_options.paths_base);
tsconfig
.compiler_options
.base_url
.as_mut()
.map(strip_prefix);

snapshot_result.push_str("## Input\n\n");
snapshot_result.push_str(&input_code);
snapshot_result.push_str("\n\n");
snapshot_result.push_str("## Data structure\n\n");
snapshot_result.push_str(&format!("{:#?}", project.tsconfig.unwrap()));
snapshot_result.push_str(&format!("{tsconfig:#?}").replace("\\\\", "/"));

insta::with_settings!({
prepend_module_to_snapshot => false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ expression: tsconfig.valid.baseUrl.json
## Data structure

TsConfigJson {
root: false,
path: "",
root: true,
path: "<PREFIX>/tsconfig/valid/tsconfig.valid.baseUrl.json",
extends: None,
compiler_options: CompilerOptions {
base_url: Some(
"src",
"<PREFIX>/tsconfig/valid/src",
),
paths: None,
paths_base: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ expression: tsconfig.valid.paths.json
## Data structure

TsConfigJson {
root: false,
path: "",
root: true,
path: "<PREFIX>/tsconfig/valid/tsconfig.valid.paths.json",
extends: None,
compiler_options: CompilerOptions {
base_url: Some(
"src",
"<PREFIX>/tsconfig/valid/src",
),
paths: Some(
{
Expand All @@ -35,7 +35,7 @@ TsConfigJson {
],
},
),
paths_base: "",
paths_base: "<PREFIX>/tsconfig/valid/src",
type_roots: None,
},
references: [],
Expand Down
Loading
Loading