diff --git a/README.md b/README.md index 3be0372..f2985a3 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,16 @@ Formatting embedded Java Properties code requires [`prettier-plugin-properties`] Formatting embedded Ruby code requires [`@prettier/plugin-ruby`](https://github.com/prettier/plugin-ruby) to be loaded and [its dependencies to be installed](https://github.com/prettier/plugin-ruby#getting-started) as well. And [options](https://github.com/prettier/plugin-ruby#configuration) supported by `@prettier/plugin-ruby` can therefore be used to further control the formatting behavior. +#### Shell + +| Option | Default | Description | +| :---------------------: | :-------------------------------------: | --------------------------------------------------------------------------------------------------- | +| `embeddedShIdentifiers` | [`[...]`](./src/embedded/sh/options.ts) | Tag or comment identifiers that make their subsequent template literals be identified as Shell code | + +Formatting embedded Shell code requires [`prettier-plugin-sh`](https://github.com/un-ts/prettier/tree/master/packages/sh#readme) to be loaded as well. And [options](https://github.com/un-ts/prettier/tree/master/packages/sh#parser-options) supported by `prettier-plugin-sh` can therefore be used to further control the formatting behavior. + +Note that `prettier-plugin-sh` supports different variants of shell syntaxes and they are specified by the [`variant` option](https://github.com/un-ts/prettier/tree/master/packages/sh#parser-options). To map a subset of identifiers to another dialect, please use [`embeddedOverrides`](#embeddedoverrides). + #### SQL | Option | Default | Description | diff --git a/package-lock.json b/package-lock.json index 58c7421..050da9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "prettier-plugin-latex": "^2.0.1", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-properties": "^0.3.0", + "prettier-plugin-sh": "^0.13.1", "prettier-plugin-sql": "^0.17.1", "prettier-plugin-toml": "^2.0.1", "semantic-release": "^22.0.10", @@ -7205,6 +7206,12 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mvdan-sh": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz", + "integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==", + "dev": true + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -11596,6 +11603,25 @@ "prettier": ">= 2.3.0" } }, + "node_modules/prettier-plugin-sh": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz", + "integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==", + "dev": true, + "dependencies": { + "mvdan-sh": "^0.10.1", + "sh-syntax": "^0.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "node_modules/prettier-plugin-sql": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/prettier-plugin-sql/-/prettier-plugin-sql-0.17.1.tgz", @@ -12645,6 +12671,21 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "node_modules/sh-syntax": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.1.tgz", + "integrity": "sha512-MW/ZsCYTu11EIYYTSZcfAgMFszAodCmQVB27XssHoIN6L4EG0KSA3h32x8whaSOKuYBX5wz9EybfnPBUFQMCKA==", + "dev": true, + "dependencies": { + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 8a7e93f..e928d3c 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "prettier-plugin-latex": "^2.0.1", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-properties": "^0.3.0", + "prettier-plugin-sh": "^0.13.1", "prettier-plugin-sql": "^0.17.1", "prettier-plugin-toml": "^2.0.1", "semantic-release": "^22.0.10", diff --git a/src/embedded/sh/embedded-language.ts b/src/embedded/sh/embedded-language.ts new file mode 100644 index 0000000..c173483 --- /dev/null +++ b/src/embedded/sh/embedded-language.ts @@ -0,0 +1,7 @@ +export const embeddedLanguage = "embeddedSh"; + +declare module "../types.js" { + interface EmbeddedLanguagesHolder { + [embeddedLanguage]: void; + } +} diff --git a/src/embedded/sh/embedder.ts b/src/embedded/sh/embedder.ts new file mode 100644 index 0000000..cbda823 --- /dev/null +++ b/src/embedded/sh/embedder.ts @@ -0,0 +1,104 @@ +import type { Options } from "prettier"; +import { builders, utils } from "prettier/doc"; +import type { Embedder } from "../../types.js"; +import { preparePlaceholder, printTemplateExpressions } from "../utils.js"; +import { embeddedLanguage } from "./embedded-language.js"; + +const { hardline, group, line, softline, indent } = builders; +const { mapDoc } = utils; + +export const embedder: Embedder = async ( + textToDoc, + print, + path, + options, + { identifier, embeddedOverrideOptions }, +) => { + options = { + ...options, + ...embeddedOverrideOptions, + }; + + const { node } = path; + + const { createPlaceholder, placeholderRegex } = preparePlaceholder(); + + const text = node.quasis + .map((quasi, index, { length }) => + index === length - 1 + ? quasi.value.cooked + : quasi.value.cooked + createPlaceholder(index), + ) + .join(""); + + const leadingWhitespaces = text.match(/^\s+/)?.[0] ?? ""; + const trailingWhitespaces = text.match(/\s+$/)?.[0] ?? ""; + + const trimmedText = text.slice( + leadingWhitespaces.length, + -trailingWhitespaces.length || undefined, + ); + + const expressionDocs = printTemplateExpressions(path, print); + + const doc = await textToDoc(trimmedText, { + ...options, + parser: "sh", + }); + + const contentDoc = mapDoc(doc, (doc) => { + if (typeof doc !== "string") { + return doc; + } + const parts = []; + const components = doc.split(placeholderRegex); + for (let i = 0; i < components.length; i++) { + let component = components[i]; + if (i % 2 == 0) { + if (!component) { + continue; + } + component = component.replaceAll(/([\\`]|\${)/g, "\\$1"); + component + .split(/(\n)/) + .forEach((c) => (c === "\n" ? parts.push(hardline) : parts.push(c))); + } else { + const placeholderIndex = Number(component); + parts.push(expressionDocs[placeholderIndex]); + } + } + return parts; + }); + + if (options.preserveEmbeddedExteriorWhitespaces?.includes(identifier)) { + // TODO: should we label the doc with { hug: false } ? + // https://github.com/prettier/prettier/blob/5cfb76ee50cf286cab267cf3cb7a26e749c995f7/src/language-js/embed/html.js#L88 + return group([ + "`", + leadingWhitespaces, + options.noEmbeddedMultiLineIndentation?.includes(identifier) + ? [group(contentDoc)] + : indent([group(contentDoc)]), + trailingWhitespaces, + "`", + ]); + } + + const leadingLineBreak = leadingWhitespaces.length ? line : softline; + const trailingLineBreak = trailingWhitespaces.length ? line : softline; + + return group([ + "`", + options.noEmbeddedMultiLineIndentation?.includes(identifier) + ? [leadingLineBreak, group(contentDoc)] + : indent([leadingLineBreak, group(contentDoc)]), + trailingLineBreak, + "`", + ]); +}; + +declare module "../types.js" { + interface EmbeddedEmbedders { + [embeddedLanguage]: typeof embedder; + } +} diff --git a/src/embedded/sh/index.ts b/src/embedded/sh/index.ts new file mode 100644 index 0000000..cee8055 --- /dev/null +++ b/src/embedded/sh/index.ts @@ -0,0 +1,3 @@ +export * from "./embedded-language.js"; +export * from "./embedder.js"; +export * from "./options.js"; diff --git a/src/embedded/sh/options.ts b/src/embedded/sh/options.ts new file mode 100644 index 0000000..f7631ca --- /dev/null +++ b/src/embedded/sh/options.ts @@ -0,0 +1,46 @@ +import type { CoreCategoryType, SupportOptions } from "prettier"; +import type { ShParserOptions } from "prettier-plugin-sh"; +import { + makeIdentifiersOptionName, + type AutocompleteStringList, + type StringListToInterfaceKey, +} from "../utils.js"; +import { embeddedLanguage } from "./embedded-language.js"; + +/** References: + * - https://github.com/github-linguist/linguist/blob/7ca3799b8b5f1acde1dd7a8dfb7ae849d3dfb4cd/lib/linguist/languages.yml#L6445C8-L6445C8 + */ +const DEFAULT_IDENTIFIERS = ["sh"] as const; +type Identifiers = AutocompleteStringList; +type DefaultIdentifiersHolder = StringListToInterfaceKey< + typeof DEFAULT_IDENTIFIERS +>; + +const EMBEDDED_LANGUAGE_IDENTIFIERS = + makeIdentifiersOptionName(embeddedLanguage); + +export interface PrettierPluginDepsOptions extends Partial {} + +export const options = { + [EMBEDDED_LANGUAGE_IDENTIFIERS]: { + category: "Global", + type: "string", + array: true, + default: [{ value: [...DEFAULT_IDENTIFIERS] }], + description: "Specify embedded Shell language identifiers.", + }, +} satisfies SupportOptions & Record; + +type Options = typeof options; + +declare module "../types.js" { + interface EmbeddedOptions extends Options {} + interface EmbeddedDefaultIdentifiersHolder extends DefaultIdentifiersHolder {} + interface PrettierPluginEmbedOptions { + [EMBEDDED_LANGUAGE_IDENTIFIERS]?: Identifiers; + } +} + +declare module "prettier" { + export interface Options extends PrettierPluginDepsOptions {} +}