fe_common/utils/
files.rs

1use serde::Deserialize;
2use std::{fs, path::Path};
3use toml::Table;
4
5use indexmap::{indexmap, IndexMap};
6use path_clean::PathClean;
7use smol_str::SmolStr;
8use walkdir::WalkDir;
9
10use crate::utils::dirs::get_fe_deps;
11use crate::utils::git;
12
13const FE_TOML: &str = "fe.toml";
14
15pub enum FileLoader {
16    Static(Vec<(&'static str, &'static str)>),
17    Fs,
18}
19
20impl FileLoader {
21    pub fn canonicalize_path(&self, path: &str) -> Result<SmolStr, String> {
22        match self {
23            FileLoader::Static(_) => Ok(SmolStr::new(
24                Path::new(path).clean().to_str().expect("path clean failed"),
25            )),
26            FileLoader::Fs => Ok(SmolStr::new(
27                fs::canonicalize(path)
28                    .map_err(|err| {
29                        format!("unable to canonicalize root project path {path}.\n{err}")
30                    })?
31                    .to_str()
32                    .expect("could not convert path to string"),
33            )),
34        }
35    }
36
37    pub fn fe_files(&self, path: &str) -> Result<Vec<(String, String)>, String> {
38        match self {
39            FileLoader::Static(files) => Ok(files
40                .iter()
41                .filter_map(|(file_path, content)| {
42                    if file_path.starts_with(path) && file_path.ends_with(".fe") {
43                        Some((file_path.to_string(), content.to_string()))
44                    } else {
45                        None
46                    }
47                })
48                .collect()),
49            FileLoader::Fs => {
50                let entries = WalkDir::new(path);
51                let mut files = vec![];
52
53                for entry in entries.into_iter() {
54                    let entry =
55                        entry.map_err(|err| format!("Error loading source files.\n{err}"))?;
56                    let path = entry.path();
57
58                    if path.is_file()
59                        && path.extension().and_then(std::ffi::OsStr::to_str) == Some("fe")
60                    {
61                        let content = std::fs::read_to_string(path)
62                            .map_err(|err| format!("Unable to read src file.\n{err}"))?;
63                        files.push((path.to_string_lossy().to_string(), content));
64                    }
65                }
66
67                Ok(files)
68            }
69        }
70    }
71
72    pub fn file_content(&self, path: &str) -> Result<String, String> {
73        match self {
74            FileLoader::Static(files) => {
75                match files.iter().find(|(file_path, _)| file_path == &path) {
76                    Some((_, content)) => Ok(content.to_string()),
77                    None => Err(format!("could not load static file {}", path)),
78                }
79            }
80            FileLoader::Fs => {
81                std::fs::read_to_string(path).map_err(|err| format!("Unable to read file.\n{err}"))
82            }
83        }
84    }
85}
86
87pub struct BuildFiles {
88    pub root_project_path: SmolStr,
89    pub project_files: IndexMap<SmolStr, ProjectFiles>,
90}
91
92impl BuildFiles {
93    pub fn root_project_mode(&self) -> ProjectMode {
94        self.project_files[&self.root_project_path].mode
95    }
96
97    /// Build files are loaded from the file system.
98    pub fn load_fs(root_path: &str) -> Result<Self, String> {
99        Self::load(&FileLoader::Fs, root_path)
100    }
101
102    /// Build files are loaded from static file vector.
103    pub fn load_static(
104        files: Vec<(&'static str, &'static str)>,
105        root_path: &str,
106    ) -> Result<Self, String> {
107        Self::load(&FileLoader::Static(files), root_path)
108    }
109
110    fn load(loader: &FileLoader, root_project_path: &str) -> Result<Self, String> {
111        let root_project_path = loader.canonicalize_path(root_project_path)?;
112
113        // Map containing canonicalized project paths and their files.
114        let mut project_files = indexmap! {
115            root_project_path.clone() => ProjectFiles::load(loader, &root_project_path)?
116        };
117
118        // The root project is the first project to have unresolved dependencies.
119        let mut unresolved_projects = vec![root_project_path.clone()];
120
121        while let Some(unresolved_project_path) = unresolved_projects.pop() {
122            // Iterate over each of `unresolved_projects` dependencies.
123            for dependency in project_files[&unresolved_project_path].dependencies.clone() {
124                if !project_files.contains_key(&dependency.canonicalized_path) {
125                    // The dependency is being encountered for the first time.
126                    let dep_project = dependency.resolve(loader)?;
127                    project_files.insert(dependency.canonicalized_path.clone(), dep_project);
128                    unresolved_projects.push(dependency.canonicalized_path);
129                };
130            }
131        }
132
133        Ok(Self {
134            root_project_path,
135            project_files,
136        })
137    }
138}
139
140pub struct ProjectFiles {
141    pub name: SmolStr,
142    pub version: SmolStr,
143    pub mode: ProjectMode,
144    pub dependencies: Vec<Dependency>,
145    pub src: Vec<(String, String)>,
146}
147
148impl ProjectFiles {
149    fn load(loader: &FileLoader, path: &str) -> Result<Self, String> {
150        let manifest_path = Path::new(path)
151            .join(FE_TOML)
152            .to_str()
153            .expect("unable to convert path to &str")
154            .to_owned();
155        let manifest = Manifest::load(loader, &manifest_path)?;
156        let name = manifest.name;
157        let version = manifest.version;
158
159        let mut dependencies = vec![];
160        let mut errors = vec![];
161
162        if let Some(deps) = &manifest.dependencies {
163            for (name, value) in deps {
164                match Dependency::new(loader, name, path, value) {
165                    Ok(dep) => dependencies.push(dep),
166                    Err(dep_err) => {
167                        errors.push(format!("Misconfigured dependency {name}:\n{dep_err}"))
168                    }
169                }
170            }
171        }
172
173        if !errors.is_empty() {
174            return Err(errors.join("\n"));
175        }
176
177        let src_path = Path::new(path)
178            .join("src")
179            .to_str()
180            .expect("unable to convert path to &str")
181            .to_owned();
182        let src = loader.fe_files(&src_path)?;
183
184        let mode = if src
185            .iter()
186            .any(|(file_path, _)| file_path.ends_with("main.fe"))
187        {
188            ProjectMode::Main
189        } else if src
190            .iter()
191            .any(|(file_path, _)| file_path.ends_with("lib.fe"))
192        {
193            ProjectMode::Lib
194        } else {
195            return Err(format!("Unable to determine mode of {}. Consider adding src/main.fe or src/lib.fe file to the project.", name));
196        };
197
198        Ok(Self {
199            name,
200            version,
201            mode,
202            dependencies,
203            src,
204        })
205    }
206}
207
208#[derive(Clone, Copy, PartialEq, Eq)]
209pub enum ProjectMode {
210    Main,
211    Lib,
212}
213
214#[derive(Clone)]
215pub struct Dependency {
216    pub name: SmolStr,
217    pub version: Option<SmolStr>,
218    pub canonicalized_path: SmolStr,
219    pub kind: DependencyKind,
220}
221
222pub trait DependencyResolver {
223    fn resolve(dep: &Dependency, loader: &FileLoader) -> Result<ProjectFiles, String>;
224}
225
226#[derive(Clone)]
227pub struct LocalDependency;
228
229impl DependencyResolver for LocalDependency {
230    fn resolve(dep: &Dependency, loader: &FileLoader) -> Result<ProjectFiles, String> {
231        let project = ProjectFiles::load(loader, &dep.canonicalized_path)?;
232
233        let mut errors = vec![];
234
235        if project.mode == ProjectMode::Main {
236            errors.push(format!("{} is not a library", project.name));
237        }
238
239        if project.name != dep.name {
240            errors.push(format!("Name mismatch: {} =/= {}", project.name, dep.name));
241        }
242
243        if let Some(version) = &dep.version {
244            if version != &project.version {
245                errors.push(format!(
246                    "Version mismatch: {} =/= {}",
247                    project.version, version
248                ));
249            }
250        }
251
252        if errors.is_empty() {
253            Ok(project)
254        } else {
255            Err(format!(
256                "Unable to resolve {} at {} due to the following errors.\n{}",
257                dep.name,
258                dep.canonicalized_path,
259                errors.join("\n")
260            ))
261        }
262    }
263}
264
265#[derive(Clone)]
266pub struct GitDependency {
267    source: String,
268    rev: String,
269}
270
271impl DependencyResolver for GitDependency {
272    fn resolve(dep: &Dependency, loader: &FileLoader) -> Result<ProjectFiles, String> {
273        if let DependencyKind::Git(GitDependency { source, rev }) = &dep.kind {
274            if let Err(e) = git::fetch_and_checkout(source, dep.canonicalized_path.as_str(), rev) {
275                return Err(format!(
276                    "Unable to clone git dependency {}.\n{}",
277                    dep.name, e
278                ));
279            }
280
281            // Load it like any local dependency which will include additional checks.
282            return LocalDependency::resolve(dep, loader);
283        }
284        Err(format!("Could not resolve git dependency {}", dep.name))
285    }
286}
287
288#[derive(Clone)]
289pub enum DependencyKind {
290    Local(LocalDependency),
291    Git(GitDependency),
292}
293
294impl Dependency {
295    fn new(
296        loader: &FileLoader,
297        name: &str,
298        orig_path: &str,
299        value: &toml::Value,
300    ) -> Result<Self, String> {
301        let join_path = |path: &str| {
302            loader.canonicalize_path(
303                Path::new(orig_path)
304                    .join(path)
305                    .to_str()
306                    .expect("unable to convert path to &str"),
307            )
308        };
309
310        match value {
311            toml::Value::String(dep_path) => Ok(Dependency {
312                name: name.into(),
313                version: None,
314                kind: DependencyKind::Local(LocalDependency),
315                canonicalized_path: join_path(dep_path)?,
316            }),
317            toml::Value::Table(table) => {
318                let version = table
319                    .get("version")
320                    .map(|version| version.as_str().unwrap().into());
321
322                let return_local_dep = |path| {
323                    Ok::<Dependency, String>(Dependency {
324                        name: name.into(),
325                        version: version.clone(),
326                        canonicalized_path: join_path(path)?,
327
328                        kind: DependencyKind::Local(LocalDependency),
329                    })
330                };
331
332                match (table.get("source"), table.get("rev"), table.get("path")) {
333                    (Some(toml::Value::String(source)), Some(toml::Value::String(rev)), None) => {
334                        let dep_path = get_fe_deps().join(format!("{}-{}", name, rev));
335                        if dep_path.exists() {
336                            // Should we at least perform some kind of integrity check here? We currently treat an
337                            // existing directory as a valid dependency no matter what.
338                            return_local_dep(dep_path.to_str().unwrap())
339                        } else {
340                            fs::create_dir_all(&dep_path).unwrap();
341                            Ok(Dependency {
342                                name: name.into(),
343                                version: version.clone(),
344                                canonicalized_path: loader.canonicalize_path(
345                                    Path::new(orig_path)
346                                        .join(dep_path)
347                                        .to_str()
348                                        .expect("unable to convert path to &str"),
349                                )?,
350                                kind: DependencyKind::Git(GitDependency {
351                                    source: source.into(),
352                                    rev: rev.into(),
353                                }),
354                            })
355                        }
356                    }
357                    (Some(_), Some(_), Some(_)) => {
358                        Err("`path` can not be used together with `rev` and `source`".into())
359                    }
360                    (Some(_), None, _) => Err("`source` specified but no `rev` given".into()),
361                    (None, Some(_), _) => Err("`rev` specified but no `source` given".into()),
362                    (None, None, Some(toml::Value::String(path))) => return_local_dep(path),
363                    _ => Err("Dependency isn't well formed".into()),
364                }
365            }
366            _ => Err("unsupported toml type".into()),
367        }
368    }
369
370    fn resolve(&self, loader: &FileLoader) -> Result<ProjectFiles, String> {
371        match &self.kind {
372            DependencyKind::Local(_) => LocalDependency::resolve(self, loader),
373            DependencyKind::Git(_) => GitDependency::resolve(self, loader),
374        }
375    }
376}
377
378#[derive(Deserialize)]
379struct Manifest {
380    pub name: SmolStr,
381    pub version: SmolStr,
382    dependencies: Option<Table>,
383}
384
385impl Manifest {
386    pub fn load(loader: &FileLoader, path: &str) -> Result<Self, String> {
387        let content = loader
388            .file_content(path)
389            .map_err(|err| format!("Failed to load manifest content from {path}.\n{err}"))?;
390        let manifest: Manifest = toml::from_str(&content)
391            .map_err(|err| format!("Failed to parse the content of {path}.\n{err}"))?;
392
393        Ok(manifest)
394    }
395}
396
397/// Returns the root path of the current Fe project
398pub fn get_project_root() -> Option<String> {
399    let current_dir = std::env::current_dir().expect("Unable to get current directory");
400
401    let mut current_path = current_dir.clone();
402    loop {
403        let fe_toml_path = current_path.join(FE_TOML);
404        if fe_toml_path.is_file() {
405            return fe_toml_path
406                .parent()
407                .map(|val| val.to_string_lossy().to_string());
408        }
409
410        if !current_path.pop() {
411            break;
412        }
413    }
414
415    None
416}