🚧 Modify toml,powershell prettier plugin(beta)

This commit is contained in:
2025-09-17 00:12:39 +08:00
parent a83c7139c9
commit 338ac358db
20 changed files with 4635 additions and 912 deletions

View File

@@ -0,0 +1,72 @@
/**
* Prettier Plugin for TOML file formatting
*
* This plugin provides support for formatting TOML (Tom's Obvious, Minimal Language) files
* using the @toml-tools/parser and custom beautifier.
*/
import type { Plugin, Parser, Printer, SupportLanguage, SupportOption } from 'prettier';
import { parse } from '@toml-tools/parser';
import { locStart, locEnd } from './loc';
import { print } from './printer';
import type { TomlDocument, TomlCstNode } from './types';
const parserName = 'toml';
// https://prettier.io/docs/en/plugins.html#languages
const languages: SupportLanguage[] = [
{
extensions: ['.toml'],
name: 'Toml',
parsers: [parserName],
filenames: ['Cargo.lock', 'Gopkg.lock'],
tmScope: 'source.toml',
aceMode: 'toml',
codemirrorMode: 'toml',
codemirrorMimeType: 'text/x-toml',
linguistLanguageId: 365,
vscodeLanguageIds: ['toml'],
},
];
// https://prettier.io/docs/en/plugins.html#parsers
const tomlParser: Parser<TomlDocument> = {
astFormat: 'toml-cst',
parse: (text: string): TomlDocument => {
try {
return parse(text) as TomlDocument;
} catch (error) {
console.error('TOML parsing error:', error);
throw error;
}
},
locStart,
locEnd,
};
// https://prettier.io/docs/en/plugins.html#printers
const tomlPrinter: Printer<TomlCstNode> = {
print,
};
// Plugin options
const options: Record<string, SupportOption> = {
};
// Plugin definition
const tomlPlugin: Plugin = {
languages,
parsers: {
[parserName]: tomlParser,
},
printers: {
'toml-cst': tomlPrinter,
},
options,
};
export default tomlPlugin;
export { languages };
export const parsers = tomlPlugin.parsers;
export const printers = tomlPlugin.printers;

View File

@@ -0,0 +1,82 @@
/**
* Location utilities for TOML CST nodes
* These functions help Prettier determine the location of nodes for formatting
*/
import type { TomlCstNode } from './types';
/**
* Get the start location of a CST node
* @param cstNode - The TOML CST node
* @returns The start offset of the node
*/
export function locStart(cstNode: TomlCstNode): number {
if (!cstNode) {
return 0;
}
// If the node has a direct startOffset, use it
if (typeof cstNode.startOffset === 'number') {
return cstNode.startOffset;
}
// If the node has children, find the earliest start offset
if (cstNode.children) {
let minOffset = Infinity;
for (const key in cstNode.children) {
const childrenArray = cstNode.children[key];
if (Array.isArray(childrenArray)) {
for (const child of childrenArray) {
const childStart = locStart(child);
if (childStart < minOffset) {
minOffset = childStart;
}
}
}
}
return minOffset === Infinity ? 0 : minOffset;
}
return 0;
}
/**
* Get the end location of a CST node
* @param cstNode - The TOML CST node
* @returns The end offset of the node
*/
export function locEnd(cstNode: TomlCstNode): number {
if (!cstNode) {
return 0;
}
// If the node has a direct endOffset, use it
if (typeof cstNode.endOffset === 'number') {
return cstNode.endOffset;
}
// If the node has children, find the latest end offset
if (cstNode.children) {
let maxOffset = -1;
for (const key in cstNode.children) {
const childrenArray = cstNode.children[key];
if (Array.isArray(childrenArray)) {
for (const child of childrenArray) {
const childEnd = locEnd(child);
if (childEnd > maxOffset) {
maxOffset = childEnd;
}
}
}
}
return maxOffset === -1 ? 0 : maxOffset;
}
// If the node has an image (token), return the length
if (cstNode.image) {
const startOffset = locStart(cstNode);
return startOffset + cstNode.image.length;
}
return 0;
}

View File

