Skip to main content

okane_core/
load.rs

1//! Contains the functions to load Ledger file,
2//! with recursively resolving the `include` directives.
3
4// Re-exported because it appears in the public [`Loader`] API (see
5// [`Loader::with_error_renderer`]); lets downstream crates name the type
6// without pinning their own (possibly mismatched) annotate-snippets
7// dependency.
8pub use annotate_snippets::Renderer;
9
10use std::{
11    borrow::Cow,
12    collections::HashMap,
13    path::{self, Path, PathBuf},
14};
15
16use crate::{parse, syntax};
17
18/// Error caused by [Loader::load].
19#[derive(thiserror::Error, Debug)]
20pub enum LoadError {
21    #[error("failed to perform IO on file {1}")]
22    IO(#[source] std::io::Error, PathBuf),
23    #[error("failed to parse file {1}")]
24    Parse(#[source] parse::ParseError, PathBuf),
25    #[error("loading file path {0} doesn't have parent, maybe filesystem root is passed")]
26    RootLoadingPath(PathBuf),
27    #[error("invalid Unicode path is not supported: {0}")]
28    InvalidUnicodePath(String),
29    #[error("invalid glob pattern specified")]
30    InvalidIncludeGlob(#[from] glob::PatternError),
31    #[error("failed to match glob pattern")]
32    GlobFailure(#[from] glob::GlobError),
33}
34
35/// Loader is an object to keep loading a given file and may recusrively load them as `repr::LedgerEntry`,
36/// with the metadata about filename or line/column to point the error in a user friendly manner.
37pub struct Loader<F: FileSystem> {
38    source: PathBuf,
39    error_style: annotate_snippets::Renderer,
40    filesystem: F,
41}
42
43/// Creates a new [`Loader`] instance with [`ProdFileSystem`].
44pub fn new_loader(source: PathBuf) -> Loader<ProdFileSystem> {
45    Loader::new(source, ProdFileSystem)
46}
47
48impl<F: FileSystem> Loader<F> {
49    /// Create a new instance of `Loader` to load the given path.
50    ///
51    /// It might look weird to have the source path as a `Loader` member,
52    /// but that would give future flexibility to support loading from stdio/network without include,
53    /// or completely static one.
54    pub fn new(source: PathBuf, filesystem: F) -> Self {
55        Self {
56            source,
57            error_style: annotate_snippets::Renderer::styled(),
58            filesystem,
59        }
60    }
61
62    /// Create a new instance with the given `renderer`.
63    pub fn with_error_renderer(self, renderer: annotate_snippets::Renderer) -> Self {
64        Self {
65            source: self.source,
66            error_style: renderer,
67            filesystem: self.filesystem,
68        }
69    }
70
71    /// Returns a [`annotate_snippets::Renderer`] instance.
72    pub(crate) fn error_style(&self) -> &annotate_snippets::Renderer {
73        &self.error_style
74    }
75
76    /// Returns a `filesystem` reference.
77    pub(crate) fn filesystem(&self) -> &F {
78        &self.filesystem
79    }
80
81    /// Loads [syntax::LedgerEntry] and invoke callback on every entry,
82    /// recursively resolving `include` directives.
83    pub fn load<T, E, Deco>(&self, mut callback: T) -> Result<(), E>
84    where
85        T: FnMut(&Path, &parse::ParsedContext<'_>, &syntax::LedgerEntry<'_, Deco>) -> Result<(), E>,
86        E: std::error::Error + From<LoadError>,
87        Deco: syntax::decoration::Decoration,
88    {
89        let popts = parse::ParseOptions::default().with_error_style(self.error_style.clone());
90        self.load_impl(&popts, &self.source, &mut callback)
91    }
92
93    fn load_impl<T, E, Deco>(
94        &self,
95        parse_options: &parse::ParseOptions,
96        path: &Path,
97        callback: &mut T,
98    ) -> Result<(), E>
99    where
100        T: FnMut(&Path, &parse::ParsedContext<'_>, &syntax::LedgerEntry<'_, Deco>) -> Result<(), E>,
101        E: std::error::Error + From<LoadError>,
102        Deco: syntax::decoration::Decoration,
103    {
104        let path: Cow<'_, Path> = F::canonicalize_path(path);
105        let content = self
106            .filesystem
107            .file_content_utf8(&path)
108            .map_err(|err| LoadError::IO(err, path.clone().into_owned()))?;
109        for parsed in parse::parse_ledger(parse_options, &content) {
110            let (ctx, entry) =
111                parsed.map_err(|e| LoadError::Parse(e, path.clone().into_owned()))?;
112            match &entry.statement {
113                syntax::LedgerStatement::Include(p) => {
114                    let include_path: PathBuf = p.0.as_ref().into();
115                    let target: String = path
116                        .as_ref()
117                        .parent()
118                        .ok_or_else(|| LoadError::RootLoadingPath(path.as_ref().to_owned()))?
119                        .join(include_path)
120                        .into_os_string()
121                        .into_string()
122                        .map_err(|x| {
123                            LoadError::InvalidUnicodePath(format!("{}", PathBuf::from(x).display()))
124                        })?;
125                    let mut paths: Vec<PathBuf> = self.filesystem.glob(&target)?;
126                    if paths.is_empty() {
127                        return Err(LoadError::IO(
128                            std::io::Error::new(
129                                std::io::ErrorKind::NotFound,
130                                format!("glob {} does not hit any files", target),
131                            ),
132                            PathBuf::from(target),
133                        )
134                        .into());
135                    }
136                    log::debug!("glob {} hit {} files", target, paths.len());
137                    paths.sort_unstable();
138                    for path in &paths {
139                        self.load_impl(parse_options, path, callback)?;
140                    }
141                    Ok(())
142                }
143                _ => callback(&path, &ctx, &entry),
144            }?;
145        }
146        Ok(())
147    }
148}
149
150/// Interface to abstract file system.
151/// Normally you want to use [ProdFileSystem].
152pub trait FileSystem {
153    /// canonicalize the given path.
154    fn canonicalize_path<'a>(path: &'a Path) -> Cow<'a, Path>;
155
156    /// Load the given path and returns it as UTF-8 String.
157    fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error>;
158
159    /// Returns all paths matching the given glob.
160    /// Paths can be in arbitrary order, and caller must sort it beforehand.
161    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, LoadError>;
162}
163
164/// [FileSystem] to regularly reads the files recursively in the local files.
165pub struct ProdFileSystem;
166
167impl FileSystem for ProdFileSystem {
168    fn canonicalize_path<'a>(path: &'a Path) -> Cow<'a, Path> {
169        dunce::canonicalize(path)
170            .map(|x| {
171                if x == path {
172                    Cow::Borrowed(path)
173                } else {
174                    Cow::Owned(x)
175                }
176            })
177            .unwrap_or_else(|x| {
178                log::warn!(
179                    "failed to canonicalize path {}, likeky to fail to load: {}",
180                    path.display(),
181                    x
182                );
183                path.into()
184            })
185    }
186
187    fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error> {
188        std::fs::read_to_string(path)
189    }
190
191    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, LoadError> {
192        let paths: Vec<PathBuf> = glob::glob_with(pattern, glob_match_options())?
193            .collect::<Result<Vec<_>, glob::GlobError>>()?;
194        Ok(paths)
195    }
196}
197
198const fn glob_match_options() -> glob::MatchOptions {
199    glob::MatchOptions {
200        case_sensitive: true,
201        require_literal_separator: true,
202        require_literal_leading_dot: true,
203    }
204}
205
206/// [FileSystem] with given set of filename and content mapping.
207/// It won't cause any actual file read.
208pub struct FakeFileSystem(HashMap<PathBuf, Vec<u8>>);
209
210impl From<HashMap<PathBuf, Vec<u8>>> for FakeFileSystem {
211    fn from(value: HashMap<PathBuf, Vec<u8>>) -> Self {
212        Self(value)
213    }
214}
215
216impl FileSystem for FakeFileSystem {
217    fn canonicalize_path<'a>(path: &'a Path) -> Cow<'a, Path> {
218        let mut components = Vec::new();
219        for pc in path.components() {
220            match pc {
221                path::Component::CurDir => (),
222                path::Component::ParentDir => {
223                    if components.pop().is_none() {
224                        log::warn!(
225                            "failed to pop parent, maybe wrong path given: {}",
226                            path.display()
227                        );
228                    }
229                }
230                path::Component::RootDir => components.push("/"),
231                path::Component::Prefix(_) => log::info!("ignore prefix: {:?}", pc),
232                path::Component::Normal(pc) => {
233                    components.push(pc.to_str().unwrap_or("invalid-unicode-component"))
234                }
235            }
236        }
237        Cow::Owned(components.join("/").into())
238    }
239
240    fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error> {
241        let path = path.as_ref();
242        self.0
243            .get(path)
244            .ok_or(std::io::Error::new(
245                std::io::ErrorKind::NotFound,
246                format!("fake file {} not found", path.display()),
247            ))
248            .and_then(|x| {
249                String::from_utf8(x.clone())
250                    .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
251            })
252    }
253
254    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, LoadError> {
255        let pattern = glob::Pattern::new(pattern)?;
256        let mut paths: Vec<PathBuf> = self
257            .0
258            .keys()
259            .filter(|x| pattern.matches_path_with(x, glob_match_options()))
260            .cloned()
261            .collect();
262        paths.sort_by(|x, y| y.cmp(x));
263        Ok(paths)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    use std::{borrow::Borrow, path::Path, vec::Vec};
272
273    use indoc::indoc;
274    use maplit::hashmap;
275    use pretty_assertions::assert_eq;
276
277    /// Parses the expected entries, given as a fragment per contiguous run of
278    /// entries the loader reports from one file.
279    ///
280    /// A fragment starts wherever the preceding `include` directive was, so its
281    /// first entry's [`syntax::Separation`] cannot be derived from the fragment
282    /// itself and is given explicitly.
283    fn parse_static_ledger_entry(
284        input: &[(&Path, syntax::Separation, &'static str)],
285    ) -> Result<Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)>, parse::ParseError> {
286        let opts = parse::ParseOptions::default();
287        input
288            .iter()
289            .flat_map(|(p, first_separation, content)| {
290                parse::parse_ledger(&opts, content)
291                    .enumerate()
292                    .map(|(i, elem)| {
293                        elem.map(|(_ctx, mut entry)| {
294                            if i == 0 {
295                                entry.separation = *first_separation;
296                            }
297                            (p.to_path_buf(), entry)
298                        })
299                    })
300            })
301            .collect()
302    }
303
304    fn parse_into_vec<L, F>(
305        loader: L,
306    ) -> Result<Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)>, LoadError>
307    where
308        L: Borrow<Loader<F>>,
309        F: FileSystem,
310    {
311        let mut ret: Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)> = Vec::new();
312        loader.borrow().load(|path, _ctx, entry| {
313            ret.push((path.to_owned(), entry.to_static()));
314            Ok::<(), LoadError>(())
315        })?;
316        Ok(ret)
317    }
318
319    #[test]
320    fn load_valid_input_real_file() {
321        let mut testdata_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
322        assert!(
323            testdata_dir.pop(),
324            "CARGO_MANIFEST_DIR={} must have parent dir",
325            testdata_dir.display()
326        );
327        testdata_dir.push("testdata/load");
328        testdata_dir = dunce::canonicalize(testdata_dir).unwrap();
329        let root = testdata_dir.join("recursive.ledger");
330        let child1 = testdata_dir.join("child1.ledger");
331        let child2 = testdata_dir.join("sub").join("child2.ledger");
332        let child3 = testdata_dir.join("child3.ledger");
333        let child4 = testdata_dir.join("sub").join("child4.ledger");
334        let want = parse_static_ledger_entry(&[
335            (
336                &root,
337                syntax::Separation::Immediate,
338                indoc! {"
339            ; Demonstrates include feature including glob, parent dir, ...
340
341            account Expenses:Grocery
342                note スーパーマーケットで買ったやつ全部
343                ; comment
344                alias Expenses:CVS
345
346            2024/01/01 Initial Balance
347                Equity:Opening Balance                  -1000.00 CHF
348                Assets:Bank:ZKB                          1000.00 CHF
349            "},
350            ),
351            (
352                &child2,
353                syntax::Separation::Immediate,
354                indoc! {"
355            2024/01/01 * Complicated salary
356                Income:Salary                          -3,000.00 CHF
357                Assets:Bank:ZKB                         2,500.00 CHF
358                Expenses:Income Tax                       312.34 CHF
359                Expenses:Social Tax                        37.66 CHF
360                Assets:Fixed:年金                         150.00 CHF
361            "},
362            ),
363            (
364                &child3,
365                syntax::Separation::Immediate,
366                indoc! {"
367            2024/03/01 * SBB CFF FFS
368                Assets:Bank:ZKB                            -5.60 CHF
369                Expenses:Travel:Train                       5.60 CHF
370            "},
371            ),
372            (
373                &child2,
374                // Follows a blank line after `include ../child3.ledger`.
375                syntax::Separation::BlankLine,
376                indoc! {"
377            2024/01/25 ! RSU
378                ; TODO: FMV not determined
379                Income:RSU                    (-50.0000 * 100.23 USD)
380                Expenses:Income Tax
381                Assets:Broker                            40.0000 OKANE @ 100.23 USD
382            "},
383            ),
384            (
385                &child4,
386                syntax::Separation::Immediate,
387                indoc! {"
388            2024/7/1 * Send money
389                Assets:Bank:ZKB                         -1000.00 CHF
390                Assets:Wire:Wise                         1000.00 CHF
391            "},
392            ),
393            (
394                &child1,
395                // Follows a blank line after `include sub/child*.ledger`.
396                syntax::Separation::BlankLine,
397                indoc! {"
398            2024/05/01 * Migros
399                Expenses:Grocery                          -10.00 CHF
400                Assets:Bank:ZKB                            10.00 CHF
401            "},
402            ),
403        ])
404        .expect("test input parse must not fail");
405        let got = parse_into_vec(new_loader(root.clone())).expect("failed to parse the test data");
406        assert_eq!(want, got);
407    }
408
409    #[test]
410    fn load_valid_fake() {
411        let fake = hashmap! {
412            PathBuf::from("path/to/root.ledger") => indoc! {"
413                include child1.ledger
414            "}.as_bytes().to_vec(),
415            PathBuf::from("path/to/child1.ledger") => indoc! {"
416                include sub/*.ledger
417            "}.as_bytes().to_vec(),
418            PathBuf::from("path/to/sub/child2.ledger") => "".as_bytes().to_vec(),
419            PathBuf::from("path/to/sub/child3.ledger") => indoc! {"
420                ; comment here
421            "}.as_bytes().to_vec(),
422            PathBuf::from("path/to/sub/.unloaded.ledger") => indoc! {"
423                completely invalid file, should not be loaded
424            "}.as_bytes().to_vec(),
425        };
426
427        let want = parse_static_ledger_entry(&[(
428            Path::new("path/to/sub/child3.ledger"),
429            syntax::Separation::Immediate,
430            indoc! {"
431            ; comment here
432            "},
433        )])
434        .expect("test input parse must not fail");
435
436        let got = parse_into_vec(Loader::new(
437            PathBuf::from("path/to/root.ledger"),
438            FakeFileSystem::from(fake),
439        ))
440        .expect("parse failed");
441        assert_eq!(want, got);
442    }
443
444    #[test]
445    fn load_non_existing_file() {
446        let fake = hashmap! {
447            PathBuf::from("/path/to/root.ledger") => indoc! {"
448                ; foo
449            "}.as_bytes().to_vec(),
450        };
451
452        let got_err = parse_into_vec(Loader::new(
453            PathBuf::from("/path/to/not_found.ledger"),
454            FakeFileSystem::from(fake),
455        ))
456        .unwrap_err();
457
458        match got_err {
459            LoadError::IO(e, _) => assert!(
460                e.kind() == std::io::ErrorKind::NotFound,
461                "should cause NotFound IO error: got {:?}",
462                e
463            ),
464            _ => panic!("unexpected error: {:?}", got_err),
465        }
466    }
467
468    #[test]
469    fn load_include_non_existing_file() {
470        let fake = hashmap! {
471            PathBuf::from("/path/to/root.ledger") => indoc! {"
472                include non_existing.ledger
473            "}.as_bytes().to_vec(),
474        };
475
476        let got_err = parse_into_vec(Loader::new(
477            PathBuf::from("/path/to/root.ledger"),
478            FakeFileSystem::from(fake),
479        ))
480        .expect_err("parse failed");
481
482        match got_err {
483            LoadError::IO(e, _) => assert!(
484                e.kind() == std::io::ErrorKind::NotFound,
485                "should cause NotFound IO error: got {:?}",
486                e
487            ),
488            _ => panic!("unexpected error: {:?}", got_err),
489        }
490    }
491
492    mod fake_file_system {
493        use super::*;
494
495        use pretty_assertions::assert_eq;
496
497        #[test]
498        fn canonicalize_simple() {
499            assert_eq!(
500                FakeFileSystem::canonicalize_path(Path::new("path/to/file")),
501                Cow::Owned::<Path>(PathBuf::from("path/to/file")),
502            );
503        }
504
505        #[test]
506        fn canonicalize_up_and_current() {
507            assert_eq!(
508                FakeFileSystem::canonicalize_path(Path::new("path/to/sub/./.././file")),
509                Cow::Owned::<Path>(PathBuf::from("path/to/file")),
510            );
511        }
512    }
513}