diff --git a/@types/prettier/plugins/estree.d.ts b/@types/prettier/plugins/estree.d.ts deleted file mode 100644 index 508c98d5..00000000 --- a/@types/prettier/plugins/estree.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Printer } from 'prettier'; - -declare const printers: { - estree: Printer; -}; diff --git a/eslint.config.mjs b/eslint.config.mjs index b6c06254..b8914875 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,7 +28,6 @@ const customRules = { rules: { 'jsdoc/check-param-names': 'off', 'jsdoc/newline-after-description': 'off', - 'jsdoc/require-jsdoc': ['error', { publicOnly: true }], 'jsdoc/require-param': 'off', 'jsdoc/require-param-type': 'off', 'jsdoc/require-returns': 'off', diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 5c90b666..00000000 --- a/src/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const PARSER_NAME = 'ember-template-tag'; -export const PRINTER_NAME = 'ember-template-tag-estree'; -export const TEMPLATE_TAG_NAME = 'template'; - -export const TEMPLATE_TAG_OPEN = `<${TEMPLATE_TAG_NAME}>`; -export const TEMPLATE_TAG_CLOSE = ``; diff --git a/src/languages.ts b/src/languages.ts new file mode 100644 index 00000000..536de8ee --- /dev/null +++ b/src/languages.ts @@ -0,0 +1,22 @@ +import type { SupportLanguage } from 'prettier'; + +import { PARSER_NAME } from './utils/index.js'; + +export const languages: SupportLanguage[] = [ + { + aliases: ['gjs', 'glimmer-js'], + extensions: ['.gjs'], + group: 'JavaScript', + name: 'Ember Template Tag (gjs)', + parsers: [PARSER_NAME], + vscodeLanguageIds: ['glimmer-js'], + }, + { + aliases: ['gts', 'glimmer-ts'], + extensions: ['.gts'], + group: 'TypeScript', + name: 'Ember Template Tag (gts)', + parsers: [PARSER_NAME], + vscodeLanguageIds: ['glimmer-ts'], + }, +]; diff --git a/src/main.ts b/src/main.ts index 0af8e15d..b6599ebc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,43 +1,4 @@ -import type { Node } from '@babel/types'; -import type { Parser, Plugin, Printer, SupportLanguage } from 'prettier'; - -import { PARSER_NAME, PRINTER_NAME } from './config.js'; -import { options } from './options.js'; -import { parser } from './parse/index.js'; -import { printer } from './print/index.js'; - -const languages: SupportLanguage[] = [ - { - name: 'Ember Template Tag (gjs)', - aliases: ['gjs', 'glimmer-js'], - extensions: ['.gjs'], - vscodeLanguageIds: ['glimmer-js'], - parsers: [PARSER_NAME], - group: 'JavaScript', - }, - { - name: 'Ember Template Tag (gts)', - aliases: ['gts', 'glimmer-ts'], - extensions: ['.gts'], - vscodeLanguageIds: ['glimmer-ts'], - parsers: [PARSER_NAME], - group: 'TypeScript', - }, -]; - -const parsers: Record> = { - [PARSER_NAME]: parser, -}; - -const printers: Record> = { - [PRINTER_NAME]: printer, -}; - -const plugin: Plugin = { - languages, - parsers, - printers, - options, -}; - -export default plugin; +export { languages } from './languages.js'; +export { options } from './options.js'; +export { parsers } from './parsers.js'; +export { printers } from './printers.js'; diff --git a/src/options.ts b/src/options.ts index 9a5b70da..db687a33 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,40 +1,28 @@ -import type { Node } from '@babel/types'; import type { BooleanSupportOption, ParserOptions, SupportOptions, } from 'prettier'; -export interface Options extends ParserOptions { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface PluginOptions extends ParserOptions { templateExportDefault?: boolean; templateSingleQuote?: boolean; } const templateExportDefault: BooleanSupportOption = { category: 'Format', - type: 'boolean', default: false, description: 'Prepend default export template tags with "export default". Since 0.1.0.', + type: 'boolean', }; -/** - * Extracts a valid `templateSingleQuote` option out of the provided options. If - * `templateSingleQuote` is defined, it will be used, otherwise the value for - * `singleQuote` will be inherited. - */ -export function getTemplateSingleQuote(options: Options): boolean { - const { singleQuote, templateSingleQuote } = options; - return typeof templateSingleQuote === 'boolean' - ? templateSingleQuote - : singleQuote; -} - const templateSingleQuote: BooleanSupportOption = { category: 'Format', - type: 'boolean', description: 'Use single quotes instead of double quotes within template tags. Since 0.0.3.', + type: 'boolean', }; export const options: SupportOptions = { diff --git a/src/parsers.ts b/src/parsers.ts new file mode 100644 index 00000000..0cb8ebb1 --- /dev/null +++ b/src/parsers.ts @@ -0,0 +1,27 @@ +import type { File } from '@babel/types'; +import type { Parser, ParserOptions } from 'prettier'; +import { parsers as prettierParsers } from 'prettier/plugins/babel'; + +import { convertAst, preprocess } from './parsers/index.js'; +import { assert } from './utils/assert.js'; +import { type NodeType, PARSER_NAME, PRINTER_NAME } from './utils/index.js'; + +const parser = prettierParsers['babel-ts'] as Parser; + +async function parse(text: string, options: ParserOptions) { + const { code, templates } = preprocess(text, options.filepath); + + const ast = await parser.parse(code, options); + assert('expected ast', ast); + convertAst(ast as File, { parser, templates }); + + return ast; +} + +export const parsers: Record> = { + [PARSER_NAME]: { + ...parser, + astFormat: PRINTER_NAME, + parse, + }, +}; diff --git a/src/parse/index.ts b/src/parsers/convert-ast.ts similarity index 73% rename from src/parse/index.ts rename to src/parsers/convert-ast.ts index 533f1159..f41a3da3 100644 --- a/src/parse/index.ts +++ b/src/parsers/convert-ast.ts @@ -2,19 +2,18 @@ import traverse from '@babel/traverse'; import type { BlockStatement, File, - Node, ObjectExpression, StaticBlock, } from '@babel/types'; import type { Parser } from 'prettier'; -import { parsers as babelParsers } from 'prettier/plugins/babel.js'; -import { PRINTER_NAME } from '../config.js'; -import type { Options } from '../options.js'; -import { assert } from '../utils/assert.js'; -import { preprocess, type Template } from './preprocess.js'; +import type { NodeType } from '../utils/index.js'; +import type { Template } from './preprocess.js'; -const typescript = babelParsers['babel-ts'] as Parser; +type Data = { + parser: Parser; + templates: Template[]; +}; /** Converts a node into a GlimmerTemplate node */ function convertNode( @@ -49,7 +48,9 @@ function findCorrectCommentBlockIndex( } /** Traverses the AST and replaces the transformed template parts with other AST */ -function convertAst(ast: File, templates: Template[]): void { +export function convertAst(ast: File, data: Data): void { + const { parser, templates } = data; + traverse(ast, { enter(path) { if (templates.length === 0) { @@ -62,10 +63,7 @@ function convertAst(ast: File, templates: Template[]): void { case 'BlockStatement': case 'ObjectExpression': case 'StaticBlock': { - const [start, end] = [ - typescript.locStart(node), - typescript.locEnd(node), - ]; + const [start, end] = [parser.locStart(node), parser.locEnd(node)]; const templateIndex = templates.findIndex((template) => { const { utf16Range } = template; @@ -119,18 +117,3 @@ function convertAst(ast: File, templates: Template[]): void { ); } } - -export const parser: Parser = { - ...typescript, - astFormat: PRINTER_NAME, - - async parse(code: string, options: Options): Promise { - const preprocessed = preprocess(code, options.filepath); - - const ast = await typescript.parse(preprocessed.code, options); - assert('expected ast', ast); - convertAst(ast as File, preprocessed.templates); - - return ast; - }, -}; diff --git a/src/parsers/index.ts b/src/parsers/index.ts new file mode 100644 index 00000000..9105d25e --- /dev/null +++ b/src/parsers/index.ts @@ -0,0 +1,2 @@ +export { convertAst } from './convert-ast.js'; +export { preprocess } from './preprocess.js'; diff --git a/src/parse/preprocess.ts b/src/parsers/preprocess.ts similarity index 100% rename from src/parse/preprocess.ts rename to src/parsers/preprocess.ts diff --git a/src/print/ambiguity.ts b/src/print/ambiguity.ts deleted file mode 100644 index 7ff879f2..00000000 --- a/src/print/ambiguity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Node } from '@babel/types'; -import type { AstPath, doc, Printer } from 'prettier'; -import { printers as estreePrinters } from 'prettier/plugins/estree.js'; - -import type { Options } from '../options.js'; -import { flattenDoc } from '../utils/doc.js'; - -const estreePrinter = estreePrinters['estree'] as Printer; - -/** - * Search next non EmptyStatement node and set current print, so we can fix it - * later if its ambiguous - */ -export function saveCurrentPrintOnSiblingNode( - path: AstPath, - printed: doc.builders.Doc[], -): void { - const { index, siblings } = path; - if (index !== null) { - const nextNode = siblings - ?.slice(index + 1) - .find((n) => n?.type !== 'EmptyStatement'); - if (nextNode) { - nextNode.extra = nextNode.extra || {}; - nextNode.extra['prevTemplatePrinted'] = printed; - } - } -} - -/** HACK to fix ASI semi-colons. */ -export function fixPreviousPrint( - previousTemplatePrinted: doc.builders.Doc[], - path: AstPath, - options: Options, - print: (path: AstPath) => doc.builders.Doc, - args: unknown, -): void { - const printedSemiFalse = estreePrinter.print( - path, - { ...options, semi: false }, - print, - args, - ); - const flat = flattenDoc(printedSemiFalse); - const previousFlat = flattenDoc(previousTemplatePrinted); - if (flat[0]?.startsWith(';') && previousFlat.at(-1) !== ';') { - previousTemplatePrinted.push(';'); - } -} diff --git a/src/print/index.ts b/src/print/index.ts deleted file mode 100644 index f25a76fb..00000000 --- a/src/print/index.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { Node } from '@babel/types'; -import type { - AstPath, - doc, - Options as PrettierOptions, - Printer, -} from 'prettier'; -import { printers as estreePrinters } from 'prettier/plugins/estree.js'; - -import { TEMPLATE_TAG_CLOSE, TEMPLATE_TAG_OPEN } from '../config.js'; -import type { Options } from '../options.js'; -import { - isGlimmerTemplate, - isGlimmerTemplateParent, -} from '../types/glimmer.js'; -import { assert } from '../utils/assert.js'; -import { - fixPreviousPrint, - saveCurrentPrintOnSiblingNode, -} from './ambiguity.js'; -import { printTemplateContent, printTemplateTag } from './template.js'; - -const estreePrinter = estreePrinters['estree'] as Printer; - -export const printer: Printer = { - ...estreePrinter, - - getVisitorKeys(node, nonTraversableKeys) { - if (node && isGlimmerTemplate(node)) { - return []; - } - return estreePrinter.getVisitorKeys?.(node, nonTraversableKeys) || []; - }, - - printPrettierIgnored(path: AstPath, options: Options) { - return printRawText(path, options); - }, - - print( - path: AstPath, - options: Options, - print: (path: AstPath) => doc.builders.Doc, - args: unknown, - ) { - const { node } = path; - - if (isGlimmerTemplateParent(node)) { - if (checkPrettierIgnore(path)) { - return printRawText(path, options); - } else { - let printed = estreePrinter.print(path, options, print, args); - - assert('Expected Glimmer doc to be an array', Array.isArray(printed)); - trimPrinted(printed); - - // Remove semicolons so we can manage them ourselves - if (docMatchesString(printed[0], ';')) { - printed.shift(); - } - if (docMatchesString(printed.at(-1), ';')) { - printed.pop(); - } - - trimPrinted(printed); - - // Always remove export default so we start with a blank slate - if ( - docMatchesString(printed[0], 'export') && - docMatchesString(printed[1], 'default') - ) { - printed = printed.slice(2); - trimPrinted(printed); - } - - if (options.templateExportDefault) { - printed.unshift('export ', 'default '); - } - - saveCurrentPrintOnSiblingNode(path, printed); - - return printed; - } - } - - if (options.semi && node?.extra?.['prevTemplatePrinted']) { - fixPreviousPrint( - node.extra['prevTemplatePrinted'] as doc.builders.Doc[], - path, - options, - print, - args, - ); - } - - return estreePrinter.print(path, options, print, args); - }, - - /** Prints embedded GlimmerExpressions/GlimmerTemplates. */ - embed(path: AstPath, embedOptions: PrettierOptions) { - const { node } = path; - - return async (textToDoc) => { - if (node && isGlimmerTemplate(node)) { - if (checkPrettierIgnore(path)) { - return printRawText(path, embedOptions as Options); - } - - try { - const content = await printTemplateContent( - node.extra.template.contents, - textToDoc, - embedOptions as Options, - ); - - const printed = printTemplateTag(content); - saveCurrentPrintOnSiblingNode(path, printed); - return printed; - } catch (error) { - console.error(error); - const printed = [printRawText(path, embedOptions as Options)]; - saveCurrentPrintOnSiblingNode(path, printed); - return printed; - } - } - - // Nothing to embed, so move on to the regular printer. - return; - }; - }, -}; - -/** Remove the empty strings that Prettier added so we can manage them. */ -function trimPrinted(printed: doc.builders.Doc[]): void { - while (docMatchesString(printed[0], '')) { - printed.shift(); - } - - while (docMatchesString(printed.at(-1), '')) { - printed.pop(); - } -} - -function printRawText( - { node }: AstPath, - options: Options, -): string { - if (!node) { - return ''; - } - if (isGlimmerTemplate(node)) { - return ( - TEMPLATE_TAG_OPEN + node.extra.template.contents + TEMPLATE_TAG_CLOSE - ); - } - assert('expected start', typeof node.start == 'number'); - assert('expected end', typeof node.end == 'number'); - return options.originalText.slice(node.start, node.end); -} - -function hasPrettierIgnore(path: AstPath): boolean { - let possibleComment = path.node?.leadingComments?.at(-1)?.value.trim(); - - // @ts-expect-error Comments exist on node sometimes - if (!path.node?.leadingComments && path.node?.comments) { - // @ts-expect-error Comments exist on node sometimes - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - possibleComment = path.node.comments?.at(-1)?.value.trim(); - } - return possibleComment === 'prettier-ignore'; -} - -function checkPrettierIgnore(path: AstPath): boolean { - return ( - hasPrettierIgnore(path) || - (!!path.getParentNode() && - path.callParent((parent) => checkPrettierIgnore(parent))) - ); -} - -function docMatchesString( - doc: doc.builders.Doc | undefined, - string: string, -): doc is string { - return typeof doc === 'string' && doc.trim() === string; -} diff --git a/src/print/template.ts b/src/print/template.ts deleted file mode 100644 index 39499d55..00000000 --- a/src/print/template.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Options as PrettierOptions } from 'prettier'; -import { doc } from 'prettier'; - -import { TEMPLATE_TAG_CLOSE, TEMPLATE_TAG_OPEN } from '../config.js'; -import type { Options } from '../options.js'; -import { getTemplateSingleQuote } from '../options.js'; -import { flattenDoc } from '../utils/doc.js'; - -const { - builders: { group, hardline, indent, softline }, -} = doc; - -/** - * Returns a Prettier `Doc` for the given `TemplateLiteral` contents formatted - * using Prettier's built-in glimmer parser. - * - * NOTE: The contents are not surrounded with "`" - */ -export async function printTemplateContent( - text: string, - textToDoc: ( - text: string, - // Don't use our `Options` here even though technically they are available - // because we don't want to accidentally pass them into `textToDoc`. We - // should normalize them into standard Prettier options at this point. - options: PrettierOptions, - ) => Promise, - options: Options, -): Promise { - return await textToDoc(text.trim(), { - ...options, - parser: 'glimmer', - singleQuote: getTemplateSingleQuote(options), - }); -} - -/** - * Prints the given template content as a template tag. - * - * If `useHardline` is `true`, will use Prettier's hardline builder to force - * each tag to print on a new line. - * - * If `useHardline` is `false`, will use Prettier's softline builder to allow - * the tags to print on the same line if they fit. - */ -export function printTemplateTag( - content: doc.builders.Doc, -): doc.builders.Doc[] { - const contents = flattenDoc(content); - const useHardline = contents.some( - (c) => - // contains angle bracket tag - /<.+>/.test(c) || - // contains hbs block - /{{~?#.+}}/.test(c), - ); - const line = useHardline ? hardline : softline; - const doc = [ - TEMPLATE_TAG_OPEN, - indent([line, group(content)]), - line, - TEMPLATE_TAG_CLOSE, - ]; - return [group(doc)]; -} diff --git a/src/printers.ts b/src/printers.ts new file mode 100644 index 00000000..78f75c3c --- /dev/null +++ b/src/printers.ts @@ -0,0 +1,140 @@ +import { type AstPath, doc as AST, type Printer } from 'prettier'; +import { printers as prettierPrinters } from 'prettier/plugins/estree'; + +import type { PluginOptions } from './options.js'; +import { + checkPrettierIgnore, + docMatchesString, + fixPreviousPrint, + printRawText, + printTemplateContent, + printTemplateTag, + saveCurrentPrintOnSiblingNode, + trimPrinted, +} from './printers/index.js'; +import { isGlimmerTemplate, isGlimmerTemplateParent } from './types/glimmer.js'; +import { assert } from './utils/assert.js'; +import { type NodeType, PRINTER_NAME } from './utils/index.js'; + +const printer = prettierPrinters['estree'] as Printer; + +function embed(path: AstPath, options: PluginOptions) { + const { node } = path; + + return async ( + textToDoc: ( + text: string, + options: PluginOptions, + ) => Promise, + ) => { + if (node && isGlimmerTemplate(node)) { + if (checkPrettierIgnore(path)) { + return printRawText(path, options); + } + + try { + const content = await printTemplateContent( + node.extra.template.contents, + textToDoc, + options, + ); + + const printed = printTemplateTag(content); + saveCurrentPrintOnSiblingNode(path, printed); + return printed; + } catch (error) { + console.error(error); + const printed = [printRawText(path, options)]; + saveCurrentPrintOnSiblingNode(path, printed); + return printed; + } + } + + // Nothing to embed, so move on to the regular printer. + return; + }; +} + +function getVisitorKeys(node: NodeType, nonTraversableKeys: Set) { + if (node && isGlimmerTemplate(node)) { + return []; + } + return printer.getVisitorKeys?.(node, nonTraversableKeys) || []; +} + +function print( + path: AstPath, + options: PluginOptions, + print: (path: AstPath) => AST.builders.Doc, + args?: unknown, +) { + const { node } = path; + + if (isGlimmerTemplateParent(node)) { + if (checkPrettierIgnore(path)) { + return printRawText(path, options); + } else { + let printed = printer.print(path, options, print, args); + + assert('Expected Glimmer doc to be an array', Array.isArray(printed)); + trimPrinted(printed); + + // Remove semicolons so we can manage them ourselves + if (docMatchesString(printed[0], ';')) { + printed.shift(); + } + if (docMatchesString(printed.at(-1), ';')) { + printed.pop(); + } + + trimPrinted(printed); + + // Always remove export default so we start with a blank slate + if ( + docMatchesString(printed[0], 'export') && + docMatchesString(printed[1], 'default') + ) { + printed = printed.slice(2); + trimPrinted(printed); + } + + if (options.templateExportDefault) { + printed.unshift('export ', 'default '); + } + + saveCurrentPrintOnSiblingNode(path, printed); + + return printed; + } + } + + if (options.semi && node?.extra?.['prevTemplatePrinted']) { + fixPreviousPrint( + node.extra['prevTemplatePrinted'] as AST.builders.Doc[], + path, + options, + print, + args, + ); + } + + return printer.print(path, options, print, args); +} + +function printPrettierIgnored( + path: AstPath, + options: PluginOptions, +) { + return printRawText(path, options); +} + +export const printers: Record> = { + [PRINTER_NAME]: { + ...printer, + // @ts-expect-error: Type <...> is not assignable to <...> + embed, + getVisitorKeys, + print, + printPrettierIgnored, + }, +}; diff --git a/src/printers/ambiguity.ts b/src/printers/ambiguity.ts new file mode 100644 index 00000000..9f456101 --- /dev/null +++ b/src/printers/ambiguity.ts @@ -0,0 +1,55 @@ +import type { AstPath, doc as AST, Printer } from 'prettier'; +import { printers as prettierPrinters } from 'prettier/plugins/estree'; + +import type { PluginOptions } from '../options.js'; +import { flattenDoc } from '../utils/doc.js'; +import type { NodeType } from '../utils/index.js'; + +const printer = prettierPrinters['estree'] as Printer; + +/** + * Search next non EmptyStatement node and set current print, so we can fix it + * later if its ambiguous + */ +export function saveCurrentPrintOnSiblingNode( + path: AstPath, + printed: AST.builders.Doc[], +): void { + const { index, siblings } = path; + + if (index === null) { + return; + } + + const nextNode = siblings + ?.slice(index + 1) + .find((n) => n?.type !== 'EmptyStatement'); + + if (nextNode) { + nextNode.extra = nextNode.extra || {}; + nextNode.extra['prevTemplatePrinted'] = printed; + } +} + +/** HACK to fix ASI semi-colons. */ +export function fixPreviousPrint( + previousTemplatePrinted: AST.builders.Doc[], + path: AstPath, + options: PluginOptions, + print: (path: AstPath) => AST.builders.Doc, + args: unknown, +): void { + const printedSemiFalse = printer.print( + path, + { ...options, semi: false }, + print, + args, + ); + + const flat = flattenDoc(printedSemiFalse); + const previousFlat = flattenDoc(previousTemplatePrinted); + + if (flat[0]?.startsWith(';') && previousFlat.at(-1) !== ';') { + previousTemplatePrinted.push(';'); + } +} diff --git a/src/printers/ignore.ts b/src/printers/ignore.ts new file mode 100644 index 00000000..7934fbf2 --- /dev/null +++ b/src/printers/ignore.ts @@ -0,0 +1,27 @@ +import type { AstPath } from 'prettier'; + +import type { NodeType } from '../utils/index.js'; + +function hasPrettierIgnore(path: AstPath): boolean { + let possibleComment = path.node?.leadingComments?.at(-1)?.value.trim(); + + // @ts-expect-error Comments exist on node sometimes + if (!path.node?.leadingComments && path.node?.comments) { + // @ts-expect-error Comments exist on node sometimes + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + possibleComment = path.node.comments?.at(-1)?.value.trim(); + } + + return possibleComment === 'prettier-ignore'; +} + +export function checkPrettierIgnore(path: AstPath): boolean { + if (hasPrettierIgnore(path)) { + return true; + } + + return ( + Boolean(path.getParentNode()) && + path.callParent((parent) => checkPrettierIgnore(parent)) + ); +} diff --git a/src/printers/index.ts b/src/printers/index.ts new file mode 100644 index 00000000..2fee7a96 --- /dev/null +++ b/src/printers/index.ts @@ -0,0 +1,12 @@ +export { + fixPreviousPrint, + saveCurrentPrintOnSiblingNode, +} from './ambiguity.js'; +export { checkPrettierIgnore } from './ignore.js'; +export { + docMatchesString, + printRawText, + printTemplateContent, + printTemplateTag, + trimPrinted, +} from './print.js'; diff --git a/src/printers/print.ts b/src/printers/print.ts new file mode 100644 index 00000000..39bec547 --- /dev/null +++ b/src/printers/print.ts @@ -0,0 +1,103 @@ +import { type AstPath, doc as AST } from 'prettier'; + +import type { PluginOptions } from '../options.js'; +import { isGlimmerTemplate } from '../types/glimmer.js'; +import { assert } from '../utils/assert.js'; +import { flattenDoc } from '../utils/doc.js'; +import { + type NodeType, + TEMPLATE_TAG_CLOSE, + TEMPLATE_TAG_OPEN, +} from '../utils/index.js'; + +export function docMatchesString( + doc: AST.builders.Doc | undefined, + string: string, +): doc is string { + return typeof doc === 'string' && doc.trim() === string; +} + +export function printRawText( + { node }: AstPath, + options: PluginOptions, +): string { + if (!node) { + return ''; + } + + if (isGlimmerTemplate(node)) { + return ( + TEMPLATE_TAG_OPEN + node.extra.template.contents + TEMPLATE_TAG_CLOSE + ); + } + + assert('expected start', typeof node.start == 'number'); + assert('expected end', typeof node.end == 'number'); + + return options.originalText.slice(node.start, node.end); +} + +/** + * Returns a Prettier `Doc` for the given `TemplateLiteral` contents formatted + * using Prettier's built-in glimmer parser. + * + * NOTE: The contents are not surrounded with "`" + */ +export async function printTemplateContent( + text: string, + textToDoc: ( + text: string, + options: PluginOptions, + ) => Promise, + options: PluginOptions, +): Promise { + return await textToDoc(text.trim(), { + ...options, + parser: 'glimmer', + singleQuote: options.templateSingleQuote ?? options.singleQuote, + }); +} + +/** + * Prints the given template content as a template tag. + * + * If `useHardline` is `true`, will use Prettier's hardline builder to force + * each tag to print on a new line. + * + * If `useHardline` is `false`, will use Prettier's softline builder to allow + * the tags to print on the same line if they fit. + */ +export function printTemplateTag( + content: AST.builders.Doc, +): AST.builders.Doc[] { + const contents = flattenDoc(content); + + const useHardline = contents.some( + (c) => + // contains angle bracket tag + /<.+>/.test(c) || + // contains hbs block + /{{~?#.+}}/.test(c), + ); + const line = useHardline ? AST.builders.hardline : AST.builders.softline; + + const doc = [ + TEMPLATE_TAG_OPEN, + AST.builders.indent([line, AST.builders.group(content)]), + line, + TEMPLATE_TAG_CLOSE, + ]; + + return [AST.builders.group(doc)]; +} + +/** Remove the empty strings that Prettier added so we can manage them. */ +export function trimPrinted(printed: AST.builders.Doc[]): void { + while (docMatchesString(printed[0], '')) { + printed.shift(); + } + + while (docMatchesString(printed.at(-1), '')) { + printed.pop(); + } +} diff --git a/src/utils/content-tag.ts b/src/utils/content-tag.ts index 24bc586d..fe4bffac 100644 --- a/src/utils/content-tag.ts +++ b/src/utils/content-tag.ts @@ -1,4 +1,4 @@ -/* eslint-disable jsdoc/require-jsdoc, unicorn/prefer-export-from */ +/* eslint-disable unicorn/prefer-export-from */ import { type Parsed as ContentTag, Preprocessor, diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..bc5153f2 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,9 @@ +import type { Node } from '@babel/types'; + +export type NodeType = Node | undefined; + +export const PARSER_NAME = 'ember-template-tag'; +export const PRINTER_NAME = 'ember-template-tag-estree'; + +export const TEMPLATE_TAG_OPEN = ''; diff --git a/tests/helpers/ambiguous.ts b/tests/helpers/ambiguous.ts index 84a4f21d..b4a961d2 100644 --- a/tests/helpers/ambiguous.ts +++ b/tests/helpers/ambiguous.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import type { Options } from '../../src/options.js'; +import type { PluginOptions } from '../../src/options.js'; import { parse } from '../../src/utils/content-tag.js'; import type { TestCase } from '../helpers/cases.js'; import { getAllCases } from '../helpers/cases.js'; @@ -89,7 +89,7 @@ export function makeAmbiguousExpressionTest( async function behavesLikeFormattedAmbiguousCase( code: string, - formatOptions: Partial = {}, + formatOptions: Partial = {}, ): Promise { try { const result = await format(code, formatOptions); diff --git a/tests/helpers/format.ts b/tests/helpers/format.ts index f15b2309..a45cb8fd 100644 --- a/tests/helpers/format.ts +++ b/tests/helpers/format.ts @@ -1,13 +1,14 @@ import type { Plugin } from 'prettier'; import { format as prettierFormat } from 'prettier'; -import { PARSER_NAME } from '../../src/config.js'; -import plugin from '../../src/main.js'; -import type { Options } from '../../src/options.js'; +import { languages, options, parsers, printers } from '../../src/main.js'; +import type { PluginOptions } from '../../src/options.js'; -const DEFAULT_OPTIONS: Partial = { - parser: PARSER_NAME, - plugins: [plugin as Plugin], +const plugin: Plugin = { + languages, + options, + parsers, + printers, }; /** @@ -18,10 +19,11 @@ const DEFAULT_OPTIONS: Partial = { */ export async function format( code: string, - overrides: Partial = {}, + overrides: Partial = {}, ): Promise { return await prettierFormat(code, { - ...DEFAULT_OPTIONS, ...overrides, + parser: 'ember-template-tag', + plugins: [plugin], }); } diff --git a/tests/helpers/make-suite.ts b/tests/helpers/make-suite.ts index 082a714d..29a1d52d 100644 --- a/tests/helpers/make-suite.ts +++ b/tests/helpers/make-suite.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import type { Options } from '../../src/options.js'; +import type { PluginOptions } from '../../src/options.js'; import { AMBIGUOUS_PLACEHOLDER, getAmbiguousCases, @@ -12,7 +12,7 @@ import { format } from './format.js'; export interface Config { name: string; - options?: Partial; + options?: Partial; } /** diff --git a/tests/unit-tests/preprocess.test.ts b/tests/unit-tests/preprocess.test.ts index 386ebfb9..245e4834 100644 --- a/tests/unit-tests/preprocess.test.ts +++ b/tests/unit-tests/preprocess.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'; import { codeToGlimmerAst, preprocessTemplateRange, -} from '../../src/parse/preprocess.js'; +} from '../../src/parsers/preprocess.js'; const TEST_CASES = [ { diff --git a/tsconfig.json b/tsconfig.json index 8473b9e4..38ec332f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,11 +9,7 @@ "resolveJsonModule": true, "isolatedModules": true, // "verbatimModuleSyntax": true, - "noEmit": true, - - "paths": { - "*": ["./@types/*"] - } + "noEmit": true }, - "include": ["src"] + "include": ["src", "types"] } diff --git a/tsconfig.lint.json b/tsconfig.lint.json index 2c2643d4..4e0901fa 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -4,5 +4,5 @@ "noEmit": true, "allowJs": true }, - "include": ["src", "tests", "vite.config.ts"] + "include": ["src", "tests", "types", "vite.config.ts"] } diff --git a/types/prettier.d.ts b/types/prettier.d.ts new file mode 100644 index 00000000..d9d99ab6 --- /dev/null +++ b/types/prettier.d.ts @@ -0,0 +1,7 @@ +import type { Printer } from 'prettier'; + +declare module 'prettier/plugins/estree' { + export const printers: { + estree: Printer; + }; +}