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

1"""Project language and package manager detection. 

2 

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. 

6 

7Usage: 

8 from lintro.utils.project_detection import detect_project_languages 

9 

10 langs = detect_project_languages() # ["docker", "python", "typescript"] 

11""" 

12 

13from __future__ import annotations 

14 

15import shutil 

16from itertools import chain 

17from pathlib import Path 

18 

19 

20def detect_project_languages() -> list[str]: 

21 """Detect all languages and ecosystems in the current project. 

22 

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. 

26 

27 Returns: 

28 Sorted list of lowercase language/ecosystem identifiers. 

29 """ 

30 cwd = Path.cwd() 

31 langs: set[str] = set() 

32 

33 # Python 

34 if (cwd / "pyproject.toml").exists() or (cwd / "setup.py").exists(): 

35 langs.add("python") 

36 

37 # JavaScript / TypeScript 

38 if (cwd / "package.json").exists(): 

39 langs.add("javascript") 

40 

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") 

61 

62 # Framework detection from package.json 

63 try: 

64 import json 

65 

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 

86 

87 # Rust 

88 if (cwd / "Cargo.toml").exists(): 

89 langs.add("rust") 

90 

91 # Go 

92 if (cwd / "go.mod").exists(): 

93 langs.add("go") 

94 

95 # Ruby 

96 if (cwd / "Gemfile").exists(): 

97 langs.add("ruby") 

98 

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") 

105 

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") 

118 

119 # GitHub Actions 

120 if (cwd / ".github" / "workflows").is_dir(): 

121 langs.add("github_actions") 

122 

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") 

133 

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") 

145 

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 

151 

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") 

158 

159 return sorted(langs) 

160 

161 

162def detect_package_managers() -> dict[str, str]: 

163 """Detect available package managers for the current project. 

164 

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] = {} 

171 

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" 

179 

180 if (cwd / "package.json").exists(): 

181 if shutil.which("bun"): 

182 managers["bun"] = "package.json" 

183 else: 

184 managers["npm"] = "package.json" 

185 

186 if (cwd / "Cargo.toml").exists() and shutil.which("cargo"): 

187 managers["cargo"] = "Cargo.toml" 

188 

189 if (cwd / "go.mod").exists() and shutil.which("go"): 

190 managers["go"] = "go.mod" 

191 

192 return managers