Skip to content

Commit

Permalink
Support knot.toml files in project discovery
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaReiser committed Jan 16, 2025
1 parent b485a63 commit e2fa098
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 17 deletions.
135 changes: 119 additions & 16 deletions crates/red_knot_workspace/src/project/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use crate::project::pyproject::{PyProject, PyProjectError};
use red_knot_python_semantic::ProgramSettings;
use thiserror::Error;

use super::options::KnotTomlError;

#[derive(Debug, PartialEq, Eq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct ProjectMetadata {
Expand All @@ -30,11 +32,7 @@ impl ProjectMetadata {

/// Loads a project from a `pyproject.toml` file.
pub(crate) fn from_pyproject(pyproject: PyProject, root: SystemPathBuf) -> Self {
let name = pyproject.project.and_then(|project| project.name);
let name = name
.map(|name| Name::new(&*name))
.unwrap_or_else(|| Name::new(root.file_name().unwrap_or("root")));

let name = Self::name_from_pyproject(Some(&pyproject), &root);
let options = pyproject
.tool
.and_then(|tool| tool.knot)
Expand All @@ -47,12 +45,34 @@ impl ProjectMetadata {
}
}

fn name_from_pyproject(pyproject: Option<&PyProject>, root: &SystemPath) -> Name {
let name = pyproject
.and_then(|pyproject| pyproject.project.as_ref())
.and_then(|project| project.name.as_ref());
name.map(|name| Name::new(&**name))
.unwrap_or_else(|| Name::new(root.file_name().unwrap_or("root")))
}

/// Loads a project form a `knot.toml` file.
pub(crate) fn from_knot_toml(
options: Options,
root: SystemPathBuf,
pyproject: Option<&PyProject>,
) -> Self {
// TODO(https://github.com/astral-sh/ruff/issues/15491): Respect requires-python
Self {
name: Self::name_from_pyproject(pyproject, &root),
root,
options,
}
}

/// Discovers the closest project at `path` and returns its metadata.
///
/// The algorithm traverses upwards in the `path`'s ancestor chain and uses the following precedence
/// the resolve the project's root.
///
/// 1. The closest `pyproject.toml` with a `tool.knot` section.
/// 1. The closest `pyproject.toml` with a `tool.knot` section or `knot.toml`.
/// 1. The closest `pyproject.toml`.
/// 1. Fallback to use `path` as the root and use the default settings.
pub fn discover(
Expand All @@ -67,21 +87,58 @@ impl ProjectMetadata {

let mut closest_project: Option<ProjectMetadata> = None;

for ancestor in path.ancestors() {
let pyproject_path = ancestor.join("pyproject.toml");
if let Ok(pyproject_str) = system.read_to_string(&pyproject_path) {
let pyproject = PyProject::from_str(&pyproject_str).map_err(|error| {
ProjectDiscoveryError::InvalidPyProject {
path: pyproject_path,
source: Box::new(error),
for project_root in path.ancestors() {
let pyproject_path = project_root.join("pyproject.toml");

let pyproject = if let Ok(pyproject_str) = system.read_to_string(&pyproject_path) {
match PyProject::from_toml_str(&pyproject_str) {
Ok(pyproject) => Some(pyproject),
Err(error) => {
return Err(ProjectDiscoveryError::InvalidPyProject {
path: pyproject_path,
source: Box::new(error),
})
}
}
} else {
None
};

// A `knot.toml` takes precedence over a `pyproject.toml`.
let knot_toml_path = project_root.join("knot.toml");
if let Ok(knot_str) = system.read_to_string(&knot_toml_path) {
let options = match Options::from_toml_str(&knot_str) {
Ok(options) => options,
Err(error) => {
return Err(ProjectDiscoveryError::InvalidKnotToml {
path: knot_toml_path,
source: Box::new(error),
})
}
})?;
};

if pyproject
.as_ref()
.is_some_and(|project| project.knot().is_some())
{
// TODO: Consider using a diagnostic here
tracing::warn!("Ignoring the `tool.knot` section in `{pyproject_path}` because `{knot_toml_path}` takes precedence.");
}

tracing::debug!("Found project at '{}'", project_root);
return Ok(ProjectMetadata::from_knot_toml(
options,
project_root.to_path_buf(),
pyproject.as_ref(),
));
}

if let Some(pyproject) = pyproject {
let has_knot_section = pyproject.knot().is_some();
let metadata = ProjectMetadata::from_pyproject(pyproject, ancestor.to_path_buf());
let metadata =
ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf());

if has_knot_section {
let project_root = ancestor;
tracing::debug!("Found project at '{}'", project_root);

return Ok(metadata);
Expand Down Expand Up @@ -152,6 +209,12 @@ pub enum ProjectDiscoveryError {
source: Box<PyProjectError>,
path: SystemPathBuf,
},

#[error("{path} is not a valid `knot.toml`: {source}")]
InvalidKnotToml {
source: Box<KnotTomlError>,
path: SystemPathBuf,
},
}

#[cfg(test)]
Expand Down Expand Up @@ -402,6 +465,46 @@ expected `.`, `]`
Ok(())
}

/// A `knot.toml` takes precedence over any `pyproject.toml`.
///
/// However, the `pyproject.toml` is still loaded to get the project name and, in the future,
/// the requires-python constraint.
#[test]
fn project_with_knot_and_pyproject_toml() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");

system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "super-app"
requires-python = ">=3.12"
[tool.knot.src]
root = "this_option_is_ignored"
"#,
),
(
root.join("knot.toml"),
r#"
[src]
root = "src"
"#,
),
])
.context("Failed to write files")?;

let root = ProjectMetadata::discover(&root, &system)?;

snapshot_project!(root);

Ok(())
}

#[track_caller]
fn assert_error_eq(error: &ProjectDiscoveryError, message: &str) {
assert_eq!(error.to_string().replace('\\', "/"), message);
Expand Down
12 changes: 12 additions & 0 deletions crates/red_knot_workspace/src/project/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use red_knot_python_semantic::{
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_macros::Combine;
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// The options for the project.
#[derive(Debug, Default, Clone, PartialEq, Eq, Combine, Serialize, Deserialize)]
Expand All @@ -15,6 +16,11 @@ pub struct Options {
}

impl Options {
pub(super) fn from_toml_str(content: &str) -> Result<Self, KnotTomlError> {
let options = toml::from_str(content)?;
Ok(options)
}

pub(super) fn to_program_settings(
&self,
project_root: &SystemPath,
Expand Down Expand Up @@ -101,3 +107,9 @@ pub struct SrcOptions {
/// The root of the project, used for finding first-party modules.
pub root: Option<SystemPathBuf>,
}

#[derive(Error, Debug)]
pub enum KnotTomlError {
#[error(transparent)]
TomlSyntax(#[from] toml::de::Error),
}
2 changes: 1 addition & 1 deletion crates/red_knot_workspace/src/project/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub enum PyProjectError {
}

impl PyProject {
pub(crate) fn from_str(content: &str) -> Result<Self, PyProjectError> {
pub(crate) fn from_toml_str(content: &str) -> Result<Self, PyProjectError> {
toml::from_str(content).map_err(PyProjectError::TomlSyntax)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
source: crates/red_knot_workspace/src/project/metadata.rs
expression: root
---
ProjectMetadata(
name: Name("super-app"),
root: "/app",
options: Options(
environment: None,
src: Some(SrcOptions(
root: Some("src"),
)),
),
)

0 comments on commit e2fa098

Please sign in to comment.