diff --git a/.changeset/sharp-days-kiss.md b/.changeset/sharp-days-kiss.md new file mode 100644 index 00000000..a8f01e48 --- /dev/null +++ b/.changeset/sharp-days-kiss.md @@ -0,0 +1,5 @@ +--- +'prettier-plugin-sh': minor +--- + +add support for file pragmas diff --git a/packages/sh/package.json b/packages/sh/package.json index 0f8c76f0..608b939a 100644 --- a/packages/sh/package.json +++ b/packages/sh/package.json @@ -40,6 +40,10 @@ "mvdan-sh": "^0.10.1", "sh-syntax": "^0.4.2" }, + "devDependencies": { + "@types/common-tags": "^1.8.4", + "common-tags": "^1.8.2" + }, "publishConfig": { "access": "public" } diff --git a/packages/sh/src/index.ts b/packages/sh/src/index.ts index 3f3dceb5..3107fb63 100644 --- a/packages/sh/src/index.ts +++ b/packages/sh/src/index.ts @@ -108,6 +108,44 @@ const ShPlugin: Plugin = { } }, astFormat: 'sh', + hasPragma: (text: string): boolean => { + // We don't want to parse every file twice but Prettier's interface + // isn't conducive to caching/memoizing an upstream Parser, so we're + // going with some minor Regex hackery. + // + // Only read empty lines, comments, and shebangs at the start of the file. + // We do not support Bash's pseudo-block comments. + + // No, we don't support unofficial block comments. + const commentLineRegex = /^\s*(#(?.*))?$/gm + let lastIndex = -1 + + // Only read leading comments, skip shebangs, and check for the pragma. + // We don't want to have to parse every file twice. + for (;;) { + const match = commentLineRegex.exec(text) + + // Found "real" content, EoF, or stuck in a loop. + if (match == null || match.index !== lastIndex + 1) { + return false + } + + lastIndex = commentLineRegex.lastIndex + const comment = match.groups?.comment?.trim() + + // Empty lines and shebangs have no captures + if (comment == null) { + continue + } + + if ( + comment.startsWith('@prettier') || + comment.startsWith('@format') + ) { + return true + } + } + }, locStart: node => isFunction(node.Pos) ? node.Pos().Offset() : node.Pos.Offset, locEnd: node => diff --git a/packages/sh/test/parser.spec.ts b/packages/sh/test/parser.spec.ts new file mode 100644 index 00000000..24829a2f --- /dev/null +++ b/packages/sh/test/parser.spec.ts @@ -0,0 +1,116 @@ +import { stripIndent } from 'common-tags' +import { describe, it, assert, expect } from 'vitest' + +import ShPlugin from 'prettier-plugin-sh' + +describe('parser', () => { + const hasPragma = ShPlugin.parsers?.sh?.hasPragma + assert(hasPragma != null) + + describe('should detect pragmas', () => { + it('at the top of the file', () => { + expect( + hasPragma(stripIndent` + # @prettier + FOO="bar" + `), + ).toBeTruthy() + }) + + it('with extra leading spaces', () => { + expect( + hasPragma(stripIndent` + # @prettier + FOO="bar" + `), + ).toBeTruthy() + }) + + it('with no leading space', () => { + expect( + hasPragma(stripIndent` + #@prettier + FOO="bar" + `), + ).toBeTruthy() + }) + + it('with "format" pragma instead', () => { + expect( + hasPragma(stripIndent` + # @format + FOO="bar" + `), + ).toBeTruthy() + }) + + it('after leading whitespace', () => { + expect( + hasPragma(stripIndent` + + + # @prettier + FOO="bar" + `), + ).toBeTruthy() + }) + + it('after leading comments', () => { + expect( + hasPragma(stripIndent` + # Testing! + + # + # + # @prettier + FOO="bar" + `), + ).toBeTruthy() + }) + + it('after a shebang', () => { + expect( + hasPragma(stripIndent` + #!/bin/bash + # + + # @prettier + FOO="bar" + `), + ).toBeTruthy() + }) + + it('unless none exist', () => { + expect( + hasPragma(stripIndent` + FOO="bar" + `), + ).toBeFalsy() + }) + + it('unless the file is empty', () => { + expect(hasPragma('')).toBeFalsy() + }) + + it('unless it comes after real content', () => { + expect( + hasPragma(stripIndent` + FOO="bar" + # @prettier + `), + ).toBeFalsy() + }) + + it('unless it comes after real content and comments', () => { + expect( + hasPragma(stripIndent` + + # Test + #! + FOO="bar" + # @prettier + `), + ).toBeFalsy() + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4e4105a..8bb10df0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,13 @@ importers: sh-syntax: specifier: ^0.4.2 version: 0.4.2 + devDependencies: + '@types/common-tags': + specifier: ^1.8.4 + version: 1.8.4 + common-tags: + specifier: ^1.8.2 + version: 1.8.2 packages/sql: dependencies: @@ -4578,6 +4585,10 @@ packages: resolution: {integrity: sha512-uLK0/0dOYdkX8hNsezpYh1gc8eerbhf9bOKZ3e24sP67703mw9S14/yW6mSTatiaKO9v+mU/a1EVy4rOXXeZTA==} dev: true + /@types/common-tags@1.8.4: + resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==} + dev: true + /@types/concat-stream@2.0.0: resolution: {integrity: sha512-t3YCerNM7NTVjLuICZo5gYAXYoDvpuuTceCcFQWcDQz26kxUR5uIWolxbIR5jRNIXpMqhOpW/b8imCR1LEmuJw==} dependencies: @@ -6321,6 +6332,11 @@ packages: engines: {node: '>= 12.0.0'} dev: true + /common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + dev: true + /commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} dev: true