diff --git a/pkg_parser/ast.mbt b/pkg_parser/ast.mbt new file mode 100644 index 00000000..7bd55b28 --- /dev/null +++ b/pkg_parser/ast.mbt @@ -0,0 +1,77 @@ +///| +pub(all) enum Ast { + Null(Location) + False(Location) + True(Location) + Str(str~ : String, loc~ : Location) + Float(float~ : String, loc~ : Location) + Arr(content~ : Array[Ast], loc~ : Location) + Obj(map~ : Map[String, Ast], loc~ : Location) +} derive(Debug, Eq) + +///| +pub fn Ast::loc(self : Self) -> Location { + match self { + Null(loc) => loc + False(loc) => loc + True(loc) => loc + Str(loc~, ..) => loc + Float(loc~, ..) => loc + Arr(loc~, ..) => loc + Obj(loc~, ..) => loc + } +} + +///| +pub fn Ast::as_bool(self : Self) -> Bool? { + match self { + True(_) => Some(true) + False(_) => Some(false) + _ => None + } +} + +///| +pub fn Ast::as_string(self : Self) -> String? { + match self { + Str(str~, ..) => Some(str) + _ => None + } +} + +///| +pub fn Ast::as_number(self : Self) -> String? { + match self { + Float(float~, ..) => Some(float) + _ => None + } +} + +///| +pub fn Ast::as_array(self : Self) -> Array[Ast]? { + match self { + Arr(content~, ..) => Some(content) + _ => None + } +} + +///| +pub fn Ast::as_object(self : Self) -> Map[String, Ast]? { + match self { + Obj(map~, ..) => Some(map) + _ => None + } +} + +///| +pub impl ToJson for Ast with to_json(self) { + match self { + Null(loc) => { "type": "Null", "loc": loc } + False(loc) => { "type": "False", "loc": loc } + True(loc) => { "type": "True", "loc": loc } + Str(str~, loc~) => { "type": "Str", "str": str, "loc": loc } + Float(float~, loc~) => { "type": "Float", "float": float, "loc": loc } + Arr(content~, loc~) => { "type": "Arr", "content": content, "loc": loc } + Obj(map~, loc~) => { "type": "Obj", "map": map, "loc": loc } + } +} diff --git a/pkg_parser/imports.mbt b/pkg_parser/imports.mbt new file mode 100644 index 00000000..dd0f9fd6 --- /dev/null +++ b/pkg_parser/imports.mbt @@ -0,0 +1,5 @@ +///| +using @basic {type Location, type Position, type Report} + +///| +using @tokens {type Token, type TokenKind, type Triple, type Triples} diff --git a/pkg_parser/moon.pkg b/pkg_parser/moon.pkg new file mode 100644 index 00000000..40731568 --- /dev/null +++ b/pkg_parser/moon.pkg @@ -0,0 +1,11 @@ +import { + "moonbitlang/parser/basic", + "moonbitlang/parser/tokens", + "moonbitlang/parser/lexer", + "moonbitlang/x/fs", +} + +import { + "moonbitlang/core/test", + "moonbitlang/core/json", +} for "test" diff --git a/pkg_parser/parser.mbt b/pkg_parser/parser.mbt new file mode 100644 index 00000000..66facaa8 --- /dev/null +++ b/pkg_parser/parser.mbt @@ -0,0 +1,654 @@ +///| +priv struct State { + tokens : Triples + diagnostics : Array[Report] + mut next : Int + mut parsed_position : Position +} + +///| +let dummy_pos : Position = { fname: "", lnum: 0, bol: 0, cnum: 0 } + +///| +fn is_layout(token : Token) -> Bool { + match token { + NEWLINE | COMMENT(_) => true + _ => false + } +} + +///| +fn State::peek(state : Self, nth? : Int = 0) -> Triple { + let mut offset = 0 + let mut step = 0 + while state.tokens.get(state.next + offset) is Some((token, _, _) as triple) { + if is_layout(token) { + offset += 1 + continue + } + if step == nth { + return triple + } + step += 1 + offset += 1 + } + match state.tokens.last() { + Some(last) => last + None => (EOF, dummy_pos, dummy_pos) + } +} + +///| +fn State::consume(state : Self) -> Triple { + while state.tokens.get(state.next) is Some((token, _, _) as triple) { + state.next += 1 + if is_layout(token) { + continue + } + state.parsed_position = triple.2 + return triple + } + panic() +} + +///| +fn State::skip(state : Self) -> Unit { + ignore(state.consume()) +} + +///| +fn State::peek_token(state : Self, nth? : Int = 0) -> Token { + state.peek(nth~).0 +} + +///| +fn State::peek_kind(state : Self, nth? : Int = 0) -> TokenKind { + state.peek(nth~).0.kind() +} + +///| +fn State::peek_spos(state : Self, nth? : Int = 0) -> Position { + state.peek(nth~).1 +} + +///| +fn State::peek_location(state : Self) -> Location { + let (_, start, end) = state.peek() + { start, end } +} + +///| +fn State::loc_start_with(state : Self, start : Position) -> Location { + { start, end: state.parsed_position } +} + +///| +fn State::consume_if(state : Self, expected : TokenKind) -> Bool { + if state.peek_kind() == expected { + state.skip() + true + } else { + false + } +} + +///| +fn State::report_failed_to_parse( + state : Self, + found : Token, + expected : String, + loc : Location, +) -> Unit { + let msg = match found { + EOF => "Unexpected end of file, missing \{expected} here." + _ => + "Unexpected token \{found.to_expect_string()}, you may expect \{expected}." + } + state.diagnostics.push(Report::{ loc, msg }) +} + +///| +fn State::report_expected_here(state : Self, expected : String) -> Unit { + state.report_failed_to_parse( + state.peek_token(), + expected, + state.peek_location(), + ) +} + +///| +fn State::skip_until(state : Self, kinds : Array[TokenKind]) -> Unit { + while state.peek_kind() != TK_EOF && !kinds.contains(state.peek_kind()) { + state.skip() + } +} + +///| +fn State::skip_until_statement_end(state : Self) -> Unit { + state.skip_until([TK_SEMI, TK_EOF]) +} + +///| +fn State::expect_lident(state : Self, context~ : String) -> String { + match state.peek_token() { + LIDENT(name) => { + state.skip() + name + } + UIDENT(name) => { + let loc = state.peek_location() + state.skip() + state.diagnostics.push(Report::{ + loc, + msg: "Unexpected uppercase identifier \{name}, expected lowercase identifier in \{context}.", + }) + "" + } + other => { + let loc = state.peek_location() + state.report_failed_to_parse( + other, + "lowercase identifier in \{context}", + loc, + ) + if other.kind() != TK_EOF { + state.skip() + } + "" + } + } +} + +///| +priv enum ImportKind { + Regular + Test + Wbtest +} + +///| +fn import_kind_to_key(kind : ImportKind) -> String { + match kind { + Regular => "import" + Test => "test-import" + Wbtest => "wbtest-import" + } +} + +///| +fn null_here(state : State) -> Ast { + Null(state.peek_location()) +} + +///| +fn has_invalid_pkg_int_suffix(integer : String) -> Bool { + integer.has_suffix("U") || integer.has_suffix("L") +} + +///| +fn[A] parse_comma_separated( + state : State, + right~ : TokenKind, + f : (State) -> A, +) -> Array[A] { + let items = [] + if state.consume_if(right) { + return items + } + if state.peek_kind() == TK_EOF { + state.report_expected_here(right.to_expect_string()) + return items + } + while state.peek_kind() != TK_EOF { + if state.peek_kind() == right { + state.skip() + break + } + let before = state.next + items.push(f(state)) + if state.peek_kind() == TK_COMMA { + state.skip() + if state.peek_kind() == right { + state.skip() + break + } + continue + } + if state.peek_kind() == right { + state.skip() + break + } + if state.peek_kind() == TK_SEMI { + state.report_expected_here(right.to_expect_string()) + break + } + if state.peek_kind() == TK_EOF { + state.report_expected_here(right.to_expect_string()) + break + } + state.report_failed_to_parse( + state.peek_token(), + "`,` or \{right.to_expect_string()}", + state.peek_location(), + ) + if state.next == before && state.peek_kind() != TK_EOF { + state.skip() + } + state.skip_until([TK_COMMA, right, TK_SEMI]) + if state.peek_kind() == TK_COMMA { + state.skip() + if state.peek_kind() == right { + state.skip() + break + } + continue + } + if state.peek_kind() == right { + state.skip() + break + } + if state.peek_kind() == TK_SEMI { + break + } + } + items +} + +///| +fn parse_array(state : State) -> Ast { + let start = state.peek_spos() + guard state.consume_if(TK_LBRACKET) else { + state.report_expected_here("\"[\"") + return null_here(state) + } + let content = parse_comma_separated(state, right=TK_RBRACKET, parse_expr) + Arr(content~, loc=state.loc_start_with(start)) +} + +///| +fn parse_map_entry(state : State) -> (String, Ast) { + let key = match state.peek_token() { + STRING(str) => { + state.skip() + str + } + other => { + state.report_failed_to_parse( + other, + "string literal as map key", + state.peek_location(), + ) + if other.kind() != TK_EOF { + state.skip() + } + "" + } + } + if !state.consume_if(TK_COLON) { + state.report_expected_here("\":\"") + match state.peek_kind() { + TK_COMMA | TK_RBRACE | TK_EOF => return (key, null_here(state)) + _ => () + } + } + (key, parse_expr(state)) +} + +///| +fn parse_map(state : State) -> Ast { + let start = state.peek_spos() + guard state.consume_if(TK_LBRACE) else { + state.report_expected_here("\"{\"") + return null_here(state) + } + let entries = parse_comma_separated(state, right=TK_RBRACE, parse_map_entry) + Obj(map=Map::from_array(entries), loc=state.loc_start_with(start)) +} + +///| +fn parse_argument(state : State) -> (String, Ast) { + if state.peek_kind(nth=1) == TK_COLON { + match state.peek_token() { + STRING(key) => { + state.skip() + ignore(state.consume_if(TK_COLON)) + (key, parse_expr(state)) + } + LIDENT(key) => { + state.skip() + ignore(state.consume_if(TK_COLON)) + (key, parse_expr(state)) + } + _ => { + state.report_failed_to_parse( + state.peek_token(), + "labeled argument", + state.peek_location(), + ) + ("", parse_expr(state)) + } + } + } else { + state.report_failed_to_parse( + state.peek_token(), + "labeled argument", + state.peek_location(), + ) + ("", parse_expr(state)) + } +} + +///| +fn parse_apply(state : State) -> (String, Ast) { + let name = state.expect_lident(context="name") + let args_start = state.peek_spos() + if !state.consume_if(TK_LPAREN) { + state.report_expected_here("\"(\"") + state.skip_until_statement_end() + let loc = Location::{ start: args_start, end: args_start } + return (name, Obj(map=Map::new(), loc~)) + } + let args = parse_comma_separated(state, right=TK_RPAREN, parse_argument) + (name, Obj(map=Map::from_array(args), loc=state.loc_start_with(args_start))) +} + +///| +fn is_statement_start_token(token : Token) -> Bool { + match token { + IMPORT | LIDENT(_) => true + _ => false + } +} + +///| +fn is_statement_start_after_newline(state : State) -> Bool { + state.peek_spos().lnum > state.parsed_position.lnum && + is_statement_start_token(state.peek_token()) +} + +///| +fn parse_assign(state : State) -> (String, Ast) { + let name = state.expect_lident(context="name") + ignore(state.consume_if(TK_EQUAL)) + if is_statement_start_after_newline(state) { + let loc = state.peek_location() + state.report_failed_to_parse(state.peek_token(), "expression", loc) + return (name, Null(loc)) + } + (name, parse_expr(state)) +} + +///| +fn parse_import_item(state : State) -> Ast { + let path_loc = state.peek_location() + let path = match state.peek_token() { + STRING(path) => { + state.skip() + path + } + other => { + state.report_failed_to_parse( + other, + "package path in string", + state.peek_location(), + ) + if other.kind() != TK_EOF { + state.skip() + } + "" + } + } + let package_alias = match state.peek_token() { + PACKAGE_NAME(alias_name) => { + let alias_loc = state.peek_location() + state.skip() + Some((alias_name, alias_loc)) + } + AS => { + let alias_loc = state.peek_location() + state.skip() + state.diagnostics.push(Report::{ + loc: alias_loc, + msg: "Old import alias syntax is no longer supported; use `\"...\" @alias` instead of `\"...\" as @alias`.", + }) + match state.peek_kind() { + TK_PACKAGE_NAME => state.skip() + TK_COMMA | TK_RBRACE | TK_EOF => () + _ => state.skip() + } + None + } + _ => None + } + match package_alias { + None => Str(str=path, loc=path_loc) + Some((alias_name, alias_loc)) => + Obj( + map=Map::from_array([ + ("path", Str(str=path, loc=path_loc)), + ("alias", Str(str=alias_name, loc=alias_loc)), + ]), + loc=path_loc, + ) + } +} + +///| +fn parse_import_kind_string(state : State) -> ImportKind { + match state.peek_token() { + STRING("test") => { + state.skip() + Test + } + STRING("wbtest") => { + state.skip() + Wbtest + } + STRING(_) => { + state.report_failed_to_parse( + state.peek_token(), + "\"test\" or \"wbtest\"", + state.peek_location(), + ) + state.skip() + Regular + } + other => { + state.report_failed_to_parse( + other, + "string literal \"test\" or \"wbtest\"", + state.peek_location(), + ) + Regular + } + } +} + +///| +fn parse_import_statement(state : State) -> (String, Ast) { + let start = state.peek_spos() + ignore(state.consume_if(TK_IMPORT)) + let mut kind = Regular + if state.peek_token() is STRING(_) { + let legacy_loc = state.peek_location() + state.skip() + state.diagnostics.push(Report::{ + loc: legacy_loc, + msg: "Old import syntax is no longer supported; use `import { ... }`, `import { ... } for \"test\"`, or `import { ... } for \"wbtest\"`.", + }) + } + if !state.consume_if(TK_LBRACE) { + state.report_expected_here("\"{\"") + state.skip_until_statement_end() + return ( + import_kind_to_key(kind), + Arr(content=[], loc=state.loc_start_with(start)), + ) + } + let packages = parse_comma_separated( + state, + right=TK_RBRACE, + parse_import_item, + ) + if state.consume_if(TK_FOR) { + kind = parse_import_kind_string(state) + } + ( + import_kind_to_key(kind), + Arr(content=packages, loc=state.loc_start_with(start)), + ) +} + +///| +fn parse_expr(state : State) -> Ast { + match state.peek_token() { + TRUE => { + let loc = state.peek_location() + state.skip() + True(loc) + } + FALSE => { + let loc = state.peek_location() + state.skip() + False(loc) + } + STRING(str) => { + let loc = state.peek_location() + state.skip() + Str(str~, loc~) + } + INT(integer) as token => { + let loc = state.peek_location() + state.skip() + if has_invalid_pkg_int_suffix(integer) { + state.report_failed_to_parse( + token, "integer literals without U or L suffixes", loc, + ) + Null(loc) + } else { + Float(float=integer, loc~) + } + } + FLOAT(_) | DOUBLE(_) as token => { + let loc = state.peek_location() + state.skip() + state.report_failed_to_parse(token, "expression", loc) + Null(loc) + } + LBRACE => parse_map(state) + LBRACKET => parse_array(state) + other => { + let loc = state.peek_location() + state.report_failed_to_parse(other, "expression", loc) + if other.kind() != TK_EOF && other.kind() != TK_SEMI { + state.skip() + } + Null(loc) + } + } +} + +///| +fn parse_statement(state : State) -> (String, Ast) { + match state.peek_token() { + IMPORT => parse_import_statement(state) + LIDENT(_) if state.peek_kind(nth=1) == TK_EQUAL => parse_assign(state) + LIDENT(_) => parse_apply(state) + other => { + let loc = state.peek_location() + state.report_failed_to_parse(other, "package statement", loc) + if other.kind() != TK_EOF { + state.skip() + } + ("", Null(loc)) + } + } +} + +///| +fn skip_statement_separators(state : State) -> Unit { + while state.peek_token() is SEMI(_) { + state.skip() + } +} + +///| +fn parse_statements(state : State) -> Array[(String, Ast)] { + let statements = [] + skip_statement_separators(state) + while state.peek_kind() != TK_EOF { + let before = state.next + let diagnostics_before = state.diagnostics.length() + let stmt = parse_statement(state) + if stmt.0 != "" { + statements.push(stmt) + } + if state.next == before && state.peek_kind() != TK_EOF { + state.skip() + } + if state.peek_kind() == TK_EOF { + break + } + if state.peek_kind() == TK_SEMI { + skip_statement_separators(state) + continue + } + if state.diagnostics.length() > diagnostics_before && + is_statement_start_after_newline(state) { + continue + } + state.report_failed_to_parse( + state.peek_token(), + "`;` or EOF", + state.peek_location(), + ) + state.skip_until_statement_end() + skip_statement_separators(state) + } + statements +} + +///| +pub fn moon_pkg_of_tokens(tokens : Triples) -> (Ast, Array[Report]) { + if tokens.is_empty() { + let loc = Location::{ start: dummy_pos, end: dummy_pos } + return (Obj(map=Map::new(), loc~), []) + } + let start = tokens[0].1 + let state = State::{ + tokens, + diagnostics: [], + next: 0, + parsed_position: start, + } + let object_start = state.peek_spos() + state.parsed_position = object_start + let statements = parse_statements(state) + let ast = Obj( + map=Map::from_array(statements), + loc=state.loc_start_with(object_start), + ) + (ast, state.diagnostics) +} + +///| +pub fn parse_string( + source : String, + name? : String = "", +) -> (Ast, Array[Report]) { + let lex_result = @lexer.tokens_from_string(source, comment=false, name~) + let (ast, reports) = moon_pkg_of_tokens(lex_result.tokens) + let diagnostics = [] + lex_result.errors.each(fn(err_triple) { + let (start, end, err) = err_triple + diagnostics.push(Report::{ loc: { start, end }, msg: err.to_string() }) + }) + reports.each(report => diagnostics.push(report)) + (ast, diagnostics) +} + +///| +pub fn parse_file(path : String) -> (Ast, Array[Report]) raise @fs.IOError { + let source = @fs.read_file_to_string(path) + parse_string(source, name=path) +} diff --git a/pkg_parser/pkg.generated.mbti b/pkg_parser/pkg.generated.mbti new file mode 100644 index 00000000..2534a61b --- /dev/null +++ b/pkg_parser/pkg.generated.mbti @@ -0,0 +1,43 @@ +// Generated using `moon info`, DON'T EDIT IT +package "moonbitlang/parser/pkg_parser" + +import { + "moonbitlang/core/debug", + "moonbitlang/parser/basic", + "moonbitlang/parser/tokens", + "moonbitlang/x/fs", +} + +// Values +pub fn moon_pkg_of_tokens(Array[(@tokens.Token, @basic.Position, @basic.Position)]) -> (Ast, Array[@basic.Report]) + +pub fn parse_file(String) -> (Ast, Array[@basic.Report]) raise @fs.IOError + +pub fn parse_string(String, name? : String) -> (Ast, Array[@basic.Report]) + +// Errors + +// Types and methods +pub(all) enum Ast { + Null(@basic.Location) + False(@basic.Location) + True(@basic.Location) + Str(str~ : String, loc~ : @basic.Location) + Float(float~ : String, loc~ : @basic.Location) + Arr(content~ : Array[Ast], loc~ : @basic.Location) + Obj(map~ : Map[String, Ast], loc~ : @basic.Location) +} +pub fn Ast::as_array(Self) -> Array[Self]? +pub fn Ast::as_bool(Self) -> Bool? +pub fn Ast::as_number(Self) -> String? +pub fn Ast::as_object(Self) -> Map[String, Self]? +pub fn Ast::as_string(Self) -> String? +pub fn Ast::loc(Self) -> @basic.Location +pub impl Eq for Ast +pub impl ToJson for Ast +pub impl @debug.Debug for Ast + +// Type aliases + +// Traits + diff --git a/pkg_parser/pkg_parser_test.mbt b/pkg_parser/pkg_parser_test.mbt new file mode 100644 index 00000000..85c88f82 --- /dev/null +++ b/pkg_parser/pkg_parser_test.mbt @@ -0,0 +1,347 @@ +///| +fn parse_pkg(source : String) -> (Ast, Array[@basic.Report]) { + parse_string(source, name="moon.pkg") +} + +///| +fn ast_summary(ast : Ast) -> Json { + match ast { + Null(_) => Json::null() + False(_) => Json::boolean(false) + True(_) => Json::boolean(true) + Str(str~, ..) => Json::string(str) + Float(float~, ..) => + Json::object(Map::from_array([("$number", Json::string(float))])) + Arr(content~, ..) => Json::array(content.map(ast_summary)) + Obj(map~, ..) => { + let object : Map[String, Json] = Map::new() + for key, value in map { + object[key] = ast_summary(value) + } + Json::object(object) + } + } +} + +///| +fn report_contains(reports : Array[@basic.Report], needle : String) -> Bool { + for report in reports { + if report.msg.contains(needle) { + return true + } + } + false +} + +///| +fn report_checks( + reports : Array[@basic.Report], + needles : Array[String], +) -> Json { + Json::object( + Map::from_array([ + ("count", Json::number(reports.length().to_double())), + ( + "contains", + Json::array( + needles.map(needle => Json::boolean(report_contains(reports, needle))), + ), + ), + ]), + ) +} + +///| +test "pkg_parser parse nested options" { + let source = + #|options( + #| nested: { "a": ["x", "y", 123], "b": { "c": false } } + #|) + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, []), content={ + "count": 0, + "contains": [], + }) + json_inspect(ast_summary(ast), content={ + "options": { + "nested": { "a": ["x", "y", { "$number": "123" }], "b": { "c": false } }, + }, + }) +} + +///| +test "pkg_parser parse import for wbtest" { + let source = + #|import { "a/pkg", "b/pkg" } for "wbtest" + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, []), content={ + "count": 0, + "contains": [], + }) + json_inspect(ast_summary(ast), content={ "wbtest-import": ["a/pkg", "b/pkg"] }) +} + +///| +test "pkg_parser rejects legacy import kind syntax" { + let source = + #|import "test" { "path/to/pkg", "p" } + #| + let (ast, reports) = parse_pkg(source) + json_inspect( + report_checks(reports, ["Old import syntax is no longer supported"]), + content={ "count": 1, "contains": [true] }, + ) + json_inspect(ast_summary(ast), content={ "import": ["path/to/pkg", "p"] }) +} + +///| +test "pkg_parser rejects legacy import alias syntax" { + let source = + #|import { "a" as @alias, "b" @ok } + #| + let (ast, reports) = parse_pkg(source) + json_inspect( + report_checks(reports, ["Old import alias syntax is no longer supported"]), + content={ "count": 1, "contains": [true] }, + ) + json_inspect(ast_summary(ast), content={ + "import": ["a", { "path": "b", "alias": "ok" }], + }) +} + +///| +test "pkg_parser avoids cascading after invalid import kind" { + let source = + #|import { "a" } for next = true + #| + let (ast, reports) = parse_pkg(source) + json_inspect( + report_checks(reports, [ + "string literal \"test\" or \"wbtest\"", "`;` or EOF", + ]), + content={ "count": 2, "contains": [true, true] }, + ) + json_inspect(ast_summary(ast), content={ "import": ["a"] }) +} + +///| +test "pkg_parser reports positional argument" { + let source = + #|apply("positional") + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["labeled argument"]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ "apply": { "": "positional" } }) +} + +///| +test "pkg_parser parse assignments" { + let source = + #|warnings = "+w1-w1" + #|supported_targets = "+wasm" + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, []), content={ + "count": 0, + "contains": [], + }) + json_inspect(ast_summary(ast), content={ + "warnings": "+w1-w1", + "supported_targets": "+wasm", + }) +} + +///| +test "pkg_parser requires statement separators" { + let source = + #|a = "x" b = "y" + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["`;` or EOF"]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ "a": "x" }) +} + +///| +test "pkg_parser keeps next statement after missing assignment value before semicolon" { + let source = + #|a = ; + #|b = 1 + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["expression", "`;` or EOF"]), content={ + "count": 1, + "contains": [true, false], + }) + json_inspect(ast_summary(ast), content={ "a": null, "b": { "$number": "1" } }) +} + +///| +test "pkg_parser keeps next statement after missing assignment value on next line" { + let source = + #|a = + #|b = 1 + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["expression", "`;` or EOF"]), content={ + "count": 1, + "contains": [true, false], + }) + json_inspect(ast_summary(ast), content={ "a": null, "b": { "$number": "1" } }) +} + +///| +test "pkg_parser rejects unsupported numeric literals" { + let source = + #|unsigned = 1U + #|floaty = 1.5 + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["suffixes", "expression"]), content={ + "count": 2, + "contains": [true, true], + }) + json_inspect(ast_summary(ast), content={ "unsigned": null, "floaty": null }) +} + +///| +test "pkg_parser accepts bigint literal in pkg values" { + let source = + #|big = 1N + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, []), content={ + "count": 0, + "contains": [], + }) + json_inspect(ast_summary(ast), content={ "big": { "$number": "1N" } }) +} + +///| +test "pkg_parser recovers map value after missing colon" { + let source = + #|options(nested: { "a" 1 }) + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["\":\""]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ + "options": { "nested": { "a": { "$number": "1" } } }, + }) +} + +///| +test "pkg_parser keeps next statement after missing array right bracket" { + let source = + #|a = [1 + #|b = 2 + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["`]`"]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ + "a": [{ "$number": "1" }], + "b": { "$number": "2" }, + }) +} + +///| +test "pkg_parser reports missing import right brace at eof" { + let source = + #|import { + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["`}`"]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ "import": [] }) +} + +///| +test "pkg_parser reports missing apply right paren at eof" { + let source = + #|options( + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["`)`"]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ "options": {} }) +} + +///| +test "pkg_parser reports missing array right bracket at eof" { + let source = + #|a = [ + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["`]`"]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ "a": [] }) +} + +///| +test "pkg_parser keeps next statement after missing apply left paren" { + let source = + #|options nested: true + #|next = false + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["\"(\""]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ "options": {}, "next": false }) +} + +///| +test "pkg_parser keeps next statement after missing import left brace" { + let source = + #|import [ "a/pkg" ] + #|next = false + #| + let (ast, reports) = parse_pkg(source) + json_inspect(report_checks(reports, ["\"{\""]), content={ + "count": 1, + "contains": [true], + }) + json_inspect(ast_summary(ast), content={ "import": [], "next": false }) +} + +///| +test "pkg_parser root loc stays ordered for layout-only source" { + let source = + #| + #| + let (ast, reports) = parse_pkg(source) + let loc = ast.loc() + json_inspect( + Json::object( + Map::from_array([ + ("ast", ast_summary(ast)), + ("reports", report_checks(reports, [])), + ("ordered", Json::boolean(loc.start.compare(loc.end) <= 0)), + ]), + ), + content={ + "ast": {}, + "reports": { "count": 0, "contains": [] }, + "ordered": true, + }, + ) +}