Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions crates/oxc_formatter_json/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 changes: 230 additions & 0 deletions crates/oxc_formatter_json/src/comments.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 84 additions & 0 deletions crates/oxc_formatter_json/src/context.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading