2025-12-14 04:17:23 +08:00
|
|
|
import { hardline, line, softline } from "../../document/index.js";
|
2022-10-19 17:40:59 +09:00
|
|
|
import {
|
2026-08-16 18:47:24 +08:00
|
|
|
isNewLine,
|
2025-12-26 19:43:49 +08:00
|
|
|
isSetextHeading,
|
2022-10-19 17:40:59 +09:00
|
|
|
KIND_CJ_LETTER,
|
2023-12-17 19:34:12 +00:00
|
|
|
KIND_CJK_PUNCTUATION,
|
2022-10-19 17:40:59 +09:00
|
|
|
KIND_K_LETTER,
|
|
|
|
|
KIND_NON_CJK,
|
2025-12-14 04:17:23 +08:00
|
|
|
} from "../utilities.js";
|
2022-10-19 17:40:59 +09:00
|
|
|
|
|
|
|
|
/**
|
2025-12-14 04:17:23 +08:00
|
|
|
* @import {WordNode, WhitespaceValue, WordKind} from "../utilities.js"
|
|
|
|
|
* @import AstPath from "../../common/ast-path.js"
|
2022-10-19 17:40:59 +09:00
|
|
|
* @typedef {"always" | "never" | "preserve"} ProseWrap
|
|
|
|
|
* @typedef {{ next?: WordNode | null, previous?: WordNode | null }}
|
|
|
|
|
* AdjacentNodes Nodes adjacent to a `whitespace` node. Are always of type
|
|
|
|
|
* `word`.
|
|
|
|
|
*/
|
|
|
|
|
|
2025-12-26 19:43:49 +08:00
|
|
|
const SINGLE_LINE_NODE_TYPES = new Set(["tableCell", "link", "wikiLink"]);
|
2022-10-19 17:40:59 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A line break between a character from this set and CJ can be converted to a
|
|
|
|
|
* space. Includes only ASCII punctuation marks for now.
|
|
|
|
|
*/
|
|
|
|
|
const lineBreakBetweenTheseAndCJConvertsToSpace = new Set(
|
|
|
|
|
"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Determine the preferred style of spacing between Chinese or Japanese and non-CJK
|
|
|
|
|
* characters in the parent `sentence` node.
|
|
|
|
|
*
|
|
|
|
|
* @param {AstPath} path
|
|
|
|
|
* @returns {boolean} `true` if Space tends to be inserted between CJ and
|
|
|
|
|
* non-CJK, `false` otherwise.
|
|
|
|
|
*/
|
|
|
|
|
function isInSentenceWithCJSpaces({ parent: sentenceNode }) {
|
|
|
|
|
if (sentenceNode.usesCJSpaces === undefined) {
|
|
|
|
|
const stats = { " ": 0, "": 0 };
|
|
|
|
|
const { children } = sentenceNode;
|
|
|
|
|
|
|
|
|
|
for (let i = 1; i < children.length - 1; ++i) {
|
|
|
|
|
const node = children[i];
|
|
|
|
|
if (
|
|
|
|
|
node.type === "whitespace" &&
|
|
|
|
|
(node.value === " " || node.value === "")
|
|
|
|
|
) {
|
|
|
|
|
const previousKind = children[i - 1].kind;
|
|
|
|
|
const nextKind = children[i + 1].kind;
|
|
|
|
|
if (
|
|
|
|
|
(previousKind === KIND_CJ_LETTER && nextKind === KIND_NON_CJK) ||
|
|
|
|
|
(previousKind === KIND_NON_CJK && nextKind === KIND_CJ_LETTER)
|
|
|
|
|
) {
|
|
|
|
|
++stats[node.value];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inject a property to cache the result.
|
|
|
|
|
sentenceNode.usesCJSpaces = stats[" "] > stats[""];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sentenceNode.usesCJSpaces;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check whether the given `"\n"` node can be converted to a space.
|
|
|
|
|
*
|
|
|
|
|
* For example, if you would like to squash English text
|
|
|
|
|
*
|
|
|
|
|
* "You might want\nto use Prettier."
|
|
|
|
|
*
|
|
|
|
|
* into a single line, you would replace `"\n"` with `" "`:
|
|
|
|
|
*
|
|
|
|
|
* "You might want to use Prettier."
|
|
|
|
|
*
|
|
|
|
|
* However, Chinese and Japanese don't use U+0020 Space to divide words, so line
|
|
|
|
|
* breaks shouldn't be replaced with spaces for those languages.
|
|
|
|
|
*
|
|
|
|
|
* PRs are welcome to support line breaking rules for other languages.
|
|
|
|
|
*
|
|
|
|
|
* @param {AstPath} path
|
|
|
|
|
* @param {boolean} isLink
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function lineBreakCanBeConvertedToSpace(path, isLink) {
|
|
|
|
|
if (isLink) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @type {AdjacentNodes} */
|
|
|
|
|
const { previous, next } = path;
|
|
|
|
|
|
|
|
|
|
// e.g. " \nletter"
|
|
|
|
|
if (!previous || !next) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const previousKind = previous.kind;
|
|
|
|
|
const nextKind = next.kind;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
// "\n" between non-CJK or Korean characters always can be converted to a
|
|
|
|
|
// space. Korean Hangul simulates Latin words. See
|
|
|
|
|
// https://github.com/prettier/prettier/issues/6516
|
|
|
|
|
(isNonCJKOrKoreanLetter(previousKind) &&
|
|
|
|
|
isNonCJKOrKoreanLetter(nextKind)) ||
|
|
|
|
|
// Han & Hangul: same way preferred
|
|
|
|
|
(previousKind === KIND_K_LETTER && nextKind === KIND_CJ_LETTER) ||
|
|
|
|
|
(nextKind === KIND_K_LETTER && previousKind === KIND_CJ_LETTER)
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Do not convert \n to a space:
|
|
|
|
|
if (
|
|
|
|
|
// around CJK punctuation
|
|
|
|
|
previousKind === KIND_CJK_PUNCTUATION ||
|
|
|
|
|
nextKind === KIND_CJK_PUNCTUATION ||
|
|
|
|
|
// between CJ
|
|
|
|
|
(previousKind === KIND_CJ_LETTER && nextKind === KIND_CJ_LETTER)
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The rest of this function deals only with line breaks between CJ and
|
|
|
|
|
// non-CJK characters.
|
|
|
|
|
|
|
|
|
|
// Convert a line break between CJ and certain non-letter characters (e.g.
|
|
|
|
|
// ASCII punctuation) to a space.
|
|
|
|
|
//
|
|
|
|
|
// E.g. :::\n句子句子句子\n::: → ::: 句子句子句子 :::
|
|
|
|
|
//
|
|
|
|
|
// Note: line breaks like "(\n句子句子\n)" or "句子\n." are suppressed in
|
|
|
|
|
// `isBreakable(...)`.
|
|
|
|
|
if (
|
|
|
|
|
lineBreakBetweenTheseAndCJConvertsToSpace.has(next.value[0]) ||
|
|
|
|
|
lineBreakBetweenTheseAndCJConvertsToSpace.has(previous.value.at(-1))
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Converting a line break between CJ and non-ASCII punctuation to a space is
|
|
|
|
|
// undesired in many cases. PRs are welcome to fine-tune this logic.
|
|
|
|
|
//
|
|
|
|
|
// Examples where \n must not be converted to a space:
|
|
|
|
|
//
|
|
|
|
|
// 1. "〜" (U+301C, belongs to Pd) in
|
|
|
|
|
//
|
|
|
|
|
// "ア〜\nエの中から1つ選べ。"
|
|
|
|
|
//
|
|
|
|
|
// 2. "…" (U+2026, belongs to Po) in
|
|
|
|
|
//
|
|
|
|
|
// "これはひどい……\nなんと汚いコミットログなんだ……"
|
|
|
|
|
if (previous.hasTrailingPunctuation || next.hasLeadingPunctuation) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the sentence uses the style with spaces between CJ and non-CJK, "\n" can
|
|
|
|
|
// be converted to a space.
|
|
|
|
|
return isInSentenceWithCJSpaces(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {WordKind | undefined} kind
|
|
|
|
|
* @returns {boolean} `true` if `kind` is Korean letter or non-CJK
|
|
|
|
|
*/
|
|
|
|
|
function isNonCJKOrKoreanLetter(kind) {
|
|
|
|
|
return kind === KIND_NON_CJK || kind === KIND_K_LETTER;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check whether whitespace can be printed as a line break.
|
|
|
|
|
*
|
|
|
|
|
* @param {AstPath} path
|
|
|
|
|
* @param {WhitespaceValue} value
|
|
|
|
|
* @param {ProseWrap} proseWrap
|
|
|
|
|
* @param {boolean} isLink
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
2025-12-26 19:43:49 +08:00
|
|
|
function isBreakable(path, value, proseWrap, isLink, options) {
|
2022-12-28 14:48:25 +08:00
|
|
|
if (
|
|
|
|
|
proseWrap !== "always" ||
|
2025-12-26 19:43:49 +08:00
|
|
|
path.hasAncestor(
|
|
|
|
|
(node) =>
|
|
|
|
|
SINGLE_LINE_NODE_TYPES.has(node.type) ||
|
|
|
|
|
(node.type === "heading" &&
|
|
|
|
|
(options.parser === "mdx" || !isSetextHeading(node))),
|
|
|
|
|
)
|
2022-12-28 14:48:25 +08:00
|
|
|
) {
|
2022-10-19 17:40:59 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isLink) {
|
|
|
|
|
return value !== "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @type {AdjacentNodes} */
|
|
|
|
|
const { previous, next } = path;
|
|
|
|
|
|
2024-10-18 16:03:09 +09:00
|
|
|
// [1]: We will make a breaking change to the rule to convert spaces between
|
|
|
|
|
// a Chinese or Japanese character and another character in the future.
|
|
|
|
|
// Such a space must have been always interchangeable with a line break.
|
|
|
|
|
// https://wpt.fyi/results/css/css-text/line-breaking?label=master&label=experimental&aligned&q=segment-break-transformation-rules-
|
|
|
|
|
// [2]: we should not break lines even between Chinese/Japanese characters because Chrome & Safari replaces "\n" between such characters with " " now.
|
|
|
|
|
// [3]: Hangul (Korean) must simulate Latin words; see https://github.com/prettier/prettier/issues/6516
|
|
|
|
|
// [printable][""][Hangul] & vice versa => Don't break
|
|
|
|
|
// [printable][\n][Hangul] will be interchangeable to [printable][" "][Hangul] in the future
|
|
|
|
|
// (will be compatible with Firefox's behavior)
|
|
|
|
|
|
|
|
|
|
if (!previous || !next) {
|
|
|
|
|
// empty side is Latin ASCII symbol (e.g. *, [, ], or `)
|
|
|
|
|
// value is " " or "\n" (not "")
|
|
|
|
|
// [1] & [2]? No, it's the only exception because " " & "\n" have been always interchangeable only here
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value === "") {
|
|
|
|
|
// [1] & [2] & [3]
|
|
|
|
|
// At least either of previous or next is non-Latin (=CJK)
|
2022-10-19 17:40:59 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-18 16:03:09 +09:00
|
|
|
if (
|
|
|
|
|
// See the same product terms as the following in lineBreakCanBeConvertedToSpace
|
|
|
|
|
// The behavior is consistent between browsers and Prettier in that line breaks between Korean and Chinese/Japanese letters are equivalent to spaces.
|
|
|
|
|
// Currently, [CJK punctuation][\n][Hangul] is interchangeable to [CJK punctuation][""][Hangul],
|
|
|
|
|
// but this is not compatible with Firefox's behavior.
|
|
|
|
|
// Will be changed to [CJK punctuation][" "][Hangul] in the future
|
|
|
|
|
(previous.kind === KIND_K_LETTER && next.kind === KIND_CJ_LETTER) ||
|
|
|
|
|
(next.kind === KIND_K_LETTER && previous.kind === KIND_CJ_LETTER)
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2022-10-19 17:40:59 +09:00
|
|
|
|
2024-10-18 16:03:09 +09:00
|
|
|
// [1] & [2]
|
|
|
|
|
if (previous.isCJ || next.isCJ) {
|
2022-10-19 17:40:59 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AstPath} path
|
|
|
|
|
* @param {WhitespaceValue} value
|
|
|
|
|
* @param {ProseWrap} proseWrap
|
2025-12-26 19:43:49 +08:00
|
|
|
* @param {boolean} isLink Special mode of (un)wrapping that preserves the
|
|
|
|
|
* @param {any} options Special mode of (un)wrapping that preserves the
|
2022-10-19 17:40:59 +09:00
|
|
|
* normalized form of link labels. https://spec.commonmark.org/0.30/#matches
|
|
|
|
|
*/
|
2025-12-26 19:43:49 +08:00
|
|
|
function printWhitespace(path, value, proseWrap, isLink, options) {
|
2026-10-04 12:20:28 +09:00
|
|
|
if (
|
|
|
|
|
(proseWrap === "preserve" || isLineBreakAmbiguous(path)) &&
|
|
|
|
|
value === "\n"
|
|
|
|
|
) {
|
2022-10-19 17:40:59 +09:00
|
|
|
return hardline;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const canBeSpace =
|
|
|
|
|
value === " " ||
|
|
|
|
|
(value === "\n" && lineBreakCanBeConvertedToSpace(path, isLink));
|
|
|
|
|
|
2025-12-26 19:43:49 +08:00
|
|
|
if (isBreakable(path, value, proseWrap, isLink, options)) {
|
2022-10-19 17:40:59 +09:00
|
|
|
return canBeSpace ? line : softline;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return canBeSpace ? " " : "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:47:24 +08:00
|
|
|
function printWhitespaceNode(path, options) {
|
|
|
|
|
return printWhitespace(
|
|
|
|
|
path,
|
|
|
|
|
path.node.value,
|
|
|
|
|
shouldPreventBreak(path, options) ? "never" : options.proseWrap,
|
|
|
|
|
false,
|
|
|
|
|
options,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 12:20:28 +09:00
|
|
|
/**
|
|
|
|
|
* Determines if a line break around a CJ character can be handled differently by browsers.
|
|
|
|
|
*
|
|
|
|
|
* If `true`, since Prettier is just a formatter, which should not change the semantics of the code, it should leave such an ambiguous line break as-is
|
|
|
|
|
* and let the browser decide how to interpret it regardless of `proseWrap`.
|
|
|
|
|
*
|
|
|
|
|
* Once all browsers behave consistently in the future, this function will no longer be needed.
|
|
|
|
|
*
|
|
|
|
|
* @param {AstPath} path
|
|
|
|
|
* @returns {boolean} `true` if a line break around a CJ character are removed by some browsers but converted to a space by others.
|
|
|
|
|
*/
|
|
|
|
|
function isLineBreakAmbiguous(path) {
|
|
|
|
|
const { previous, next } = path;
|
|
|
|
|
// All browsers treat a line break between a CJ letter and a non-CJ character as
|
|
|
|
|
// equivalent to a space. However, Prettier v3 intentionally decided that, in
|
|
|
|
|
// some cases, such a line break is equivalent to removing it without
|
|
|
|
|
// converting it to a space, so in v3 we leave it as-is instead of converting it
|
|
|
|
|
// to a space or removing it.
|
|
|
|
|
// See also: https://github.com/prettier/prettier/pull/16805
|
|
|
|
|
return (
|
|
|
|
|
(Boolean(previous?.isCJ) || Boolean(next?.isCJ)) &&
|
|
|
|
|
// A line break between a CJ letter and a Korean letter has already been interchangeable with a space since Prettier v3.0.0.
|
|
|
|
|
// A line break between a CJK punctuation character and a Korean letter is handled differently depending on the browser and the content language.
|
|
|
|
|
!(
|
|
|
|
|
(previous.kind === KIND_K_LETTER && next.kind === KIND_CJ_LETTER) ||
|
|
|
|
|
(next.kind === KIND_K_LETTER && previous.kind === KIND_CJ_LETTER)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:47:24 +08:00
|
|
|
function shouldPreventBreak(path, options) {
|
|
|
|
|
const { proseWrap } = options;
|
|
|
|
|
if (proseWrap === "never") {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path.isLast) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
// leading char that may cause different syntax
|
|
|
|
|
/^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(path.next.value) &&
|
|
|
|
|
// Avoid https://github.com/prettier/prettier/issues/18861
|
|
|
|
|
!hasFakeWhitespaceAfterNextToken(path) &&
|
|
|
|
|
// Next fake setext h2 `-` is going to be escaped, so no need to join adjacent words
|
|
|
|
|
!(proseWrap === "preserve" && isNextTokenFakeSetextH2Line(path))
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AstPath} path
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function hasFakeWhitespaceAfterNextToken(path) {
|
|
|
|
|
const afterNext = path.siblings[path.index + 2];
|
|
|
|
|
return afterNext?.type === "whitespace" && afterNext.value === "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AstPath} path
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function isNextTokenFakeSetextH2Line(path) {
|
|
|
|
|
if (!isNewLine(path.node) || path.next.value !== "-") {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const afterNext = path.siblings[path.index + 2];
|
|
|
|
|
return !afterNext || isNewLine(afterNext);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { printWhitespace, printWhitespaceNode };
|