Coverage for lintro / utils / project_detection.py: 84%
80 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Project language and package manager detection.
3Scans the current working directory for language/framework indicators
4and available package managers. Used by the ``setup`` and ``install``
5commands so that the detection logic lives in a single shared module.
7Usage:
8 from lintro.utils.project_detection import detect_project_languages
10 langs = detect_project_languages() # ["docker", "python", "typescript"]
11"""
13from __future__ import annotations
15import shutil
16from itertools import chain
17from pathlib import Path
20def detect_project_languages() -> list[str]:
21 """Detect all languages and ecosystems in the current project.
23 Checks for Python, JavaScript/TypeScript (including Astro, Svelte, Vue),
24 Rust, Go, Ruby, Shell, Docker, GitHub Actions, SQL, YAML, Markdown, and
25 TOML by inspecting manifest files, directory structure, and file extensions.
27 Returns:
28 Sorted list of lowercase language/ecosystem identifiers.
29 """
30 cwd = Path.cwd()
31 langs: set[str] = set()
33 # Python
34 if (cwd / "pyproject.toml").exists() or (cwd / "setup.py").exists():
35 langs.add("python")
37 # JavaScript / TypeScript
38 if (cwd / "package.json").exists():
39 langs.add("javascript")
41 # TypeScript detection — next() short-circuits so at most one file
42 # is visited per glob pattern, avoiding a full tree scan.
43 if (
44 (cwd / "tsconfig.json").exists()
45 or next(
46 (
47 p
48 for p in cwd.glob("**/*.ts")
49 if "node_modules" not in p.parts and not p.name.endswith(".d.ts")
50 ),
51 None,
52 )
53 is not None
54 or next(
55 (p for p in cwd.glob("**/*.tsx") if "node_modules" not in p.parts),
56 None,
57 )
58 is not None
59 ):
60 langs.add("typescript")
62 # Framework detection from package.json
63 try:
64 import json
66 pkg = json.loads((cwd / "package.json").read_text())
67 if not isinstance(pkg, dict):
68 pkg = {}
69 deps = pkg.get("dependencies") or {}
70 dev_deps = pkg.get("devDependencies") or {}
71 if not isinstance(deps, dict):
72 deps = {}
73 if not isinstance(dev_deps, dict):
74 dev_deps = {}
75 all_deps = {**deps, **dev_deps}
76 if "typescript" in all_deps:
77 langs.add("typescript")
78 if "astro" in all_deps:
79 langs.add("astro")
80 if "svelte" in all_deps:
81 langs.add("svelte")
82 if "vue" in all_deps:
83 langs.add("vue")
84 except (ImportError, OSError, ValueError):
85 pass
87 # Rust
88 if (cwd / "Cargo.toml").exists():
89 langs.add("rust")
91 # Go
92 if (cwd / "go.mod").exists():
93 langs.add("go")
95 # Ruby
96 if (cwd / "Gemfile").exists():
97 langs.add("ruby")
99 # Shell scripts (root *.sh or .sh files inside scripts/)
100 scripts_dir = cwd / "scripts"
101 if next(cwd.glob("*.sh"), None) is not None or (
102 scripts_dir.is_dir() and next(scripts_dir.glob("*.sh"), None) is not None
103 ):
104 langs.add("shell")
106 # Docker (Dockerfile, docker-compose, and standalone compose files)
107 if any(
108 next(cwd.glob(pat), None) is not None
109 for pat in (
110 "Dockerfile*",
111 "docker-compose*.yml",
112 "docker-compose*.yaml",
113 "compose.yml",
114 "compose.yaml",
115 )
116 ):
117 langs.add("docker")
119 # GitHub Actions
120 if (cwd / ".github" / "workflows").is_dir():
121 langs.add("github_actions")
123 # SQL — next() short-circuits so only one file is visited.
124 _skip_dirs = {"node_modules", ".venv", "venv", "vendor", ".git", "__pycache__"}
125 if (
126 next(
127 (p for p in cwd.glob("**/*.sql") if not _skip_dirs.intersection(p.parts)),
128 None,
129 )
130 is not None
131 ):
132 langs.add("sql")
134 # YAML (beyond config files — actual YAML content)
135 config_names = {
136 ".lintro-config.yaml",
137 ".lintro-config.yml",
138 "docker-compose.yml",
139 "docker-compose.yaml",
140 }
141 if any(
142 f.name not in config_names for f in chain(cwd.glob("*.yaml"), cwd.glob("*.yml"))
143 ):
144 langs.add("yaml")
146 # Markdown (more than just README)
147 for md_count, _ in enumerate(cwd.glob("*.md"), 1):
148 if md_count >= 2:
149 langs.add("markdown")
150 break
152 # TOML (beyond pyproject.toml / Cargo.toml)
153 toml_files = [
154 f for f in cwd.glob("*.toml") if f.name not in ("pyproject.toml", "Cargo.toml")
155 ]
156 if toml_files:
157 langs.add("toml")
159 return sorted(langs)
162def detect_package_managers() -> dict[str, str]:
163 """Detect available package managers for the current project.
165 Returns:
166 Dict mapping manager name (e.g., ``"uv"``) to its manifest file
167 (e.g., ``"pyproject.toml"``).
168 """
169 cwd = Path.cwd()
170 managers: dict[str, str] = {}
172 if (cwd / "pyproject.toml").exists():
173 if shutil.which("uv"):
174 managers["uv"] = "pyproject.toml"
175 else:
176 managers["pip"] = "pyproject.toml"
177 elif (cwd / "setup.py").exists():
178 managers["pip"] = "setup.py"
180 if (cwd / "package.json").exists():
181 if shutil.which("bun"):
182 managers["bun"] = "package.json"
183 else:
184 managers["npm"] = "package.json"
186 if (cwd / "Cargo.toml").exists() and shutil.which("cargo"):
187 managers["cargo"] = "Cargo.toml"
189 if (cwd / "go.mod").exists() and shutil.which("go"):
190 managers["go"] = "go.mod"
192 return managers