use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::loc::language::{LanguageSpec, languages};
use crate::string_mask::starts_in_string_mask;
#[derive(Debug, Clone, PartialEq)]
pub struct Binding {
pub module: String,
pub name: Option<String>,
pub bound: Option<String>,
}
impl Binding {
pub fn import(&self) -> String {
match &self.name {
Some(name) => format!("{}:{name}", self.module),
None => self.module.clone(),
}
}
pub fn submodule(&self) -> Option<String> {
let name = self.name.as_ref()?;
let dot = if self.module.ends_with('.') { "" } else { "." };
Some(format!("{}{dot}{name}", self.module))
}
}
#[derive(Debug, Default)]
pub struct Read {
pub bindings: Vec<Binding>,
pub code: Vec<String>,
}
pub fn spec() -> Option<&'static LanguageSpec> {
languages().iter().find(|s| s.name == "Python")
}
pub fn extract(source: &str) -> Vec<String> {
read(source).bindings.iter().map(Binding::import).collect()
}
pub fn read(source: &str) -> Read {
let mut read = Read::default();
let Some(spec) = spec() else {
return read;
};
let lines: Vec<String> = source.lines().map(String::from).collect();
let in_string = starts_in_string_mask(&lines, spec);
let mut code = lines
.iter()
.zip(in_string)
.filter(|(_, masked)| !masked)
.map(|(line, _)| without_comment(line));
while let Some(written) = code.next() {
let line = written.trim_start();
if keyword(line, "import").is_none() && keyword(line, "from").is_none() {
read.code.push(written.to_string());
continue;
}
let mut statement = line.to_string();
while unfinished(&mut statement) {
let Some(more) = code.next() else { break };
statement.push(' ');
statement.push_str(more);
}
for part in statement.split(';') {
read_statement(part.trim(), &mut read.bindings);
}
}
read
}
fn without_comment(line: &str) -> &str {
line.split('#').next().unwrap_or(line)
}
fn unfinished(statement: &mut String) -> bool {
let written = statement.trim_end();
if written.ends_with('\\') {
statement.truncate(written.len() - 1);
return true;
}
statement.matches('(').count() > statement.matches(')').count()
}
pub fn keyword<'a>(text: &'a str, word: &str) -> Option<&'a str> {
let rest = text.strip_prefix(word)?;
let apart = |c: char| !(c.is_alphanumeric() || c == '_');
rest.starts_with(apart).then_some(rest)
}
fn read_statement(statement: &str, bindings: &mut Vec<Binding>) {
if let Some(listed) = keyword(statement, "import") {
bindings.extend(
names(listed)
.filter(|(name, _)| is_module(name))
.map(|(name, alias)| Binding {
module: name.to_string(),
name: None,
bound: Some(alias.unwrap_or(name).to_string()),
}),
);
return;
}
let Some(rest) = keyword(statement, "from") else {
return;
};
let Some((module, rest)) = rest.trim_start().split_once(char::is_whitespace) else {
return;
};
let Some(listed) = keyword(rest.trim_start(), "import") else {
return;
};
let path = module.trim_start_matches('.');
if !(is_module(path) || path.is_empty() && !module.is_empty()) {
return;
}
for (name, alias) in names(listed) {
let star = name == "*";
if !star && !is_identifier(name) {
continue;
}
bindings.push(Binding {
module: module.to_string(),
name: (!star).then(|| name.to_string()),
bound: (!star).then(|| alias.unwrap_or(name).to_string()),
});
}
}
fn names(listed: &str) -> impl Iterator<Item = (&str, Option<&str>)> {
let around = |c: char| c.is_whitespace() || c == '(' || c == ')';
listed.split(',').filter_map(move |item| {
let mut words = item.trim_matches(around).split_whitespace();
let name = words.next()?;
let alias = words.next().filter(|&word| word == "as").and(words.next());
Some((name, alias))
})
}
fn is_identifier(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn is_module(path: &str) -> bool {
path.split('.').all(is_identifier)
}
pub fn resolve(
importer: &Path,
import: &str,
known: &HashSet<PathBuf>,
declared: &[(PathBuf, Vec<PathBuf>)],
) -> Option<PathBuf> {
let (module, name) = match import.split_once(':') {
Some((module, name)) => (module, Some(name)),
None => (import, None),
};
let path = module.trim_start_matches('.');
let dots = module.len() - path.len();
let module: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
let submodule: Option<Vec<&str>> = name.map(|n| module.iter().copied().chain([n]).collect());
let dir = importer.parent().unwrap_or(Path::new(""));
let roots: Vec<PathBuf> = match dots {
0 => roots(dir, known)
.into_iter()
.chain(declared_for(dir, declared).iter().cloned())
.collect(),
_ => dir
.ancestors()
.nth(dots - 1)
.map(Path::to_path_buf)
.into_iter()
.collect(),
};
roots.iter().find_map(|root| {
submodule
.iter()
.chain([&module])
.filter(|segments| dots > 0 || !segments.is_empty())
.find_map(|segments| module_file(root, segments, known))
})
}
fn roots(dir: &Path, known: &HashSet<PathBuf>) -> Vec<PathBuf> {
dir.ancestors()
.filter(|above| module_file(above, &[], known).is_none())
.flat_map(|above| [above.to_path_buf(), above.join("src")])
.collect()
}
fn declared_for<'a>(dir: &Path, declared: &'a [(PathBuf, Vec<PathBuf>)]) -> &'a [PathBuf] {
declared
.iter()
.filter(|(project, _)| dir.starts_with(project))
.max_by_key(|(project, _)| project.components().count())
.map_or(&[], |(_, roots)| roots)
}
fn module_file(root: &Path, segments: &[&str], known: &HashSet<PathBuf>) -> Option<PathBuf> {
let package = segments.iter().fold(root.to_path_buf(), |p, s| p.join(s));
["py", "pyi"]
.iter()
.flat_map(|ext| {
let file = segments.split_last().map(|(last, above)| {
let dir = above.iter().fold(root.to_path_buf(), |p, s| p.join(s));
dir.join(format!("{last}.{ext}"))
});
file.into_iter()
.chain([package.join(format!("__init__.{ext}"))])
})
.find(|path| known.contains(path))
}
#[cfg(test)]
#[path = "python_test.rs"]
mod tests;