Skip to main content

argumentation/parsers/
apx.rs

1//! Parser for ICCMA APX format.
2//!
3//! APX is a simple text format:
4//!   arg(a).
5//!   arg(b).
6//!   att(a, b).
7//!
8//! Comments start with `%`. Whitespace is flexible but each statement must
9//! match the shape `arg(NAME).` or `att(NAME, NAME).` exactly — mixed
10//! whitespace or stray characters between the closing paren and the period
11//! produce a parse error rather than silently mangling the argument name.
12
13use crate::Error;
14use crate::framework::ArgumentationFramework;
15
16/// Parse an APX document into an argumentation framework with `String` arguments.
17pub fn parse_apx(input: &str) -> Result<ArgumentationFramework<String>, Error> {
18    let mut af = ArgumentationFramework::new();
19    for (lineno, raw_line) in input.lines().enumerate() {
20        let line = raw_line.split('%').next().unwrap_or("").trim();
21        if line.is_empty() {
22            continue;
23        }
24        if let Some(rest) = line.strip_prefix("arg(") {
25            let body = extract_paren_body(rest, lineno)?;
26            if body.is_empty() {
27                return Err(Error::Parse(format!("line {}: empty arg", lineno + 1)));
28            }
29            af.add_argument(body.to_string());
30        } else if let Some(rest) = line.strip_prefix("att(") {
31            let body = extract_paren_body(rest, lineno)?;
32            let parts: Vec<&str> = body.split(',').map(str::trim).collect();
33            if parts.len() != 2 {
34                return Err(Error::Parse(format!(
35                    "line {}: att expects 2 args, got {}",
36                    lineno + 1,
37                    parts.len()
38                )));
39            }
40            af.add_attack(&parts[0].to_string(), &parts[1].to_string())
41                .map_err(|e| Error::Parse(format!("line {}: {}", lineno + 1, e)))?;
42        } else {
43            return Err(Error::Parse(format!(
44                "line {}: unrecognised: {}",
45                lineno + 1,
46                line
47            )));
48        }
49    }
50    Ok(af)
51}
52
53/// Extract the body between `(` (already consumed) and the final `)`,
54/// requiring only whitespace and an optional `.` terminator after the `)`.
55/// Returns the trimmed body or `Error::Parse` on malformed input.
56fn extract_paren_body(rest: &str, lineno: usize) -> Result<&str, Error> {
57    let Some(close_idx) = rest.rfind(')') else {
58        return Err(Error::Parse(format!(
59            "line {}: missing closing `)`",
60            lineno + 1
61        )));
62    };
63    let (body, tail) = rest.split_at(close_idx);
64    let tail = &tail[1..]; // skip the `)`
65    if !tail.is_empty() && tail != "." {
66        return Err(Error::Parse(format!(
67            "line {}: unexpected text after `)`: {:?}",
68            lineno + 1,
69            tail
70        )));
71    }
72    Ok(body.trim())
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn parse_simple_apx() {
81        let input = "arg(a).\narg(b).\natt(a,b).\n";
82        let af = parse_apx(input).unwrap();
83        assert_eq!(af.arguments().count(), 2);
84        assert_eq!(af.attackers(&"b".to_string()).len(), 1);
85    }
86
87    #[test]
88    fn parse_apx_with_comments() {
89        let input = "% test\narg(x).\narg(y).\n% comment\natt(x, y).\n";
90        let af = parse_apx(input).unwrap();
91        assert_eq!(af.arguments().count(), 2);
92    }
93
94    #[test]
95    fn parse_apx_rejects_unknown_syntax() {
96        let input = "foo(a).\n";
97        assert!(parse_apx(input).is_err());
98    }
99
100    #[test]
101    fn parse_apx_rejects_tab_between_paren_and_period() {
102        // Previously: silently accepted as argument name "a)".
103        let input = "arg(a)\t.\n";
104        let err = parse_apx(input).unwrap_err();
105        assert!(
106            matches!(err, Error::Parse(_)),
107            "expected Parse error, got {:?}",
108            err
109        );
110    }
111
112    #[test]
113    fn parse_apx_rejects_trailing_garbage_after_close() {
114        let input = "arg(a)extra.\n";
115        assert!(parse_apx(input).is_err());
116    }
117
118    #[test]
119    fn parse_apx_rejects_missing_close_paren() {
120        let input = "arg(a.\n";
121        assert!(parse_apx(input).is_err());
122    }
123
124    #[test]
125    fn parse_apx_accepts_extra_whitespace_inside() {
126        // `arg( a ).` and `att( a , b ).` should still be accepted.
127        let input = "arg( a ).\narg( b ).\natt( a , b ).\n";
128        let af = parse_apx(input).unwrap();
129        assert_eq!(af.arguments().count(), 2);
130        assert_eq!(af.attackers(&"b".to_string()).len(), 1);
131    }
132}