@@ -0,0 +1,284 @@
/**
* Utility functions for TOML printer
*/
import type { TomlCstNode, TomlComment, TomlContext } from './types';
/**
* Trim trailing whitespace from comment text
* @param commentText - The comment text to trim
* @returns Trimmed comment text
*/
export function trimComment(commentText: string): string {
return commentText.replace(/[ \t]+$/, '');
}
/**
* Check if a quoted string can be unquoted
* @param quotedText - The quoted text to check
* @returns Whether the text can be unquoted
*/
export function canUnquote(quotedText: string): boolean {
// Remove quotes if present
let text = quotedText;
if (text.startsWith('"') && text.endsWith('"')) {
text = text.slice(1, -1);
} else if (text.startsWith("'") && text.endsWith("'")) {
text = text.slice(1, -1);
}
// Empty string needs quotes
if (text.length === 0) {
return false;
}
// Check if the string is a valid unquoted key
// TOML unquoted keys can contain:
// - A-Z, a-z, 0-9, _, -
const unquotedKeyRegex = /^[A-Za-z0-9_-]+$/;
// Additional checks for values that might be confused with other TOML types
if (unquotedKeyRegex.test(text)) {
// Don't unquote strings that look like booleans
if (text === 'true' || text === 'false') {
return false;
}
// Don't unquote strings that look like numbers
if (/^[+-]?(\d+\.?\d*|\d*\.\d+)([eE][+-]?\d+)?$/.test(text)) {
return false;
}
// Don't unquote strings that look like dates/times
if (/^\d{4}-\d{2}-\d{2}/.test(text)) {
return false;
}
return true;
}
return false;
}
/**
* Check if a key needs quotes
* @param keyText - The key text to check
* @returns Whether the key needs quotes
*/
export function keyNeedsQuotes(keyText: string): boolean {
return !canUnquote(`"${keyText}"`);
}
/**
* Format a key, adding or removing quotes as needed
* @param keyText - The key text to format
* @returns Formatted key
*/
export function formatKey(keyText: string): string {
// If already quoted, check if we can unquote
if ((keyText.startsWith('"') && keyText.endsWith('"')) ||
(keyText.startsWith("'") && keyText.endsWith("'"))) {
if (canUnquote(keyText)) {
return keyText.slice(1, -1);
}
return keyText;
}
// If not quoted, check if we need to add quotes
if (keyNeedsQuotes(keyText)) {
return `"${keyText}"`;
}
return keyText;
}
/**
* Check if a string contains escape sequences that need to be preserved
* @param str - The string to check
* @returns Whether the string contains escape sequences
*/
export function containsEscapeSequences(str: string): boolean {
// Check for common escape sequences
return /\\[btnfr"\\\/]|\\u[0-9a-fA-F]{4}|\\U[0-9a-fA-F]{8}/.test(str);
}
/**
* Check if a string can use literal string syntax (single quotes)
* @param str - The string to check (without quotes)
* @returns Whether literal string syntax can be used
*/
export function canUseLiteralString(str: string): boolean {
// Literal strings cannot contain single quotes or control characters
// and don't need escape sequences
return !str.includes("'") &&
!/[\x00-\x08\x0A-\x1F\x7F]/.test(str) &&
!containsEscapeSequences(str);
}
/**
* Check if a string should use multiline syntax
* @param str - The string to check (without quotes)
* @returns Whether multiline syntax should be used
*/
export function shouldUseMultiline(str: string): boolean {
// Use multiline for strings that contain newlines
return str.includes('\n') || str.includes('\r');
}
/**
* Format a string value optimally
* @param value - The string value (potentially with quotes)
* @returns Optimally formatted string
*/
export function formatStringValue(value: string): string {
// If it's already a properly formatted string, keep it
if (!value.startsWith('"') && !value.startsWith("'")) {
return value;
}
// Extract the actual string content
let content: string;
let isLiteral = false;
if (value.startsWith('"""') && value.endsWith('"""')) {
// Multiline basic string
content = value.slice(3, -3);
} else if (value.startsWith("'''") && value.endsWith("'''")) {
// Multiline literal string
content = value.slice(3, -3);
isLiteral = true;
} else if (value.startsWith('"') && value.endsWith('"')) {
// Basic string
content = value.slice(1, -1);
} else if (value.startsWith("'") && value.endsWith("'")) {
// Literal string
content = value.slice(1, -1);
isLiteral = true;
} else {
return value; // Fallback
}
// Decide on the best format
if (shouldUseMultiline(content)) {
if (isLiteral || !containsEscapeSequences(content)) {
// Use multiline literal string if no escapes needed
return `'''${content}'''`;
} else {
// Use multiline basic string
return `"""${content}"""`;
}
} else {
if (canUseLiteralString(content) && !containsEscapeSequences(content)) {
// Use literal string for simple cases
return `'${content}'`;
} else {
// Use basic string
return `"${content}"`;
}
}
}
/**
* Optimize value representation (for strings, numbers, etc.)
* @param value - The value to optimize
* @returns Optimized value representation
*/
export function optimizeValue(value: string): string {
// Handle string values
if (value.startsWith('"') || value.startsWith("'")) {
return formatStringValue(value);
}
// For non-strings, return as-is
return value;
}
/**
* Collect all comments from comment newline nodes
* @param commentsNL - Array of comment newline nodes
* @returns Array of comment tokens
*/
export function collectComments(commentsNL: TomlCstNode[] = []): TomlComment[] {
const comments: TomlComment[] = [];
commentsNL.forEach((commentNLNode) => {
if (commentNLNode.children?.Comment) {
const commentsTok = commentNLNode.children.Comment;
for (const comment of commentsTok) {
if (comment.image) {
comments.push(comment as TomlComment);
}
}
}
});
return comments;
}
/**
* Get a single element from a context that should contain exactly one key-value pair
* @param ctx - The context to extract from
* @returns The single element
* @throws Error if the context doesn't contain exactly one element
*/
export function getSingle(ctx: TomlContext): TomlCstNode {
const ctxKeys = Object.keys(ctx);
if (ctxKeys.length !== 1) {
throw new Error(
`Expecting single key CST ctx but found: <${ctxKeys.length}> keys`
);
}
const singleElementKey = ctxKeys[0];
const singleElementValues = ctx[singleElementKey];
if (!Array.isArray(singleElementValues) || singleElementValues.length !== 1) {
throw new Error(
`Expecting single item in CST ctx key but found: <${singleElementValues?.length || 0}> items`
);
}
return singleElementValues[0];
}
/**
* Get the start offset of an array item (deprecated - use arrItemProp instead)
* @param item - The array item node
* @returns The start offset
*/
export function arrItemOffset(item: TomlCstNode): number {
return arrItemProp(item, 'startOffset') as number;
}
/**
* Get a specific property from an array item, handling wrapped values
* @param item - The array item node
* @param propName - The property name to retrieve
* @returns The property value
* @throws Error for non-exhaustive matches
*/
export function arrItemProp(item: TomlCstNode, propName: keyof TomlCstNode): any {
let currentItem = item;
// Unwrap 'val' nodes
if (currentItem.name === 'val' && currentItem.children) {
currentItem = getSingle(currentItem.children);
}
// Direct property access
if (currentItem[propName] !== undefined) {
return currentItem[propName];
}
// Check for LSquare (array start)
if (currentItem.children?.LSquare?.[0]?.[propName] !== undefined) {
return currentItem.children.LSquare[0][propName];
}
// Check for LCurly (inline table start)
if (currentItem.children?.LCurly?.[0]?.[propName] !== undefined) {
return currentItem.children.LCurly[0][propName];
}
throw new Error(`Non-exhaustive match for property ${propName}`);
}

View File

@@ -0,0 +1,413 @@
/**
* 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);
}

View File

@@ -0,0 +1,62 @@
/**
* TypeScript type definitions for TOML Prettier plugin
*/
// TOML CST Node types based on @toml-tools/parser
export interface TomlCstNode {
name?: string;
image?: string;
children?: Record<string, TomlCstNode[]>;
startOffset?: number;
endOffset?: number;
startLine?: number;
endLine?: number;
tokenType?: any;
}
export interface TomlComment extends TomlCstNode {
image: string;
}
export interface TomlContext {
[key: string]: TomlCstNode[];
}
export interface TomlValue extends TomlCstNode {
children: TomlContext;
}
export interface TomlKeyVal extends TomlCstNode {
key: TomlCstNode[];
val: TomlCstNode[];
}
export interface TomlArray extends TomlCstNode {
arrayValues?: TomlCstNode;
commentNewline?: TomlCstNode[];
}
export interface TomlInlineTable extends TomlCstNode {
inlineTableKeyVals?: TomlCstNode;
}
export interface TomlTable extends TomlCstNode {
table: TomlCstNode[];
}
export interface TomlExpression extends TomlCstNode {
keyval?: TomlKeyVal[];
table?: TomlTable[];
Comment?: TomlComment[];
}
export interface TomlDocument extends TomlCstNode {
expression?: TomlExpression[];
}
// Print options for TOML formatting
export interface TomlPrintOptions {
printWidth?: number;
tabWidth?: number;
useTabs?: boolean;
}