Description
Since the inception of fluent-rs
we've been facing a dilemma of fitting the concept of parser that recovers from errors into Rust Result
model.
In all those years there has been very little progress or consolation in the approach to such paradigm, and as we advance to 1.0, I'd like to make a last effort to gather arguments and decide on the API approach we're going to use.
Of course we can revisit such decision in 2.0
, but I'd like to minimize the chance that we'll want to.
Current Design
fn parse(input: &str) -> Result<AST, (AST, Vec<Error>)>
This API is clean, fits into the Result
model and in my opinion strikes the right balance of communicating the necessity to plan for errors to the consumer, while returning the AST in both cases.
Unfortunately, it also has some limitations, that are either real or not:
- The "error" is not really an error. It's a
Vec
of errors. See ReplaceVec<ParserError>
with opaque Error type. #176 for the impact on API ergonomics - It requires a new
Vec
per resource, while fairly often a bunch of resources are parsed together. - For some users it leads to the following consumption snippet:
let (ast, errors) = match parse(input) {
Ok(ast) => (ast, None),
Err((ast, errors)) => (ast, Some(errors))
}
Design ideas
When scooping around, I encountered several possible alternative designs:
a) fn parse(input: &str) -> (AST, Vec<Error>);
b) fn parse(input: &str, errors: &mut Vec<Error>) -> AST;
c) fn parse(input: &str, errors: Option<&mut Vec<Error>>) -> AST;
d) fn parse<T: ErrorHandler>(input: &str, errors: T) -> AST;
I see pros and cons to each of them.
a) This one hides whether parsing was successful. You need to check output.1.is_empty()
. I think it's a bit unnatural and doesn't allow for bubbling up errors.
b) This one feels C-like and forces the user to construct a mutable Vec
to pass it and than again is_empty()
. The value is that you need only one Vec
for a loop of parses.
c) This one allows the user to ignore errors, which I'm concerned about. Parsing errors are errors, and should almost never be ignored. Designing API around an easy way to hide them, and not even report them, feels like it may cascade into lowering the quality of the ecosystem.
d) This is some variant of (b) to me, were we allow custom ways to handle error handling. I use that model in multiple higher-level Fluent APIs where the errors are closer to the user and the ErrorHandler
is mostly some form of console.error
from JS. I'm a bit reluctant to do it here because syntax errors feel like they should be explicitly handled, but it is arbitrary (compared to Fluent resolver errors, missing files when looking for fallbacks etc.) and I can see an argument that we should handle it similarly.
Use of traits such as FromStr
or TryFrom
Finally currently we have a function parse
, but depending on the decisions in this thread, we could instead have impl FromStr for ast::Resource
or impl TryFrom<&str> for ast::Resource
.
This would fit nicely into Rust API design, but wouldn't work for signatures that take error handlers or mutable vec of errors, and only work if we return a Result.
Conclusion
I think that the choice is mostly arbitrary and I can't see any argument that would heavily favor one over others. There will be customers who would work well with (d) (Firefox runtime being one! If we encounter parser error, we just want to report it in console!), and ones that cater to (c) (user has no way of handling errors, so they just want to get whatever AST was built), and users who'd naturally prefer the current model of (a), where the current model feels more "Rusty", and (a) is what users often end up doing with it.
When thinking about if Fluent parser fits into any category, I keep coming back to CSS parsers, which also are lenient, accumulate errors, but return AST. There are probably other classes, but I also keep thinking that this is not a canonical case of "perform an operation, give me the result, and here's a reporter for any errors" like (d) suggests. I think parsers are different in that they are meant to convert input to output, rather than perform some side-effect operations which may fail.
Ultimately tho, the choice is arbitrary, and I think whatever we chose here will propagate to analogous scenarios, so I'd like to ask the wider Rust community for feedback. There may be arguments I haven't consider that would make one of the choices (or yet another one!) a clear fit for Rust and Fluent Parser.