shell.rs

  1use schemars::JsonSchema;
  2use serde::{Deserialize, Serialize};
  3use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
  4
  5use crate::command::new_std_command;
  6
  7/// Shell configuration to open the terminal with.
  8#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
  9#[serde(rename_all = "snake_case")]
 10pub enum Shell {
 11    /// Use the system's default terminal configuration in /etc/passwd
 12    #[default]
 13    System,
 14    /// Use a specific program with no arguments.
 15    Program(String),
 16    /// Use a specific program with arguments.
 17    WithArguments {
 18        /// The program to run.
 19        program: String,
 20        /// The arguments to pass to the program.
 21        args: Vec<String>,
 22        /// An optional string to override the title of the terminal tab
 23        title_override: Option<String>,
 24    },
 25}
 26
 27impl Shell {
 28    pub fn program(&self) -> String {
 29        match self {
 30            Shell::Program(program) => program.clone(),
 31            Shell::WithArguments { program, .. } => program.clone(),
 32            Shell::System => get_system_shell(),
 33        }
 34    }
 35
 36    pub fn program_and_args(&self) -> (String, &[String]) {
 37        match self {
 38            Shell::Program(program) => (program.clone(), &[]),
 39            Shell::WithArguments { program, args, .. } => (program.clone(), args),
 40            Shell::System => (get_system_shell(), &[]),
 41        }
 42    }
 43
 44    pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
 45        match self {
 46            Shell::Program(program) => ShellKind::new(program, is_windows),
 47            Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
 48            Shell::System => ShellKind::system(),
 49        }
 50    }
 51}
 52
 53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
 54pub enum ShellKind {
 55    #[default]
 56    Posix,
 57    Csh,
 58    Tcsh,
 59    Rc,
 60    Fish,
 61    PowerShell,
 62    Nushell,
 63    Cmd,
 64    Xonsh,
 65    Elvish,
 66}
 67
 68pub fn get_system_shell() -> String {
 69    if cfg!(windows) {
 70        get_windows_system_shell()
 71    } else {
 72        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
 73    }
 74}
 75
 76pub fn get_default_system_shell() -> String {
 77    if cfg!(windows) {
 78        get_windows_system_shell()
 79    } else {
 80        "/bin/sh".to_string()
 81    }
 82}
 83
 84/// Get the default system shell, preferring git-bash on Windows.
 85pub fn get_default_system_shell_preferring_bash() -> String {
 86    if cfg!(windows) {
 87        get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
 88    } else {
 89        "/bin/sh".to_string()
 90    }
 91}
 92
 93pub fn get_windows_git_bash() -> Option<String> {
 94    static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
 95        // /path/to/git/cmd/git.exe/../../bin/bash.exe
 96        let git = which::which("git").ok()?;
 97        let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
 98        if git_bash.is_file() {
 99            log::info!("Found git-bash at {}", git_bash.display());
100            Some(git_bash.to_string_lossy().to_string())
101        } else {
102            None
103        }
104    });
105
106    (*GIT_BASH).clone()
107}
108
109pub fn get_windows_system_shell() -> String {
110    use std::path::PathBuf;
111
112    fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
113        let env_var = if find_alternate {
114            if cfg!(target_pointer_width = "64") {
115                "ProgramFiles(x86)"
116            } else {
117                "ProgramW6432"
118            }
119        } else {
120            "ProgramFiles"
121        };
122
123        let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
124        install_base_dir
125            .read_dir()
126            .ok()?
127            .filter_map(Result::ok)
128            .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
129            .filter_map(|entry| {
130                let dir_name = entry.file_name();
131                let dir_name = dir_name.to_string_lossy();
132
133                let version = if find_preview {
134                    let dash_index = dir_name.find('-')?;
135                    if &dir_name[dash_index + 1..] != "preview" {
136                        return None;
137                    };
138                    dir_name[..dash_index].parse::<u32>().ok()?
139                } else {
140                    dir_name.parse::<u32>().ok()?
141                };
142
143                let exe_path = entry.path().join("pwsh.exe");
144                if exe_path.exists() {
145                    Some((version, exe_path))
146                } else {
147                    None
148                }
149            })
150            .max_by_key(|(version, _)| *version)
151            .map(|(_, path)| path)
152    }
153
154    fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
155        let msix_app_dir =
156            PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
157        if !msix_app_dir.exists() {
158            return None;
159        }
160
161        let prefix = if find_preview {
162            "Microsoft.PowerShellPreview_"
163        } else {
164            "Microsoft.PowerShell_"
165        };
166        msix_app_dir.read_dir().ok()?.find_map(|entry| {
167            let entry = entry.ok()?;
168            if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
169                return None;
170            }
171
172            if !entry.file_name().to_string_lossy().starts_with(prefix) {
173                return None;
174            }
175
176            let exe_path = entry.path().join("pwsh.exe");
177            exe_path.exists().then_some(exe_path)
178        })
179    }
180
181    fn find_pwsh_in_scoop() -> Option<PathBuf> {
182        let pwsh_exe =
183            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
184        pwsh_exe.exists().then_some(pwsh_exe)
185    }
186
187    // check whether the found powershell is executable for us
188    static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
189        let can_execute_pwsh = |p: &PathBuf| {
190            #[allow(clippy::disallowed_methods)]
191            let status = new_std_command(p).arg("-NoProfile").arg("-Help").status();
192            let success = status.as_ref().is_ok_and(|status| status.success());
193            if !success {
194                log::warn!(
195                    "Powershell found at `{}` is not executable: {status:?}",
196                    p.display()
197                );
198            }
199            success
200        };
201
202        let locations = [
203            || find_pwsh_in_programfiles(false, false),
204            || find_pwsh_in_programfiles(true, false),
205            || find_pwsh_in_msix(false),
206            || find_pwsh_in_programfiles(false, true),
207            || find_pwsh_in_msix(true),
208            || find_pwsh_in_programfiles(true, true),
209            || find_pwsh_in_scoop(),
210            || which::which_global("pwsh.exe").ok(),
211            || which::which_global("powershell.exe").ok(),
212        ];
213        locations
214            .into_iter()
215            .filter_map(|f| f())
216            .find(|p| can_execute_pwsh(&p))
217            .map(|p| p.to_string_lossy().trim().to_owned())
218            .inspect(|shell| log::info!("Found powershell in: {}", shell))
219            .unwrap_or_else(|| {
220                log::warn!("Powershell not found, falling back to `cmd`");
221                "cmd.exe".to_string()
222            })
223    });
224
225    (*SYSTEM_SHELL).clone()
226}
227
228impl fmt::Display for ShellKind {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        match self {
231            ShellKind::Posix => write!(f, "sh"),
232            ShellKind::Csh => write!(f, "csh"),
233            ShellKind::Tcsh => write!(f, "tcsh"),
234            ShellKind::Fish => write!(f, "fish"),
235            ShellKind::PowerShell => write!(f, "powershell"),
236            ShellKind::Nushell => write!(f, "nu"),
237            ShellKind::Cmd => write!(f, "cmd"),
238            ShellKind::Rc => write!(f, "rc"),
239            ShellKind::Xonsh => write!(f, "xonsh"),
240            ShellKind::Elvish => write!(f, "elvish"),
241        }
242    }
243}
244
245impl ShellKind {
246    pub fn system() -> Self {
247        Self::new(&get_system_shell(), cfg!(windows))
248    }
249
250    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
251        let program = program.as_ref();
252        let program = program
253            .file_stem()
254            .unwrap_or_else(|| program.as_os_str())
255            .to_string_lossy();
256
257        match &*program {
258            "powershell" | "pwsh" => ShellKind::PowerShell,
259            "cmd" => ShellKind::Cmd,
260            "nu" => ShellKind::Nushell,
261            "fish" => ShellKind::Fish,
262            "csh" => ShellKind::Csh,
263            "tcsh" => ShellKind::Tcsh,
264            "rc" => ShellKind::Rc,
265            "xonsh" => ShellKind::Xonsh,
266            "elvish" => ShellKind::Elvish,
267            "sh" | "bash" | "zsh" => ShellKind::Posix,
268            _ if is_windows => ShellKind::PowerShell,
269            // Some other shell detected, the user might install and use a
270            // unix-like shell.
271            _ => ShellKind::Posix,
272        }
273    }
274
275    pub fn to_shell_variable(self, input: &str) -> String {
276        match self {
277            Self::PowerShell => Self::to_powershell_variable(input),
278            Self::Cmd => Self::to_cmd_variable(input),
279            Self::Posix => input.to_owned(),
280            Self::Fish => input.to_owned(),
281            Self::Csh => input.to_owned(),
282            Self::Tcsh => input.to_owned(),
283            Self::Rc => input.to_owned(),
284            Self::Nushell => Self::to_nushell_variable(input),
285            Self::Xonsh => input.to_owned(),
286            Self::Elvish => input.to_owned(),
287        }
288    }
289
290    fn to_cmd_variable(input: &str) -> String {
291        if let Some(var_str) = input.strip_prefix("${") {
292            if var_str.find(':').is_none() {
293                // If the input starts with "${", remove the trailing "}"
294                format!("%{}%", &var_str[..var_str.len() - 1])
295            } else {
296                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
297                // which will result in the task failing to run in such cases.
298                input.into()
299            }
300        } else if let Some(var_str) = input.strip_prefix('$') {
301            // If the input starts with "$", directly append to "$env:"
302            format!("%{}%", var_str)
303        } else {
304            // If no prefix is found, return the input as is
305            input.into()
306        }
307    }
308
309    fn to_powershell_variable(input: &str) -> String {
310        if let Some(var_str) = input.strip_prefix("${") {
311            if var_str.find(':').is_none() {
312                // If the input starts with "${", remove the trailing "}"
313                format!("$env:{}", &var_str[..var_str.len() - 1])
314            } else {
315                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
316                // which will result in the task failing to run in such cases.
317                input.into()
318            }
319        } else if let Some(var_str) = input.strip_prefix('$') {
320            // If the input starts with "$", directly append to "$env:"
321            format!("$env:{}", var_str)
322        } else {
323            // If no prefix is found, return the input as is
324            input.into()
325        }
326    }
327
328    fn to_nushell_variable(input: &str) -> String {
329        let mut result = String::new();
330        let mut source = input;
331        let mut is_start = true;
332
333        loop {
334            match source.chars().next() {
335                None => return result,
336                Some('$') => {
337                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
338                    is_start = false;
339                }
340                Some(_) => {
341                    is_start = false;
342                    let chunk_end = source.find('$').unwrap_or(source.len());
343                    let (chunk, rest) = source.split_at(chunk_end);
344                    result.push_str(chunk);
345                    source = rest;
346                }
347            }
348        }
349    }
350
351    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
352        if source.starts_with("env.") {
353            text.push('$');
354            return source;
355        }
356
357        match source.chars().next() {
358            Some('{') => {
359                let source = &source[1..];
360                if let Some(end) = source.find('}') {
361                    let var_name = &source[..end];
362                    if !var_name.is_empty() {
363                        if !is_start {
364                            text.push_str("(");
365                        }
366                        text.push_str("$env.");
367                        text.push_str(var_name);
368                        if !is_start {
369                            text.push_str(")");
370                        }
371                        &source[end + 1..]
372                    } else {
373                        text.push_str("${}");
374                        &source[end + 1..]
375                    }
376                } else {
377                    text.push_str("${");
378                    source
379                }
380            }
381            Some(c) if c.is_alphabetic() || c == '_' => {
382                let end = source
383                    .find(|c: char| !c.is_alphanumeric() && c != '_')
384                    .unwrap_or(source.len());
385                let var_name = &source[..end];
386                if !is_start {
387                    text.push_str("(");
388                }
389                text.push_str("$env.");
390                text.push_str(var_name);
391                if !is_start {
392                    text.push_str(")");
393                }
394                &source[end..]
395            }
396            _ => {
397                text.push('$');
398                source
399            }
400        }
401    }
402
403    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
404        match self {
405            ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
406            ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
407            ShellKind::Posix
408            | ShellKind::Nushell
409            | ShellKind::Fish
410            | ShellKind::Csh
411            | ShellKind::Tcsh
412            | ShellKind::Rc
413            | ShellKind::Xonsh
414            | ShellKind::Elvish => interactive
415                .then(|| "-i".to_owned())
416                .into_iter()
417                .chain(["-c".to_owned(), combined_command])
418                .collect(),
419        }
420    }
421
422    pub const fn command_prefix(&self) -> Option<char> {
423        match self {
424            ShellKind::PowerShell => Some('&'),
425            ShellKind::Nushell => Some('^'),
426            ShellKind::Posix
427            | ShellKind::Csh
428            | ShellKind::Tcsh
429            | ShellKind::Rc
430            | ShellKind::Fish
431            | ShellKind::Cmd
432            | ShellKind::Xonsh
433            | ShellKind::Elvish => None,
434        }
435    }
436
437    pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
438        match self.command_prefix() {
439            Some(prefix) if !command.starts_with(prefix) => {
440                Cow::Owned(format!("{prefix}{command}"))
441            }
442            _ => Cow::Borrowed(command),
443        }
444    }
445
446    pub const fn sequential_commands_separator(&self) -> char {
447        match self {
448            ShellKind::Cmd => '&',
449            ShellKind::Posix
450            | ShellKind::Csh
451            | ShellKind::Tcsh
452            | ShellKind::Rc
453            | ShellKind::Fish
454            | ShellKind::PowerShell
455            | ShellKind::Nushell
456            | ShellKind::Xonsh
457            | ShellKind::Elvish => ';',
458        }
459    }
460
461    pub const fn sequential_and_commands_separator(&self) -> &'static str {
462        match self {
463            ShellKind::Cmd
464            | ShellKind::Posix
465            | ShellKind::Csh
466            | ShellKind::Tcsh
467            | ShellKind::Rc
468            | ShellKind::Fish
469            | ShellKind::PowerShell
470            | ShellKind::Xonsh => "&&",
471            ShellKind::Nushell | ShellKind::Elvish => ";",
472        }
473    }
474
475    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
476        shlex::try_quote(arg).ok().map(|arg| match self {
477            // If we are running in PowerShell, we want to take extra care when escaping strings.
478            // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
479            ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
480            ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
481            ShellKind::Posix
482            | ShellKind::Csh
483            | ShellKind::Tcsh
484            | ShellKind::Rc
485            | ShellKind::Fish
486            | ShellKind::Nushell
487            | ShellKind::Xonsh
488            | ShellKind::Elvish => arg,
489        })
490    }
491
492    /// Quotes the given argument if necessary, taking into account the command prefix.
493    ///
494    /// In other words, this will consider quoting arg without its command prefix to not break the command.
495    /// You should use this over `try_quote` when you want to quote a shell command.
496    pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
497        if let Some(char) = self.command_prefix() {
498            if let Some(arg) = arg.strip_prefix(char) {
499                // we have a command that is prefixed
500                for quote in ['\'', '"'] {
501                    if let Some(arg) = arg
502                        .strip_prefix(quote)
503                        .and_then(|arg| arg.strip_suffix(quote))
504                    {
505                        // and the command itself is wrapped as a literal, that
506                        // means the prefix exists to interpret a literal as a
507                        // command. So strip the quotes, quote the command, and
508                        // re-add the quotes if they are missing after requoting
509                        let quoted = self.try_quote(arg)?;
510                        return Some(if quoted.starts_with(['\'', '"']) {
511                            Cow::Owned(self.prepend_command_prefix(&quoted).into_owned())
512                        } else {
513                            Cow::Owned(
514                                self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
515                                    .into_owned(),
516                            )
517                        });
518                    }
519                }
520                return self
521                    .try_quote(arg)
522                    .map(|quoted| Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()));
523            }
524        }
525        self.try_quote(arg)
526    }
527
528    pub fn split(&self, input: &str) -> Option<Vec<String>> {
529        shlex::split(input)
530    }
531
532    pub const fn activate_keyword(&self) -> &'static str {
533        match self {
534            ShellKind::Cmd => "",
535            ShellKind::Nushell => "overlay use",
536            ShellKind::PowerShell => ".",
537            ShellKind::Fish
538            | ShellKind::Csh
539            | ShellKind::Tcsh
540            | ShellKind::Posix
541            | ShellKind::Rc
542            | ShellKind::Xonsh
543            | ShellKind::Elvish => "source",
544        }
545    }
546
547    pub const fn clear_screen_command(&self) -> &'static str {
548        match self {
549            ShellKind::Cmd => "cls",
550            ShellKind::Posix
551            | ShellKind::Csh
552            | ShellKind::Tcsh
553            | ShellKind::Rc
554            | ShellKind::Fish
555            | ShellKind::PowerShell
556            | ShellKind::Nushell
557            | ShellKind::Xonsh
558            | ShellKind::Elvish => "clear",
559        }
560    }
561
562    #[cfg(windows)]
563    /// We do not want to escape arguments if we are using CMD as our shell.
564    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
565    pub const fn tty_escape_args(&self) -> bool {
566        match self {
567            ShellKind::Cmd => false,
568            ShellKind::Posix
569            | ShellKind::Csh
570            | ShellKind::Tcsh
571            | ShellKind::Rc
572            | ShellKind::Fish
573            | ShellKind::PowerShell
574            | ShellKind::Nushell
575            | ShellKind::Xonsh
576            | ShellKind::Elvish => true,
577        }
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    // Examples
586    // WSL
587    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
588    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
589    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
590    // PowerShell from Nushell
591    // nu -c overlay use "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\activate.nu"; ^"C:\Program Files\PowerShell\7\pwsh.exe" -C "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\python.exe -m pytest \"test_foo.py::test_foo\""
592    // PowerShell from CMD
593    // cmd /C \" \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\activate.bat\"& \"C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe\" -C \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"\"
594
595    #[test]
596    fn test_try_quote_powershell() {
597        let shell_kind = ShellKind::PowerShell;
598        assert_eq!(
599            shell_kind
600                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
601                .unwrap()
602                .into_owned(),
603            "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string()
604        );
605    }
606
607    #[test]
608    fn test_try_quote_cmd() {
609        let shell_kind = ShellKind::Cmd;
610        assert_eq!(
611            shell_kind
612                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
613                .unwrap()
614                .into_owned(),
615            "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string()
616        );
617    }
618
619    #[test]
620    fn test_try_quote_nu_command() {
621        let shell_kind = ShellKind::Nushell;
622        assert_eq!(
623            shell_kind.try_quote("'uname'").unwrap().into_owned(),
624            "\"'uname'\"".to_string()
625        );
626        assert_eq!(
627            shell_kind
628                .try_quote_prefix_aware("'uname'")
629                .unwrap()
630                .into_owned(),
631            "\"'uname'\"".to_string()
632        );
633        assert_eq!(
634            shell_kind.try_quote("^uname").unwrap().into_owned(),
635            "'^uname'".to_string()
636        );
637        assert_eq!(
638            shell_kind
639                .try_quote_prefix_aware("^uname")
640                .unwrap()
641                .into_owned(),
642            "^uname".to_string()
643        );
644        assert_eq!(
645            shell_kind.try_quote("^'uname'").unwrap().into_owned(),
646            "'^'\"'uname\'\"".to_string()
647        );
648        assert_eq!(
649            shell_kind
650                .try_quote_prefix_aware("^'uname'")
651                .unwrap()
652                .into_owned(),
653            "^'uname'".to_string()
654        );
655        assert_eq!(
656            shell_kind.try_quote("'uname a'").unwrap().into_owned(),
657            "\"'uname a'\"".to_string()
658        );
659        assert_eq!(
660            shell_kind
661                .try_quote_prefix_aware("'uname a'")
662                .unwrap()
663                .into_owned(),
664            "\"'uname a'\"".to_string()
665        );
666        assert_eq!(
667            shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
668            "'^'\"'uname a'\"".to_string()
669        );
670        assert_eq!(
671            shell_kind
672                .try_quote_prefix_aware("^'uname a'")
673                .unwrap()
674                .into_owned(),
675            "^'uname a'".to_string()
676        );
677        assert_eq!(
678            shell_kind.try_quote("uname").unwrap().into_owned(),
679            "uname".to_string()
680        );
681        assert_eq!(
682            shell_kind
683                .try_quote_prefix_aware("uname")
684                .unwrap()
685                .into_owned(),
686            "uname".to_string()
687        );
688    }
689}