414 lines
11 KiB
TypeScript
414 lines
11 KiB
TypeScript
/**
|
||
* TOML Printer for Prettier
|
||
*
|
||
* This module provides a visitor-based printer for TOML CST nodes,
|
||
* converting them to Prettier's document format.
|
||
*/
|
||
|
||
import { BaseTomlCstVisitor } from '@toml-tools/parser';
|
||
import { tokensDictionary as t } from '@toml-tools/lexer';
|
||
import { doc } from 'prettier';
|
||
import type { AstPath, Doc } from 'prettier';
|
||
import {
|
||
trimComment,
|
||
collectComments,
|
||
arrItemOffset,
|
||
arrItemProp,
|
||
getSingle,
|
||
formatKey,
|
||
optimizeValue,
|
||
} from './printer-utils';
|
||
import type {
|
||
TomlCstNode,
|
||
TomlDocument,
|
||
TomlExpression,
|
||
TomlKeyVal,
|
||
TomlComment,
|
||
TomlContext
|
||
} from './types';
|
||
|
||
const { join, line, hardline, softline, ifBreak, indent, group } = doc.builders;
|
||
|
||
/**
|
||
* TOML Beautifier Visitor class that extends the base CST visitor
|
||
*/
|
||
class TomlBeautifierVisitor extends BaseTomlCstVisitor {
|
||
// Helper methods
|
||
public mapVisit: (elements: TomlCstNode[] | undefined) => (Doc | string)[];
|
||
public visitSingle: (ctx: TomlContext) => Doc | string;
|
||
public visit: (ctx: TomlCstNode, inParam?: any) => Doc | string;
|
||
|
||
constructor() {
|
||
super();
|
||
|
||
// Try to call validateVisitor if it exists
|
||
if (typeof (this as any).validateVisitor === 'function') {
|
||
(this as any).validateVisitor();
|
||
}
|
||
|
||
// Initialize helper methods
|
||
this.mapVisit = (elements: TomlCstNode[] | undefined): (Doc | string)[] => {
|
||
if (!elements) {
|
||
return [];
|
||
}
|
||
return elements.map((element) => this.visit(element));
|
||
};
|
||
|
||
this.visitSingle = (ctx: TomlContext): Doc | string => {
|
||
const singleElement = getSingle(ctx);
|
||
return this.visit(singleElement);
|
||
};
|
||
|
||
// Store reference to inherited visit method and override it
|
||
const originalVisit = Object.getPrototypeOf(this).visit?.bind(this);
|
||
this.visit = (ctx: TomlCstNode, inParam?: any): Doc | string => {
|
||
if (!ctx) {
|
||
return '';
|
||
}
|
||
|
||
// Try to use the inherited visit method first
|
||
if (originalVisit) {
|
||
try {
|
||
return originalVisit(ctx, inParam);
|
||
} catch (error) {
|
||
console.warn('Original visit method failed:', error);
|
||
}
|
||
}
|
||
|
||
// Fallback: manually dispatch based on node name/type
|
||
const methodName = ctx.name;
|
||
if (methodName && typeof (this as any)[methodName] === 'function') {
|
||
const visitMethod = (this as any)[methodName];
|
||
try {
|
||
if (ctx.children) {
|
||
return visitMethod.call(this, ctx.children);
|
||
} else {
|
||
return visitMethod.call(this, ctx);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`Visit method ${methodName} failed:`, error);
|
||
}
|
||
}
|
||
|
||
// Final fallback: return image if available
|
||
return ctx.image || '';
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Visit the root TOML document
|
||
*/
|
||
toml(ctx: TomlDocument): Doc {
|
||
// Handle empty toml document
|
||
if (!ctx.expression) {
|
||
return [line];
|
||
}
|
||
|
||
const isTable = (node: TomlExpression): boolean => {
|
||
return !!node.table;
|
||
};
|
||
|
||
const isOnlyComment = (node: TomlExpression): boolean => {
|
||
return !!node.Comment && Object.keys(node).length === 1;
|
||
};
|
||
|
||
const expsCsts = ctx.expression;
|
||
const cstGroups: TomlExpression[][] = [];
|
||
let currCstGroup: TomlExpression[] = [];
|
||
|
||
// Split expressions into groups defined by tables
|
||
for (let i = expsCsts.length - 1; i >= 0; i--) {
|
||
const currCstNode = expsCsts[i];
|
||
currCstGroup.push(currCstNode);
|
||
|
||
if (isTable(currCstNode)) {
|
||
let j = i - 1;
|
||
let stillInComments = true;
|
||
|
||
// Add leading comments to current group
|
||
while (j >= 0 && stillInComments) {
|
||
const priorCstNode = expsCsts[j];
|
||
if (isOnlyComment(priorCstNode)) {
|
||
currCstGroup.push(priorCstNode);
|
||
j--;
|
||
i--;
|
||
} else {
|
||
stillInComments = false;
|
||
}
|
||
}
|
||
|
||
// Reverse since we scanned backwards
|
||
currCstGroup.reverse();
|
||
cstGroups.push(currCstGroup);
|
||
currCstGroup = [];
|
||
}
|
||
}
|
||
|
||
if (currCstGroup.length > 0) {
|
||
currCstGroup.reverse();
|
||
cstGroups.push(currCstGroup);
|
||
}
|
||
|
||
// Adjust for reverse scanning
|
||
cstGroups.reverse();
|
||
const docGroups = cstGroups.map((currGroup) => this.mapVisit(currGroup));
|
||
|
||
// Add newlines between group elements
|
||
const docGroupsInnerNewlines = docGroups.map((currGroup) =>
|
||
join(line, currGroup)
|
||
);
|
||
const docGroupsOuterNewlines = join([line, line], docGroupsInnerNewlines);
|
||
|
||
return [docGroupsOuterNewlines, line];
|
||
}
|
||
|
||
/**
|
||
* Visit an expression (keyval, table, or comment)
|
||
*/
|
||
expression(ctx: TomlExpression): Doc | string {
|
||
if (ctx.keyval) {
|
||
let keyValDoc = this.visit(ctx.keyval[0]);
|
||
if (ctx.Comment) {
|
||
const commentText = trimComment(ctx.Comment[0].image);
|
||
keyValDoc = [keyValDoc, ' ' + commentText];
|
||
}
|
||
return keyValDoc;
|
||
} else if (ctx.table) {
|
||
let tableDoc = this.visit(ctx.table[0]);
|
||
if (ctx.Comment) {
|
||
const commentText = trimComment(ctx.Comment[0].image);
|
||
tableDoc = [tableDoc, ' ' + commentText];
|
||
}
|
||
return tableDoc;
|
||
} else if (ctx.Comment) {
|
||
return trimComment(ctx.Comment[0].image);
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Visit a key-value pair
|
||
*/
|
||
keyval(ctx: TomlKeyVal): Doc {
|
||
const keyDoc = this.visit(ctx.key[0]);
|
||
const valueDoc = this.visit(ctx.val[0]);
|
||
return [keyDoc, ' = ', valueDoc];
|
||
}
|
||
|
||
/**
|
||
* Visit a key
|
||
*/
|
||
key(ctx: any): Doc {
|
||
const keyTexts = ctx.IKey?.map((tok: any) => {
|
||
const keyText = tok.image;
|
||
// Apply key formatting (add/remove quotes as needed)
|
||
return formatKey(keyText);
|
||
}) || [];
|
||
|
||
return join('.', keyTexts);
|
||
}
|
||
|
||
/**
|
||
* Visit a value
|
||
*/
|
||
val(ctx: any): Doc | string {
|
||
try {
|
||
const actualValueNode = getSingle(ctx);
|
||
if (actualValueNode.image !== undefined) {
|
||
// Terminal token - 优化值的表示
|
||
return optimizeValue(actualValueNode.image);
|
||
} else {
|
||
return this.visit(actualValueNode);
|
||
}
|
||
} catch (error) {
|
||
// 如果getSingle失败,尝试直接处理children
|
||
if (ctx.children) {
|
||
// 处理不同类型的值
|
||
for (const [childKey, childNodes] of Object.entries(ctx.children)) {
|
||
if (Array.isArray(childNodes) && childNodes.length > 0) {
|
||
const firstChild = childNodes[0];
|
||
|
||
// 处理基本类型
|
||
if (firstChild.image !== undefined) {
|
||
// 优化值的表示(特别是字符串)
|
||
return optimizeValue(firstChild.image);
|
||
}
|
||
|
||
// 处理复杂类型(如数组、内联表等)
|
||
if (firstChild.name) {
|
||
return this.visit(firstChild);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Visit an array
|
||
*/
|
||
array(ctx: any): Doc {
|
||
const arrayValuesDocs = ctx.arrayValues ? this.visit(ctx.arrayValues) : '';
|
||
const postComments = collectComments(ctx.commentNewline);
|
||
const commentsDocs = postComments.map((commentTok) => {
|
||
const trimmedCommentText = trimComment(commentTok.image);
|
||
return [hardline, trimmedCommentText];
|
||
});
|
||
|
||
return group(['[', indent([arrayValuesDocs, commentsDocs]), softline, ']']);
|
||
}
|
||
|
||
/**
|
||
* Visit array values
|
||
*/
|
||
arrayValues(ctx: any): Doc {
|
||
const values = ctx.val || [];
|
||
const commas = ctx.Comma || [];
|
||
const comments = collectComments(ctx.commentNewline);
|
||
|
||
const itemsCst = [...values, ...commas, ...comments];
|
||
itemsCst.sort((a, b) => {
|
||
const aOffset = arrItemOffset(a);
|
||
const bOffset = arrItemOffset(b);
|
||
return aOffset - bOffset;
|
||
});
|
||
|
||
const itemsDoc: Doc[] = [];
|
||
|
||
for (let i = 0; i < itemsCst.length; i++) {
|
||
const cstItem = itemsCst[i];
|
||
|
||
if (cstItem.name === 'val') {
|
||
const valDoc = this.visit(cstItem);
|
||
const valEndLine = arrItemProp(cstItem, 'endLine');
|
||
let potentialComma = '';
|
||
|
||
// Handle next item (comma or comment)
|
||
if (itemsCst[i + 1]) {
|
||
let nextPossibleComment = itemsCst[i + 1];
|
||
|
||
// Skip commas
|
||
if (nextPossibleComment.tokenType === t.Comma) {
|
||
potentialComma = ',';
|
||
i++;
|
||
nextPossibleComment = itemsCst[i + 1];
|
||
}
|
||
|
||
// Handle same-line comments
|
||
if (
|
||
nextPossibleComment &&
|
||
nextPossibleComment.tokenType === t.Comment &&
|
||
nextPossibleComment.startLine === valEndLine
|
||
) {
|
||
i++;
|
||
const trimmedComment = trimComment(nextPossibleComment.image);
|
||
const comment = ' ' + trimmedComment;
|
||
itemsDoc.push([valDoc, potentialComma, comment, hardline]);
|
||
} else {
|
||
// No comment on same line
|
||
const isTrailingComma = i === itemsCst.length - 1;
|
||
const optionalCommaLineBreak = isTrailingComma
|
||
? ifBreak(',', '') // Only print trailing comma if multiline array
|
||
: [potentialComma, line];
|
||
itemsDoc.push([valDoc, optionalCommaLineBreak]);
|
||
}
|
||
} else {
|
||
// Last item without followup
|
||
itemsDoc.push([valDoc]);
|
||
}
|
||
} else if (cstItem.tokenType === t.Comment) {
|
||
// Separate line comment
|
||
const trimmedComment = trimComment(cstItem.image);
|
||
itemsDoc.push([trimmedComment, hardline]);
|
||
} else {
|
||
throw new Error('Non-exhaustive match in arrayValues');
|
||
}
|
||
}
|
||
|
||
return [softline, itemsDoc];
|
||
}
|
||
|
||
/**
|
||
* Visit an inline table
|
||
*/
|
||
inlineTable(ctx: any): Doc {
|
||
const inlineTableKeyValsDocs = ctx.inlineTableKeyVals
|
||
? this.visit(ctx.inlineTableKeyVals)
|
||
: '';
|
||
return group(['{ ', inlineTableKeyValsDocs, ' }']);
|
||
}
|
||
|
||
/**
|
||
* Visit inline table key-value pairs
|
||
*/
|
||
inlineTableKeyVals(ctx: any): Doc {
|
||
const keyValDocs = this.mapVisit(ctx.keyval);
|
||
return join(', ', keyValDocs);
|
||
}
|
||
|
||
/**
|
||
* Visit a table
|
||
*/
|
||
table(ctx: any): Doc | string {
|
||
return this.visitSingle(ctx);
|
||
}
|
||
|
||
/**
|
||
* Visit a standard table
|
||
*/
|
||
stdTable(ctx: any): Doc {
|
||
if (ctx.key && ctx.key[0] && ctx.key[0].children && ctx.key[0].children.IKey) {
|
||
const keyTexts = ctx.key[0].children.IKey.map((tok: any) => {
|
||
return formatKey(tok.image);
|
||
});
|
||
return ['[', join('.', keyTexts), ']'];
|
||
}
|
||
return '[]';
|
||
}
|
||
|
||
/**
|
||
* Visit an array table
|
||
*/
|
||
arrayTable(ctx: any): Doc {
|
||
if (ctx.key && ctx.key[0] && ctx.key[0].children && ctx.key[0].children.IKey) {
|
||
const keyTexts = ctx.key[0].children.IKey.map((tok: any) => {
|
||
return formatKey(tok.image);
|
||
});
|
||
return ['[[', join('.', keyTexts), ']]'];
|
||
}
|
||
return '[[]]';
|
||
}
|
||
|
||
/**
|
||
* Visit newline (should not be called)
|
||
*/
|
||
nl(ctx: any): never {
|
||
throw new Error('Should not get here!');
|
||
}
|
||
|
||
/**
|
||
* Visit comment newline (no-op)
|
||
*/
|
||
commentNewline(ctx: any): void {
|
||
// No operation needed
|
||
}
|
||
}
|
||
|
||
// Create singleton visitor instance
|
||
const beautifierVisitor = new TomlBeautifierVisitor();
|
||
|
||
/**
|
||
* Main print function for Prettier
|
||
* @param path - AST path from Prettier
|
||
* @param options - Print options
|
||
* @param print - Print function (unused in this implementation)
|
||
* @returns Formatted document
|
||
*/
|
||
export function print(path: AstPath<TomlCstNode>, options?: any, print?: any): Doc {
|
||
const cst = path.node as TomlDocument;
|
||
return beautifierVisitor.visit(cst);
|
||
}
|