//! Support for reading NeXTSTEP/OPENSTEP/Window Maker property list files.
//!
//! Files are parsed into the WINGs-internal [`PropList`] structure, which does
//! not support GNUstep extensions for special data types (`NSValue`, `NSDate`,
//! etc.) or comments.
//!
//! This is not exactly the same as the original WINGs parser, but it should
//! handle Window Maker configurations just fine. Notable differences include:
//!
//! * A single trailing backslash in quoted strings is not accepted (and a
//!   properly escaped backslash must be used).
//! * Strings are UTF-8 (and input must be in UTF-8). Files with strings that
//!   contain a zero byte will be rejected because we still use C-style strings
//!   internally to support FFI with code that has not yet been ported to Rust.

use std::{collections::HashMap, ffi::CString};

use nom::{
    branch::alt,
    character::complete::{char, multispace0, none_of, satisfy},
    combinator::{cut, eof, fail, map, map_res, opt},
    error::context,
    multi::{fold_many0, many1, many_m_n, separated_list0},
    sequence::{delimited, preceded, terminated},
    AsChar, Finish, IResult, Input, Parser,
};
use nom_language::error::{convert_error, VerboseError};

use super::{Node, PropList};

fn read_dictionary_key<I: Input<Item: AsChar>>(input: I) -> IResult<I, PropList, VerboseError<I>> {
    preceded(
        multispace0,
        terminated(
            read_string,
            cut(context(
                "looking for '=' after dictionary key",
                (multispace0, char('='), multispace0),
            )),
        ),
    )
    .parse(input)
}

fn read_dictionary_value<I: Input<Item: AsChar>>(
    input: I,
) -> IResult<I, PropList, VerboseError<I>> {
    terminated(
        delimited(multispace0, read_prop_list, multispace0),
        cut(context(
            "looking for ';' after dictionary value",
            (char(';'), multispace0),
        )),
    )
    .parse(input)
}

fn read_dictionary<I: Input<Item: AsChar>>(input: I) -> IResult<I, PropList, VerboseError<I>> {
    delimited(
        context(
            "looking for dictionary to start with '{'",
            (multispace0, char('{'), multispace0),
        ),
        map(
            fold_many0(
                (read_dictionary_key, read_dictionary_value),
                HashMap::new,
                |mut items: HashMap<PropList, PropList>, (k, v): (PropList, PropList)| {
                    items.insert(k, v);
                    items
                },
            ),
            |items: HashMap<PropList, PropList>| PropList::new(Node::Dictionary(items)),
        ),
        cut(context(
            "looking for dictionary to end with '}'",
            (multispace0, char('}'), multispace0),
        )),
    )
    .parse(input)
}

fn read_array_elements<I: Input<Item: AsChar>>(
    input: I,
) -> IResult<I, Vec<PropList>, VerboseError<I>> {
    delimited(
        multispace0,
        separated_list0(
            context("looking for comma between array elements", char(',')),
            delimited(multispace0, read_prop_list, multispace0),
        ),
        multispace0,
    )
    .parse(input)
}

fn read_array<I: Input<Item: AsChar>>(input: I) -> IResult<I, PropList, VerboseError<I>> {
    map(
        preceded(
            (multispace0, char('(')),
            terminated(
                read_array_elements,
                (
                    multispace0,
                    opt(char(',')),
                    multispace0,
                    cut(context("looking for array that ends with ')'", char(')'))),
                ),
            ),
        ),
        |items: Vec<PropList>| PropList::new(Node::Array(items)),
    )
    .parse(input)
}

fn unescape_character(c: char) -> char {
    match c {
        '\\' => '\\',
        '"' => '"',
        'a' => '\x07',
        'b' => '\x08',
        't' => '\t',
        'n' => '\n',
        'v' => '\x0B',
        'f' => '\x0D',
        x @ _ => x,
    }
}

fn unescape_octal(bytes: Vec<char>) -> Result<char, String> {
    let mut result = 0u32;
    for b in &bytes {
        result <<= 3;
        result |= (*b as u32) & 0o7;
    }
    char::from_u32(result).ok_or_else(|| {
        format!(
            "unable to convert octal escape sequence '{:?}' to character",
            &bytes
        )
    })
}

