Skip to main content

tmux_workspace/config/
parse.rs

1//! Turning a YAML document into a [`Workspace`], enforcing the shape tmuxp
2//! files have and this crate's own strictness policy.
3
4use 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    /// Parse one workspace from tmuxp-style YAML.
21    ///
22    /// This accepts the shape tmuxp uses for the parts a builder needs. A key
23    /// this parser does not act on is recorded rather than rejected, so a
24    /// richer tmuxp file still loads; [`Self::from_yaml_strict`] refuses one
25    /// instead.
26    ///
27    /// As tmuxp does, it expands `~` and `$NAME` or `${NAME}` from this
28    /// process's environment in names, start directories, and `environment`
29    /// and option values, leaving an unset variable as written; commands are
30    /// typed as written, for the pane's shell to expand. A start directory
31    /// that begins with `.` is relative to the one it inherits, or to the
32    /// current directory at the top: [`Self::from_file`] uses the file's
33    /// directory instead.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error when the document is not valid YAML, does not hold
38    /// exactly one workspace, or is missing `session_name`. Every error
39    /// except the document count names the line and column to look at.
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// use tmux_workspace::Workspace;
45    ///
46    /// let workspace = Workspace::from_yaml(
47    ///     "
48    /// session_name: demo
49    /// windows:
50    ///   - window_name: editor
51    ///     panes:
52    ///       - echo one
53    ///       - shell_command: echo two
54    /// ",
55    /// )?;
56    ///
57    /// assert_eq!(workspace.session_name, "demo");
58    /// assert_eq!(workspace.windows.len(), 1);
59    /// assert_eq!(workspace.windows[0].panes.len(), 2);
60    /// # Ok::<(), tmux_workspace::ConfigError>(())
61    /// ```
62    pub fn from_yaml(source: &str) -> Result<Self, ConfigError> {
63        Self::parse(source, None, Strictness::Lenient)
64    }
65
66    /// Parse as [`Self::from_yaml`] does, refusing a key that is neither this
67    /// parser's own vocabulary nor tmuxp's, unless it starts with `x-`.
68    ///
69    /// This is the policy the `tmux-workspace` command applies to every
70    /// document it loads.
71    ///
72    /// # Errors
73    ///
74    /// What [`Self::from_yaml`] returns, and also an unrecognized key.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use tmux_workspace::Workspace;
80    ///
81    /// let error = Workspace::from_yaml_strict("session_name: demo\nwindows: []\nfrobnicate: 1\n")
82    ///     .expect_err("an unrecognized key is refused");
83    /// assert!(error.to_string().contains("frobnicate"));
84    /// ```
85    pub fn from_yaml_strict(source: &str) -> Result<Self, ConfigError> {
86        Self::parse(source, None, Strictness::Strict)
87    }
88
89    /// Read and parse one workspace file, as `tmuxp load` does.
90    ///
91    /// Everything [`Self::from_yaml`] says holds, except that a start
92    /// directory beginning with `.` and inheriting none is relative to the
93    /// file's directory. JSON is read too, as the YAML it is a subset of.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`ConfigError::Read`] when the file cannot be read, and
98    /// otherwise what [`Self::from_yaml`] returns.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use tmux_workspace::Workspace;
104    ///
105    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
106    /// let project = tempfile::tempdir()?;
107    /// let file = project.path().join(".tmuxp.yaml");
108    /// std::fs::write(&file, "session_name: project\nstart_directory: ./\nwindows: []\n")?;
109    ///
110    /// let workspace = Workspace::from_file(&file)?;
111    /// assert_eq!(workspace.start_directory.as_deref(), Some(project.path()));
112    /// # Ok(())
113    /// # }
114    /// ```
115    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
116        Self::read_file(path, Strictness::Lenient)
117    }
118
119    /// Read as [`Self::from_file`] does, under [`Self::from_yaml_strict`]'s
120    /// policy.
121    ///
122    /// # Errors
123    ///
124    /// What [`Self::from_file`] returns, and also an unrecognized key.
125    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        // tmuxp refuses a document missing `windows`; a null one would crash
179        // its builder the same way, so both are refused here too. An empty
180        // list is a list, so it keeps the window tmux made, as tmuxp does.
181        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            // tmuxp suppresses unless a file says otherwise.
209            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        // tmuxp joins a window's relative directory onto the session's.
231        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            // A window with no panes still has the one tmux creates with it.
240            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            // tmuxp lets a pane be its commands alone, or nothing at all.
294            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            // tmuxp's builder replaces the window's environment with the
318            // pane's own when the pane sets the key at all, even to an empty
319            // mapping, rather than adding to it; presence is what decides it.
320            environment: has_key(value, "environment")
321                .then(|| pairs(&value["environment"], &format!("{at}.environment")))
322                .transpose()?,
323            // tmuxp does not join a pane's relative directory onto the
324            // window's; only a `.` path starts from it.
325            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            // tmuxp's builder resolves a pane's own shell before falling
333            // back to the window's, the same way it resolves environment.
334            shell: value["shell"].as_str().map(ToOwned::to_owned),
335            // tmuxp presses Enter unless a file says otherwise.
336            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    /// The value a single-document YAML fragment parses to, for feeding
354    /// these functions the same shapes tmuxp's parser hands them.
355    fn value(source: &str) -> yaml_rust2::Yaml {
356        YamlLoader::load_from_str(source).unwrap().remove(0)
357    }
358
359    /// tmuxp's builder replaces the window's environment with the pane's own
360    /// when the pane sets the key at all, even to an empty mapping, rather
361    /// than adding to it: presence, not emptiness, is what decides it.
362    #[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}