Skip to main content

endbasic_std/console/
spec.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License.  You may obtain a copy
6// of the License at:
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
13// License for the specific language governing permissions and limitations
14// under the License.
15
16//! Configuration support for the graphical console.
17
18use std::collections::{HashMap, HashSet};
19use std::fmt::Display;
20use std::io;
21use std::num::NonZeroU32;
22use std::str::FromStr;
23
24/// An error while parsing a console specification.
25#[derive(Debug, thiserror::Error)]
26#[error("{}", .0)]
27pub struct ParseError(pub String);
28
29impl From<ParseError> for io::Error {
30    fn from(value: ParseError) -> Self {
31        Self::new(io::ErrorKind::InvalidInput, value.0)
32    }
33}
34
35/// Syntactic sugar to create an error.
36macro_rules! mkerror {
37    ($($arg:tt)*) => ({
38        ParseError(format!($($arg)*))
39    })
40}
41
42/// Result type for console specification parse errors.
43type Result<T> = std::result::Result<T, ParseError>;
44
45/// Representation of a screen resolution in pixels.
46#[derive(Debug, PartialEq)]
47pub enum Resolution {
48    /// Tells the console to start in full screen mode at the current desktop resolution.
49    FullScreenDesktop,
50
51    /// Tells the console to start in full screen mode at the given resolution.
52    FullScreen((NonZeroU32, NonZeroU32)),
53
54    /// Tells the console to start in windowed mode at the given resolution.
55    Windowed((NonZeroU32, NonZeroU32)),
56}
57
58/// Parses a graphical `resolution` of the form `[WIDTHxHEIGHT][fs]`.
59fn parse_resolution(resolution: &str) -> Result<Resolution> {
60    if resolution == "fs" {
61        return Ok(Resolution::FullScreenDesktop);
62    }
63
64    let (dimensions, fullscreen) = match resolution.strip_suffix("fs") {
65        Some(prefix) => (prefix, true),
66        None => (resolution, false),
67    };
68
69    match dimensions.split_once('x') {
70        Some((width, height)) => {
71            let width = NonZeroU32::from_str(width).map_err(|e| {
72                mkerror!("Invalid width {} in resolution {}: {}", width, resolution, e)
73            })?;
74            let height = NonZeroU32::from_str(height).map_err(|e| {
75                mkerror!("Invalid height {} in resolution {}: {}", height, resolution, e)
76            })?;
77
78            if fullscreen {
79                Ok(Resolution::FullScreen((width, height)))
80            } else {
81                Ok(Resolution::Windowed((width, height)))
82            }
83        }
84        _ => Err(mkerror!(
85            "Invalid resolution {}: must be of the form [WIDTHxHEIGHT][fs]",
86            resolution
87        )),
88    }
89}
90
91impl FromStr for Resolution {
92    type Err = ParseError;
93
94    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
95        parse_resolution(s)
96    }
97}
98
99/// Parser for a console specification.
100///
101/// A console specification is a string of the form `driver[:flags]`.  The optional flags are a
102/// sequence of comma-separated flags where each flag can be a a boolean flag represented as a plain
103/// string or keyed flag represented as a `key=value` string.
104///
105/// The interface of this parser is designed to be instantiated from `main` based on a user-supplied
106/// flag and then passed on to the specific console drivers for additional parsing.  `main` then
107/// needs to call `finish` to ensure that all provided flags have been parsed.
108pub struct ConsoleSpec<'a> {
109    /// The name of the desired console driver.
110    pub driver: &'a str,
111
112    /// Collection of boolean flags that appear in the input specification and that haven't been
113    /// queried yet.
114    flags: HashSet<&'a str>,
115
116    /// Collection of keyed flags that appear in the input specification and that haven't been
117    /// queried yet.
118    keyed_flags: HashMap<&'a str, &'a str>,
119}
120
121impl<'a> ConsoleSpec<'a> {
122    /// Initializes the console specification parser from `s`.
123    ///
124    /// `s` *must* not be empty.  The caller must supply, at least, a default console driver name
125    /// if the user did not specify any console flag.
126    pub fn init(s: &'a str) -> Self {
127        assert!(!s.is_empty());
128
129        let (driver, rest) = s.split_once(':').unwrap_or((s, ""));
130
131        let mut flags = HashSet::default();
132        let mut keyed_flags = HashMap::default();
133        for pair in rest.split(',') {
134            if pair.is_empty() {
135                continue;
136            }
137
138            match pair.split_once('=') {
139                None => {
140                    let _exists = flags.insert(pair);
141                }
142                Some((k, v)) => {
143                    let _old = keyed_flags.insert(k, v);
144                }
145            }
146        }
147
148        Self { driver, flags, keyed_flags }
149    }
150
151    /// Queries whether the boolean `flag` is in the specification or not.
152    ///
153    /// The flag is marked as "checked" so that `finish` won't raise it as residual.
154    pub fn take_flag(&mut self, flag: &str) -> bool {
155        self.flags.remove(flag)
156    }
157
158    /// Queries the value of the keyed `flag` from the specification, which may or may not be
159    /// present.  The value is returned as a raw string.
160    pub fn take_keyed_flag_str(&mut self, key: &str) -> Option<&str> {
161        self.keyed_flags.remove(key)
162    }
163
164    /// Queries the value of the keyed `flag` from the specification, which may or may not be
165    /// present.  The value is parsed according to the type `V`.
166    ///
167    /// The flag is marked as  "checked" so that `finish` won't raise it as residual.
168    pub fn take_keyed_flag<V>(&mut self, key: &str) -> Result<Option<V>>
169    where
170        V: FromStr,
171        V::Err: Display,
172    {
173        match self.take_keyed_flag_str(key) {
174            Some(v) => V::from_str(v)
175                .map(|v| Some(v))
176                .map_err(|e| mkerror!("Invalid console flag {}: {}", key, e)),
177            None => Ok(None),
178        }
179    }
180
181    /// Validates that all provided flags have been queried by the driver.
182    pub fn finish(self) -> Result<()> {
183        if self.flags.is_empty() && self.keyed_flags.is_empty() {
184            Ok(())
185        } else {
186            let flags_iter = self.flags.into_iter();
187            let keyed_iter = self.keyed_flags.into_keys();
188            let mut unknown = flags_iter.chain(keyed_iter).collect::<Vec<&'a str>>();
189            unknown.sort();
190            Err(mkerror!(
191                "Console driver {} does not recognize flags: {}",
192                self.driver,
193                unknown.join(", ")
194            ))
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_resolution_ok() -> Result<()> {
205        let nz100 = NonZeroU32::new(100).unwrap();
206        let nz200 = NonZeroU32::new(200).unwrap();
207        for (s, exp_resolution) in [
208            ("100x200", Resolution::Windowed((nz100, nz200))),
209            ("100x200fs", Resolution::FullScreen((nz100, nz200))),
210            ("fs", Resolution::FullScreenDesktop),
211        ] {
212            assert_eq!(exp_resolution, Resolution::from_str(s)?);
213        }
214        Ok(())
215    }
216
217    #[test]
218    fn test_resolution_errors() -> Result<()> {
219        for (s, exp_error) in [
220            ("100", "Invalid resolution 100: must be of the form [WIDTHxHEIGHT][fs]"),
221            ("100fs", "Invalid resolution 100fs: must be of the form [WIDTHxHEIGHT][fs]"),
222            (
223                "100x200x300",
224                "Invalid height 200x300 in resolution 100x200x300: invalid digit found in string",
225            ),
226            ("100x", "Invalid height  in resolution 100x: cannot parse integer from empty string"),
227            ("x200", "Invalid width  in resolution x200: cannot parse integer from empty string"),
228            ("0x2", "Invalid width 0 in resolution 0x2: number would be zero for non-zero type"),
229            ("1x0", "Invalid height 0 in resolution 1x0: number would be zero for non-zero type"),
230        ] {
231            match Resolution::from_str(s) {
232                Ok(_) => panic!("Invalid resolution {} not raised as an error", s),
233                Err(e) => assert_eq!(exp_error, e.0),
234            }
235        }
236        Ok(())
237    }
238
239    #[test]
240    fn test_console_spec_just_driver() -> Result<()> {
241        let spec = ConsoleSpec::init("default");
242        assert_eq!("default", spec.driver);
243        spec.finish()
244    }
245
246    #[test]
247    fn test_console_spec_driver_no_opts() -> Result<()> {
248        let spec = ConsoleSpec::init("default:");
249        assert_eq!("default", spec.driver);
250        spec.finish()
251    }
252
253    #[test]
254    fn test_console_spec_flags() -> Result<()> {
255        let mut spec = ConsoleSpec::init("default:foo,baz");
256        assert_eq!("default", spec.driver);
257        assert!(spec.take_flag("foo"));
258        assert!(!spec.take_flag("bar"));
259        assert!(spec.take_flag("baz"));
260        spec.finish()
261    }
262
263    #[test]
264    fn test_console_spec_keyed_flags() -> Result<()> {
265        let mut spec = ConsoleSpec::init("default:a=b=c,foo=bar");
266        assert_eq!("default", spec.driver);
267        assert_eq!(Some("b=c"), spec.take_keyed_flag_str("a"));
268        assert_eq!(Some("bar"), spec.take_keyed_flag_str("foo"));
269        assert_eq!(None, spec.take_keyed_flag_str("baz"));
270        spec.finish()
271    }
272
273    #[test]
274    fn test_console_spec_keyed_flags_last_wins() -> Result<()> {
275        let mut spec = ConsoleSpec::init("default:x=1,y=2,x=3");
276        assert_eq!("default", spec.driver);
277        assert_eq!(Some("3"), spec.take_keyed_flag_str("x"));
278        assert_eq!(Some("2"), spec.take_keyed_flag_str("y"));
279        spec.finish()
280    }
281
282    #[test]
283    fn test_console_spec_keyed_flags_typed_ok() -> Result<()> {
284        let mut spec = ConsoleSpec::init("default:x=1");
285        assert_eq!("default", spec.driver);
286        assert_eq!(Some(1_i32), spec.take_keyed_flag("x")?);
287        assert_eq!(None as Option<i32>, spec.take_keyed_flag("x")?);
288        spec.finish()
289    }
290
291    #[test]
292    fn test_console_spec_keyed_flags_typed_err() -> Result<()> {
293        let mut spec = ConsoleSpec::init("default:x=0");
294        assert_eq!("default", spec.driver);
295        assert_eq!(
296            "Invalid console flag x: number would be zero for non-zero type",
297            spec.take_keyed_flag::<NonZeroU32>("x").unwrap_err().0
298        );
299        spec.finish()
300    }
301
302    #[test]
303    fn test_console_spec_residue_errors() -> Result<()> {
304        let mut spec = ConsoleSpec::init("abc:foo,y,x=z,bar=baz");
305        assert!(spec.take_flag("foo"));
306        assert!(!spec.take_flag("x"));
307        assert_eq!(Some("baz"), spec.take_keyed_flag_str("bar"));
308        assert_eq!(None, spec.take_keyed_flag_str("y"));
309        match spec.finish() {
310            Ok(()) => panic!("Residual flags not detected"),
311            Err(e) => {
312                assert_eq!("Console driver abc does not recognize flags: x, y", e.0);
313                Ok(())
314            }
315        }
316    }
317}