feat(formatter_json): Implement oxc_formatter_json (json variant only) (#22641)

Part of #19704

In Prettier, there are 4 variants for JSON formatting.

- json 👈🏻 this
- jsonc
- json5
- json-stringify

Although Prettier calls "JSON", it doesn't strictly adhere to the JSON specification; in practice, it behaves more like JS...

To begin with, it uses `babel.parseExpression()`, which makes the input parsing very lenient. The output also doesn't necessarily conform to the specification—for instance, it allows comments and sometimes omits double quotes on keys.

Because of this, we are re-implementing it using `oxc_parser`. Since `parse_expression()` cannot capture comments, I've achieved this by wrapping the input in `()`.

Also considered adding JSON support to `oxc_formatter`, but I have decided NOT to for now. We may reconsider this once we have bothered on various edge cases.
This commit is contained in:
leaysgur committed 2026-05-29 00:49:50 +00:00
1 parent c323187f37
commit 49db054b6a
14 files changed
+1513

No files matched your search

Generated
+13
View File
@@ -2029,6 +2029,19 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "oxc_formatter_json"
version = "0.51.0"
dependencies = [
"oxc_allocator",
"oxc_ast",
"oxc_diagnostics",
"oxc_formatter_core",
"oxc_parser",
"oxc_span",
"oxc_syntax",
]
[[package]]
name = "oxc_index"
version = "4.1.0"
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "oxc_formatter_json"
version = "0.51.0"
authors.workspace = true
categories.workspace = true
edition.workspace = true
homepage.workspace = true
include = ["/src"]
keywords.workspace = true
license.workspace = true
publish = false
repository.workspace = true
rust-version.workspace = true
description.workspace = true
[lints]
workspace = true
[dependencies]
oxc_allocator = { workspace = true }
oxc_ast = { workspace = true }
oxc_diagnostics = { workspace = true }
oxc_formatter_core = { workspace = true }
oxc_parser = { workspace = true }
oxc_span = { workspace = true }
oxc_syntax = { workspace = true, features = ["to_js_string"] }
[lib]
doctest = false
+230
View File
@@ -0,0 +1,230 @@
use std::cell::Cell;
use oxc_allocator::StringBuilder;
use oxc_ast::Comment;
use oxc_formatter_core::{
Buffer, Format,
builders::{empty_line, hard_line_break, space, text},
write,
};
use oxc_span::Span;
use oxc_syntax::line_terminator::LineTerminatorSplitter;
use crate::{context::JsonFormatContext, print::JsonFormatter};
/// Cursor over a sorted comment list that hands out unprinted slices in span order.
///
/// `cursor` is a [`Cell`] so the API works through `&self`, allowing simultaneous
/// borrows alongside other context fields. The `Format` trait dispatches via `&self`,
/// so a `&mut Comments` accessor would force every drain site to go through
/// `f.context_mut()` and conflict with read-only context accesses.
pub struct Comments<'a> {
inner: &'a [Comment],
cursor: Cell<usize>,
}
impl<'a> Comments<'a> {
pub fn new(comments: &'a [Comment]) -> Self {
Self { inner: comments, cursor: Cell::new(0) }
}
/// Returns unprinted comments whose `span.end <= upper_bound`,
/// and advances the cursor past them so they won't be returned again.
pub fn take_before(&self, upper_bound: u32) -> &'a [Comment] {
let start = self.cursor.get();
let mut end = start;
while end < self.inner.len() && self.inner[end].span.end <= upper_bound {
end += 1;
}
self.cursor.set(end);
&self.inner[start..end]
}
/// Drains all remaining unprinted comments and returns them.
pub fn take_remaining(&self) -> &'a [Comment] {
let start = self.cursor.get();
self.cursor.set(self.inner.len());
&self.inner[start..]
}
/// Iterator over unprinted comments whose `span.end <= upper_bound`.
/// Does NOT advance the cursor, callers that want to mark these as
/// printed must call [`Self::take_before`] instead.
///
/// Mirrors `oxc_formatter::formatter::comments::Comments::comments_before_iter`
/// so suppression / leading-comment checks can compose `.any(...)` / `.next()` directly and short-circuit.
pub fn iter_before(&self, upper_bound: u32) -> impl Iterator<Item = &'a Comment> {
let start = self.cursor.get();
self.inner[start..].iter().take_while(move |c| c.span.end <= upper_bound)
}
}
/// Emit a single comment, re-aligning interior `*`-prefixed lines
/// so the stars line up with the opening `/*` regardless of the source's original indentation.
///
/// Mirrors `oxc_formatter`'s `impl Format for Comment` (`formatter/trivia.rs`):
/// - Single-line comments (line and one-line block) emit as-is (trim trailing whitespace).
/// - Multi-line block comments whose interior lines all start with `*` (an
/// "indentable" / JSDoc-shaped comment) split into lines; the first is emitted
/// trimmed, and each subsequent line as `[hard_line_break, " ", trimmed]` so
/// the surrounding indent context re-indents the stars.
/// - Other multi-line block comments normalize `\r\n` → `\n` but otherwise stay
/// verbatim; their first line still gets its trailing whitespace trimmed.
pub fn write_single_comment(comment: &Comment, f: &mut JsonFormatter<'_, '_>) {
let content = comment.span.source_text(f.context().source_text());
if !comment.is_multiline_block() {
write!(f, text(content.trim_end()));
return;
}
let mut lines = LineTerminatorSplitter::new(content);
if is_alignable_comment(content) {
// `unwrap` is safe because `content` contains at least one line.
let first_line = lines.next().unwrap();
write!(f, text(first_line.trim_end()));
for line in lines {
write!(f, [hard_line_break(), " ", text(line.trim())]);
}
} else {
// Normalize line endings (`\r\n` → `\n`) but otherwise preserve the body.
let mut builder = StringBuilder::with_capacity_in(content.len(), f.allocator());
// `unwrap` is safe because `content` contains at least one line.
builder.push_str(lines.next().unwrap().trim_end());
for line in lines {
builder.push('\n');
builder.push_str(line);
}
write!(f, text(builder.into_str()));
}
}
/// Returns `true` if every line after the first starts with `*`.
/// (after stripping leading whitespace)
/// These comments are "alignable":
/// their interior lines can be re-indented so the stars line up with the opening `/*`.
fn is_alignable_comment(content: &str) -> bool {
LineTerminatorSplitter::new(content).skip(1).all(|line| line.trim_start().starts_with('*'))
}
/// Emit comments that precede an AST value,
/// preserving the source's vertical spacing (0/1/blank) between each comment and the next position.
/// Another comment for in-group separators, or `value_start` for the last comment's break to the value.
pub fn write_leading_comments(
comments: &[Comment],
value_start: u32,
f: &mut JsonFormatter<'_, '_>,
) {
let source = f.context().source_text();
for (i, comment) in comments.iter().enumerate() {
write_single_comment(comment, f);
let next_pos = comments.get(i + 1).map_or(value_start, |c| c.span.start);
write_gap(&source.as_bytes()[comment.span.end as usize..next_pos as usize], f);
}
}
/// Counts `\n` bytes in `slice`.
#[expect(clippy::naive_bytecount, reason = "tiny slice, not worth a bytecount dep")]
pub fn count_newlines(slice: &[u8]) -> usize {
slice.iter().filter(|&&b| b == b'\n').count()
}
/// Emits the formatter element that reproduces the vertical spacing implied by `gap`:
/// `space` for 0 newlines, `hard_line_break` for 1, `empty_line` for 2+ (blank line).
fn write_gap(gap: &[u8], f: &mut JsonFormatter<'_, '_>) {
match count_newlines(gap) {
0 => write!(f, space()),
1 => write!(f, hard_line_break()),
_ => write!(f, empty_line()),
}
}
/// Emit dangling comments inside an empty container (the caller wraps the result in
/// [`oxc_formatter_core::builders::block_indent`] or similar).
pub fn write_dangling_comments(comments: &[Comment], f: &mut JsonFormatter<'_, '_>) {
for (i, comment) in comments.iter().enumerate() {
if i > 0 {
write!(f, hard_line_break());
}
write_single_comment(comment, f);
}
}
/// Emit comments that sit between the last child of a container and its closing delimiter.
///
/// Like [`write_leading_comments`], preserves the source's vertical spacing (0/1/blank)
/// in front of each comment.
/// `lower_bound` is the position immediately after the last emitted content
/// (typically the container's last child's `span.end`)
/// and seeds the gap measurement for the first comment.
pub fn write_trailing_inside_comments(
comments: &[Comment],
lower_bound: u32,
f: &mut JsonFormatter<'_, '_>,
) {
let source = f.context().source_text();
let mut prev_end = lower_bound;
for comment in comments {
write_gap(&source.as_bytes()[prev_end as usize..comment.span.start as usize], f);
write_single_comment(comment, f);
prev_end = comment.span.end;
}
}
/// `Format` adapter that drains and prints all pending comments ending at or before
/// `span.start`. Lets callers replace the 3-line `comments().take_before` + `if !empty`
/// dance with `write!(f, [FormatLeadingComments(span), value])`.
pub struct FormatLeadingComments(pub Span);
impl<'a> Format<'a, JsonFormatContext<'a>> for FormatLeadingComments {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
let leading = f.context().comments().take_before(self.0.start);
write_leading_comments(leading, self.0.start, f);
}
}
/// `Format` adapter that drains comments before `upper_bound`
/// (typically the container's closing-delimiter position) and writes them.
/// `lower_bound` is the position right after the last emitted child
/// so the first comment's gap can be measured for blank-line preservation;
/// pass `upper_bound` when there is no prior child.
pub struct FormatTrailingInsideComments {
pub lower_bound: u32,
pub upper_bound: u32,
}
impl<'a> Format<'a, JsonFormatContext<'a>> for FormatTrailingInsideComments {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
let trailing = f.context().comments().take_before(self.upper_bound);
write_trailing_inside_comments(trailing, self.lower_bound, f);
}
}
/// Returns `true` if `comment` is an ignore marker (`oxfmt-ignore` / `prettier-ignore`).
/// Mirrors `oxc_formatter`'s suppression rule so JSON honors the same authoring convention as JS/TS.
pub fn is_suppression_comment(source: &str, comment: &Comment) -> bool {
let body = comment.content_span().source_text(source);
oxc_formatter_core::util::is_suppression_marker(body)
}
/// Returns `true` if any pending comment up to `before` is a suppression marker.
/// `before` is typically the next AST node's `span.start`.
pub fn is_suppressed_before(f: &JsonFormatter<'_, '_>, before: u32) -> bool {
let source = f.context().source_text();
f.context().comments().iter_before(before).any(|c| is_suppression_comment(source, c))
}
/// `Format` adapter that emits a node's leading comments, then the node's source
/// verbatim, then advances the comment cursor past the span. Used for both
/// `oxfmt-ignore` / `prettier-ignore` suppression and JSON-invalid fallback paths.
pub struct FormatSuppressedNode(pub Span);
impl<'a> Format<'a, JsonFormatContext<'a>> for FormatSuppressedNode {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
write!(f, FormatLeadingComments(self.0));
write!(f, text(self.0.source_text(f.context().source_text())));
// The verbatim text already includes inside-span comments;
// advance the cursor so they aren't re-emitted as leading comments of a later node.
let _ = f.context().comments().take_before(self.0.end);
}
}
+84
View File
@@ -0,0 +1,84 @@
use std::cell::Cell;
use oxc_ast::Comment;
use oxc_diagnostics::OxcDiagnostic;
use oxc_formatter_core::FormatContext;
use oxc_span::Span;
use crate::{comments::Comments, options::JsonFormatOptions};
/// Formatting context for JSON.
pub struct JsonFormatContext<'a> {
options: JsonFormatOptions,
source_code: &'a str,
comments: Comments<'a>,
/// Byte offset within `source_code` where the user's original source begins.
/// `1` on the wrapped parse path (skip the leading `(`), `0` on the bare fallback.
/// Used by [`Self::report_invalid_json`] to report user-visible line/column.
source_offset: u32,
/// First-error slot.
/// `Box` keeps the happy-path field size to a single word.
/// (the Option<Box<...>> is `None` for valid JSON and never allocates).
error: Cell<Option<Box<OxcDiagnostic>>>,
}
impl<'a> JsonFormatContext<'a> {
pub fn new(
options: JsonFormatOptions,
source_code: &'a str,
comments: &'a [Comment],
source_offset: u32,
) -> Self {
Self {
options,
source_code,
comments: Comments::new(comments),
source_offset,
error: Cell::new(None),
}
}
/// Returns the source text with the arena lifetime (vs the trait's borrow-elided `&str`).
/// Slices taken via this method don't have to be re-allocated for `text(...)`.
pub fn source_text(&self) -> &'a str {
self.source_code
}
/// Returns the comment cursor.
pub fn comments(&self) -> &Comments<'a> {
&self.comments
}
/// Records the first invalid-JSON occurrence, subsequent calls are ignored.
/// Since `span` is in `source_code` (wrapped) coordinates,
/// we adjust it by `source_offset` so the error label is in user-source coordinates.
pub fn report_invalid_json(&self, span: Span) {
// Peek-without-consume on `Cell<Option<Box<_>>>`: take and put back if already set.
let existing = self.error.take();
if existing.is_some() {
self.error.set(existing);
return;
}
let user_span = span.move_left(self.source_offset);
self.error.set(Some(Box::new(
OxcDiagnostic::error("This syntax is not allowed in JSON").with_label(user_span),
)));
}
/// Drains and returns any recorded error.
pub fn take_error(&self) -> Option<OxcDiagnostic> {
self.error.take().map(|b| *b)
}
}
impl FormatContext for JsonFormatContext<'_> {
type Options = JsonFormatOptions;
fn options(&self) -> &Self::Options {
&self.options
}
fn source_code(&self) -> &str {
self.source_code
}
}
+87
View File
@@ -0,0 +1,87 @@
use oxc_allocator::Allocator;
use oxc_ast::ast::Expression;
use oxc_diagnostics::OxcDiagnostic;
use oxc_formatter_core::{
Buffer, Document, Format, FormatState, Formatted, VecBuffer,
builders::{hard_line_break, text},
write,
};
use oxc_span::GetSpan;
use oxc_syntax::identifier::ZWNBSP;
use crate::{
comments::write_trailing_inside_comments,
context::JsonFormatContext,
options::JsonFormatOptions,
parse::parse_json,
print::{FmtJsonValue, JsonFormatter},
};
/// Parse `source` as JSON and build its formatter IR.
///
/// # Errors
/// Returns an [`OxcDiagnostic`] when the parser rejects `source`.
pub fn format<'a>(
allocator: &'a Allocator,
source: &str,
options: JsonFormatOptions,
) -> Result<Formatted<'a, JsonFormatContext<'a>>, OxcDiagnostic> {
let parsed = parse_json(allocator, source, options.variant)?;
let context = JsonFormatContext::new(
options,
parsed.wrapped_source,
parsed.comments,
parsed.source_offset,
);
let mut state = FormatState::new(context, allocator);
// TODO: Use `with_capacity` for perf, like `oxc_formatter` does
let mut buffer = VecBuffer::new(&mut state);
// BOM detection runs on the original `source`; `wrapped_source` may prepend `(`.
let has_bom = source.starts_with(ZWNBSP);
write!(&mut buffer, FormatJsonRoot { expression: parsed.expression, has_bom });
let elements = buffer.into_vec();
let context = state.into_context();
if let Some(err) = context.take_error() {
return Err(err);
}
let document = Document::new(elements, Vec::new());
document.propagate_expand();
Ok(Formatted::new(document, context))
}
// ---
/// Emits the root expression (when present) followed by any trailing comments
/// at the end of the document.
struct FormatJsonRoot<'a, 'b> {
expression: Option<&'b Expression<'a>>,
has_bom: bool,
}
impl<'a> Format<'a, JsonFormatContext<'a>> for FormatJsonRoot<'a, '_> {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
if self.has_bom {
write!(f, text("\u{feff}"));
}
let trailing_anchor = if let Some(expression) = self.expression {
FmtJsonValue { expression }.fmt(f);
expression.span().end
} else {
// Comments-only source: emit pending comments from the start of the source
0
};
let trailing = f.context().comments().take_remaining();
write_trailing_inside_comments(trailing, trailing_anchor, f);
// POSIX convention: every formatted file ends with a newline.
// Prettier does the same for all parsers.
write!(f, hard_line_break());
}
}
+25
View File
@@ -0,0 +1,25 @@
//! JSON formatter built on top of `oxc_formatter_core`.
//!
//! ```ignore
//! use oxc_allocator::Allocator;
//! use oxc_formatter_json::{JsonFormatOptions, format};
//!
//! let allocator = Allocator::new();
//! let formatted = format(&allocator, "{\"a\":1}", JsonFormatOptions::default()).unwrap();
//! let out = formatted.print().unwrap().into_code();
//! assert_eq!(out, "{ \"a\": 1 }");
//! ```
mod comments;
mod context;
mod format;
mod options;
mod parse;
mod print;
mod separated;
pub use crate::{
context::JsonFormatContext,
format::format,
options::{Expand, JsonFormatOptions, JsonVariant},
};
+98
View File
@@ -0,0 +1,98 @@
use oxc_formatter_core::{
FormatOptions, IndentStyle, IndentWidth, LineEnding, LineWidth, PrinterOptions,
};
/// JSON parser variant.
///
/// All variants share the same lenient input acceptance:
/// the underlying parser is the JS expression parser, so comments, trailing commas,
/// single quotes, and unquoted keys are all parseable regardless of variant.
/// What differs is the output formatting and a few variant-specific behaviors noted below.
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub enum JsonVariant {
/// Prettier's `parser: json` equivalent.
/// Output: double-quoted strings, quoted object keys,
/// trailing commas forced off (regardless of user option).
#[default]
Json,
/// Prettier's `parser: jsonc` equivalent.
/// Output: double-quoted strings, quoted object keys;
/// trailing commas follow the user option.
/// Empty input is allowed.
Jsonc,
/// Prettier's `parser: json5` equivalent.
/// Output: object keys may stay unquoted,
/// string quote style and trailing commas follow user options.
Json5,
/// Prettier's `parser: json-stringify` equivalent.
/// Output: `JSON.parse()`-compatible.
/// double-quoted strings, quoted keys, no trailing commas,
/// always pretty-printed with hard line breaks between entries.
/// The only variant that rejects comments at parse time.
JsonStringify,
}
/// Whether objects keep their authored multi-line shape or collapse to one line when they fit.
/// Mirrors Prettier's `objectWrap` option for the `json` parser.
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub enum Expand {
/// `objectWrap: "preserve"`.
/// An object stays multi-line if there's a newline after `{` in the source;
/// otherwise it collapses when it fits.
#[default]
Auto,
/// `objectWrap: "collapse"`.
/// Objects collapse when they fit regardless of the authored shape.
Never,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct JsonFormatOptions {
pub indent_style: IndentStyle,
pub indent_width: IndentWidth,
pub line_width: LineWidth,
pub line_ending: LineEnding,
pub variant: JsonVariant,
pub bracket_spacing: bool,
pub expand: Expand,
}
impl Default for JsonFormatOptions {
fn default() -> Self {
Self {
indent_style: IndentStyle::default(),
indent_width: IndentWidth::default(),
line_width: LineWidth::default(),
line_ending: LineEnding::default(),
variant: JsonVariant::default(),
bracket_spacing: true,
expand: Expand::default(),
}
}
}
impl FormatOptions for JsonFormatOptions {
fn indent_style(&self) -> IndentStyle {
self.indent_style
}
fn indent_width(&self) -> IndentWidth {
self.indent_width
}
fn line_width(&self) -> LineWidth {
self.line_width
}
fn line_ending(&self) -> LineEnding {
self.line_ending
}
fn as_print_options(&self) -> PrinterOptions {
PrinterOptions::default()
.with_indent_style(self.indent_style)
.with_indent_width(self.indent_width)
.with_line_ending(self.line_ending)
.with_print_width(self.line_width.into())
}
}
+195
View File
@@ -0,0 +1,195 @@
use oxc_allocator::{Allocator, Vec as ArenaVec};
use oxc_ast::{
Comment,
ast::{Expression, Statement},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_parser::{ParseOptions, Parser};
use oxc_span::SourceType;
use crate::options::JsonVariant;
/// Result of [`parse_json`].
/// All borrows are arena-lifetime,
/// so the parser's `Program` does not need to be kept alive by the caller.
pub struct ParsedJson<'a> {
/// `None` when `source` contains only comments and whitespace.
pub expression: Option<&'a Expression<'a>>,
/// Sorted comments. Spans are in [`Self::wrapped_source`] coordinates.
pub comments: &'a [Comment],
/// Either:
/// - `"(" + source + "\n)"` (normal path)
/// - or the original `source` (comments-only fallback).
///
/// All AST and comment spans index into this.
pub wrapped_source: &'a str,
/// Byte offset within `wrapped_source` where the user's original source begins.
/// `1` on the normal wrapped path (skip the leading `(`), `0` on the bare fallback.
/// Used to map AST spans back to user-visible line/column for diagnostics.
pub source_offset: u32,
}
/// Parse a JSON document into a single arena-resident expression.
///
/// # Errors
/// Returns an [`OxcDiagnostic`] if:
/// - `source` has syntax errors,
/// - or when `variant` rejects the comments present in `source`
/// - see [`validate_comments_for_variant`])
pub fn parse_json<'a>(
allocator: &'a Allocator,
source: &str,
variant: JsonVariant,
) -> Result<ParsedJson<'a>, OxcDiagnostic> {
// JSON object literals like `{"a":1}` are syntax errors when parsed as a JS program
// (the leading `{` starts a `BlockStatement`),
// so we wrap the source in `(...)` to force expression context.
// `preserve_parens: false` keeps the wrapping `ParenthesizedExpression` out of the AST.
// The trailing `\n` before `)` prevents a closing paren
// from being swallowed by a trailing line comment in `source`.
let wrapped_source: &'a str = allocator.alloc_concat_strs_array(["(", source, "\n)"]);
let options = ParseOptions { preserve_parens: false, ..ParseOptions::default() };
let ret =
Parser::new(allocator, wrapped_source, SourceType::default()).with_options(options).parse();
// If the wrapped parse fails,
// we retry without the wrap and accept the result only when it contains no statements.
// i.e. `source` is comments / whitespace only.
// This lets comment-only JSON files round-trip without changing the normal path's cost.
if !ret.errors.is_empty() || ret.panicked {
if let Some(parsed) = try_parse_comments_only(allocator, source, options) {
validate_comments_for_variant(variant, parsed.comments, false)?;
return Ok(parsed);
}
if let Some(err) = ret.errors.into_iter().next() {
return Err(err);
}
return Err(OxcDiagnostic::error("Failed to parse JSON source"));
}
let mut program = ret.program;
validate_comments_for_variant(variant, &program.comments, true)?;
// `Vec::into_arena_slice` consumes the stack-resident `Vec` header and
// exposes its arena-resident storage as `&'a [_]`.
// This is needed so neither the returned expression reference
// nor the comments slice borrow from the local `program`.
let comments =
std::mem::replace(&mut program.comments, ArenaVec::new_in(allocator)).into_arena_slice();
let body: &'a [Statement<'a>] =
std::mem::replace(&mut program.body, ArenaVec::new_in(allocator)).into_arena_slice();
// The wrap source guarantees exactly one top-level `ExpressionStatement`
let stmt = body.first().ok_or_else(|| OxcDiagnostic::error("Empty JSON source"))?;
let Statement::ExpressionStatement(expr_stmt) = stmt else {
return Err(OxcDiagnostic::error("Expected a single expression at the top level"));
};
Ok(ParsedJson {
expression: Some(&expr_stmt.expression),
comments,
wrapped_source,
source_offset: 1,
})
}
/// Fallback path for the wrapped-parse failure:
/// try parsing `source` as-is and accept it only when there are no statements.
/// (i.e. comments / whitespace only)
fn try_parse_comments_only<'a>(
allocator: &'a Allocator,
source: &str,
options: ParseOptions,
) -> Option<ParsedJson<'a>> {
// `Parser::new` ties `source_text` to the arena lifetime;
// Copy `source` into the arena so the resulting comment spans index into a string that outlives `ret`.
let bare_source: &'a str = allocator.alloc_str(source);
let ret =
Parser::new(allocator, bare_source, SourceType::default()).with_options(options).parse();
if !ret.errors.is_empty() || ret.panicked || !ret.program.body.is_empty() {
return None;
}
let mut program = ret.program;
let comments =
std::mem::replace(&mut program.comments, ArenaVec::new_in(allocator)).into_arena_slice();
Some(ParsedJson { expression: None, comments, wrapped_source: bare_source, source_offset: 0 })
}
/// Reject comments according to Prettier's per-variant rules.
/// - `Json` and `Json5`: comments are allowed,
/// but a source consisting of only comments (no value expression) is an error
/// - `Jsonc`: comments are always allowed, including comments-only sources
/// - `JsonStringify`: comments are never allowed
fn validate_comments_for_variant(
variant: JsonVariant,
comments: &[Comment],
has_expression: bool,
) -> Result<(), OxcDiagnostic> {
if comments.is_empty() {
return Ok(());
}
match variant {
JsonVariant::Jsonc => Ok(()),
JsonVariant::Json | JsonVariant::Json5 => {
if has_expression {
Ok(())
} else {
Err(OxcDiagnostic::error("The input is empty or contains only comments"))
}
}
JsonVariant::JsonStringify => {
if has_expression {
Err(OxcDiagnostic::error("Comment is not allowed in JSON"))
} else {
Err(OxcDiagnostic::error("The input is empty or contains only comments"))
}
}
}
}
#[cfg(test)]
mod tests {
use oxc_allocator::Allocator;
use super::parse_json;
use crate::options::JsonVariant::{Json, Json5, JsonStringify, Jsonc};
#[test]
fn variant_comment_acceptance() {
// (src, variant, should_succeed)
let cases = [
// empty input is accepted by all variants
("", Json, true),
("", Jsonc, true),
("", Json5, true),
("", JsonStringify, true),
(" \n", Json, true),
(" \n", Jsonc, true),
(" \n", Json5, true),
(" \n", JsonStringify, true),
// comments-only is accepted only by Jsonc
("// hi\n", Json, false),
("// hi\n", Jsonc, true),
("// hi\n", Json5, false),
("// hi\n", JsonStringify, false),
// value + comments is rejected only by JsonStringify
(r#"{"a":1}//c"#, Json, true),
(r#"{"a":1}//c"#, Jsonc, true),
(r#"{"a":1}//c"#, Json5, true),
(r#"{"a":1}//c"#, JsonStringify, false),
];
for (src, variant, should_succeed) in cases {
let allocator = Allocator::default();
let result = parse_json(&allocator, src, variant);
assert_eq!(result.is_ok(), should_succeed, "src={src:?} variant={variant:?}");
}
}
}
@@ -0,0 +1,202 @@
use oxc_ast::ast::{ArrayExpression, ArrayExpressionElement, Expression};
use oxc_formatter_core::{
Buffer, Format,
builders::{
block_indent, empty_line, group, soft_block_indent, soft_line_break_or_space, text,
},
write,
};
use oxc_span::GetSpan;
use crate::{
comments::{FormatTrailingInsideComments, write_dangling_comments},
context::JsonFormatContext,
separated::{TrailingSeparator, blank_line_after_comma, write_separated},
};
use super::{FmtJsonValue, FormatInvalidJson, JsonFormatter, format_with};
pub struct FmtJsonArray<'a, 'b> {
pub array: &'b ArrayExpression<'a>,
}
impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonArray<'a, '_> {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
write!(f, "[");
if self.array.elements.is_empty() {
let dangling = f.context().comments().take_before(self.array.span.end);
if dangling.is_empty() {
write!(f, "]");
return;
}
let inner = format_with(move |f: &mut JsonFormatter<'_, 'a>| {
write_dangling_comments(dangling, f);
});
write!(f, [block_indent(&inner), "]"]);
return;
}
// Sparse-only arrays like `[,]`, `[, , ,]`
// would produce empty fill/group entries (Elision emits nothing).
// Group builders assert on empty content, so emit the source slice verbatim
// which already captures the user's holes.
if self.array.elements.iter().all(|el| matches!(el, ArrayExpressionElement::Elision(_))) {
let inner_start = (self.array.span.start + 1) as usize;
let inner_end = (self.array.span.end - 1) as usize;
let source = f.context().source_text();
if inner_end > inner_start && inner_end <= source.len() {
write!(f, text(&source[inner_start..inner_end]));
}
write!(f, "]");
return;
}
// When the last element is an Elision (e.g. `[1, , 2,,,,]`),
// the trailing `,` is load-bearing for the hole count.
// Otherwise (`[1, 2, 3,]`) the trailing comma is just JSON5 syntax that Prettier's `json` parser strips.
let trailing_comma =
matches!(self.array.elements.last(), Some(ArrayExpressionElement::Elision(_)));
// Fill packs multiple numeric entries on one line.
// The trailing `,` lives on the item (mirrors Prettier's `printArrayElementsConcisely`)
// so the fill measurer counts it toward "does this item fit on the current line?".
// Without that, the comma trailing the last on-line item gets pushed past `line_width`.
// The separator carries only the break (or `empty_line` to preserve a user blank).
if can_concisely_print(&self.array.elements) {
let elements = &self.array.elements;
let source = f.context().source_text();
let last_idx = elements.len() - 1;
let body = format_with(|f: &mut JsonFormatter<'_, 'a>| {
let mut filler = f.fill();
let mut prev_end: Option<u32> = None;
for (i, element) in elements.iter().enumerate() {
let curr_start = element.span().start;
let pe = prev_end;
let sep = format_with(move |f: &mut JsonFormatter<'_, 'a>| {
if let Some(pe_val) = pe {
let between = &source[pe_val as usize..curr_start as usize];
if blank_line_after_comma(between.as_bytes()) {
write!(f, empty_line());
} else {
write!(f, soft_line_break_or_space());
}
}
});
let item = format_with(move |f: &mut JsonFormatter<'_, 'a>| {
write_array_element(element, f);
if i < last_idx {
write!(f, ",");
}
});
filler.entry(&sep, &item);
prev_end = Some(element.span().end);
}
filler.finish();
// `elements` is non-empty in this branch (the empty-array early return is above),
// so `last()` cannot be `None`.
let last_end = elements.last().expect("non-empty elements").span().end;
write!(
f,
FormatTrailingInsideComments {
lower_bound: last_end,
upper_bound: self.array.span.end,
}
);
});
write!(f, [group(&soft_block_indent(&body)), "]"]);
return;
}
let spans: Vec<_> = self.array.elements.iter().map(oxc_span::GetSpan::span).collect();
let elements = format_with(|f: &mut JsonFormatter<'_, 'a>| {
write_separated(f, &spans, TrailingSeparator::Disallowed, |i, f| {
write_array_element(&self.array.elements[i], f);
});
if trailing_comma {
write!(f, ",");
}
let last_end = spans.last().expect("non-empty elements").end;
write!(
f,
FormatTrailingInsideComments {
lower_bound: last_end,
upper_bound: self.array.span.end,
}
);
});
// Mirrors Prettier's `shouldBreak` heuristic for arrays of arrays/objects
// (`language-js/print/array.js`): force-expand only when there's > 1 element,
// every element is the same kind of composite (all arrays or all objects),
// and every element has at least 2 inner items.
// Matrices and arrays-of-records qualify;
// `[[1, 2], [3]]` or a single-element wrapper does not.
let expand = should_force_expand(&self.array.elements);
write!(f, [group(&soft_block_indent(&elements)).should_expand(expand), "]"]);
}
}
fn should_force_expand(elements: &[ArrayExpressionElement<'_>]) -> bool {
if elements.len() < 2 {
return false;
}
let mut prev_is_array: Option<bool> = None;
for el in elements {
let (is_array, inner_len) = match el.as_expression() {
Some(Expression::ArrayExpression(a)) => (true, a.elements.len()),
Some(Expression::ObjectExpression(o)) => (false, o.properties.len()),
_ => return false,
};
if inner_len < 2 {
return false;
}
if let Some(prev) = prev_is_array
&& prev != is_array
{
return false;
}
prev_is_array = Some(is_array);
}
true
}
/// Writes one array element.
/// Elision (`[1, , 2]` holes) emits nothing,
/// the surrounding separator alone represents the hole.
/// `SpreadElement` isn't valid JSON;
/// we record a diagnostic and emit its source slice verbatim.
fn write_array_element<'a>(element: &ArrayExpressionElement<'a>, f: &mut JsonFormatter<'_, 'a>) {
if matches!(element, ArrayExpressionElement::Elision(_)) {
return;
}
if let Some(expr) = element.as_expression() {
FmtJsonValue { expression: expr }.fmt(f);
} else {
// The only remaining variant (after `Elision` and `Expression`) is `SpreadElement`.
write!(f, FormatInvalidJson(element.span()));
}
}
/// Returns `true` if the array elements are all numeric literals and can be "fill-printed".
///
/// Accepts both bare numbers (`1`, `0.5`) and signed numbers (`-1`, `+0.5`),
/// the JS parser models the latter as `UnaryExpression(±, NumericLiteral)`,
/// so omitting it would knock common JSON shapes like coordinate or bounding arrays out of fill mode.
fn can_concisely_print(elements: &[ArrayExpressionElement<'_>]) -> bool {
if elements.is_empty() {
return false;
}
elements.iter().all(|el| {
let Some(expr) = el.as_expression() else { return false };
match expr {
Expression::NumericLiteral(_) => true,
Expression::UnaryExpression(u) if u.operator.is_arithmetic() => {
matches!(u.argument, Expression::NumericLiteral(_))
}
_ => false,
}
})
}
@@ -0,0 +1,75 @@
use std::borrow::Cow;
use oxc_ast::ast::{NumericLiteral, StringLiteral};
use oxc_formatter_core::{
Buffer, Format,
builders::text,
util::{NumberFormatOptions, Quote, format_trimmed_number, normalize_string},
write,
};
use crate::context::JsonFormatContext;
use super::{JsonFormatter, arena_cow_str};
pub struct FmtJsonString<'a, 'b> {
pub lit: &'b StringLiteral<'a>,
}
impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonString<'a, '_> {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
// The parser always populates `raw` (see `oxc_parser::js::expression`)
let raw = self.lit.raw.as_ref().unwrap_or_else(|| unreachable!("parser always sets `raw`"));
let raw_str = raw.as_str();
// Detect the outer quote.
// Anything unexpected (no surrounding quotes, mismatched delimiters) falls back to verbatim source.
let Some(outer_quote) = outer_quote_of(raw_str) else {
write!(f, text(raw_str));
return;
};
let inner = &raw_str[1..raw_str.len() - 1];
let quotes_will_change = outer_quote != Quote::Double;
// Prettier's `json` parser normalizes string quotes to `"` and `\r\n` / lone `\r` to `\n`.
// The shared `normalize_string` handles both.
let normalized = normalize_string(inner, Quote::Double, quotes_will_change);
// Fast path: outer was already `"` and the body wasn't rewritten.
if !quotes_will_change && matches!(normalized, Cow::Borrowed(_)) {
write!(f, text(raw_str));
return;
}
write!(f, [text("\""), text(arena_cow_str(normalized, f)), text("\"")]);
}
}
/// Returns the outer quote of `raw`
/// if it's a valid quoted-string literal (matching `'…'` or `"…"`),
/// otherwise `None`.
fn outer_quote_of(raw: &str) -> Option<Quote> {
let bytes = raw.as_bytes();
if bytes.len() < 2 || bytes[0] != bytes[bytes.len() - 1] {
return None;
}
Quote::from_byte(bytes[0])
}
pub struct FmtJsonNumber<'a, 'b> {
pub lit: &'b NumericLiteral<'a>,
}
impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonNumber<'a, '_> {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
let raw = self.lit.raw.as_ref().unwrap_or_else(|| unreachable!("parser always sets `raw`"));
// Apply Prettier's number normalization: `.1` → `0.1`, `1.0e+2` → `1.0e2`,
// `1e00` → `1`, `1.00000` → `1.0`, trailing `.` removal, etc.
// `keep_one_trailing_decimal_zero` matches Prettier's JS/JSON behavior (`1.00000` → `1.0`, not `1`).
let normalized = format_trimmed_number(
raw.as_str(),
NumberFormatOptions::keep_one_trailing_decimal_zero(),
);
write!(f, text(arena_cow_str(normalized, f)));
}
}
+103
View File
@@ -0,0 +1,103 @@
use std::borrow::Cow;
use oxc_ast::ast::Expression;
use oxc_formatter_core::{Buffer, Format, Formatter, builders::FormatWith, write};
use oxc_span::{GetSpan, Span};
use crate::{
comments::{FormatLeadingComments, FormatSuppressedNode, is_suppressed_before},
context::JsonFormatContext,
};
pub mod array;
pub mod literal;
pub mod object;
pub type JsonFormatter<'buf, 'a> = Formatter<'buf, 'a, JsonFormatContext<'a>>;
/// `Format` impl for `&'static str` specialized to `JsonFormatContext`.
///
/// Hardcoded to `JsonFormatContext` rather than generic over `C` so the blanket
/// `&T where T: Format` doesn't overlap (`str` doesn't impl `Format` for any C).
impl<'a> Format<'a, JsonFormatContext<'a>> for &'static str {
#[inline]
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
write!(f, oxc_formatter_core::builders::token(self));
}
}
/// Lifts a `Cow<'a, str>` to `&'a str`, allocating in the arena only for the owned case.
/// Borrowed Cows already point into arena-resident source, so they pass through unchanged.
pub fn arena_cow_str<'a>(cow: Cow<'a, str>, f: &JsonFormatter<'_, 'a>) -> &'a str {
match cow {
Cow::Borrowed(s) => s,
Cow::Owned(s) => f.allocator().alloc_str(&s),
}
}
/// Wraps a re-entrant JSON closure in a [`FormatWith`]. The closure's context is
/// pinned to [`JsonFormatContext`] so call sites don't have to annotate it.
#[inline]
pub const fn format_with<'a, T>(formatter: T) -> FormatWith<T>
where
T: Fn(&mut JsonFormatter<'_, 'a>),
{
FormatWith::new(formatter)
}
/// Top-level wrapper around an [`Expression`]. Dispatches by variant.
///
/// Drains any leading comments that precede `expression.span.start` and emits them before
/// the value itself.
pub struct FmtJsonValue<'a, 'b> {
pub expression: &'b Expression<'a>,
}
impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonValue<'a, '_> {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
let span = self.expression.span();
if is_suppressed_before(f, span.start) {
write!(f, FormatSuppressedNode(span));
return;
}
write!(f, FormatLeadingComments(span));
match self.expression {
Expression::NullLiteral(_) => write!(f, "null"),
Expression::BooleanLiteral(lit) => {
write!(f, if lit.value { "true" } else { "false" });
}
Expression::NumericLiteral(lit) => literal::FmtJsonNumber { lit }.fmt(f),
Expression::StringLiteral(lit) => literal::FmtJsonString { lit }.fmt(f),
Expression::ArrayExpression(arr) => {
array::FmtJsonArray { array: arr }.fmt(f);
}
Expression::ObjectExpression(obj) => {
object::FmtJsonObject { object: obj }.fmt(f);
}
// `-9876.54321`, `+123`, `-Infinity`, etc. Prettier's `json` parser routes
// through the JS estree printer, which keeps both `+` and `-` operators
// while recursing into the argument so the inner number is normalized
// (`-1.0e+2` → `-1.0e2`).
Expression::UnaryExpression(unary) => {
write!(f, unary.operator.as_str());
FmtJsonValue { expression: &unary.argument }.fmt(f);
}
_ => write!(f, FormatInvalidJson(span)),
}
}
}
/// `Format` adapter for JSON-invalid AST nodes: records a diagnostic via
/// [`JsonFormatContext::report_invalid_json`] and emits the node's source verbatim
/// via [`FormatSuppressedNode`] so IR construction can still complete.
pub struct FormatInvalidJson(pub Span);
impl<'a> Format<'a, JsonFormatContext<'a>> for FormatInvalidJson {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
f.context().report_invalid_json(self.0);
write!(f, FormatSuppressedNode(self.0));
}
}
@@ -0,0 +1,176 @@
use oxc_ast::ast::{ObjectExpression, ObjectPropertyKind, PropertyKey};
use oxc_formatter_core::{
Buffer, Format, FormatContext,
builders::{block_indent, group, soft_block_indent_with_maybe_space, space, text},
util::{NumberFormatOptions, format_trimmed_number, is_simple_number},
write,
};
use oxc_span::GetSpan;
use oxc_syntax::number::ToJsString;
use crate::{
comments::{
FormatLeadingComments, FormatSuppressedNode, FormatTrailingInsideComments,
is_suppressed_before, write_dangling_comments,
},
context::JsonFormatContext,
options::Expand,
};
use crate::separated::{TrailingSeparator, write_separated};
use super::{
FmtJsonValue, FormatInvalidJson, JsonFormatter, arena_cow_str, format_with,
literal::FmtJsonString,
};
pub struct FmtJsonObject<'a, 'b> {
pub object: &'b ObjectExpression<'a>,
}
impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonObject<'a, '_> {
fn fmt(&self, f: &mut JsonFormatter<'_, 'a>) {
write!(f, "{");
if self.object.properties.is_empty() {
let dangling = f.context().comments().take_before(self.object.span.end);
if dangling.is_empty() {
write!(f, "}");
return;
}
let inner = format_with(move |f: &mut JsonFormatter<'_, 'a>| {
write_dangling_comments(dangling, f);
});
write!(f, [block_indent(&inner), "}"]);
return;
}
// Collect property spans up-front for blank-line detection
let spans: Vec<_> = self.object.properties.iter().map(oxc_span::GetSpan::span).collect();
let properties = format_with(|f: &mut JsonFormatter<'_, 'a>| {
write_separated(f, &spans, TrailingSeparator::Disallowed, |i, f| {
let property = &self.object.properties[i];
match property {
ObjectPropertyKind::ObjectProperty(prop) => {
if is_suppressed_before(f, prop.span.start) {
write!(f, FormatSuppressedNode(prop.span));
} else {
write!(f, FormatLeadingComments(prop.span));
write_object_key(&prop.key, f);
write!(f, [":", space()]);
FmtJsonValue { expression: &prop.value }.fmt(f);
}
}
ObjectPropertyKind::SpreadProperty(spread) => {
write!(f, FormatInvalidJson(spread.span));
}
}
});
// `properties` is non-empty here, the empty-object early return is above
let last_end = spans.last().expect("non-empty properties").end;
write!(
f,
FormatTrailingInsideComments {
lower_bound: last_end,
upper_bound: self.object.span.end,
}
);
});
// `bracketSpacing` (default: true) puts a single space inside braces when
// the group fits on one line.
//
// `objectWrap` controls the multi-line shape preservation: with `Auto` (preserve),
// an object whose source has a newline right after `{` stays expanded.
// With `Never` (collapse), the group decides purely by width.
// The array side does NOT do this — only objects.
let options = f.context().options();
let expand = match options.expand {
Expand::Auto => opens_on_new_line(self.object, f.context().source_text()),
Expand::Never => false,
};
write!(
f,
[
group(&soft_block_indent_with_maybe_space(&properties, options.bracket_spacing))
.should_expand(expand),
"}"
]
);
}
}
/// Returns `true` if the source between `{` and the first property contains a newline.
///
/// Mirrors Prettier's `language-js` estree-printer trigger for forcing multi-line
/// object output: `{<NL>...` keeps the object expanded, `{prop, prop}` stays inline.
fn opens_on_new_line(object: &ObjectExpression<'_>, source: &str) -> bool {
let after_open = object.span.start as usize + 1;
let body_start =
object.properties.first().map_or(object.span.end as usize, |p| p.span().start as usize);
if body_start <= after_open || body_start > source.len() {
return false;
}
source[after_open..body_start].contains('\n')
}
/// Emits an object property's key, applying Prettier's `json`-parser conventions.
///
/// Identifier keys (`a:`, `null:`, `true:`) are always quoted: `"a":`/`"null":`/`"true":`.
///
/// Numeric keys are normalized via [`format_trimmed_number`];
/// whether the result is quoted follows Prettier's `shouldQuotePropertyKey` rule
/// (mirrors `language-js/print/property.js:shouldQuotePropertyKey`):
/// a number key is quoted only when its normalized form is:
/// - a "simple number"
/// - AND round-trips through `f64` losslessly.
///
/// Otherwise it's emitted bare.
/// Examples (raw → emitted):
/// - `0` → `"0":`
/// - `0.1` → `"0.1":`
/// - `1.0` → `1.0:` (round-trip via f64 gives `1`, mismatch → unquoted)
/// - `1.00000` → `1.0:`
/// - `1e2` → `1e2:` (not a simple number after normalization)
/// - `0xdecaf` → `0xdecaf:`
fn write_object_key<'a>(key: &PropertyKey<'a>, f: &mut JsonFormatter<'_, 'a>) {
match key {
PropertyKey::StringLiteral(lit) => FmtJsonString { lit: lit.as_ref() }.fmt(f),
PropertyKey::StaticIdentifier(ident) => {
write!(f, [text("\""), text(ident.name.as_str()), text("\"")]);
}
PropertyKey::NumericLiteral(lit) => {
let raw = lit.raw.as_ref().map_or("", oxc_ast::ast::Str::as_str);
let printed =
format_trimmed_number(raw, NumberFormatOptions::keep_one_trailing_decimal_zero());
let printed_str = arena_cow_str(printed, f);
if should_quote_numeric_key(printed_str) {
write!(f, [text("\""), text(printed_str), text("\"")]);
} else {
write!(f, text(printed_str));
}
}
_ => write!(f, FormatInvalidJson(key.span())),
}
}
/// Returns `true` if a normalized numeric key should be wrapped in double quotes.
///
/// Matches Prettier's `shouldQuotePropertyKey` for the `json`/`jsonc` parsers:
/// the key is quoted only when both
/// 1. the printed form is a "simple number" (`\d+` or `\d+\.\d+`), and
/// 2. it equals `String(Number(printed))` — i.e. survives an f64 round-trip without changing shape.
///
/// This keeps `0`/`0.1` quoted while leaving `1e2`/`1.0`/`0xdecaf`/
/// `999999999999999999999999999999`/`0.000...001` (which lose precision or
/// change shape under JS `String(Number)`) unquoted.
fn should_quote_numeric_key(printed: &str) -> bool {
if !is_simple_number(printed) {
return false;
}
let Ok(v) = printed.parse::<f64>() else { return false };
v.to_js_string() == printed
}
+195
View File
@@ -0,0 +1,195 @@
//! Light-weight separated-list helper for JSON,
//! modelled after `oxc_formatter::formatter::separated::FormatSeparatedIter` but trimmed for JSON's needs:
//! - only one separator character (`,`),
//! - one inter-entry break style (`soft_line_break_or_space`),
//! - and an optional trailing separator that only materializes when the surrounding group breaks
use oxc_formatter_core::{
Buffer,
builders::{
empty_line, hard_line_break, if_group_breaks, line_suffix, soft_line_break_or_space, space,
},
write,
};
use oxc_span::Span;
use crate::{
comments::{count_newlines, write_single_comment},
print::{JsonFormatter, format_with},
};
/// Whether a trailing `,` should follow the last entry.
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub enum TrailingSeparator {
/// Never emit a trailing separator. Used for the `json` variant.
#[default]
Disallowed,
/// Emit `,` only when the enclosing group breaks (multi-line).
#[expect(dead_code)] // Reserved for future `jsonc` / `json5` variants.
AllowedWhenBreaking,
}
/// Writes entries with `, ` (or a soft line break) between them.
/// If the source between consecutive entries contains a blank line,
/// that blank line is preserved in the output.
/// This matches Prettier's behavior of keeping user-authored grouping.
///
/// `emit_entry(index, f)` is invoked once per entry; the first call receives no leading separator.
/// `spans` provides per-entry source spans used for the blank-line detection.
pub fn write_separated<'a, F>(
f: &mut JsonFormatter<'_, 'a>,
spans: &[Span],
trailing: TrailingSeparator,
mut emit_entry: F,
) where
F: FnMut(usize, &mut JsonFormatter<'_, 'a>),
{
let count = spans.len();
for i in 0..count {
emit_entry(i, f);
if i + 1 < count {
write_inter_entry_separator(spans[i], spans[i + 1], f);
}
}
if count > 0 {
match trailing {
TrailingSeparator::Disallowed => {}
TrailingSeparator::AllowedWhenBreaking => {
write!(f, if_group_breaks(&","));
}
}
}
}
/// Writes the `,` separator between two entries,
/// threading any "trailing-of-prev" comments around the comma.
///
/// Mirrors Prettier's comment placement:
/// a comment that physically sits on the same line as the previous entry's value end is
/// treated as trailing on that entry, not as leading on the next.
/// - block comments emit *before* the comma (`value /* block */,`)
/// - a line comment emits *after* the comma followed by a hard line break
/// (`value, // line\n`); since line comments terminate the line,
/// at most one can appear per gap and it's necessarily the last
///
/// Own-line comments (newline between prev value end and comment start) stay
/// pending so the next entry's leading-comment pass picks them up.
fn write_inter_entry_separator(prev: Span, curr: Span, f: &mut JsonFormatter<'_, '_>) {
// Same-line trailing-of-prev comments are exactly those with no preceding newline
let trailing_end = f
.context()
.comments()
.iter_before(curr.start)
.take_while(|c| !c.preceded_by_newline())
.last()
.map(|c| c.span.end);
let Some(trailing_end) = trailing_end else {
write!(f, ",");
write_break_after(prev.end, curr.start, f);
return;
};
// Drain only the trailing-of-prev comments;
// the rest stay pending so the next entry's `FormatLeadingComments` picks them up.
let trailing_comments = f.context().comments().take_before(trailing_end);
// A line comment can only be the last entry in the group (its `\n` ends the line)
let (block_comments, line_comment) = match trailing_comments.last() {
Some(c) if c.is_line() => (&trailing_comments[..trailing_comments.len() - 1], Some(c)),
_ => (trailing_comments, None),
};
for c in block_comments {
write!(f, space());
write_single_comment(c, f);
}
write!(f, ",");
if let Some(lc) = line_comment {
// Defer the line comment via `line_suffix`,
// so its width doesn't count against the preceding value's group budget.
// Without this, `"k": [a, b], // long...`
// would force the array to expand even when it fits on its own.
let lc = *lc;
let suffix = format_with(move |f: &mut JsonFormatter<'_, '_>| {
write!(f, space());
write_single_comment(&lc, f);
});
write!(f, line_suffix(&suffix));
// Promote to `empty_line` when the source preserves a blank line after the trailing comment;
// otherwise a hard break (also flushes the line_suffix).
if has_blank_line(lc.span.end, curr.start, f) {
write!(f, empty_line());
} else {
write!(f, hard_line_break());
}
} else {
write_break_after(trailing_end, curr.start, f);
}
}
/// Emits `empty_line()` when:
/// the gap from `start` to the next own-line content has a user-authored blank line,
/// otherwise `soft_line_break_or_space()`.
///
/// `start` is `prev.end` in the no-trailing-comments path (gap still contains the source `,`)
/// and `trailing_end` after trailing blocks (gap is past the source `,`).
/// The blank-line policy differs between those two cases, but the bounding logic is shared.
fn write_break_after(start: u32, curr_start: u32, f: &mut JsonFormatter<'_, '_>) {
if has_blank_line(start, curr_start, f) {
write!(f, empty_line());
} else {
write!(f, soft_line_break_or_space());
}
}
/// Returns `true` if the source gap from `start` to the next own-line content
/// (first pending leading comment of `curr` or `curr_start`) contains a blank line.
///
/// When the gap still includes the source `,` (no-trailing-comments path),
/// newlines before that comma are ignored (`1\n,2` is layout style, not grouping).
/// After the comma the slice doesn't contain it and we count raw newlines.
fn has_blank_line(start: u32, curr_start: u32, f: &JsonFormatter<'_, '_>) -> bool {
let Some(slice) = gap_slice(start, curr_start, f) else { return false };
if slice.contains(&b',') { blank_line_after_comma(slice) } else { count_newlines(slice) >= 2 }
}
/// Returns the source byte slice from `start` to the first pending leading comment of `curr`
/// (or `curr_start` if none).
/// Bounding by the first leading comment is what prevents newlines *inside* comment bodies
/// from being misread as blank-line markers in the inter-entry gap.
fn gap_slice<'a>(start: u32, curr_start: u32, f: &JsonFormatter<'_, 'a>) -> Option<&'a [u8]> {
let source = f.context().source_text();
let start_usize = start as usize;
let end = f
.context()
.comments()
.iter_before(curr_start)
.find(|c| c.span.start >= start)
.map_or(curr_start as usize, |c| c.span.start as usize);
if end <= start_usize || end > source.len() {
return None;
}
Some(&source.as_bytes()[start_usize..end])
}
/// Returns `true` if `between` (the source slice between two adjacent entries) places a blank line
/// after the separator comma.
/// Newlines before the comma (e.g. `1\n,2`) don't count,
/// they represent the user spacing the comma off from the value, not grouping entries.
pub fn blank_line_after_comma(between: &[u8]) -> bool {
let mut after_comma = false;
let mut newlines = 0;
for &b in between {
if !after_comma {
if b == b',' {
after_comma = true;
}
} else if b == b'\n' {
newlines += 1;
}
}
newlines >= 2
}
+1
View File
@@ -33,5 +33,6 @@ versioned_files = [
"apps/oxfmt/package.json",
"crates/oxc_formatter_core/Cargo.toml",
"crates/oxc_formatter/Cargo.toml",
"crates/oxc_formatter_json/Cargo.toml",
"npm/oxfmt/package.json",
]