Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,6 @@ rust/debug
rust/target
rust/flamegraph.svg
target

# UV
uv.lock
1 change: 1 addition & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Building the graph
:return: An import graph that you can use to analyse the package.
:rtype: ``ImportGraph``

# TODO correct this.
This method uses multiple operating system processes to build the graph, if the number of modules to scan (not
including modules in the cache) is 50 or more. This threshold can be adjusted by setting the ``GRIMP_MIN_MULTIPROCESSING_MODULES``
environment variable to a different number. To disable multiprocessing altogether, set it to a large number (more than
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ authors = [
]
requires-python = ">=3.9"
dependencies = [
"joblib>=1.3.0",
"typing-extensions>=3.10.0.0",
]
classifiers = [
Expand Down
22 changes: 22 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const_format = "0.2.34"
ruff_python_parser = { git = "https://github.com/astral-sh/ruff.git", tag = "v0.4.10" }
ruff_python_ast = { git = "https://github.com/astral-sh/ruff.git", tag = "v0.4.10" }
ruff_source_file = { git = "https://github.com/astral-sh/ruff.git", tag = "v0.4.10" }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
unindent = "0.2.4"

[dependencies.pyo3]
version = "0.24.1"
Expand Down
308 changes: 308 additions & 0 deletions rust/src/filesystem.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
use pyo3::exceptions::{PyFileNotFoundError, PyRuntimeError};
use pyo3::prelude::*;
use std::collections::HashMap;
type FileSystemContents = HashMap<String, String>;
use std::fs;
use std::path::{Path, PathBuf};
use unindent::unindent;

pub trait FileSystem: Send + Sync {
fn sep(&self) -> String;

fn join(&self, components: Vec<String>) -> String;

fn split(&self, file_name: &str) -> (String, String);

fn exists(&self, file_name: &str) -> bool;

fn read(&self, file_name: &str) -> PyResult<String>;
}

#[derive(Clone)]
pub struct RealBasicFileSystem {}

#[pyclass(name = "RealBasicFileSystem")]
pub struct PyRealBasicFileSystem {
pub inner: RealBasicFileSystem,
}

impl FileSystem for RealBasicFileSystem {
fn sep(&self) -> String {
std::path::MAIN_SEPARATOR.to_string()
}

fn join(&self, components: Vec<String>) -> String {
let mut path = PathBuf::new();
for component in components {
path.push(component);
}
path.to_str().unwrap().to_string()
}

fn split(&self, file_name: &str) -> (String, String) {
let path = Path::new(file_name);

// Get the "tail" part (the file name or last directory)
let tail = match path.file_name() {
Some(name) => PathBuf::from(name),
None => PathBuf::new(), // If there's no file name (e.g., path is a root), return empty
};

// Get the "head" part (the parent directory)
let head = match path.parent() {
Some(parent_path) => parent_path.to_path_buf(),
None => PathBuf::new(), // If there's no parent (e.g., just a filename), return empty
};

(
head.to_str().unwrap().to_string(),
tail.to_str().unwrap().to_string(),
)
}

fn exists(&self, file_name: &str) -> bool {
Path::new(file_name).is_file()
}

fn read(&self, file_name: &str) -> PyResult<String> {
// TODO: is this good enough for handling non-UTF8 encodings?
fs::read_to_string(file_name)
.map_err(|_| PyRuntimeError::new_err(format!("Could not read {}", file_name)))
}
}

#[pymethods]
impl PyRealBasicFileSystem {
#[new]
fn new() -> Self {
PyRealBasicFileSystem {
inner: RealBasicFileSystem {},
}
}

#[getter]
fn sep(&self) -> String {
self.inner.sep()
}

#[pyo3(signature = (*components))]
fn join(&self, components: Vec<String>) -> String {
self.inner.join(components)
}

fn split(&self, file_name: &str) -> (String, String) {
self.inner.split(file_name)
}

fn exists(&self, file_name: &str) -> bool {
self.inner.exists(file_name)
}

fn read(&self, file_name: &str) -> PyResult<String> {
self.inner.read(file_name)
}
}
#[derive(Clone)]
pub struct FakeBasicFileSystem {
contents: Box<FileSystemContents>,
}

#[pyclass(name = "FakeBasicFileSystem")]
pub struct PyFakeBasicFileSystem {
pub inner: FakeBasicFileSystem,
}

impl FakeBasicFileSystem {
fn new(contents: Option<&str>, content_map: Option<HashMap<String, String>>) -> PyResult<Self> {
let mut parsed_contents = match contents {
Some(contents) => parse_indented_fs_string(contents),
None => HashMap::new(),
};
if let Some(content_map) = content_map {
let unindented_map: HashMap<String, String> = content_map
.into_iter()
.map(|(key, val)| (key, unindent(&val).trim().to_string()))
.collect();
parsed_contents.extend(unindented_map);
};
Ok(FakeBasicFileSystem {
contents: Box::new(parsed_contents),
})
}
}

impl FileSystem for FakeBasicFileSystem {
fn sep(&self) -> String {
"/".to_string()
}

fn join(&self, components: Vec<String>) -> String {
let sep = self.sep(); // Get the separator from the getter method
components
.into_iter()
.map(|c| c.trim_end_matches(&sep).to_string())
.collect::<Vec<String>>()
.join(&sep)
}

fn split(&self, file_name: &str) -> (String, String) {
let components: Vec<&str> = file_name.split('/').collect();

if components.is_empty() {
return ("".to_string(), "".to_string());
}

let tail = components.last().unwrap_or(&""); // Last component, or empty if components is empty (shouldn't happen from split)

let head_components = &components[..components.len() - 1]; // All components except the last

let head = if head_components.is_empty() {
// Case for single component paths like "filename.txt" or empty string ""
"".to_string()
} else if file_name.starts_with('/')
&& head_components.len() == 1
&& head_components[0].is_empty()
{
// Special handling for paths starting with '/', e.g., "/" or "/filename.txt"
// If components were ["", ""], head_components is [""] -> should be "/"
// If components were ["", "file.txt"], head_components is [""] -> should be "/"
"/".to_string()
} else {
// Default joining for multiple components
head_components.join("/")
};

(head, tail.to_string())
}

fn exists(&self, file_name: &str) -> bool {
self.contents.contains_key(file_name)
}

fn read(&self, file_name: &str) -> PyResult<String> {
match self.contents.get(file_name) {
Some(file_name) => Ok(file_name.clone()),
None => Err(PyFileNotFoundError::new_err("")),
}
}
}

#[pymethods]
impl PyFakeBasicFileSystem {
#[pyo3(signature = (contents=None, content_map=None))]
#[new]
fn new(contents: Option<&str>, content_map: Option<HashMap<String, String>>) -> PyResult<Self> {
Ok(PyFakeBasicFileSystem {
inner: FakeBasicFileSystem::new(contents, content_map)?,
})
}

#[getter]
fn sep(&self) -> String {
self.inner.sep()
}

#[pyo3(signature = (*components))]
fn join(&self, components: Vec<String>) -> String {
self.inner.join(components)
}

fn split(&self, file_name: &str) -> (String, String) {
self.inner.split(file_name)
}

/// Checks if a file or directory exists within the file system.
fn exists(&self, file_name: &str) -> bool {
self.inner.exists(file_name)
}

fn read(&self, file_name: &str) -> PyResult<String> {
self.inner.read(file_name)
}
}

/// Parses an indented string representing a file system structure
/// into a HashMap where keys are full file paths.
///
pub fn parse_indented_fs_string(input: &str) -> HashMap<String, String> {
let mut file_paths_map: HashMap<String, String> = HashMap::new();
let mut path_stack: Vec<String> = Vec::new(); // Stores current directory path components
let mut first_line = true; // Flag to handle the very first path component

// Normalize newlines and split into lines
let buffer = input.replace("\r\n", "\n");
let lines: Vec<&str> = buffer.split('\n').collect();

for line_raw in lines.clone() {
let line = line_raw.trim_end(); // Remove trailing whitespace
if line.is_empty() {
continue; // Skip empty lines
}

let current_indent = line.chars().take_while(|&c| c.is_whitespace()).count();
let trimmed_line = line.trim_start();

// Assuming 4 spaces per indentation level for calculating depth
// Adjust this if your indentation standard is different (e.g., 2 spaces, tabs)
let current_depth = current_indent / 4;

if first_line {
// The first non-empty line sets the base path.
// It might be absolute (/a/b/) or relative (a/b/).
let root_component = trimmed_line.trim_end_matches('/').to_string();
path_stack.push(root_component);
first_line = false;
} else {
// Adjust the path_stack based on indentation level
// Pop elements from the stack until we reach the correct parent directory depth
while path_stack.len() > current_depth {
path_stack.pop();
}

// If the current line is a file, append it to the path for inserting into map,
// then pop it off so that subsequent siblings are correctly handled.
// If it's a directory, append it and it stays on the stack for its children.
let component_to_add = trimmed_line.trim_end_matches('/').to_string();
if !component_to_add.is_empty() {
// Avoid pushing empty strings due to lines like just "/"
path_stack.push(component_to_add);
}
}

// Construct the full path
// Join components on the stack. If the first component started with '/',
// ensure the final path also starts with '/'.
let full_path = if !path_stack.is_empty() {
let mut joined = path_stack.join("/");
// If the original root started with a slash, ensure the final path does too.
// But be careful not to double-slash if a component is e.g. "/root"
if lines[0].trim().starts_with('/') && !joined.starts_with('/') {
joined = format!("/{}", joined);
}
joined
} else {
"".to_string()
};

// If it's a file (doesn't end with '/'), add it to the map
// A file is not a directory, so its name should be removed from the stack after processing
// so that sibling items are at the correct level.
if !trimmed_line.ends_with('/') {
file_paths_map.insert(full_path, String::new()); // Value can be empty or actual content
if !path_stack.is_empty() {
path_stack.pop(); // Pop the file name off the stack
}
}
}

// Edge case: If the very first line was a file and it ended up on the stack, it needs to be processed.
// This handles single-file inputs like "myfile.txt"
if !path_stack.is_empty()
&& !path_stack.last().unwrap().ends_with('/')
&& !file_paths_map.contains_key(&path_stack.join("/"))
{
file_paths_map.insert(path_stack.join("/"), String::new());
}

file_paths_map
}
Loading
Loading