diff --git a/crates/red_knot_workspace/src/project/metadata.rs b/crates/red_knot_workspace/src/project/metadata.rs index 996f18c0dc..9d30e6ed6b 100644 --- a/crates/red_knot_workspace/src/project/metadata.rs +++ b/crates/red_knot_workspace/src/project/metadata.rs @@ -3,10 +3,12 @@ use ruff_python_ast::name::Name; use crate::project::combine::Combine; use crate::project::options::Options; -use crate::project::pyproject::{PyProject, PyProjectError}; +use crate::project::pyproject::{Project, 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 { @@ -30,16 +32,28 @@ 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)) + Self::from_options( + pyproject + .tool + .and_then(|tool| tool.knot) + .unwrap_or_default(), + root, + pyproject.project.as_ref(), + ) + } + + /// Loads a project from a set of options with an optional pyproject-project table. + pub(crate) fn from_options( + options: Options, + root: SystemPathBuf, + project: Option<&Project>, + ) -> Self { + let name = project + .and_then(|project| project.name.as_ref()) + .map(|name| Name::new(&**name)) .unwrap_or_else(|| Name::new(root.file_name().unwrap_or("root"))); - let options = pyproject - .tool - .and_then(|tool| tool.knot) - .unwrap_or_default(); - + // TODO(https://github.com/astral-sh/ruff/issues/15491): Respect requires-python Self { name, root, @@ -52,7 +66,7 @@ impl ProjectMetadata { /// 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( @@ -67,21 +81,60 @@ impl ProjectMetadata { let mut closest_project: Option = 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_options( + options, + project_root.to_path_buf(), + pyproject + .as_ref() + .and_then(|pyproject| pyproject.project.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); @@ -152,6 +205,12 @@ pub enum ProjectDiscoveryError { source: Box, path: SystemPathBuf, }, + + #[error("{path} is not a valid `knot.toml`: {source}")] + InvalidKnotToml { + source: Box, + path: SystemPathBuf, + }, } #[cfg(test)] @@ -402,6 +461,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); diff --git a/crates/red_knot_workspace/src/project/options.rs b/crates/red_knot_workspace/src/project/options.rs index 1e502f925b..d9db769a73 100644 --- a/crates/red_knot_workspace/src/project/options.rs +++ b/crates/red_knot_workspace/src/project/options.rs @@ -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)] @@ -15,6 +16,11 @@ pub struct Options { } impl Options { + pub(super) fn from_toml_str(content: &str) -> Result { + let options = toml::from_str(content)?; + Ok(options) + } + pub(super) fn to_program_settings( &self, project_root: &SystemPath, @@ -101,3 +107,9 @@ pub struct SrcOptions { /// The root of the project, used for finding first-party modules. pub root: Option, } + +#[derive(Error, Debug)] +pub enum KnotTomlError { + #[error(transparent)] + TomlSyntax(#[from] toml::de::Error), +} diff --git a/crates/red_knot_workspace/src/project/pyproject.rs b/crates/red_knot_workspace/src/project/pyproject.rs index ebb78ae6dd..84a2ebdccb 100644 --- a/crates/red_knot_workspace/src/project/pyproject.rs +++ b/crates/red_knot_workspace/src/project/pyproject.rs @@ -30,7 +30,7 @@ pub enum PyProjectError { } impl PyProject { - pub(crate) fn from_str(content: &str) -> Result { + pub(crate) fn from_toml_str(content: &str) -> Result { toml::from_str(content).map_err(PyProjectError::TomlSyntax) } } diff --git a/crates/red_knot_workspace/src/project/snapshots/red_knot_workspace__project__metadata__tests__project_with_knot_and_pyproject_toml.snap b/crates/red_knot_workspace/src/project/snapshots/red_knot_workspace__project__metadata__tests__project_with_knot_and_pyproject_toml.snap new file mode 100644 index 0000000000..2ba17fbeec --- /dev/null +++ b/crates/red_knot_workspace/src/project/snapshots/red_knot_workspace__project__metadata__tests__project_with_knot_and_pyproject_toml.snap @@ -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"), + )), + ), +)