fn read_quoted_char<I: Input<Item: AsChar>>(input: I) -> IResult<I, char, VerboseError<I>> {
    alt((
        preceded(
            char('\\'),
            cut(alt((
                map(
                    context(
                        "looking for an escaped character",
                        satisfy(|c: char| !c.is_oct_digit()),
                    ),
                    unescape_character,
                ),
                map_res(
                    context(
                        "looking for an octal sequence (0-7, up to 3 times)",
                        many_m_n(1, 3, satisfy(|c| c.is_oct_digit())),
                    ),
                    unescape_octal,
                ),
            ))),
        ),
        // Characters that do not need escaping.
        none_of(r#""\"#),
    ))
    .parse(input)
}

fn read_nullbyte_char<I: Input<Item: AsChar>>(
    input: I,
) -> IResult<I, ([u8; 4], usize), VerboseError<I>> {
    map_res(read_quoted_char, |c: char| {
        let mut bytes = [0u8; 4];
        let s = c.encode_utf8(&mut bytes).as_bytes();
        for b in s {
            if *b == 0 {
                let len = s.len();
                return Ok((bytes, len));
            }
        }
        Err(())
    })
    .parse(input)
}

fn read_char_bytes<I: Input<Item: AsChar>>(
    input: I,
) -> IResult<I, ([u8; 4], usize), VerboseError<I>> {
    map(read_quoted_char, |c: char| {
        let mut bytes = [0u8; 4];
        let s = c.encode_utf8(&mut bytes).as_bytes();
        let len = s.len();
        (bytes, len)
    })
    .parse(input)
}

fn read_quoted_string<I: Input<Item: AsChar>>(input: I) -> IResult<I, CString, VerboseError<I>> {
    map(
        fold_many0(
            alt((
                terminated(
                    // First we try to read a char with a null byte. If we do,
                    // unconditionally fail.
                    read_nullbyte_char,
                    cut(context(
                        "null bytes are not allowed in strings",
                        fail::<I, (), VerboseError<I>>(),
                    )),
                ),
                // Character is valid, so proceed.
                read_char_bytes,
            )),
            Vec::new,
            |mut buf: Vec<u8>, (bytes, len): ([u8; 4], usize)| {
                buf.extend_from_slice(&bytes[0..len]);
                buf
            },
        ),
        |buf: Vec<u8>| unsafe { CString::from_vec_unchecked(buf) },
    )
    .parse(input)
}

pub(crate) fn is_unquoted_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '/' || c == '+'
}

fn read_unquoted_string<I: Input<Item: AsChar>>(input: I) -> IResult<I, CString, VerboseError<I>> {
    map(
        many1(map(satisfy(is_unquoted_char), |c: char| c as u8)),
        |bytes: Vec<u8>| unsafe { CString::from_vec_unchecked(bytes) },
    )
    .parse(input)
}

fn read_string<I: Input<Item: AsChar>>(input: I) -> IResult<I, PropList, VerboseError<I>> {
    map(
        delimited(
            multispace0,
            alt((
                delimited(
                    char('"'),
                    read_quoted_string,
                    cut(context("looking for closing \" for string", char('"'))),
                ),
                read_unquoted_string,
            )),
            multispace0,
        ),
        |s: CString| PropList::new(Node::String(s)),
    )
    .parse(input)
}

fn read_prop_list<I: Input<Item: AsChar>>(input: I) -> IResult<I, PropList, VerboseError<I>> {
    delimited(
        multispace0,
        alt((read_array, read_dictionary, read_string)),
        multispace0,
    )
    .parse(input)
}

fn is_context_error(e: &nom_language::error::VerboseErrorKind) -> bool {
    if let nom_language::error::VerboseErrorKind::Context(_) = e {
        true
    } else {
        false
    }
}

/// Reads a `PropList` from `s`. Returns a human-readable error message if
/// there's an error.
pub fn from_str(s: &str) -> Result<PropList, String> {
    terminated(
        read_prop_list,
        (
            multispace0,
            cut(context(
                "looking for complete property list at start of input, with no trailing garbage",
                eof,
            )),
        ),
    )
    .parse(s)
    .finish()
    .map(|(_, plist)| plist)
    .map_err(|e| {
        if let Some(e) = e.errors.iter().filter(|(_, e)| is_context_error(e)).last() {
            // Drop all but the deepest context error from the VerboseError trace.
            convert_error(
                s,
                VerboseError {
                    errors: vec![e.clone()],
                },
            )
        } else {
            convert_error(s, e)
        }
    })
}

#[cfg(test)]
mod test {
    use std::ffi::CString;

    use crate::prop_list::{parser::from_str, Node, PropList};

    fn pl_array(xs: Vec<PropList>) -> PropList {
        PropList::new(Node::Array(xs))
    }

    fn pl_dict(kvs: Vec<(PropList, PropList)>) -> PropList {
        PropList::new(Node::Dictionary(kvs.into_iter().collect()))
    }

    fn pl_string(s: &str) -> PropList {
        PropList::new(Node::String(CString::new(s.as_bytes()).unwrap()))
    }

    #[test]
    fn parse_string() {
        let plist = from_str("foobar").unwrap();
        assert_eq!(plist, pl_string("foobar"));
    }

