1use std::{
7 collections::HashMap,
8 env,
9 fmt::Write,
10 fs::File,
11 io::{BufRead, BufReader, Read},
12 path::{Path, PathBuf},
13 str::FromStr,
14 string::ToString,
15};
16
17use comfy_table::{Table, TableComponent};
18
19use crate::{ExactVersion, RequestedVersion};
20
21pub static DEFAULT_VENV_DIR: &str = ".venv";
23
24#[derive(Clone, Debug, Hash, PartialEq, Eq)]
26pub enum Action {
27 Help(String, PathBuf),
33 List(String),
37 Execute {
39 launcher_path: PathBuf,
41 executable: PathBuf,
43 args: Vec<String>,
45 },
46}
47
48impl Action {
49 pub fn from_main(argv: &[String]) -> crate::Result<Self> {
107 let launcher_path = PathBuf::from(&argv[0]); match argv.get(1) {
110 Some(flag) if flag == "-h" || flag == "--help" || flag == "--list" => {
111 if argv.len() > 2 {
112 Err(crate::Error::IllegalArgument(
113 launcher_path,
114 flag.to_string(),
115 ))
116 } else if flag == "--list" {
117 Ok(Action::List(list_executables(&crate::all_executables())?))
118 } else {
119 crate::find_executable(RequestedVersion::Any)
120 .ok_or(crate::Error::NoExecutableFound(RequestedVersion::Any))
121 .map(|executable_path| {
122 Action::Help(
123 help_message(&launcher_path, &executable_path),
124 executable_path,
125 )
126 })
127 }
128 }
129 Some(version) if version_from_flag(version).is_some() => {
130 Ok(Action::Execute {
131 launcher_path,
132 executable: find_executable(version_from_flag(version).unwrap(), &argv[2..])?,
134 args: argv[2..].to_vec(),
135 })
136 }
137 Some(_) | None => Ok(Action::Execute {
138 launcher_path,
139 executable: find_executable(RequestedVersion::Any, &argv[1..])?,
141 args: argv[1..].to_vec(),
142 }),
143 }
144 }
145}
146
147fn help_message(launcher_path: &Path, executable_path: &Path) -> String {
148 let mut message = String::new();
149 writeln!(
150 message,
151 include_str!("HELP.txt"),
152 env!("CARGO_PKG_VERSION"),
153 launcher_path.to_string_lossy(),
154 executable_path.to_string_lossy()
155 )
156 .unwrap();
157 message
158}
159
160fn version_from_flag(arg: &str) -> Option<RequestedVersion> {
165 if !arg.starts_with('-') {
166 None
167 } else {
168 RequestedVersion::from_str(&arg[1..]).ok()
169 }
170}
171
172fn list_executables(executables: &HashMap<ExactVersion, PathBuf>) -> crate::Result<String> {
173 if executables.is_empty() {
174 return Err(crate::Error::NoExecutableFound(RequestedVersion::Any));
175 }
176
177 let mut executable_pairs = Vec::from_iter(executables);
178 executable_pairs.sort_unstable();
179 executable_pairs.reverse();
180
181 let mut table = Table::new();
182 table.load_preset(comfy_table::presets::NOTHING);
183 table.set_style(TableComponent::VerticalLines, '│');
188
189 for (version, path) in executable_pairs {
190 table.add_row(vec![version.to_string(), path.display().to_string()]);
191 }
192
193 Ok(table.to_string() + "\n")
194}
195
196fn relative_venv_path(add_default: bool) -> PathBuf {
197 let mut path = PathBuf::new();
198 if add_default {
199 path.push(DEFAULT_VENV_DIR);
200 }
201 path.push("bin");
202 path.push("python");
203 path
204}
205
206fn venv_executable_path(venv_root: &str) -> PathBuf {
211 PathBuf::from(venv_root).join(relative_venv_path(false))
212}
213
214fn activated_venv() -> Option<PathBuf> {
215 log::info!("Checking for VIRTUAL_ENV environment variable");
216 env::var_os("VIRTUAL_ENV").map(|venv_root| {
217 log::debug!("VIRTUAL_ENV set to {venv_root:?}");
218 venv_executable_path(&venv_root.to_string_lossy())
219 })
220}
221
222fn venv_path_search() -> Option<PathBuf> {
223 if env::current_dir().is_err() {
224 log::warn!("current working directory is invalid");
225 None
226 } else {
227 let cwd = env::current_dir().unwrap();
228 let printable_cwd = cwd.display();
229 log::info!("Searching for a venv in {printable_cwd} and parent directories");
230 cwd.ancestors().find_map(|path| {
231 let venv_path = path.join(relative_venv_path(true));
232 let printable_venv_path = venv_path.display();
233 log::info!("Checking {printable_venv_path}");
234 venv_path.is_file().then_some(venv_path)
236 })
237 }
238}
239
240fn venv_executable() -> Option<PathBuf> {
241 activated_venv().or_else(venv_path_search)
242}
243
244fn parse_python_shebang(reader: &mut impl Read) -> Option<RequestedVersion> {
246 let mut shebang_buffer = [0; 2];
247 log::info!("Looking for a Python-related shebang");
248 if reader.read(&mut shebang_buffer).is_err() || shebang_buffer != [0x23, 0x21] {
249 log::debug!("No '#!' at the start of the first line of the file");
251 return None;
252 }
253
254 let mut buffered_reader = BufReader::new(reader);
255 let mut first_line = String::new();
256
257 if buffered_reader.read_line(&mut first_line).is_err() {
258 log::debug!("Can't read first line of the file");
259 return None;
260 };
261
262 let line = first_line.trim();
264
265 let accepted_paths = [
266 "python",
267 "/usr/bin/python",
268 "/usr/local/bin/python",
269 "/usr/bin/env python",
270 ];
271
272 for acceptable_path in &accepted_paths {
273 if !line.starts_with(acceptable_path) {
274 continue;
275 }
276
277 log::debug!("Found shebang: {acceptable_path}");
278 let version = line[acceptable_path.len()..].to_string();
279 log::debug!("Found version: {version}");
280 return RequestedVersion::from_str(&version).ok();
281 }
282
283 None
284}
285
286fn find_executable(version: RequestedVersion, args: &[String]) -> crate::Result<PathBuf> {
287 let mut requested_version = version;
288 let mut chosen_path: Option<PathBuf> = None;
289
290 if requested_version == RequestedVersion::Any {
291 if let Some(venv_path) = venv_executable() {
292 chosen_path = Some(venv_path);
293 } else if !args.is_empty() {
294 let possible_file = &args[0];
302 log::info!("Checking {possible_file:?} for a shebang");
303 if let Ok(mut open_file) = File::open(possible_file) {
304 if let Some(shebang_version) = parse_python_shebang(&mut open_file) {
305 requested_version = shebang_version;
306 }
307 }
308 }
309 }
310
311 if chosen_path.is_none() {
312 if let Some(env_var) = requested_version.env_var() {
313 log::info!("Checking the {env_var} environment variable");
314 if let Ok(env_var_value) = env::var(&env_var) {
315 if !env_var_value.is_empty() {
316 log::debug!("{env_var} = '{env_var_value}'");
317 let env_requested_version = RequestedVersion::from_str(&env_var_value)?;
318 requested_version = env_requested_version;
319 }
320 } else {
321 log::info!("{env_var} not set");
322 };
323 }
324
325 if let Some(executable_path) = crate::find_executable(requested_version) {
326 chosen_path = Some(executable_path);
327 }
328 }
329
330 chosen_path.ok_or(crate::Error::NoExecutableFound(requested_version))
331}
332
333#[cfg(test)]
334mod tests {
335 use test_case::test_case;
336
337 use super::*;
338
339 #[test_case(&["py".to_string(), "--help".to_string(), "--list".to_string()] => Err(crate::Error::IllegalArgument(PathBuf::from("py"), "--help".to_string())))]
340 #[test_case(&["py".to_string(), "--list".to_string(), "--help".to_string()] => Err(crate::Error::IllegalArgument(PathBuf::from("py"), "--list".to_string())))]
341 fn from_main_illegal_argument_tests(argv: &[String]) -> crate::Result<Action> {
342 Action::from_main(argv)
343 }
344
345 #[test_case("-S" => None ; "unrecognized short flag is None")]
346 #[test_case("--something" => None ; "unrecognized long flag is None")]
347 #[test_case("-3" => Some(RequestedVersion::MajorOnly(3)) ; "major version")]
348 #[test_case("-3.6" => Some(RequestedVersion::Exact(3, 6)) ; "Exact/major.minor")]
349 #[test_case("-42.13" => Some(RequestedVersion::Exact(42, 13)) ; "double-digit major & minor versions")]
350 #[test_case("-3.6.4" => None ; "version flag with micro version is None")]
351 fn version_from_flag_tests(flag: &str) -> Option<RequestedVersion> {
352 version_from_flag(flag)
353 }
354
355 #[test]
356 fn test_help_message() {
357 let launcher_path = "/some/path/to/launcher";
358 let python_path = "/a/path/to/python";
359
360 let help = help_message(&PathBuf::from(launcher_path), &PathBuf::from(python_path));
361 assert!(help.contains(env!("CARGO_PKG_VERSION")));
362 assert!(help.contains(launcher_path));
363 assert!(help.contains(python_path));
364 }
365
366 #[test]
367 fn test_list_executables() {
368 let mut executables: HashMap<ExactVersion, PathBuf> = HashMap::new();
369
370 assert_eq!(
371 list_executables(&executables),
372 Err(crate::Error::NoExecutableFound(RequestedVersion::Any))
373 );
374
375 let python27_path = "/path/to/2/7/python";
376 executables.insert(
377 ExactVersion { major: 2, minor: 7 },
378 PathBuf::from(python27_path),
379 );
380 let python36_path = "/path/to/3/6/python";
381 executables.insert(
382 ExactVersion { major: 3, minor: 6 },
383 PathBuf::from(python36_path),
384 );
385 let python37_path = "/path/to/3/7/python";
386 executables.insert(
387 ExactVersion { major: 3, minor: 7 },
388 PathBuf::from(python37_path),
389 );
390
391 let executables_list = list_executables(&executables).unwrap();
395 assert!(executables_list.contains("2.7"));
397 assert!(executables_list.contains(python27_path));
398 assert!(executables_list.contains("3.6"));
399 assert!(executables_list.contains(python36_path));
400 assert!(executables_list.contains("3.7"));
401 assert!(executables_list.contains(python37_path));
402
403 assert!(executables_list.find("3.7").unwrap() < executables_list.find("3.6").unwrap());
405 assert!(executables_list.find("3.6").unwrap() < executables_list.find("2.7").unwrap());
406
407 assert!(
409 executables_list.find("3.6").unwrap() < executables_list.find(python36_path).unwrap()
410 );
411 assert!(
412 executables_list.find("3.7").unwrap() < executables_list.find(python36_path).unwrap()
413 );
414 }
415
416 #[test]
417 fn test_venv_executable_path() {
418 let venv_root = "/path/to/venv";
419 assert_eq!(
420 venv_executable_path(venv_root),
421 PathBuf::from("/path/to/venv/bin/python")
422 );
423 }
424
425 #[test_case("/usr/bin/python" => None ; "missing shebang comment")]
426 #[test_case("# /usr/bin/python" => None ; "missing exclamation point")]
427 #[test_case("! /usr/bin/python" => None ; "missing octothorpe")]
428 #[test_case("#! /bin/sh" => None ; "non-Python shebang")]
429 #[test_case("#! /usr/bin/env python" => Some(RequestedVersion::Any) ; "typical 'env python'")]
430 #[test_case("#! /usr/bin/python" => Some(RequestedVersion::Any) ; "typical 'python'")]
431 #[test_case("#! /usr/local/bin/python" => Some(RequestedVersion::Any) ; "/usr/local")]
432 #[test_case("#! python" => Some(RequestedVersion::Any) ; "bare 'python'")]
433 #[test_case("#! /usr/bin/env python3.7" => Some(RequestedVersion::Exact(3, 7)) ; "typical 'env python' with minor version")]
434 #[test_case("#! /usr/bin/python3.7" => Some(RequestedVersion::Exact(3, 7)) ; "typical 'python' with minor version")]
435 #[test_case("#! python3.7" => Some(RequestedVersion::Exact(3, 7)) ; "bare 'python' with minor version")]
436 #[test_case("#!/usr/bin/python" => Some(RequestedVersion::Any) ; "no space between shebang and path")]
437 fn parse_python_shebang_tests(shebang: &str) -> Option<RequestedVersion> {
438 parse_python_shebang(&mut shebang.as_bytes())
439 }
440
441 #[test_case(&[0x23, 0x21, 0xc0, 0xaf] => None ; "invalid UTF-8")]
442 fn parse_python_sheban_include_invalid_bytes_tests(
443 mut shebang: &[u8],
444 ) -> Option<RequestedVersion> {
445 parse_python_shebang(&mut shebang)
446 }
447}