diff --git a/cli/adopt/src/scaffold.rs b/cli/adopt/src/scaffold.rs index 4c5db6d666..22b6f6111e 100644 --- a/cli/adopt/src/scaffold.rs +++ b/cli/adopt/src/scaffold.rs @@ -263,7 +263,53 @@ pub fn plan_init_files_at(root: &Path, module_name: &str) -> Result bool { + let mut python = false; + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() { + let skip = name.starts_with('.') + || name.starts_with("bazel-") + || matches!(name.as_str(), "node_modules" | "target" | "site-packages"); + if !skip { + pending.push(entry.path()); + } + } else if name == ".ruff.toml" || name == "ruff.toml" { + return false; + } else if name == "pyproject.toml" { + let text = std::fs::read_to_string(entry.path()).unwrap_or_default(); + let has_ruff = text + .parse::() + .is_ok_and(|table| table.get("tool").and_then(|t| t.get("ruff")).is_some()); + if has_ruff { + return false; + } + } else if name.ends_with(".py") { + python = true; + } + } + } + python } fn plan_init_files_full(module_name: &str, branch: &str) -> Result, AdoptError> { @@ -881,6 +927,28 @@ mod tests { ); } + #[test] + fn empty_ruff_config_only_when_python_has_none() { + let root = std::env::temp_dir().join(format!("dx-adopt-ruff-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("src")).expect("temp dir"); + assert!(!super::needs_empty_ruff_config(&root)); + std::fs::write(root.join("src/app.py"), "").expect("source"); + assert!(super::needs_empty_ruff_config(&root)); + std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"a\"\n").expect("toml"); + assert!(super::needs_empty_ruff_config(&root)); + std::fs::write( + root.join("pyproject.toml"), + "[tool.ruff]\nline-length = 100\n", + ) + .expect("toml"); + assert!(!super::needs_empty_ruff_config(&root)); + std::fs::remove_file(root.join("pyproject.toml")).expect("remove"); + std::fs::write(root.join("src/.ruff.toml"), "").expect("ruff"); + assert!(!super::needs_empty_ruff_config(&root)); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn caller_leaves_reusable_defaults_out() { let caller = render_consumer_caller(); diff --git a/gazelle/dispatch/conformance_test.go b/gazelle/dispatch/conformance_test.go index 421346115e..55277ee11b 100644 --- a/gazelle/dispatch/conformance_test.go +++ b/gazelle/dispatch/conformance_test.go @@ -155,6 +155,9 @@ var conformanceRows = map[string]conformanceRow{ kind: "python_library", target: "demo", defs: "python/rules:defs.bzl", symbols: []string{"python_library", "python_test", "python_binary"}, inert: []string{"notes.txt", "orphan.pyi"}, + extraLoads: map[string][]string{ + "@rules_dx//quality:native_config.bzl": {"ruff_config"}, + }, }, "ruby": { files: map[string]string{"demo.rb": "module Demo\nend\n"}, diff --git a/gazelle/python/BUILD.bazel b/gazelle/python/BUILD.bazel index 19c46997d7..eec6c1b4de 100644 --- a/gazelle/python/BUILD.bazel +++ b/gazelle/python/BUILD.bazel @@ -11,6 +11,7 @@ go_library( "lang.go", "naming.go", "parser.go", + "ruff.go", "stdlib.go", ], importpath = "github.com/devhusdk/rules_dx/gazelle/python", @@ -20,6 +21,8 @@ go_library( "//gazelle/common/treesitter", "@com_github_odvcencio_gotreesitter//:gotreesitter", "@com_github_odvcencio_gotreesitter//grammars/python", + "@com_github_pelletier_go_toml_v2//:go-toml", + "@gazelle//config", "@gazelle//language", "@gazelle//v2/rule", ], @@ -62,7 +65,9 @@ real_source_target( aspect_hints = ["//quality:corpus_vale_config"], markdown_srcs = [ "testdata/entries/README.md", + "testdata/pyproject_no_ruff/README.md", "testdata/pytest_plugins/README.md", + "testdata/ruff_pyproject/README.md", "testdata/source_only/README.md", ], tags = ["corpus"], diff --git a/gazelle/python/lang.go b/gazelle/python/lang.go index 948ad20ec9..a0b2906d08 100644 --- a/gazelle/python/lang.go +++ b/gazelle/python/lang.go @@ -22,6 +22,7 @@ var pythonKinds = map[string]rule.KindInfo{ libraryKind: kindInfo(), testKind: kindInfo(), binaryKind: binaryKindInfo(), + ruffKind: ruffKindInfo(), } func kindInfo() rule.KindInfo { @@ -30,19 +31,29 @@ func kindInfo() rule.KindInfo { NonEmptyAttrs: map[string]bool{"srcs": true}, MergeableAttrs: map[string]bool{ "srcs": true, - "imports": true, - "deps": true, + "imports": true, + "deps": true, + "aspect_hints": true, }, ResolveAttrs: map[string]bool{"deps": true}, } } +func ruffKindInfo() rule.KindInfo { + return rule.KindInfo{ + MatchAttrs: []string{"src"}, + NonEmptyAttrs: map[string]bool{"src": true}, + MergeableAttrs: map[string]bool{"src": true}, + } +} + func binaryKindInfo() rule.KindInfo { return rule.KindInfo{ MatchAttrs: []string{"main"}, MergeableAttrs: map[string]bool{ - "imports": true, - "deps": true, + "imports": true, + "deps": true, + "aspect_hints": true, }, ResolveAttrs: map[string]bool{"deps": true}, } @@ -54,7 +65,9 @@ func newLang() *common.SingleLang { return common.NewSingleLang(common.SingleLangSpec{ LanguageName: languageName, LibraryKind: libraryKind, - ExtraKinds: []string{testKind, binaryKind}, + ExtraKinds: []string{testKind, binaryKind, ruffKind}, + KindDefs: map[string]string{ruffKind: nativeDefs}, + ConfigureExt: configureRuff, Kinds: pythonKinds, DefsPackage: "python/rules:defs.bzl", ImportAttr: "srcs", @@ -79,8 +92,14 @@ func generateRules(args language.GenerateArgs, _ map[string]rule.KindInfo, rep c } } sort.Strings(sources) + ruff := ruffAt(args.Config) + var result language.GenerateResult + if ruff != nil && ruff.rel == args.Rel { + result.Gen = append(result.Gen, ruffRule(ruff, args.Rel)) + result.Imports = append(result.Imports, common.WrapImportSet(nil)) + } if len(sources) == 0 { - return mergeStale(args.File, language.GenerateResult{}) + return mergeStale(args.File, result) } type plan struct { @@ -140,7 +159,6 @@ func generateRules(args language.GenerateArgs, _ map[string]rule.KindInfo, rep c } plain := common.CustomRunnerTests(args.File, args.OtherGen, testKind, "pytest") - var result language.GenerateResult for _, p := range plans { kind := libraryKind if p.test { @@ -167,8 +185,14 @@ func generateRules(args language.GenerateArgs, _ map[string]rule.KindInfo, rep c result.Imports = append(result.Imports, common.WrapImportSet(nil)) } } - if isFixturePath(args.Rel) { - for _, r := range result.Gen { + for _, r := range result.Gen { + if r.Kind() == ruffKind { + continue + } + if hints := withRuffHint(existingRule(args.File, r.Kind(), r.Name()), ruff, args.Rel); len(hints) > 0 { + r.SetAttr("aspect_hints", hints) + } + if isFixturePath(args.Rel) { r.SetAttr("testonly", true) } } diff --git a/gazelle/python/ruff.go b/gazelle/python/ruff.go new file mode 100644 index 0000000000..cf1931d80b --- /dev/null +++ b/gazelle/python/ruff.go @@ -0,0 +1,103 @@ +package python + +import ( + "os" + "path/filepath" + + "github.com/bazel-contrib/bazel-gazelle/v2/rule" + "github.com/bazelbuild/bazel-gazelle/config" + "github.com/devhusdk/rules_dx/gazelle/common" + "github.com/pelletier/go-toml/v2" +) + +const ( + ruffKind = "ruff_config" + ruffName = "ruff_config" + ruffExt = "python_ruff" + nativeDefs = "quality:native_config.bzl" +) + +// ruffState is the nearest ruff config at or above a directory. +type ruffState struct { + rel string + file string +} + +func (s *ruffState) label() string { return "//" + s.rel + ":" + ruffName } + +func ruffFile(dir string) string { + for _, name := range []string{".ruff.toml", "ruff.toml"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return name + } + } + content, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + if err != nil { + return "" + } + var raw struct { + Tool map[string]any `toml:"tool"` + } + if toml.Unmarshal(common.StripBOM(content), &raw) != nil { + return "" + } + if _, ok := raw.Tool["ruff"]; ok { + return "pyproject.toml" + } + return "" +} + +func configureRuff(c *config.Config, rel string, _ *rule.File, _ common.Reporter) { + if file := ruffFile(filepath.Join(c.RepoRoot, filepath.FromSlash(rel))); file != "" { + c.Exts[ruffExt] = &ruffState{rel: rel, file: file} + } +} + +func ruffAt(c *config.Config) *ruffState { + if c == nil { + return nil + } + state, _ := c.Exts[ruffExt].(*ruffState) + return state +} + +func ruffRule(state *ruffState, rel string) *rule.Rule { + r := rule.NewRule(ruffKind, ruffName) + r.SetAttr("src", state.file) + r.SetAttr("visibility", []string{"//" + rel + ":__subpackages__"}) + return r +} + +// withRuffHint returns the user's aspect_hints plus the ruff config label. +func withRuffHint(existing *rule.Rule, state *ruffState, rel string) []string { + var hints []string + if existing != nil { + hints = existing.AttrStrings("aspect_hints") + } + if state == nil { + return hints + } + label := state.label() + if state.rel == rel { + label = ":" + ruffName + } + for _, hint := range hints { + if hint == label || hint == state.label() { + return hints + } + } + return append(hints, label) +} + +func existingRule(file *rule.File, kind, name string) *rule.Rule { + if file == nil { + return nil + } + for _, r := range file.Rules { + if r.Name() == name && r.Kind() == kind { + return r + } + } + return nil +} + diff --git a/gazelle/python/testdata/pyproject_no_ruff/README.md b/gazelle/python/testdata/pyproject_no_ruff/README.md new file mode 100644 index 0000000000..13173879f7 --- /dev/null +++ b/gazelle/python/testdata/pyproject_no_ruff/README.md @@ -0,0 +1,4 @@ +# pyproject.toml Without Ruff + +Verifies a `pyproject.toml` with no `[tool.ruff]` table generates no `ruff_config` +and no `aspect_hints`. diff --git a/gazelle/python/testdata/pyproject_no_ruff/WORKSPACE b/gazelle/python/testdata/pyproject_no_ruff/WORKSPACE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/pyproject_no_ruff/pkg/BUILD.out b/gazelle/python/testdata/pyproject_no_ruff/pkg/BUILD.out new file mode 100644 index 0000000000..b15916dae6 --- /dev/null +++ b/gazelle/python/testdata/pyproject_no_ruff/pkg/BUILD.out @@ -0,0 +1,7 @@ +load("@rules_dx//python/rules:defs.bzl", "python_library") + +python_library( + name = "lib", + srcs = ["lib.py"], + imports = ["."], +) diff --git a/gazelle/python/testdata/pyproject_no_ruff/pkg/lib.py b/gazelle/python/testdata/pyproject_no_ruff/pkg/lib.py new file mode 100644 index 0000000000..b15b1b0797 --- /dev/null +++ b/gazelle/python/testdata/pyproject_no_ruff/pkg/lib.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/gazelle/python/testdata/pyproject_no_ruff/pyproject.toml b/gazelle/python/testdata/pyproject_no_ruff/pyproject.toml new file mode 100644 index 0000000000..6c0bc4e7dc --- /dev/null +++ b/gazelle/python/testdata/pyproject_no_ruff/pyproject.toml @@ -0,0 +1,2 @@ +[project] +name = "demo" diff --git a/gazelle/python/testdata/ruff_pyproject/BUILD.out b/gazelle/python/testdata/ruff_pyproject/BUILD.out new file mode 100644 index 0000000000..3d53fa8a76 --- /dev/null +++ b/gazelle/python/testdata/ruff_pyproject/BUILD.out @@ -0,0 +1,7 @@ +load("@rules_dx//quality:native_config.bzl", "ruff_config") + +ruff_config( + name = "ruff_config", + src = "pyproject.toml", + visibility = ["//:__subpackages__"], +) diff --git a/gazelle/python/testdata/ruff_pyproject/README.md b/gazelle/python/testdata/ruff_pyproject/README.md new file mode 100644 index 0000000000..85a4717bae --- /dev/null +++ b/gazelle/python/testdata/ruff_pyproject/README.md @@ -0,0 +1,4 @@ +# Ruff Config From pyproject.toml + +Verifies a `[tool.ruff]` table yields a `ruff_config` that generated rules hint at, +keeping the hints a user already wrote. diff --git a/gazelle/python/testdata/ruff_pyproject/WORKSPACE b/gazelle/python/testdata/ruff_pyproject/WORKSPACE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/ruff_pyproject/pkg/BUILD.in b/gazelle/python/testdata/ruff_pyproject/pkg/BUILD.in new file mode 100644 index 0000000000..834ad68c68 --- /dev/null +++ b/gazelle/python/testdata/ruff_pyproject/pkg/BUILD.in @@ -0,0 +1,8 @@ +load("@rules_dx//python/rules:defs.bzl", "python_library") + +python_library( + name = "lib", + srcs = ["lib.py"], + aspect_hints = ["//:user_hint"], + imports = ["."], +) diff --git a/gazelle/python/testdata/ruff_pyproject/pkg/BUILD.out b/gazelle/python/testdata/ruff_pyproject/pkg/BUILD.out new file mode 100644 index 0000000000..0134c08b11 --- /dev/null +++ b/gazelle/python/testdata/ruff_pyproject/pkg/BUILD.out @@ -0,0 +1,19 @@ +load("@rules_dx//python/rules:defs.bzl", "python_library", "python_test") + +python_library( + name = "lib", + srcs = ["lib.py"], + aspect_hints = [ + "//:user_hint", + "//:ruff_config", + ], + imports = ["."], +) + +python_test( + name = "lib_test", + srcs = ["lib_test.py"], + aspect_hints = ["//:ruff_config"], + imports = ["."], + deps = [":lib"], +) diff --git a/gazelle/python/testdata/ruff_pyproject/pkg/lib.py b/gazelle/python/testdata/ruff_pyproject/pkg/lib.py new file mode 100644 index 0000000000..b15b1b0797 --- /dev/null +++ b/gazelle/python/testdata/ruff_pyproject/pkg/lib.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/gazelle/python/testdata/ruff_pyproject/pkg/lib_test.py b/gazelle/python/testdata/ruff_pyproject/pkg/lib_test.py new file mode 100644 index 0000000000..153e65dab7 --- /dev/null +++ b/gazelle/python/testdata/ruff_pyproject/pkg/lib_test.py @@ -0,0 +1 @@ +import lib diff --git a/gazelle/python/testdata/ruff_pyproject/pyproject.toml b/gazelle/python/testdata/ruff_pyproject/pyproject.toml new file mode 100644 index 0000000000..24968bce15 --- /dev/null +++ b/gazelle/python/testdata/ruff_pyproject/pyproject.toml @@ -0,0 +1,2 @@ +[tool.ruff] +line-length = 100