    #[test]
    fn parse_escaped_string() {
        let plist = from_str(r#""\t""#).unwrap();
        assert_eq!(plist, pl_string("\t"));
    }

    #[test]
    fn parse_embedded_escaped_string() {
        let plist = from_str(r#""hello\t world""#).unwrap();
        assert_eq!(plist, pl_string("hello\t world"));

        let plist = from_str(r#""hello, \v world! \\^_^\/ ""#).unwrap();
        assert_eq!(plist, pl_string("hello, \x0B world! \\^_^/ "));
    }

    #[test]
    fn parse_octal_escaped_string() {
        let plist = from_str(r#""\3""#).unwrap();
        assert_eq!(plist, pl_string("\x03"));

        let plist = from_str(r#""\11""#).unwrap();
        assert_eq!(plist, pl_string("\x09"));

        let plist = from_str(r#""\011""#).unwrap();
        assert_eq!(plist, pl_string("\x09"));

        let plist = from_str(r#""\107""#).unwrap();
        assert_eq!(plist, pl_string("G"));
    }

    #[test]
    fn parse_octal_invalid_chars() {
        let plist = from_str(r#""\99""#).unwrap();
        assert_eq!(plist, pl_string("99"));

        let plist = from_str(r#""hello\8t""#).unwrap();
        assert_eq!(plist, pl_string("hello8t"));
    }

    #[test]
    fn error_unclosed_string() {
        let e = from_str(r#""hello, world!"#).unwrap_err();
        assert_eq!(
            e,
            r#"0: at line 1, in looking for closing " for string:
"hello, world!
              ^

"#
        );
    }

    #[test]
    fn error_null_byte_not_allowed_in_string() {
        let e = from_str(r#""hello\00world""#).unwrap_err();
        assert_eq!(
            format!("{}", e),
            r#"0: at line 1, in null bytes are not allowed in strings:
"hello\00world"
         ^

"#
        );
    }

    #[test]
    fn parse_flat_array() {
        let plist = from_str(
            r#"
(hello,
 world,
 blah)
"#,
        )
        .unwrap();
        assert_eq!(
            plist,
            pl_array(vec![
                pl_string("hello"),
                pl_string("world"),
                pl_string("blah")
            ]),
        )
    }

    #[test]
    fn parse_empty_array() {
        let plist = from_str("()").unwrap();
        assert_eq!(plist, pl_array(vec![]));

        let plist = from_str("  ()  ").unwrap();
        assert_eq!(plist, pl_array(vec![]));
    }

    #[test]
    fn parse_shallow_array() {
        let plist = from_str(r#"(deeper, (what), what)"#).unwrap();
        assert_eq!(
            plist,
            pl_array(vec![
                pl_string("deeper"),
                pl_array(vec![pl_string("what")]),
                pl_string("what")
            ])
        );
    }

    #[test]
    fn parse_nested_array() {
        let plist = from_str(
            r#"
     (hello,
 "world?? \7",
 (what, "'s the deal", etc.,
 (but, (we, (can, (go, (deeper, (what, (is, (this, lisp, or, something))), "???"))))) ),
)"#,
        )
        .unwrap();
        assert_eq!(
            plist,
            pl_array(vec![
                pl_string("hello"),
                pl_string("world?? \x07"),
                pl_array(vec![
                    pl_string("what"),
                    pl_string("'s the deal"),
                    pl_string("etc."),
                    pl_array(vec![
                        pl_string("but"),
                        pl_array(vec![
                            pl_string("we"),
                            pl_array(vec![
                                pl_string("can"),
                                pl_array(vec![
                                    pl_string("go"),
                                    pl_array(vec![
                                        pl_string("deeper"),
                                        pl_array(vec![
                                            pl_string("what"),
                                            pl_array(vec![
                                                pl_string("is"),
                                                pl_array(vec![
                                                    pl_string("this"),
                                                    pl_string("lisp"),
                                                    pl_string("or"),
                                                    pl_string("something"),
                                                ])
                                            ])
                                        ]),
                                        pl_string("???"),
                                    ])
                                ])
                            ])
                        ])
                    ])
                ])
            ])
        );
    }

    #[test]
    fn error_unclosed_array() {
        let e = from_str("(hello, world, stuff, blah").unwrap_err();
        assert_eq!(
            e,
            r#"0: at line 1, in looking for array that ends with ')':
(hello, world, stuff, blah
                          ^

"#
        );
    }

    #[test]
    fn parse_empty_dictionary() {
        let plist = from_str("{}").unwrap();
        assert_eq!(plist, pl_dict(vec![]));

        let plist = from_str("{ }").unwrap();
        assert_eq!(plist, pl_dict(vec![]));

        let plist = from_str(
            r#"
{
} "#,
        )
        .unwrap();
        assert_eq!(plist, pl_dict(vec![]));
    }

    #[test]
    fn parse_flat_dictionary() {
        let plist = from_str(r#"{ Clip = (); "Hello" = "World!"; }"#).unwrap();
        assert_eq!(
            plist,
            pl_dict(vec![
                (pl_string("Clip"), pl_array(vec![])),
                (pl_string("Hello"), pl_string("World!"))
            ])
        );
    }

    #[test]
    fn error_unterminated_dictionary() {
        let e = from_str(r#"{ Clip = (); Hello = world; Workspaces = ();  "#).unwrap_err();
        assert_eq!(
            e,
            r#"0: at line 1, in looking for dictionary to end with '}':
{ Clip = (); Hello = world; Workspaces = ();
                                            ^

"#
        );
    }

    #[test]
    fn error_dictionary_missing_kv_separator() {
        let e = from_str(r#"{ Clip = (); Hello world; }"#).unwrap_err();
        assert_eq!(
            e,
            r#"0: at line 1, in looking for '=' after dictionary key:
{ Clip = (); Hello world; }
                   ^

"#
        );
    }

    #[test]
    fn error_dictionary_missing_kv_terminator() {
        let e = from_str(r#"{ Clip = (); Hello = world"#).unwrap_err();
        assert_eq!(
            e,
            r#"0: at line 1, in looking for ';' after dictionary value:
{ Clip = (); Hello = world
                          ^

"#
        );
    }

    #[test]
    fn parse_wm_root_menu() {
        let plist = from_str(
            r#"(
  Debian,
  (
    Applications,
    OPEN_PLMENU,
    "|| wmmenugen -parser:xdg /usr/share/applications/"
  ),
  (Run..., EXEC, "%A(Run,Type command to run)"),
  (Terminal, SHORTCUT, "Mod4+Return", EXEC, "/usr/bin/urxvtcd"),
  (Lock, SHORTCUT, "Mod4+l", EXEC, "/usr/bin/xscreensaver-command -lock"),
  (Firefox, SHORTCUT, "Mod4+b", EXEC, "/usr/bin/firefox"),
  (Emacsclient, SHORTCUT, "Mod4+e", EXEC, "/usr/bin/emacsclient -"),
  (bluetoothctl, EXEC, "/home/stu/bin/bluetoothctl-term"),
  (
    "Window Maker",
    ("Info Panel ...", INFO_PANEL),
    ("Legal Panel ...", LEGAL_PANEL),
    (Preferences, EXEC, WPrefs),
    ("Refresh screen", REFRESH),
    (Restart, RESTART)
  ),
  (
    WorkSpace,
    (Appearance, OPEN_MENU, appearance.menu),
    ("Arrange Icons", ARRANGE_ICONS),
    ("Clear Session", CLEAR_SESSION),
    ("Hide Others", HIDE_OTHERS),
    ("Save Session", SAVE_SESSION),
    ("Show All", SHOW_ALL),
    (Workspaces, WORKSPACE_MENU)
  ),
  (Exit, EXIT),
  ("Exit session", SHUTDOWN)
)
"#,
        )
        .unwrap();
        assert_eq!(
            plist,
            pl_array(vec![
                pl_string("Debian"),
                pl_array(vec![
                    pl_string("Applications"),
                    pl_string("OPEN_PLMENU"),
                    pl_string("|| wmmenugen -parser:xdg /usr/share/applications/")
                ]),
                pl_array(vec![
                    pl_string("Run..."),
                    pl_string("EXEC"),
                    pl_string("%A(Run,Type command to run)")
                ]),
                pl_array(vec![
                    pl_string("Terminal"),
                    pl_string("SHORTCUT"),
                    pl_string("Mod4+Return"),
                    pl_string("EXEC"),
                    pl_string("/usr/bin/urxvtcd")
                ]),
                pl_array(vec![
                    pl_string("Lock"),
                    pl_string("SHORTCUT"),
                    pl_string("Mod4+l"),
                    pl_string("EXEC"),
                    pl_string("/usr/bin/xscreensaver-command -lock")
                ]),
                pl_array(vec![
                    pl_string("Firefox"),
                    pl_string("SHORTCUT"),
                    pl_string("Mod4+b"),
                    pl_string("EXEC"),
                    pl_string("/usr/bin/firefox")
                ]),
                pl_array(vec![
                    pl_string("Emacsclient"),
                    pl_string("SHORTCUT"),
                    pl_string("Mod4+e"),
                    pl_string("EXEC"),
                    pl_string("/usr/bin/emacsclient -")
                ]),
                pl_array(vec![
                    pl_string("bluetoothctl"),
                    pl_string("EXEC"),
                    pl_string("/home/stu/bin/bluetoothctl-term")
                ]),
                pl_array(vec![
                    pl_string("Window Maker"),
                    pl_array(vec![pl_string("Info Panel ..."), pl_string("INFO_PANEL")]),
                    pl_array(vec![pl_string("Legal Panel ..."), pl_string("LEGAL_PANEL")]),
                    pl_array(vec![
                        pl_string("Preferences"),
                        pl_string("EXEC"),
                        pl_string("WPrefs")
                    ]),
                    pl_array(vec![pl_string("Refresh screen"), pl_string("REFRESH")]),
                    pl_array(vec![pl_string("Restart"), pl_string("RESTART")])
                ]),
                pl_array(vec![
                    pl_string("WorkSpace"),
                    pl_array(vec![
                        pl_string("Appearance"),
                        pl_string("OPEN_MENU"),
                        pl_string("appearance.menu")
                    ]),
                    pl_array(vec![pl_string("Arrange Icons"), pl_string("ARRANGE_ICONS")]),
                    pl_array(vec![pl_string("Clear Session"), pl_string("CLEAR_SESSION")]),
                    pl_array(vec![pl_string("Hide Others"), pl_string("HIDE_OTHERS")]),
                    pl_array(vec![pl_string("Save Session"), pl_string("SAVE_SESSION")]),
                    pl_array(vec![pl_string("Show All"), pl_string("SHOW_ALL")]),
                    pl_array(vec![pl_string("Workspaces"), pl_string("WORKSPACE_MENU")])
                ]),
                pl_array(vec![pl_string("Exit"), pl_string("EXIT")]),
                pl_array(vec![pl_string("Exit session"), pl_string("SHUTDOWN")])
            ])
        );
    }

    #[test]
    fn parse_wm_state() {
        let plist = from_str(
            r#"{
  Dock = {
    AutoRaiseLower = No;
    Applications = (
      {
        Forced = No;
        Name = Logo.WMDock;
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,0";
        Lock = Yes;
        Command = "/usr/bin/WPrefs";
      },
      {
        Forced = No;
        Name = wmweather.wmweather;
        DropCommand = "wmweather -s KBOS %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,4";
        Lock = Yes;
        PasteCommand = "wmweather -s KBOS %s";
        Command = "wmweather -s KBOS";
      },
      {
        Forced = No;
        Name = wmclock.WMClock;
        DropCommand = "wmclock %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,3";
        Lock = Yes;
        PasteCommand = "wmclock %s";
        Command = wmclock;
      },
      {
        Forced = No;
        Name = emacs.Emacs;
        DropCommand = "emacsclient -c %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,2";
        Lock = Yes;
        PasteCommand = "emacsclient -c %s";
        Command = "emacsclient -";
      },
      {
        Forced = No;
        Name = wmfire.wmfire;
        DropCommand = "wmfire -m %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,5";
        Lock = Yes;
        PasteCommand = "wmfire -m %s";
        Command = "wmfire -m";
      },
      {
        Forced = No;
        Name = wmnet.WMNET;
        DropCommand = "wmnet -W enp0s31f6 %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,7";
        Lock = Yes;
        PasteCommand = "wmnet -W enp0s31f6 %s";
        Command = "wmnet -W enp0s31f6";
      },
      {
        Forced = No;
        Name = wmtemp.DockApp;
        DropCommand = "wmtemp -f %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,6";
        Lock = Yes;
        PasteCommand = "wmtemp -f %s";
        Command = "wmtemp -f";
      },
      {
        Forced = No;
        Name = firefox.Firefox;
        DropCommand = "firefox %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,1";
        Lock = Yes;
        PasteCommand = "firefox %s";
        Command = firefox;
      },
      {
        Forced = No;
        Name = "org\\.gnome\\.Weather.org\\.gnome\\.Weather";
        DropCommand = "org.gnome.Weather %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,8";
        Lock = Yes;
        PasteCommand = "org.gnome.Weather %s";
        Command = "/usr/bin/gnome-weather";
      },
      {
        Forced = No;
        Name = Zotero.Zotero;
        DropCommand = "/home/stu/bin/zotero %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,9";
        Lock = Yes;
        PasteCommand = "/home/stu/bin/zotero %s";
        Command = "/home/stu/bin/zotero";
      }
    );
    Lowered = Yes;
    Position = "2496,0";
    Applications1440 = (
      {
        Forced = No;
        Name = Logo.WMDock;
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,0";
        Lock = Yes;
        Command = "/usr/bin/WPrefs";
      },
      {
        Forced = No;
        Name = wmweather.wmweather;
        DropCommand = "wmweather -s KBOS %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,4";
        Lock = Yes;
        PasteCommand = "wmweather -s KBOS %s";
        Command = "wmweather -s KBOS";
      },
      {
        Forced = No;
        Name = wmclock.WMClock;
        DropCommand = "wmclock %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,3";
        Lock = Yes;
        PasteCommand = "wmclock %s";
        Command = wmclock;
      },
      {
        Forced = No;
        Name = emacs.Emacs;
        DropCommand = "emacsclient -c %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,2";
        Lock = Yes;
        PasteCommand = "emacsclient -c %s";
        Command = "emacsclient -";
      },
      {
        Forced = No;
        Name = wmfire.wmfire;
        DropCommand = "wmfire -m %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,5";
        Lock = Yes;
        PasteCommand = "wmfire -m %s";
        Command = "wmfire -m";
      },
      {
        Forced = No;
        Name = wmnet.WMNET;
        DropCommand = "wmnet -W enp0s31f6 %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,7";
        Lock = Yes;
        PasteCommand = "wmnet -W enp0s31f6 %s";
        Command = "wmnet -W enp0s31f6";
      },
      {
        Forced = No;
        Name = wmtemp.DockApp;
        DropCommand = "wmtemp -f %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,6";
        Lock = Yes;
        PasteCommand = "wmtemp -f %s";
        Command = "wmtemp -f";
      },
      {
        Forced = No;
        Name = firefox.Firefox;
        DropCommand = "firefox %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,1";
        Lock = Yes;
        PasteCommand = "firefox %s";
        Command = firefox;
      },
      {
        Forced = No;
        Name = "org\\.gnome\\.Weather.org\\.gnome\\.Weather";
        DropCommand = "org.gnome.Weather %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,8";
        Lock = Yes;
        PasteCommand = "org.gnome.Weather %s";
        Command = "/usr/bin/gnome-weather";
      },
      {
        Forced = No;
        Name = Zotero.Zotero;
        DropCommand = "/home/stu/bin/zotero %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,9";
        Lock = Yes;
        PasteCommand = "/home/stu/bin/zotero %s";
        Command = "/home/stu/bin/zotero";
      }
    );
  };
  Clip = {
    Forced = No;
    Name = Logo.WMClip;
    DropCommand = "wmsetbg -u -t %d";
    BuggyApplication = No;
    AutoLaunch = No;
    Position = "2496,1376";
    Lock = No;
    Command = "-";
  };
  Drawers = ();
  Workspaces = (
    {
      Clip = {
        AutoRaiseLower = No;
        AutoCollapse = No;
        Applications = (
          {
            Forced = No;
            Name = xsnow.Xsnow;
            DropCommand = "xsnow %d";
            BuggyApplication = No;
            AutoLaunch = No;
            Position = "-2,0";
            Lock = No;
            PasteCommand = "xsnow %s";
            Command = xsnow;
            Omnipresent = No;
          },
          {
            Forced = No;
            Name = "thunderbird.Thunderbird-default";
            DropCommand = "thunderbird %d";
            BuggyApplication = No;
            AutoLaunch = No;
            Position = "-1,0";
            Lock = No;
            PasteCommand = "thunderbird %s";
            Command = thunderbird;
            Omnipresent = No;
          }
        );
        Collapsed = No;
        AutoAttractIcons = No;
        Lowered = Yes;
      };
      Name = Mail;
    },
    {
      Clip = {
        AutoRaiseLower = No;
        AutoCollapse = No;
        Applications = ();
        Collapsed = No;
        AutoAttractIcons = No;
        Lowered = Yes;
      };
      Name = Plans;
    },
    {
      Clip = {
        AutoRaiseLower = No;
        AutoCollapse = No;
        Applications = ();
        Collapsed = No;
        AutoAttractIcons = No;
        Lowered = Yes;
      };
      Name = Misc;
    },
  );
}
"#,
        )
        .unwrap();

        assert_eq!(
            plist,
            pl_dict(vec![
                (
                    pl_string("Dock"),
                    pl_dict(vec![
                        (pl_string("AutoRaiseLower"), pl_string("No")),
                        (
                            pl_string("Applications"),
                            pl_array(vec![
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("Logo.WMDock")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,0")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("Command"), pl_string("/usr/bin/WPrefs")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmweather.wmweather")),
                                    (pl_string("DropCommand"), pl_string("wmweather -s KBOS %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,4")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmweather -s KBOS %s")),
                                    (pl_string("Command"), pl_string("wmweather -s KBOS")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmclock.WMClock")),
                                    (pl_string("DropCommand"), pl_string("wmclock %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,3")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmclock %s")),
                                    (pl_string("Command"), pl_string("wmclock")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("emacs.Emacs")),
                                    (pl_string("DropCommand"), pl_string("emacsclient -c %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,2")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("emacsclient -c %s")),
                                    (pl_string("Command"), pl_string("emacsclient -")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmfire.wmfire")),
                                    (pl_string("DropCommand"), pl_string("wmfire -m %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,5")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmfire -m %s")),
                                    (pl_string("Command"), pl_string("wmfire -m")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmnet.WMNET")),
                                    (pl_string("DropCommand"), pl_string("wmnet -W enp0s31f6 %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,7")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (
                                        pl_string("PasteCommand"),
                                        pl_string("wmnet -W enp0s31f6 %s")
                                    ),
                                    (pl_string("Command"), pl_string("wmnet -W enp0s31f6")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmtemp.DockApp")),
                                    (pl_string("DropCommand"), pl_string("wmtemp -f %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,6")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmtemp -f %s")),
                                    (pl_string("Command"), pl_string("wmtemp -f")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("firefox.Firefox")),
                                    (pl_string("DropCommand"), pl_string("firefox %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,1")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("firefox %s")),
                                    (pl_string("Command"), pl_string("firefox")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (
                                        pl_string("Name"),
                                        pl_string("org\\.gnome\\.Weather.org\\.gnome\\.Weather")
                                    ),
                                    (pl_string("DropCommand"), pl_string("org.gnome.Weather %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,8")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("org.gnome.Weather %s")),
                                    (pl_string("Command"), pl_string("/usr/bin/gnome-weather")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("Zotero.Zotero")),
                                    (
                                        pl_string("DropCommand"),
                                        pl_string("/home/stu/bin/zotero %d")
                                    ),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,9")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (
                                        pl_string("PasteCommand"),
                                        pl_string("/home/stu/bin/zotero %s")
                                    ),
                                    (pl_string("Command"), pl_string("/home/stu/bin/zotero")),
                                ]),
                            ])
                        ),
                        (pl_string("Lowered"), pl_string("Yes")),
                        (pl_string("Position"), pl_string("2496,0")),
                        (
                            pl_string("Applications1440"),
                            pl_array(vec![
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("Logo.WMDock")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,0")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("Command"), pl_string("/usr/bin/WPrefs")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmweather.wmweather")),
                                    (pl_string("DropCommand"), pl_string("wmweather -s KBOS %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,4")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmweather -s KBOS %s")),
                                    (pl_string("Command"), pl_string("wmweather -s KBOS")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmclock.WMClock")),
                                    (pl_string("DropCommand"), pl_string("wmclock %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,3")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmclock %s")),
                                    (pl_string("Command"), pl_string("wmclock")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("emacs.Emacs")),
                                    (pl_string("DropCommand"), pl_string("emacsclient -c %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,2")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("emacsclient -c %s")),
                                    (pl_string("Command"), pl_string("emacsclient -")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmfire.wmfire")),
                                    (pl_string("DropCommand"), pl_string("wmfire -m %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,5")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmfire -m %s")),
                                    (pl_string("Command"), pl_string("wmfire -m")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmnet.WMNET")),
                                    (pl_string("DropCommand"), pl_string("wmnet -W enp0s31f6 %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,7")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (
                                        pl_string("PasteCommand"),
                                        pl_string("wmnet -W enp0s31f6 %s")
                                    ),
                                    (pl_string("Command"), pl_string("wmnet -W enp0s31f6")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("wmtemp.DockApp")),
                                    (pl_string("DropCommand"), pl_string("wmtemp -f %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("Yes")),
                                    (pl_string("Position"), pl_string("0,6")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("wmtemp -f %s")),
                                    (pl_string("Command"), pl_string("wmtemp -f")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("firefox.Firefox")),
                                    (pl_string("DropCommand"), pl_string("firefox %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,1")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("firefox %s")),
                                    (pl_string("Command"), pl_string("firefox")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (
                                        pl_string("Name"),
                                        pl_string("org\\.gnome\\.Weather.org\\.gnome\\.Weather")
                                    ),
                                    (pl_string("DropCommand"), pl_string("org.gnome.Weather %d")),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,8")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (pl_string("PasteCommand"), pl_string("org.gnome.Weather %s")),
                                    (pl_string("Command"), pl_string("/usr/bin/gnome-weather")),
                                ]),
                                pl_dict(vec![
                                    (pl_string("Forced"), pl_string("No")),
                                    (pl_string("Name"), pl_string("Zotero.Zotero")),
                                    (
                                        pl_string("DropCommand"),
                                        pl_string("/home/stu/bin/zotero %d")
                                    ),
                                    (pl_string("BuggyApplication"), pl_string("No")),
                                    (pl_string("AutoLaunch"), pl_string("No")),
                                    (pl_string("Position"), pl_string("0,9")),
                                    (pl_string("Lock"), pl_string("Yes")),
                                    (
                                        pl_string("PasteCommand"),
                                        pl_string("/home/stu/bin/zotero %s")
                                    ),
                                    (pl_string("Command"), pl_string("/home/stu/bin/zotero")),
                                ])
                            ])
                        )
                    ])
                ),
                (
                    pl_string("Clip"),
                    pl_dict(vec![
                        (pl_string("Forced"), pl_string("No")),
                        (pl_string("Name"), pl_string("Logo.WMClip")),
                        (pl_string("DropCommand"), pl_string("wmsetbg -u -t %d")),
                        (pl_string("BuggyApplication"), pl_string("No")),
                        (pl_string("AutoLaunch"), pl_string("No")),
                        (pl_string("Position"), pl_string("2496,1376")),
                        (pl_string("Lock"), pl_string("No")),
                        (pl_string("Command"), pl_string("-")),
                    ])
                ),
                (pl_string("Drawers"), pl_array(vec![])),
                (
                    pl_string("Workspaces"),
                    pl_array(vec![
                        pl_dict(vec![
                            (
                                pl_string("Clip"),
                                pl_dict(vec![
                                    (pl_string("AutoRaiseLower"), pl_string("No")),
                                    (pl_string("AutoCollapse"), pl_string("No")),
                                    (
                                        pl_string("Applications"),
                                        pl_array(vec![
                                            pl_dict(vec![
                                                (pl_string("Forced"), pl_string("No")),
                                                (pl_string("Name"), pl_string("xsnow.Xsnow")),
                                                (pl_string("DropCommand"), pl_string("xsnow %d")),
                                                (pl_string("BuggyApplication"), pl_string("No")),
                                                (pl_string("AutoLaunch"), pl_string("No")),
                                                (pl_string("Position"), pl_string("-2,0")),
                                                (pl_string("Lock"), pl_string("No")),
                                                (pl_string("PasteCommand"), pl_string("xsnow %s")),
                                                (pl_string("Command"), pl_string("xsnow")),
                                                (pl_string("Omnipresent"), pl_string("No")),
                                            ]),
                                            pl_dict(vec![
                                                (pl_string("Forced"), pl_string("No")),
                                                (
                                                    pl_string("Name"),
                                                    pl_string("thunderbird.Thunderbird-default")
                                                ),
                                                (
                                                    pl_string("DropCommand"),
                                                    pl_string("thunderbird %d")
                                                ),
                                                (pl_string("BuggyApplication"), pl_string("No")),
                                                (pl_string("AutoLaunch"), pl_string("No")),
                                                (pl_string("Position"), pl_string("-1,0")),
                                                (pl_string("Lock"), pl_string("No")),
                                                (
                                                    pl_string("PasteCommand"),
                                                    pl_string("thunderbird %s")
                                                ),
                                                (pl_string("Command"), pl_string("thunderbird")),
                                                (pl_string("Omnipresent"), pl_string("No")),
                                            ])
                                        ])
                                    ),
                                    (pl_string("Collapsed"), pl_string("No")),
                                    (pl_string("AutoAttractIcons"), pl_string("No")),
                                    (pl_string("Lowered"), pl_string("Yes"))
                                ])
                            ),
                            (pl_string("Name"), pl_string("Mail")),
                        ]),
                        pl_dict(vec![
                            (
                                pl_string("Clip"),
                                pl_dict(vec![
                                    (pl_string("AutoRaiseLower"), pl_string("No")),
                                    (pl_string("AutoCollapse"), pl_string("No")),
                                    (pl_string("Applications"), pl_array(vec![])),
                                    (pl_string("Collapsed"), pl_string("No")),
                                    (pl_string("AutoAttractIcons"), pl_string("No")),
                                    (pl_string("Lowered"), pl_string("Yes")),
                                ])
                            ),
                            (pl_string("Name"), pl_string("Plans")),
                        ]),
                        pl_dict(vec![
                            (
                                pl_string("Clip"),
                                pl_dict(vec![
                                    (pl_string("AutoRaiseLower"), pl_string("No")),
                                    (pl_string("AutoCollapse"), pl_string("No")),
                                    (pl_string("Applications"), pl_array(vec![])),
                                    (pl_string("Collapsed"), pl_string("No")),
                                    (pl_string("AutoAttractIcons"), pl_string("No")),
                                    (pl_string("Lowered"), pl_string("Yes")),
                                ])
                            ),
                            (pl_string("Name"), pl_string("Misc")),
                        ]),
                    ])
                )
            ]),
        );
    }

    #[test]
    fn error_trailing_garbage() {
        let e = from_str(r#"good bad"#).unwrap_err();
        assert_eq!(e, "0: at line 1, in looking for complete property list at start of input, with no trailing garbage:
good bad
     ^

");
    }
}
