424 lines
11 KiB
JavaScript
424 lines
11 KiB
JavaScript
import escapeStringRegexp from "escape-string-regexp";
|
|
import {
|
|
skip,
|
|
skipWhitespace,
|
|
skipSpaces,
|
|
skipToLineEnd,
|
|
skipEverythingButNewLine,
|
|
} from "../utils/text/skip.js";
|
|
import skipInlineComment from "../utils/text/skip-inline-comment.js";
|
|
import skipTrailingComment from "../utils/text/skip-trailing-comment.js";
|
|
import skipNewline from "../utils/text/skip-newline.js";
|
|
import getNextNonSpaceNonCommentCharacterIndex from "../utils/text/get-next-non-space-non-comment-character-index.js";
|
|
|
|
/**
|
|
* @typedef {{backwards?: boolean}} SkipOptions
|
|
*/
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {number} index
|
|
* @param {SkipOptions=} opts
|
|
* @returns {boolean}
|
|
*/
|
|
function hasNewline(text, index, opts = {}) {
|
|
const idx = skipSpaces(text, opts.backwards ? index - 1 : index, opts);
|
|
const idx2 = skipNewline(text, idx, opts);
|
|
return idx !== idx2;
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {number} start
|
|
* @param {number} end
|
|
* @returns {boolean}
|
|
*/
|
|
function hasNewlineInRange(text, start, end) {
|
|
for (let i = start; i < end; ++i) {
|
|
if (text.charAt(i) === "\n") {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Note: this function doesn't ignore leading comments unlike isNextLineEmpty
|
|
/**
|
|
* @param {string} text
|
|
* @param {number} startIndex
|
|
* @returns {boolean}
|
|
*/
|
|
function isPreviousLineEmpty(text, startIndex) {
|
|
/** @type {number | false} */
|
|
let idx = startIndex - 1;
|
|
idx = skipSpaces(text, idx, { backwards: true });
|
|
idx = skipNewline(text, idx, { backwards: true });
|
|
idx = skipSpaces(text, idx, { backwards: true });
|
|
const idx2 = skipNewline(text, idx, { backwards: true });
|
|
return idx !== idx2;
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {number} startIndex
|
|
* @returns {boolean}
|
|
*/
|
|
function isNextLineEmpty(text, startIndex) {
|
|
/** @type {number | false} */
|
|
let oldIdx = null;
|
|
/** @type {number | false} */
|
|
let idx = startIndex;
|
|
while (idx !== oldIdx) {
|
|
// We need to skip all the potential trailing inline comments
|
|
oldIdx = idx;
|
|
idx = skipToLineEnd(text, idx);
|
|
idx = skipInlineComment(text, idx);
|
|
idx = skipSpaces(text, idx);
|
|
}
|
|
idx = skipTrailingComment(text, idx);
|
|
idx = skipNewline(text, idx);
|
|
return idx !== false && hasNewline(text, idx);
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {number} startIndex
|
|
* @returns {string}
|
|
*/
|
|
function getNextNonSpaceNonCommentCharacter(text, startIndex) {
|
|
const index = getNextNonSpaceNonCommentCharacterIndex(text, startIndex);
|
|
return index === false ? "" : text.charAt(index);
|
|
}
|
|
|
|
// Not using, but it's public utils
|
|
/* c8 ignore start */
|
|
/**
|
|
* @param {string} text
|
|
* @param {number} index
|
|
* @param {SkipOptions=} opts
|
|
* @returns {boolean}
|
|
*/
|
|
function hasSpaces(text, index, opts = {}) {
|
|
const idx = skipSpaces(text, opts.backwards ? index - 1 : index, opts);
|
|
return idx !== index;
|
|
}
|
|
/* c8 ignore stop */
|
|
|
|
/**
|
|
* @param {string} value
|
|
* @param {number} tabWidth
|
|
* @param {number=} startIndex
|
|
* @returns {number}
|
|
*/
|
|
function getAlignmentSize(value, tabWidth, startIndex = 0) {
|
|
let size = 0;
|
|
for (let i = startIndex; i < value.length; ++i) {
|
|
if (value[i] === "\t") {
|
|
// Tabs behave in a way that they are aligned to the nearest
|
|
// multiple of tabWidth:
|
|
// 0 -> 4, 1 -> 4, 2 -> 4, 3 -> 4
|
|
// 4 -> 8, 5 -> 8, 6 -> 8, 7 -> 8 ...
|
|
size = size + tabWidth - (size % tabWidth);
|
|
} else {
|
|
size++;
|
|
}
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* @param {string} value
|
|
* @param {number} tabWidth
|
|
* @returns {number}
|
|
*/
|
|
function getIndentSize(value, tabWidth) {
|
|
const lastNewlineIndex = value.lastIndexOf("\n");
|
|
/* c8 ignore next 3 */
|
|
if (lastNewlineIndex === -1) {
|
|
return 0;
|
|
}
|
|
|
|
return getAlignmentSize(
|
|
// All the leading whitespaces
|
|
value.slice(lastNewlineIndex + 1).match(/^[\t ]*/)[0],
|
|
tabWidth
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @typedef {'"' | "'"} Quote
|
|
*/
|
|
|
|
/**
|
|
*
|
|
* @param {string} rawContent
|
|
* @param {Quote} preferredQuote
|
|
* @returns {{ quote: Quote, regex: RegExp, escaped: string }}
|
|
*/
|
|
|
|
function getPreferredQuote(rawContent, preferredQuote) {
|
|
/** @type {{ quote: '"', regex: RegExp, escaped: """ }} */
|
|
const double = { quote: '"', regex: /"/g, escaped: """ };
|
|
/** @type {{ quote: "'", regex: RegExp, escaped: "'" }} */
|
|
const single = { quote: "'", regex: /'/g, escaped: "'" };
|
|
|
|
const preferred = preferredQuote === "'" ? single : double;
|
|
const alternate = preferred === single ? double : single;
|
|
|
|
let result = preferred;
|
|
|
|
// If `rawContent` contains at least one of the quote preferred for enclosing
|
|
// the string, we might want to enclose with the alternate quote instead, to
|
|
// minimize the number of escaped quotes.
|
|
if (
|
|
rawContent.includes(preferred.quote) ||
|
|
rawContent.includes(alternate.quote)
|
|
) {
|
|
const numPreferredQuotes = (rawContent.match(preferred.regex) || []).length;
|
|
const numAlternateQuotes = (rawContent.match(alternate.regex) || []).length;
|
|
|
|
result = numPreferredQuotes > numAlternateQuotes ? alternate : preferred;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function printString(raw, options) {
|
|
// `rawContent` is the string exactly like it appeared in the input source
|
|
// code, without its enclosing quotes.
|
|
const rawContent = raw.slice(1, -1);
|
|
|
|
/** @type {Quote} */
|
|
const enclosingQuote =
|
|
options.parser === "json" ||
|
|
(options.parser === "json5" &&
|
|
options.quoteProps === "preserve" &&
|
|
!options.singleQuote)
|
|
? '"'
|
|
: options.__isInHtmlAttribute
|
|
? "'"
|
|
: getPreferredQuote(rawContent, options.singleQuote ? "'" : '"').quote;
|
|
|
|
// It might sound unnecessary to use `makeString` even if the string already
|
|
// is enclosed with `enclosingQuote`, but it isn't. The string could contain
|
|
// unnecessary escapes (such as in `"\'"`). Always using `makeString` makes
|
|
// sure that we consistently output the minimum amount of escaped quotes.
|
|
return makeString(
|
|
rawContent,
|
|
enclosingQuote,
|
|
!(
|
|
options.parser === "css" ||
|
|
options.parser === "less" ||
|
|
options.parser === "scss" ||
|
|
options.__embeddedInHtml
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {string} rawContent
|
|
* @param {Quote} enclosingQuote
|
|
* @param {boolean=} unescapeUnnecessaryEscapes
|
|
* @returns {string}
|
|
*/
|
|
function makeString(rawContent, enclosingQuote, unescapeUnnecessaryEscapes) {
|
|
const otherQuote = enclosingQuote === '"' ? "'" : '"';
|
|
|
|
// Matches _any_ escape and unescaped quotes (both single and double).
|
|
const regex = /\\(.)|(["'])/gs;
|
|
|
|
// Escape and unescape single and double quotes as needed to be able to
|
|
// enclose `rawContent` with `enclosingQuote`.
|
|
const newContent = rawContent.replaceAll(regex, (match, escaped, quote) => {
|
|
// If we matched an escape, and the escaped character is a quote of the
|
|
// other type than we intend to enclose the string with, there's no need for
|
|
// it to be escaped, so return it _without_ the backslash.
|
|
if (escaped === otherQuote) {
|
|
return escaped;
|
|
}
|
|
|
|
// If we matched an unescaped quote and it is of the _same_ type as we
|
|
// intend to enclose the string with, it must be escaped, so return it with
|
|
// a backslash.
|
|
if (quote === enclosingQuote) {
|
|
return "\\" + quote;
|
|
}
|
|
|
|
if (quote) {
|
|
return quote;
|
|
}
|
|
|
|
// Unescape any unnecessarily escaped character.
|
|
// Adapted from https://github.com/eslint/eslint/blob/de0b4ad7bd820ade41b1f606008bea68683dc11a/lib/rules/no-useless-escape.js#L27
|
|
return unescapeUnnecessaryEscapes &&
|
|
/^[^\n\r"'0-7\\bfnrt-vx\u2028\u2029]$/.test(escaped)
|
|
? escaped
|
|
: "\\" + escaped;
|
|
});
|
|
|
|
return enclosingQuote + newContent + enclosingQuote;
|
|
}
|
|
|
|
function printNumber(rawNumber) {
|
|
return (
|
|
rawNumber
|
|
.toLowerCase()
|
|
// Remove unnecessary plus and zeroes from scientific notation.
|
|
.replace(/^([+-]?[\d.]+e)(?:\+|(-))?0*(?=\d)/, "$1$2")
|
|
// Remove unnecessary scientific notation (1e0).
|
|
.replace(/^([+-]?[\d.]+)e[+-]?0+$/, "$1")
|
|
// Make sure numbers always start with a digit.
|
|
.replace(/^([+-])?\./, "$10.")
|
|
// Remove extraneous trailing decimal zeroes.
|
|
.replace(/(\.\d+?)0+(?=e|$)/, "$1")
|
|
// Remove trailing dot.
|
|
.replace(/\.(?=e|$)/, "")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {string} str
|
|
* @param {string} target
|
|
* @returns {number}
|
|
*/
|
|
function getMaxContinuousCount(str, target) {
|
|
const results = str.match(
|
|
new RegExp(`(${escapeStringRegexp(target)})+`, "g")
|
|
);
|
|
|
|
if (results === null) {
|
|
return 0;
|
|
}
|
|
|
|
return results.reduce(
|
|
(maxCount, result) => Math.max(maxCount, result.length / target.length),
|
|
0
|
|
);
|
|
}
|
|
|
|
function getMinNotPresentContinuousCount(str, target) {
|
|
const matches = str.match(
|
|
new RegExp(`(${escapeStringRegexp(target)})+`, "g")
|
|
);
|
|
|
|
if (matches === null) {
|
|
return 0;
|
|
}
|
|
|
|
const countPresent = new Map();
|
|
let max = 0;
|
|
|
|
for (const match of matches) {
|
|
const count = match.length / target.length;
|
|
countPresent.set(count, true);
|
|
if (count > max) {
|
|
max = count;
|
|
}
|
|
}
|
|
|
|
for (let i = 1; i < max; i++) {
|
|
if (!countPresent.get(i)) {
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return max + 1;
|
|
}
|
|
|
|
function addCommentHelper(node, comment) {
|
|
const comments = (node.comments ??= []);
|
|
comments.push(comment);
|
|
comment.printed = false;
|
|
comment.nodeDescription = describeNodeForDebugging(node);
|
|
}
|
|
|
|
function addLeadingComment(node, comment) {
|
|
comment.leading = true;
|
|
comment.trailing = false;
|
|
addCommentHelper(node, comment);
|
|
}
|
|
|
|
function addDanglingComment(node, comment, marker) {
|
|
comment.leading = false;
|
|
comment.trailing = false;
|
|
if (marker) {
|
|
comment.marker = marker;
|
|
}
|
|
addCommentHelper(node, comment);
|
|
}
|
|
|
|
function addTrailingComment(node, comment) {
|
|
comment.leading = false;
|
|
comment.trailing = true;
|
|
addCommentHelper(node, comment);
|
|
}
|
|
|
|
function isFrontMatterNode(node) {
|
|
return node?.type === "front-matter";
|
|
}
|
|
|
|
/**
|
|
* @param {string} description
|
|
* @returns {(node: any) => symbol}
|
|
*/
|
|
function createGroupIdMapper(description) {
|
|
const groupIds = new WeakMap();
|
|
return function (node) {
|
|
if (!groupIds.has(node)) {
|
|
groupIds.set(node, Symbol(description));
|
|
}
|
|
return groupIds.get(node);
|
|
};
|
|
}
|
|
|
|
function describeNodeForDebugging(node) {
|
|
const nodeType = node.type || node.kind || "(unknown type)";
|
|
let nodeName = String(
|
|
node.name ||
|
|
(node.id && (typeof node.id === "object" ? node.id.name : node.id)) ||
|
|
(node.key && (typeof node.key === "object" ? node.key.name : node.key)) ||
|
|
(node.value &&
|
|
(typeof node.value === "object" ? "" : String(node.value))) ||
|
|
node.operator ||
|
|
""
|
|
);
|
|
if (nodeName.length > 20) {
|
|
nodeName = nodeName.slice(0, 19) + "…";
|
|
}
|
|
return nodeType + (nodeName ? " " + nodeName : "");
|
|
}
|
|
|
|
export {
|
|
getMaxContinuousCount,
|
|
getMinNotPresentContinuousCount,
|
|
getNextNonSpaceNonCommentCharacterIndex,
|
|
getNextNonSpaceNonCommentCharacter,
|
|
skip,
|
|
skipWhitespace,
|
|
skipSpaces,
|
|
skipToLineEnd,
|
|
skipEverythingButNewLine,
|
|
skipInlineComment,
|
|
skipTrailingComment,
|
|
skipNewline,
|
|
isNextLineEmpty,
|
|
isPreviousLineEmpty,
|
|
hasNewline,
|
|
hasNewlineInRange,
|
|
hasSpaces,
|
|
getAlignmentSize,
|
|
getIndentSize,
|
|
getPreferredQuote,
|
|
printString,
|
|
printNumber,
|
|
makeString,
|
|
addLeadingComment,
|
|
addDanglingComment,
|
|
addTrailingComment,
|
|
isFrontMatterNode,
|
|
createGroupIdMapper,
|
|
};
|
|
export { default as isNonEmptyArray } from "../utils/is-non-empty-array.js";
|
|
export { default as getStringWidth } from "../utils/get-string-width.js";
|