use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::TldrError;
use crate::TldrResult;
use super::types::ResolvedPackage;
pub fn resolve_python_package(package_name: &str) -> TldrResult<ResolvedPackage> {
if !is_valid_python_identifier(package_name) {
return Err(TldrError::parse_error(
PathBuf::new(),
None,
format!("Invalid Python package name: {}", package_name),
));
}
let output = Command::new("python3")
.arg("-c")
.arg(format!(
"import {pkg}; print({pkg}.__file__)",
pkg = package_name
))
.output()
.map_err(|e| {
TldrError::parse_error(
PathBuf::new(),
None,
format!("Failed to run python3 to resolve package '{}': {}", package_name, e),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TldrError::parse_error(
PathBuf::new(),
None,
format!("Cannot import Python package '{}': {}", package_name, stderr.trim()),
));
}
let file_path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
let file_path = PathBuf::from(&file_path_str);
let root_dir = if file_path.ends_with("__init__.py") {
file_path
.parent()
.ok_or_else(|| {
TldrError::parse_error(
file_path.clone(),
None,
format!("Cannot determine parent directory of '{}'", file_path_str),
)
})?
.to_path_buf()
} else {
file_path
.parent()
.ok_or_else(|| {
TldrError::parse_error(
file_path.clone(),
None,
format!("Cannot determine parent directory of '{}'", file_path_str),
)
})?
.to_path_buf()
};
let is_pure_source = has_python_source(&root_dir, package_name, &file_path);
let init_file = root_dir.join("__init__.py");
let public_names = if init_file.exists() {
extract_all_names(&init_file)
} else {
None
};
Ok(ResolvedPackage {
root_dir,
package_name: package_name.to_string(),
is_pure_source,
public_names,
})
}
pub fn resolve_target(target: &str, lang: Option<&str>) -> TldrResult<ResolvedPackage> {
let target_path = Path::new(target);
if target_path.is_dir() {
let package_name = target_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(target)
.to_string();
return Ok(ResolvedPackage {
root_dir: target_path.to_path_buf(),
package_name,
is_pure_source: true,
public_names: None,
});
}
match lang {
Some("python") | None => resolve_python_package(target),
Some(other) => Err(TldrError::UnsupportedLanguage(format!(
"Package resolution not yet supported for language: {}",
other
))),
}
}
fn is_valid_python_identifier(name: &str) -> bool {
if name.is_empty() {
return false;
}
for part in name.split('.') {
if part.is_empty() {
return false;
}
let mut chars = part.chars();
if let Some(first) = chars.next() {
if !first.is_alphabetic() && first != '_' {
return false;
}
}
for c in chars {
if !c.is_alphanumeric() && c != '_' {
return false;
}
}
}
true
}
fn has_python_source(root_dir: &Path, _package_name: &str, init_file: &Path) -> bool {
if init_file.ends_with("__init__.py") && root_dir.join("__init__.py").exists() {
if let Ok(entries) = std::fs::read_dir(root_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("py")
&& path.file_name().and_then(|n| n.to_str()) != Some("__init__.py")
{
return true;
}
}
}
return true;
}
if init_file.extension().and_then(|e| e.to_str()) == Some("py") {
return true;
}
false
}
fn extract_all_names(init_file: &Path) -> Option<Vec<String>> {
let source = std::fs::read_to_string(init_file).ok()?;
extract_all_names_from_source(&source)
}
pub fn extract_all_names_from_source(source: &str) -> Option<Vec<String>> {
use crate::ast::parser::parse;
use crate::Language;
let tree = parse(source, Language::Python).ok()?;
let root = tree.root_node();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "expression_statement" {
if let Some(inner) = child.child(0) {
if inner.kind() == "assignment" {
if let Some(left) = inner.child_by_field_name("left") {
let name = &source[left.byte_range()];
if name == "__all__" {
if let Some(right) = inner.child_by_field_name("right") {
return Some(extract_string_list_elements(
&right, source,
));
}
}
}
}
}
}
}
None
}
fn extract_string_list_elements(node: &tree_sitter::Node, source: &str) -> Vec<String> {
let mut names = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "string" {
let text = &source[child.byte_range()];
let unquoted = text
.trim_start_matches(&['"', '\''][..])
.trim_end_matches(&['"', '\''][..]);
if !unquoted.is_empty() {
names.push(unquoted.to_string());
}
}
}
names
}
pub fn find_python_files(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
find_python_files_recursive(root, &mut files);
files.sort();
files
}
fn find_python_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !dir_name.starts_with('.')
&& dir_name != "__pycache__"
&& dir_name != "node_modules"
&& dir_name != ".git"
{
find_python_files_recursive(&path, files);
}
} else if path.extension().and_then(|e| e.to_str()) == Some("py") {
files.push(path);
}
}
}
pub fn has_c_extensions(dir: &Path) -> bool {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext == "so" || ext == "pyd" {
return true;
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_valid_python_identifier() {
assert!(is_valid_python_identifier("json"));
assert!(is_valid_python_identifier("flask"));
assert!(is_valid_python_identifier("my_package"));
assert!(is_valid_python_identifier("_private"));
assert!(is_valid_python_identifier("pkg123"));
assert!(is_valid_python_identifier("xml.etree.ElementTree"));
}
#[test]
fn test_is_valid_python_identifier_rejects_invalid() {
assert!(!is_valid_python_identifier(""));
assert!(!is_valid_python_identifier("123abc"));
assert!(!is_valid_python_identifier("has space"));
assert!(!is_valid_python_identifier("has;semicolon"));
assert!(!is_valid_python_identifier("import os; os.system('rm -rf /')"));
assert!(!is_valid_python_identifier(".dotstart"));
assert!(!is_valid_python_identifier("dotend."));
}
#[test]
fn test_extract_all_names_from_source() {
let source = r#"
__all__ = ["loads", "dumps", "JSONEncoder", "JSONDecoder"]
"#;
let names = extract_all_names_from_source(source);
assert!(names.is_some());
let names = names.unwrap();
assert_eq!(names, vec!["loads", "dumps", "JSONEncoder", "JSONDecoder"]);
}
#[test]
fn test_extract_all_names_single_quotes() {
let source = r#"
__all__ = ['one', 'two', 'three']
"#;
let names = extract_all_names_from_source(source);
assert!(names.is_some());
let names = names.unwrap();
assert_eq!(names, vec!["one", "two", "three"]);
}
#[test]
fn test_extract_all_names_tuple() {
let source = r#"
__all__ = ("alpha", "beta")
"#;
let names = extract_all_names_from_source(source);
assert!(names.is_some());
let names = names.unwrap();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn test_extract_all_names_absent() {
let source = r#"
import os
def foo(): pass
"#;
let names = extract_all_names_from_source(source);
assert!(names.is_none());
}
#[test]
fn test_resolve_target_directory() {
let tmp = std::env::temp_dir().join("tldr_test_resolve");
let _ = std::fs::create_dir_all(&tmp);
let result = resolve_target(tmp.to_str().unwrap(), Some("python"));
assert!(result.is_ok());
let pkg = result.unwrap();
assert!(pkg.is_pure_source);
let _ = std::fs::remove_dir_all(&tmp);
}
}