1use std::{
5 borrow::Cow,
6 collections::HashMap,
7 path::{self, Path, PathBuf},
8};
9
10use crate::{parse, syntax};
11
12#[derive(thiserror::Error, Debug)]
14pub enum LoadError {
15 #[error("failed to perform IO")]
16 IO(#[from] std::io::Error),
17 #[error("failed to parse file {1}")]
18 Parse(#[source] Box<parse::ParseError>, PathBuf),
19 #[error("unexpected include path {0}, maybe filesystem root is passed")]
20 IncludePath(PathBuf),
21}
22
23pub struct Loader<F: FileSystem> {
26 source: PathBuf,
27 error_style: annotate_snippets::Renderer,
28 filesystem: F,
29}
30
31pub fn new_loader(source: PathBuf) -> Loader<ProdFileSystem> {
33 Loader::new(source, ProdFileSystem)
34}
35
36impl<F: FileSystem> Loader<F> {
37 pub fn new(source: PathBuf, filesystem: F) -> Self {
43 Self {
44 source,
45 error_style: annotate_snippets::Renderer::styled(),
47 filesystem,
48 }
49 }
50
51 pub(crate) fn error_style(&self) -> &annotate_snippets::Renderer {
53 &self.error_style
54 }
55
56 pub fn load<T, E, Deco>(&self, mut callback: T) -> Result<(), E>
59 where
60 T: FnMut(&Path, &parse::ParsedContext<'_>, &syntax::LedgerEntry<'_, Deco>) -> Result<(), E>,
61 E: std::error::Error + From<LoadError>,
62 Deco: syntax::decoration::Decoration,
63 {
64 let popts = parse::ParseOptions::default().with_error_style(self.error_style.clone());
65 self.load_impl(&popts, &self.source, &mut callback)
66 }
67
68 fn load_impl<T, E, Deco>(
69 &self,
70 parse_options: &parse::ParseOptions,
71 path: &Path,
72 callback: &mut T,
73 ) -> Result<(), E>
74 where
75 T: FnMut(&Path, &parse::ParsedContext<'_>, &syntax::LedgerEntry<'_, Deco>) -> Result<(), E>,
76 E: std::error::Error + From<LoadError>,
77 Deco: syntax::decoration::Decoration,
78 {
79 let path: Cow<'_, Path> = self.filesystem.canonicalize_path(path);
80 let content = self
81 .filesystem
82 .file_content_utf8(&path)
83 .map_err(LoadError::IO)?;
84 for parsed in parse_options.parse_ledger(&content) {
85 let (ctx, entry) =
86 parsed.map_err(|e| LoadError::Parse(Box::new(e), path.clone().into_owned()))?;
87 match entry {
88 syntax::LedgerEntry::Include(p) => {
89 let include_path: PathBuf = p.0.as_ref().into();
90 let target = path
91 .as_ref()
92 .parent()
93 .ok_or_else(|| LoadError::IncludePath(path.as_ref().to_owned()))?
94 .join(include_path);
95 self.load_impl(parse_options, &target, callback)
96 }
97 _ => callback(&path, &ctx, &entry),
98 }?;
99 }
100 Ok(())
101 }
102}
103
104pub trait FileSystem {
107 fn canonicalize_path<'a>(&self, path: &'a Path) -> Cow<'a, Path>;
109
110 fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error>;
112}
113
114pub struct ProdFileSystem;
116
117impl FileSystem for ProdFileSystem {
118 fn canonicalize_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
119 std::fs::canonicalize(path)
120 .map(|x| {
121 if x == path {
122 Cow::Borrowed(path)
123 } else {
124 Cow::Owned(x)
125 }
126 })
127 .unwrap_or_else(|x| {
128 log::warn!(
129 "failed to canonicalize path {}, likeky to fail to load: {}",
130 path.display(),
131 x
132 );
133 path.into()
134 })
135 }
136
137 fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error> {
138 std::fs::read_to_string(path)
139 }
140}
141
142pub struct FakeFileSystem(HashMap<PathBuf, Vec<u8>>);
145
146impl From<HashMap<PathBuf, Vec<u8>>> for FakeFileSystem {
147 fn from(value: HashMap<PathBuf, Vec<u8>>) -> Self {
148 Self(value)
149 }
150}
151
152impl FileSystem for FakeFileSystem {
153 fn canonicalize_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
154 let mut ret = PathBuf::new();
155 for pc in path.components() {
156 match pc {
157 path::Component::CurDir => (),
158 path::Component::ParentDir => {
159 if !ret.pop() {
160 log::warn!(
161 "failed to pop parent, maybe wrong path given: {}",
162 path.display()
163 );
164 }
165 }
166 _ => ret.push(pc),
167 }
168 }
169 Cow::Owned(ret)
170 }
171
172 fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error> {
173 let path = path.as_ref();
174 self.0
175 .get(path)
176 .ok_or(std::io::Error::new(
177 std::io::ErrorKind::NotFound,
178 format!("fake file {} not found", path.display()),
179 ))
180 .and_then(|x| {
181 String::from_utf8(x.clone())
182 .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
183 })
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use std::{borrow::Borrow, path::Path, vec::Vec};
190
191 use indoc::indoc;
192 use maplit::hashmap;
193 use pretty_assertions::assert_eq;
194
195 use crate::parse::{self, ParseOptions};
196
197 use super::*;
198
199 fn parse_static_ledger_entry<'a>(
200 input: &[(&Path, &'static str)],
201 ) -> Result<Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)>, parse::ParseError> {
202 let opts = ParseOptions::default();
203 input
204 .iter()
205 .flat_map(|(p, content)| {
206 opts.parse_ledger(content)
207 .map(|elem| elem.map(|(_ctx, entry)| (p.to_path_buf(), entry)))
208 })
209 .collect()
210 }
211
212 fn parse_into_vec<L, F>(
213 loader: L,
214 ) -> Result<Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)>, LoadError>
215 where
216 L: Borrow<Loader<F>>,
217 F: FileSystem,
218 {
219 let mut ret: Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)> = Vec::new();
220 loader.borrow().load(|path, _ctx, entry| {
221 ret.push((path.to_owned(), entry.to_static()));
222 Ok::<(), LoadError>(())
223 })?;
224 Ok(ret)
225 }
226
227 #[test]
228 fn load_valid_input_real_file() {
229 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
230 .join("testdata/root.ledger")
231 .canonicalize()
232 .unwrap();
233 let child1 = Path::new(env!("CARGO_MANIFEST_DIR"))
234 .join("testdata/child1.ledger")
235 .canonicalize()
236 .unwrap();
237 let child2 = Path::new(env!("CARGO_MANIFEST_DIR"))
238 .join("testdata/sub/child2.ledger")
239 .canonicalize()
240 .unwrap();
241 let child3 = Path::new(env!("CARGO_MANIFEST_DIR"))
242 .join("testdata/child3.ledger")
243 .canonicalize()
244 .unwrap();
245 let want = parse_static_ledger_entry(&[
246 (
247 &root,
248 indoc! {"
249 account Expenses:Grocery
250 note スーパーマーケットで買ったやつ全部
251 ; comment
252 alias Expenses:CVS
253
254 2024/01/01 Initial Balance
255 Equity:Opening Balance -1000.00 CHF
256 Assets:Bank:ZKB 1000.00 CHF
257 "},
258 ),
259 (
260 &child2,
261 indoc! {"
262 2024/01/01 * Complicated salary
263 Income:Salary -3,000.00 CHF
264 Assets:Bank:ZKB 2,500.00 CHF
265 Expenses:Income Tax 312.34 CHF
266 Expenses:Social Tax 37.66 CHF
267 Assets:Fixed:年金 150.00 CHF
268 "},
269 ),
270 (
271 &child3,
272 indoc! {"
273 2024/03/01 * SBB CFF FFS
274 Assets:Bank:ZKB -5.60 CHF
275 Expenses:Travel:Train 5.60 CHF
276 "},
277 ),
278 (
279 &child2,
280 indoc! {"
281 2024/01/25 ! RSU
282 ; TODO: FMV not determined
283 Income:RSU (-50.0000 * 100.23 USD)
284 Expenses:Income Tax
285 Assets:Broker 40.0000 OKANE @ 100.23 USD
286 "},
287 ),
288 (
289 &child1,
290 indoc! {"
291 2024/05/01 * Migros
292 Expenses:Grocery -10.00 CHF
293 Assets:Bank:ZKB 10.00 CHF
294 "},
295 ),
296 ])
297 .expect("test input parse must not fail");
298 let got = parse_into_vec(new_loader(root.clone())).expect("failed to parse the test data");
299 assert_eq!(want, got);
300 }
301
302 #[test]
303 fn load_valid_fake() {
304 let fake = hashmap! {
305 PathBuf::from("/path/to/root.ledger") => indoc! {"
306 include child1.ledger
307 "}.as_bytes().to_vec(),
308 PathBuf::from("/path/to/child1.ledger") => indoc! {"
309 include sub/child2.ledger
310 "}.as_bytes().to_vec(),
311 PathBuf::from("/path/to/sub/child2.ledger") => indoc! {"
312 include child3.ledger
313 "}.as_bytes().to_vec(),
314 PathBuf::from("/path/to/sub/child3.ledger") => indoc! {"
315 ; comment here
316 "}.as_bytes().to_vec(),
317 };
318 let want = parse_static_ledger_entry(&[(
319 Path::new("/path/to/sub/child3.ledger"),
320 indoc! {"
321 ; comment here
322 "},
323 )])
324 .expect("test input parse must not fail");
325 let got = parse_into_vec(Loader::new(
326 PathBuf::from("/path/to/root.ledger"),
327 FakeFileSystem::from(fake),
328 ))
329 .expect("parse failed");
330 assert_eq!(want, got);
331 }
332}