Файли
Andrea Basileтаandreabasile a623e5d385 feat(python): add ruff language server for lint, format and organize imports (#859)
In projects configured for Ruff (ruff.toml, .ruff.toml, or pyproject.toml with a [tool.ruff] table), ruff server runs next to basedpyright as a managed pip tool: its lint diagnostics, Reformat Code / format on save, Optimize Imports and Code Cleanup's fix-all. It is listed after the type checkers, so hover, navigation and rename stay with them; projects without a Ruff configuration never start or install it. Root markers accept file#table to require a TOML table, and a hand-defined server named ruff now only overrides how the built-in one launches. Verified with the real-servers end-to-end test against ruff and basedpyright.

Co-authored-by: andreabasile <[email protected]>
2026-11-02 20:27:24 +08:00

131 рядки
6.1 KiB
Rust

//! Root markers: files whose presence in a directory marks a tool's or a
//! framework's project there (`Cargo.toml`, `angular.json`). Language server
//! roots (`root-markers`) and framework modes (`framework-ui-modes`) share them.
use std::path::{Path, PathBuf};
/// The outermost ancestor of `file` containing a root marker, within
/// `project_root` (any ancestor without one: loose files).
pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) -> Option<PathBuf> {
let mut best = None;
for dir in file.ancestors().skip(1) {
if project_root.is_some_and(|root| !dir.starts_with(root)) {
break;
}
if markers.iter().any(|m| has_marker(dir, m)) {
best = Some(dir.to_path_buf());
}
}
best
}
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
fn has_marker(dir: &Path, marker: &str) -> bool {
if let Some((file, table)) = marker.split_once('#') {
return has_toml_table(&dir.join(file), table);
}
let Some((prefix, suffix)) = marker.split_once('*') else {
return dir.join(marker).exists();
};
std::fs::read_dir(dir).is_ok_and(|entries| {
entries.flatten().any(|e| matches_pattern(&e.file_name().to_string_lossy(), prefix, suffix))
})
}
/// `file` parses as TOML and has the dotted `table` (`tool.ruff`).
fn has_toml_table(file: &Path, table: &str) -> bool {
let Ok(text) = std::fs::read_to_string(file) else { return false };
let value = match toml::from_str::<toml::Value>(&text) {
Ok(value) => value,
Err(e) => {
tracing::debug!("root marker {}: {e}", file.display());
return false;
}
};
table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table)
}
/// `path` is (or, after its creation or removal, was) a root marker of the
/// directory above it.
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
let marker = marker.split_once('#').map_or(marker, |(file, _)| file);
match marker.split_once('*') {
None => path.ends_with(marker),
Some((prefix, suffix)) => {
path.file_name().is_some_and(|name| matches_pattern(&name.to_string_lossy(), prefix, suffix))
}
}
}
fn matches_pattern(name: &str, prefix: &str, suffix: &str) -> bool {
name.len() >= prefix.len() + suffix.len() && name.starts_with(prefix) && name.ends_with(suffix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marked_root_needs_a_marker() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let file = root.join("apps/web/src/app.ts");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
let markers = vec!["angular.json".to_string(), "node_modules/@angular/core".to_string()];
assert_eq!(marked_root(Some(root), &file, &markers), None);
std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("apps/web")));
assert_eq!(marked_root(Some(root), &root.join("apps/api/main.ts"), &markers), None);
}
#[test]
fn markers_above_the_project_root_only_count_without_one() {
let tmp = tempfile::tempdir().unwrap();
let (home, project) = (tmp.path(), tmp.path().join("work/site"));
let file = project.join("src/index.html");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::create_dir_all(home.join("node_modules/@angular/core")).unwrap();
let markers = vec!["node_modules/@angular/core".to_string()];
assert_eq!(marked_root(Some(&project), &file, &markers), None);
assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf()));
}
#[test]
fn toml_markers_need_their_table() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let file = root.join("svc/app/main.py");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()];
std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), None);
std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
// Outermost wins: an inline table counts; a value that is no table, or a
// file that does not parse, does not.
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf()));
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff"));
assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff"));
}
#[test]
fn patterns_match_file_names() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("App.csproj"), "").unwrap();
assert!(has_marker(dir, "*.csproj") && has_marker(dir, "App.*") && !has_marker(dir, "*.fsproj"));
assert!(is_marker_path(&dir.join("App.csproj"), "*.csproj"));
assert!(is_marker_path(Path::new("/p/web/node_modules/@angular/core"), "node_modules/@angular/core"));
assert!(is_marker_path(Path::new("/p/web/angular.json"), "angular.json"));
assert!(!is_marker_path(Path::new("/p/web/angular.json.bak"), "angular.json"));
assert!(!is_marker_path(Path::new("/p/core"), "node_modules/@angular/core"));
}
}