1use std::path::Path;
5
6use yaml_rust2::{Yaml, YamlLoader};
7
8use super::expand::{Directories, expand};
9use super::keys::{
10 PANE_KEYS, SESSION_KEYS, SESSION_RECOGNIZED, Strictness, WINDOW_KEYS, WINDOW_RECOGNIZED,
11 check_known, has_key, unsupported,
12};
13use super::merge::resolve_merges;
14use super::values::{
15 commands, is_true, optional_bool, optional_index, optional_seconds, optional_text, pairs,
16};
17use super::{ConfigError, PaneConfig, Problem, WindowConfig, Workspace};
18
19impl Workspace {
20 pub fn from_yaml(source: &str) -> Result<Self, ConfigError> {
63 Self::parse(source, None, Strictness::Lenient)
64 }
65
66 pub fn from_yaml_strict(source: &str) -> Result<Self, ConfigError> {
86 Self::parse(source, None, Strictness::Strict)
87 }
88
89 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
116 Self::read_file(path, Strictness::Lenient)
117 }
118
119 pub fn from_file_strict(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
126 Self::read_file(path, Strictness::Strict)
127 }
128
129 fn read_file(path: impl AsRef<Path>, strictness: Strictness) -> Result<Self, ConfigError> {
130 let path = path.as_ref();
131 let read = |source| ConfigError::Read {
132 path: path.to_owned(),
133 source,
134 };
135 let source = std::fs::read_to_string(path).map_err(read)?;
136 let directory = std::path::absolute(path)
137 .map_err(read)?
138 .parent()
139 .map(Path::to_path_buf);
140 Self::parse(&source, directory.as_deref(), strictness)
141 }
142
143 fn parse(
144 source: &str,
145 base: Option<&Path>,
146 strictness: Strictness,
147 ) -> Result<Self, ConfigError> {
148 let documents = YamlLoader::load_from_str(source)?;
149 let [document] = documents.as_slice() else {
150 return Err(ConfigError::DocumentCount {
151 found: documents.len(),
152 });
153 };
154 let document = resolve_merges(document);
155 Self::from_document(&document, &Directories { base }, strictness)
156 .map_err(|problem| problem.locate(source))
157 }
158
159 fn from_document(
160 document: &Yaml,
161 directories: &Directories<'_>,
162 strictness: Strictness,
163 ) -> Result<Self, Problem> {
164 check_known(document, SESSION_KEYS, SESSION_RECOGNIZED, strictness, "")?;
165 let session_name = document["session_name"]
166 .as_str()
167 .ok_or_else(|| Problem::new("session_name", "must be a string"))?;
168 let session_name = expand(session_name, "session_name")?;
169 let start_directory =
170 directories.resolve(&document["start_directory"], "start_directory", None, false)?;
171 if let Some(separator) = session_name.chars().find(|c| matches!(c, ':' | '.')) {
172 return Err(Problem::new(
173 "session_name",
174 format!("must not contain {separator:?}; tmux reads it as a target separator"),
175 ));
176 }
177
178 let windows = match &document["windows"] {
182 Yaml::Array(entries) => entries
183 .iter()
184 .enumerate()
185 .map(|(index, window)| {
186 WindowConfig::from_yaml(
187 window,
188 index,
189 directories,
190 start_directory.as_deref(),
191 strictness,
192 )
193 })
194 .collect::<Result<Vec<_>, _>>()?,
195 _ => return Err(Problem::new("windows", "must be a list")),
196 };
197
198 Ok(Self {
199 session_name,
200 start_directory,
201 environment: pairs(&document["environment"], "environment")?,
202 options: pairs(&document["options"], "options")?,
203 global_options: pairs(&document["global_options"], "global_options")?,
204 shell_command_before: commands(
205 &document["shell_command_before"],
206 "shell_command_before",
207 )?,
208 suppress_history: optional_bool(&document["suppress_history"], "suppress_history")?
210 .unwrap_or(true),
211 windows,
212 unsupported_keys: unsupported(document, SESSION_KEYS),
213 })
214 }
215}
216
217impl WindowConfig {
218 fn from_yaml(
219 value: &Yaml,
220 index: usize,
221 directories: &Directories<'_>,
222 session: Option<&Path>,
223 strictness: Strictness,
224 ) -> Result<Self, Problem> {
225 let at = format!("windows[{index}]");
226 if !matches!(value, Yaml::Hash(_)) {
227 return Err(Problem::new(at, "must be a mapping"));
228 }
229 check_known(value, WINDOW_KEYS, WINDOW_RECOGNIZED, strictness, &at)?;
230 let start_directory = directories.resolve(
232 &value["start_directory"],
233 &format!("{at}.start_directory"),
234 session,
235 true,
236 )?;
237 let inherited = start_directory.as_deref().or(session);
238 let panes = match &value["panes"] {
239 Yaml::BadValue | Yaml::Null => vec![PaneConfig::default()],
241 Yaml::Array(entries) => entries
242 .iter()
243 .enumerate()
244 .map(|(pane, entry)| {
245 PaneConfig::from_yaml(entry, &at, pane, directories, inherited, strictness)
246 })
247 .collect::<Result<Vec<_>, _>>()?,
248 _ => return Err(Problem::new(format!("{at}.panes"), "must be a list")),
249 };
250 let window_name = value["window_name"]
251 .as_str()
252 .map(|name| expand(name, &format!("{at}.window_name")))
253 .transpose()?;
254
255 Ok(Self {
256 window_name,
257 window_index: optional_index(&value["window_index"], &format!("{at}.window_index"))?,
258 window_shell: value["window_shell"].as_str().map(ToOwned::to_owned),
259 environment: pairs(&value["environment"], &format!("{at}.environment"))?,
260 layout: optional_text(&value["layout"], &format!("{at}.layout"))?,
261 start_directory,
262 focus: is_true(&value["focus"], &format!("{at}.focus"))?,
263 options: pairs(&value["options"], &format!("{at}.options"))?,
264 shell_command_before: commands(
265 &value["shell_command_before"],
266 &format!("{at}.shell_command_before"),
267 )?,
268 suppress_history: optional_bool(
269 &value["suppress_history"],
270 &format!("{at}.suppress_history"),
271 )?,
272 unsupported_keys: unsupported(value, WINDOW_KEYS),
273 panes: if panes.is_empty() {
274 vec![PaneConfig::default()]
275 } else {
276 panes
277 },
278 })
279 }
280}
281
282impl PaneConfig {
283 fn from_yaml(
284 value: &Yaml,
285 window: &str,
286 index: usize,
287 directories: &Directories<'_>,
288 inherited: Option<&Path>,
289 strictness: Strictness,
290 ) -> Result<Self, Problem> {
291 let at = format!("{window}.panes[{index}]");
292 match value {
293 Yaml::Null | Yaml::String(_) | Yaml::Array(_) => {
295 return Ok(Self {
296 shell_commands: commands(value, &at)?,
297 ..Self::default()
298 });
299 }
300 Yaml::Hash(_) => {}
301 _ => {
302 return Err(Problem::new(
303 at,
304 "must be a command, a list of commands, or a mapping; \
305 quote a command YAML would read as a number or a boolean",
306 ));
307 }
308 }
309 check_known(value, PANE_KEYS, &[], strictness, &at)?;
310
311 Ok(Self {
312 shell_command_before: commands(
313 &value["shell_command_before"],
314 &format!("{at}.shell_command_before"),
315 )?,
316 shell_commands: commands(&value["shell_command"], &format!("{at}.shell_command"))?,
317 environment: has_key(value, "environment")
321 .then(|| pairs(&value["environment"], &format!("{at}.environment")))
322 .transpose()?,
323 start_directory: directories.resolve(
326 &value["start_directory"],
327 &format!("{at}.start_directory"),
328 inherited,
329 false,
330 )?,
331 focus: is_true(&value["focus"], &format!("{at}.focus"))?,
332 shell: value["shell"].as_str().map(ToOwned::to_owned),
335 enter: optional_bool(&value["enter"], &format!("{at}.enter"))?.unwrap_or(true),
337 sleep_before: optional_seconds(&value["sleep_before"], &format!("{at}.sleep_before"))?,
338 sleep_after: optional_seconds(&value["sleep_after"], &format!("{at}.sleep_after"))?,
339 suppress_history: optional_bool(
340 &value["suppress_history"],
341 &format!("{at}.suppress_history"),
342 )?,
343 unsupported_keys: unsupported(value, PANE_KEYS),
344 })
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::{Directories, PaneConfig, Strictness};
351 use yaml_rust2::YamlLoader;
352
353 fn value(source: &str) -> yaml_rust2::Yaml {
356 YamlLoader::load_from_str(source).unwrap().remove(0)
357 }
358
359 #[test]
363 fn pane_environment_distinguishes_absent_from_explicitly_set() {
364 let directories = Directories { base: None };
365 let pane = |source| {
366 PaneConfig::from_yaml(
367 &value(source),
368 "windows[0]",
369 0,
370 &directories,
371 None,
372 Strictness::Lenient,
373 )
374 };
375
376 assert_eq!(pane("{}").unwrap().environment, None);
377 assert_eq!(
378 pane("{environment: {}}").unwrap().environment,
379 Some(Vec::new())
380 );
381 assert_eq!(
382 pane("{environment: {FOO: bar}}").unwrap().environment,
383 Some(vec![("FOO".into(), "bar".into())]),
384 );
385 }
386}