1pub 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#[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
35pub struct Loader<F: FileSystem> {
38 source: PathBuf,
39 error_style: annotate_snippets::Renderer,
40 filesystem: F,
41}
42
43pub fn new_loader(source: PathBuf) -> Loader<ProdFileSystem> {
45 Loader::new(source, ProdFileSystem)
46}
47
48impl<F: FileSystem> Loader<F> {
49 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 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 pub(crate) fn error_style(&self) -> &annotate_snippets::Renderer {
73 &self.error_style
74 }
75
76 pub(crate) fn filesystem(&self) -> &F {
78 &self.filesystem
79 }
80
81 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
150pub trait FileSystem {
153 fn canonicalize_path<'a>(path: &'a Path) -> Cow<'a, Path>;
155
156 fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error>;
158
159 fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, LoadError>;
162}
163
164pub 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
206pub 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 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 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 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}