diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8b1157..2022a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: RUST_BACKTRACE: full steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dsherret/rust-toolchain-file@v1 - uses: Swatinem/rust-cache@v2 with: @@ -35,32 +35,19 @@ jobs: if: matrix.config.kind == 'test_release' run: cargo test --release --all-features - # CARGO PUBLISH - - name: Cargo login - if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') - run: cargo login ${{ secrets.CRATES_TOKEN }} - - - name: Cargo publish - if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') - run: cargo publish - benchmark: name: Benchmarks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install latest nightly uses: actions-rs/toolchain@v1 with: toolchain: nightly override: true - name: Cache cargo - uses: actions/cache@v4 + uses: Swatinem/rust-cache@v2 with: - path: | - ~/.cargo/registry - ~/.cargo/git - target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} # Run benchmark and stores the output to a file - name: Run benchmark diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9cf7cb5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +name: publish + +on: + push: + tags: + - "*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Clone repository + uses: actions/checkout@v6 + - uses: rust-lang/crates-io-auth-action@v1 + id: auth + - run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7839356..b78affe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GH_DPRINTBOT_PAT }} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 1be126d..711b3a4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.89.0" +channel = "1.92.0" components = ["clippy", "rustfmt"] diff --git a/src/errors.rs b/src/errors.rs index fcfeff4..abd0ad6 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -14,6 +14,7 @@ pub enum ParseErrorKind { ExpectedPlusMinusOrDigitInNumberLiteral, ExpectedStringObjectProperty, HexadecimalNumbersNotAllowed, + ExpectedComma, MultipleRootJsonValues, SingleQuotedStringsNotAllowed, String(ParseStringErrorKind), @@ -59,6 +60,9 @@ impl std::fmt::Display for ParseErrorKind { HexadecimalNumbersNotAllowed => { write!(f, "Hexadecimal numbers are not allowed") } + ExpectedComma => { + write!(f, "Expected comma") + } MultipleRootJsonValues => { write!(f, "Text cannot contain more than one JSON value") } diff --git a/src/lib.rs b/src/lib.rs index 5755a68..70fd3a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,7 +94,8 @@ //! //! ## Parse Strictly as JSON //! -//! Provide `ParseOptions` and set all the options to false: +//! By default this library is extremely loose in what it allows parsing. To be strict, +//! provide `ParseOptions` and set all the options to false: //! //! ``` //! use jsonc_parser::parse_to_value; @@ -106,6 +107,7 @@ //! allow_comments: false, //! allow_loose_object_property_names: false, //! allow_trailing_commas: false, +//! allow_missing_commas: false, //! allow_single_quoted_strings: false, //! allow_hexadecimal_numbers: false, //! allow_unary_plus_numbers: false, diff --git a/src/parse_to_ast.rs b/src/parse_to_ast.rs index a98be25..8b540cd 100644 --- a/src/parse_to_ast.rs +++ b/src/parse_to_ast.rs @@ -55,6 +55,8 @@ pub struct ParseOptions { pub allow_loose_object_property_names: bool, /// Allow trailing commas on object literal and array literal values (defaults to `true`). pub allow_trailing_commas: bool, + /// Allow missing commas between object properties or array elements (defaults to `true`). + pub allow_missing_commas: bool, /// Allow single-quoted strings (defaults to `true`). pub allow_single_quoted_strings: bool, /// Allow hexadecimal numbers like 0xFF (defaults to `true`). @@ -69,6 +71,7 @@ impl Default for ParseOptions { allow_comments: true, allow_loose_object_property_names: true, allow_trailing_commas: true, + allow_missing_commas: true, allow_single_quoted_strings: true, allow_hexadecimal_numbers: true, allow_unary_plus_numbers: true, @@ -102,6 +105,7 @@ struct Context<'a> { collect_comments_as_tokens: bool, allow_comments: bool, allow_trailing_commas: bool, + allow_missing_commas: bool, allow_loose_object_property_names: bool, } @@ -261,6 +265,7 @@ pub fn parse_to_ast<'a>( collect_comments_as_tokens: collect_options.comments == CommentCollectionStrategy::AsTokens, allow_comments: parse_options.allow_comments, allow_trailing_commas: parse_options.allow_trailing_commas, + allow_missing_commas: parse_options.allow_missing_commas, allow_loose_object_property_names: parse_options.allow_loose_object_property_names, }; context.scan()?; @@ -321,13 +326,24 @@ fn parse_object<'a>(context: &mut Context<'a>) -> Result, ParseError> } // skip the comma - if let Some(Token::Comma) = context.scan()? { - let comma_range = context.create_range_from_last_token(); - if let Some(Token::CloseBrace) = context.scan()? - && !context.allow_trailing_commas - { - return Err(context.create_error_for_range(comma_range, ParseErrorKind::TrailingCommasNotAllowed)); + let after_value_end = context.last_token_end; + match context.scan()? { + Some(Token::Comma) => { + let comma_range = context.create_range_from_last_token(); + if let Some(Token::CloseBrace) = context.scan()? + && !context.allow_trailing_commas + { + return Err(context.create_error_for_range(comma_range, ParseErrorKind::TrailingCommasNotAllowed)); + } + } + Some(Token::String(_) | Token::Word(_) | Token::Number(_)) if !context.allow_missing_commas => { + let range = Range { + start: after_value_end, + end: after_value_end, + }; + return Err(context.create_error_for_range(range, ParseErrorKind::ExpectedComma)); } + _ => {} } } @@ -567,6 +583,7 @@ mod tests { allow_comments: false, allow_loose_object_property_names: false, allow_trailing_commas: false, + allow_missing_commas: false, allow_single_quoted_strings: false, allow_hexadecimal_numbers: false, allow_unary_plus_numbers: false, @@ -673,4 +690,49 @@ mod tests { let number_value = obj.properties[0].value.as_number_lit().unwrap(); assert_eq!(number_value.value, "+42"); } + + #[test] + fn missing_comma_between_properties() { + let text = r#"{ + "name": "alice" + "age": 25 +}"#; + let result = parse_to_ast(text, &Default::default(), &Default::default()).unwrap(); + assert_eq!( + result + .value + .unwrap() + .as_object() + .unwrap() + .get_number("age") + .unwrap() + .value, + "25" + ); + + // but is strict when strict + assert_has_strict_error(text, "Expected comma on line 2 column 18"); + } + + #[test] + fn missing_comma_with_comment_between_properties() { + // when comments are allowed but missing commas are not, + // should still detect the missing comma after the comment is skipped + let result = parse_to_ast( + r#"{ + "name": "alice" // comment here + "age": 25 +}"#, + &Default::default(), + &ParseOptions { + allow_comments: true, + allow_missing_commas: false, + ..Default::default() + }, + ); + match result { + Ok(_) => panic!("Expected error, but did not find one."), + Err(err) => assert_eq!(err.to_string(), "Expected comma on line 2 column 18"), + } + } } diff --git a/src/serde.rs b/src/serde.rs index 7c21c73..80c5c80 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -3,7 +3,7 @@ use super::ParseOptions; use super::errors::ParseError; use super::parse_to_ast; -/// Parses a string containing JSONC to a `serde_json::Value. +/// Parses a string containing JSONC to a `serde_json::Value`. /// /// Requires the "serde" cargo feature: /// @@ -16,7 +16,10 @@ use super::parse_to_ast; /// ```rs /// use jsonc_parser::parse_to_serde_value; /// -/// let json_value = parse_to_serde_value(r#"{ "test": 5 } // test"#, &Default::default()).unwrap(); +/// let json_value = parse_to_serde_value( +/// r#"{ "test": 5 } // test"#, +/// &Default::default(), +/// ).unwrap(); /// ``` pub fn parse_to_serde_value(text: &str, parse_options: &ParseOptions) -> Result, ParseError> { let value = parse_to_ast(