1use std::collections::BTreeMap;
2use std::fs;
3use std::iter;
4use std::process::Command;
5use std::process::Stdio;
6use std::str;
7
8use serde::de::Error as _;
9use serde::{Deserialize, Deserializer};
10
11use crate::app::App;
12use crate::book::{self, Book, Song, SongRef};
13use crate::default_project::DEFAULT_PROJECT;
14use crate::music::Notation;
15use crate::parser::Diagnostic;
16use crate::parser::Parser;
17use crate::parser::ParserConfig;
18use crate::prelude::*;
19use crate::render::tex_tools::TexConfig;
20use crate::render::tex_tools::TexTools;
21use crate::render::Renderer;
22use crate::util::ExitStatusExt;
23
24pub use toml::Value;
25
26mod input;
27use input::{InputSet, SongsGlobs};
28mod output;
29pub use output::{Format, Output};
30
31pub type Metadata = BTreeMap<Box<str>, Value>;
32
33type TomlMap = toml::map::Map<String, Value>;
34
35fn dir_songs() -> PathBuf {
36 "songs".into()
37}
38
39fn dir_templates() -> PathBuf {
40 "templates".into()
41}
42
43fn dir_output() -> PathBuf {
44 "output".into()
45}
46
47fn meta_default_chorus_label<'de, D>(de: D) -> Result<Metadata, D::Error>
48where
49 D: Deserializer<'de>,
50{
51 let mut meta = Metadata::deserialize(de)?;
52 if !meta.contains_key("chorus_label") {
53 meta.insert("chorus_label".into(), "Ch".into());
54 }
55 Ok(meta)
56}
57
58fn pathbuf_relative_only<'de, D>(de: D) -> Result<PathBuf, D::Error>
59where
60 D: Deserializer<'de>,
61{
62 let path = PathBuf::deserialize(de)?;
63 if !path.is_relative() {
64 let err = D::Error::custom(format!(
65 "Configured paths must be relative to the project directory. Path: {:?}",
66 path
67 ));
68 Err(err)
69 } else {
70 Ok(path)
71 }
72}
73
74fn default_smart_punctuation() -> bool {
75 true
76}
77
78#[derive(Deserialize, Debug)]
79pub struct Settings {
80 songs: SongsGlobs,
81
82 #[serde(default = "dir_songs", deserialize_with = "pathbuf_relative_only")]
83 dir_songs: PathBuf,
84 #[serde(default = "dir_templates", deserialize_with = "pathbuf_relative_only")]
85 dir_templates: PathBuf,
86 #[serde(default = "dir_output", deserialize_with = "pathbuf_relative_only")]
87 dir_output: PathBuf,
88
89 #[serde(default)]
90 pub notation: Notation,
91 #[serde(default = "default_smart_punctuation")]
92 pub smart_punctuation: bool,
93 tex: Option<TexConfig>,
94
95 pub output: Vec<Output>,
96 #[serde(deserialize_with = "meta_default_chorus_label")]
97 pub book: Metadata,
98}
99
100impl Settings {
101 pub fn version() -> u32 {
102 let major = env!("CARGO_PKG_VERSION_MAJOR");
103 major.parse().unwrap()
104 }
105
106 pub fn from_file(path: &Path, project_dir: &Path) -> Result<Settings> {
107 let contents = fs::read_to_string(path)
108 .with_context(|| format!("Failed to read project file {:?}", path))?;
109
110 let parse_err = || format!("Could not parse project file {:?}", path);
111
112 let settings: TomlMap = toml::from_str(&contents).with_context(parse_err)?;
114 let version = settings.get("version").unwrap_or(&Value::Integer(1));
115 let version = version
116 .as_integer()
117 .ok_or_else(|| anyhow!("'version' field expected to be an interger"))
118 .with_context(parse_err)?;
119 let self_ver = Self::version();
120 if version < self_ver as _ {
121 bail!(
122 "This project was created with bard {}.x - to build with bard {}.x please follow the migration guide: https://bard.md/book/migration-{1}.html",
123 version, self_ver);
124 } else if version > self_ver as _ {
125 bail!("This project was created with a newer version {}.x of bard, the project cannot be built by bard {}.x", version, self_ver);
126 }
127
128 let mut settings: Settings = toml::from_str(&contents).with_context(parse_err)?;
129
130 settings.resolve(project_dir)?;
131 Ok(settings)
132 }
133
134 pub fn dir_songs(&self) -> &Path {
135 self.dir_songs.as_ref()
136 }
137
138 pub fn dir_output(&self) -> &Path {
139 self.dir_output.as_ref()
140 }
141
142 fn resolve(&mut self, project_dir: &Path) -> Result<()> {
143 self.dir_songs.resolve(project_dir);
144 self.dir_templates.resolve(project_dir);
145 self.dir_output.resolve(project_dir);
146
147 for output in self.output.iter_mut() {
148 output.resolve(&self.dir_templates, &self.dir_output)?;
149 }
150
151 Ok(())
152 }
153}
154
155#[cfg(unix)]
156static SCRIPT_EXT: &str = "sh";
157#[cfg(windows)]
158static SCRIPT_EXT: &str = "bat";
159
160#[derive(Debug)]
161pub struct Project {
162 pub project_dir: PathBuf,
163 pub settings: Settings,
164 pub book: Book,
165
166 project_file: PathBuf,
167 input_paths: Vec<PathBuf>,
168}
169
170impl Project {
171 pub fn new<P: AsRef<Path>>(app: &App, cwd: P) -> Result<Project> {
172 let cwd = cwd.as_ref();
173 let (project_file, project_dir) = Self::find_in_parents(cwd).ok_or_else(|| {
174 anyhow!(
175 "Could not find bard.toml file in current or parent directories\nCurrent directory: {:?}",
176 cwd,
177 )
178 })?;
179
180 app.status("Loading", format!("project at {:?}", project_dir));
181
182 let settings = Settings::from_file(&project_file, &project_dir)?;
183 let book = Book::new(&settings);
184
185 let mut project = Project {
186 project_file,
187 project_dir,
188 settings,
189 input_paths: vec![],
190 book,
191 };
192
193 project
194 .load_md_files(app)
195 .context("Failed to load input files")?;
196
197 Ok(project)
198 }
199
200 fn find_in_parents(start_dir: &Path) -> Option<(PathBuf, PathBuf)> {
201 assert!(start_dir.is_dir());
202
203 let mut parent = start_dir;
204 loop {
205 let project_file = parent.join("bard.toml");
206 if project_file.exists() {
207 return Some((project_file, parent.into()));
208 }
209
210 parent = parent.parent()?;
211 }
212 }
213
214 fn load_md_files(&mut self, app: &App) -> Result<()> {
215 let input_set = InputSet::new(&self.settings.dir_songs)?;
216 self.input_paths = self
217 .settings
218 .songs
219 .iter()
220 .try_fold(input_set, InputSet::apply_glob)?
221 .finalize()?;
222
223 let diag_sink = move |diag: Diagnostic| {
224 app.parser_diag(diag);
225 };
226
227 for path in self.input_paths.iter() {
228 app.check_interrupted()?;
229 let source = fs::read_to_string(path)?;
230 let config = ParserConfig::new(self.settings.notation, self.settings.smart_punctuation);
231 let rel_path = path.strip_prefix(&self.project_dir).unwrap_or(path);
232 let mut parser = Parser::new(&source, rel_path, config, diag_sink);
233 let songs = parser
234 .parse()
235 .map_err(|_| anyhow!("Could not parse file {:?}", path))?;
236 self.book.add_songs(songs);
237 }
238
239 self.book
240 .postprocess(&self.settings.dir_output, app.img_cache())?;
241
242 Ok(())
243 }
244
245 pub fn init<P: AsRef<Path>>(project_dir: P) -> Result<()> {
246 DEFAULT_PROJECT.resolve(project_dir.as_ref()).create()
247 }
248
249 pub fn book_section(&self) -> &Metadata {
250 &self.settings.book
251 }
252
253 pub fn songs(&self) -> &[Song] {
254 &self.book.songs
255 }
256
257 pub fn songs_sorted(&self) -> &[SongRef] {
258 &self.book.songs_sorted
259 }
260
261 fn run_script(&self, app: &App, output: &Output) -> Result<()> {
262 let script_fn = match output.script.as_deref() {
263 Some(s) => format!("{}.{}", s, SCRIPT_EXT),
264 None => return Ok(()),
265 };
266
267 let script_path = self.settings.dir_output().join(&script_fn);
268 if !script_path.exists() {
269 bail!(
270 "Could not find script file '{}' in the output directory.",
271 script_fn
272 );
273 }
274
275 app.status("Running", format!("script '{}'", script_fn));
276 let mut child = Command::new(script_path)
277 .current_dir(self.settings.dir_output())
278 .stdin(Stdio::null())
279 .stdout(Stdio::inherit())
280 .stderr(Stdio::inherit())
281 .env("BARD", app.bard_exe())
282 .env("OUTPUT", output.file.as_os_str())
283 .env("OUTPUT_STEM", output.file.file_stem().unwrap()) .env("PROJECT_DIR", self.project_dir.as_os_str())
285 .env("OUTPUT_DIR", self.settings.dir_output().as_os_str())
286 .spawn()?;
287 app.child_wait(&mut child)?.into_result()?;
288
289 Ok(())
290 }
291
292 pub fn render(&self, app: &App) -> Result<()> {
293 fs::create_dir_all(&self.settings.dir_output)?;
294
295 if self.settings.output.iter().any(|o| o.is_pdf()) {
296 TexTools::initialize(app, self.settings.tex.as_ref())
299 .context("Could not initialize TeX tools.")?;
300 }
301
302 self.settings.output.iter().try_for_each(|output| {
303 app.check_interrupted()?;
304 app.status("Rendering", output.output_filename());
305 let context = || {
306 format!(
307 "Could not render output file {:?}",
308 output.file.file_name().unwrap()
309 )
310 };
311
312 let renderer = Renderer::new(self, output, app.img_cache()).with_context(context)?;
313 let tpl_version = renderer.version();
314
315 let res = renderer.render(app).with_context(context).and_then(|_| {
316 if app.post_process() {
317 self.run_script(app, output).with_context(|| {
318 format!(
319 "Could not run script for output file {:?}",
320 output.file.file_name().unwrap()
321 )
322 })
323 } else {
324 Ok(())
325 }
326 });
327
328 if let Some((tpl_version, tpl_path)) = tpl_version.zip(output.template.as_ref()) {
332 book::version::compat_check(app, tpl_path, &tpl_version);
333 }
334
335 res
336 })
337 }
338
339 pub fn input_paths(&self) -> &Vec<PathBuf> {
340 &self.input_paths
341 }
342
343 pub fn output_paths(&self) -> impl Iterator<Item = &Path> {
344 self.settings.output.iter().map(|o| o.file.as_path())
345 }
346
347 pub fn watch_paths(&self) -> impl Iterator<Item = &Path> {
348 let inputs = self.input_paths.iter().map(PathBuf::as_ref);
351
352 let templates = self
354 .settings
355 .output
356 .iter()
357 .filter_map(Output::template_path);
358
359 let images = self.book.iter_images().map(|i| i.full_path());
361
362 iter::once(self.project_file.as_path())
364 .chain(inputs)
365 .chain(templates)
366 .chain(images)
367 }
368}