diff --git a/.editorconfig b/.editorconfig index 49a6d74cdd..1cbc8fe300 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,7 @@ insert_final_newline = true [cspell.json] indent_size = 4 +insert_final_newline = false [website/blog/*.md] trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore index a1aa75db9b..1c82b7c66a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,9 +1,9 @@ !/*.js -/tests/**/*.js -!/tests/**/jsfmt.spec.js +/tests/format/**/*.js +/tests/integration/cli/ +!/tests/format/**/jsfmt.spec.js !/**/.eslintrc.js -/test*.js -/scripts/build/shims +/test*.* /scripts/release/node_modules /coverage/ /dist/ @@ -11,4 +11,3 @@ /website/build/ /website/static/playground.js /website/static/lib/ -/tests_integration/cli/ diff --git a/.eslintrc.yml b/.eslintrc.yml index 7fb4a0000f..1e6f74f65c 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,27 +1,36 @@ root: true env: - es6: true + es2020: true node: true extends: - eslint:recommended - - plugin:prettier/recommended -parserOptions: - ecmaVersion: 2018 + - prettier plugins: + - prettier-internal-rules - import + - regexp - unicorn +settings: + import/internal-regex: ^linguist-languages/ rules: + arrow-body-style: + - error + - as-needed curly: error dot-notation: error - eqeqeq: - - error - - always - - null: ignore + eqeqeq: error import/no-extraneous-dependencies: - error - devDependencies: ["tests*/**", "scripts/**"] - no-else-return: error + import/order: error + no-else-return: + - error + - allowElseIf: false + no-implicit-coercion: error no-inner-declarations: error + no-restricted-syntax: + - error + - 'BinaryExpression[operator=/^[!=]==$/] > UnaryExpression.left[operator="!"]' no-unneeded-ternary: error no-useless-return: error no-unused-vars: @@ -33,7 +42,9 @@ rules: - error - never prefer-arrow-callback: error - prefer-const: error + prefer-const: + - error + - destructuring: all prefer-destructuring: - error - VariableDeclarator: @@ -46,6 +57,9 @@ rules: prefer-object-spread: error prefer-rest-params: error prefer-spread: error + prettier-internal-rules/jsx-identifier-case: error + prettier-internal-rules/require-json-extensions: error + prettier-internal-rules/no-identifier-n: error quotes: - error - double @@ -56,8 +70,28 @@ rules: - error - never - exceptRange: true + regexp/match-any: + - error + - allows: + - dotAll + regexp/no-useless-flag: error + unicorn/better-regex: error + unicorn/explicit-length-check: error unicorn/new-for-builtins: error + unicorn/no-array-for-each: error + unicorn/no-array-push-push: error + unicorn/no-useless-undefined: error + unicorn/prefer-array-flat: + - error + - functions: + - flat + - flatten + unicorn/prefer-array-flat-map: error unicorn/prefer-includes: error + unicorn/prefer-number-properties: error + unicorn/prefer-optional-catch-binding: error + unicorn/prefer-regexp-test: error + unicorn/prefer-spread: error unicorn/prefer-string-slice: error overrides: - files: @@ -65,12 +99,68 @@ overrides: rules: no-console: off - files: - - "{tests,tests_config,tests_integration}/**/*.js" + - "**/*.mjs" + parserOptions: + sourceType: module + rules: + unicorn/prefer-module: error + unicorn/prefer-node-protocol: error + - files: + - "tests/format/**/jsfmt.spec.js" + - "tests/config/**/*.js" + - "tests/integration/**/*.js" env: jest: true + plugins: + - jest + rules: + jest/valid-expect: + - error + - alwaysAwait: true - files: - tests/**/*.js rules: strict: off + unicorn/prefer-array-flat: off + unicorn/prefer-array-flat-map: off globals: run_spec: false + - files: + - src/cli/**/*.js + rules: + no-restricted-modules: + - error + - patterns: + - ".." + - files: src/language-js/needs-parens.js + rules: + prettier-internal-rules/better-parent-property-check-in-needs-parens: error + - files: src/**/*.js + rules: + prettier-internal-rules/consistent-negative-index-access: error + prettier-internal-rules/flat-ast-path-call: error + prettier-internal-rules/no-conflicting-comment-check-flags: error + prettier-internal-rules/no-doc-builder-concat: error + prettier-internal-rules/no-empty-flat-contents-for-if-break: error + prettier-internal-rules/no-unnecessary-ast-path-call: error + prettier-internal-rules/prefer-ast-path-each: error + prettier-internal-rules/prefer-indent-if-break: error + prettier-internal-rules/prefer-is-non-empty-array: error + - files: + - src/language-*/**/*.js + rules: + prettier-internal-rules/directly-loc-start-end: error + - files: + - src/language-js/**/*.js + rules: + prettier-internal-rules/no-node-comments: + - error + - file: "src/language-js/utils.js" + functions: + - hasComment + - getComments + - "src/language-js/parse-postprocess.js" + - "src/language-js/parser-babel.js" + - "src/language-js/parser-meriyah.js" + - "src/language-js/pragma.js" + - "src/language-js/parser/json.js" diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index b2680ab2ad..0000000000 --- a/.flowconfig +++ /dev/null @@ -1,4 +0,0 @@ -[ignore] -.*/tests/.* -.*/node_modules/.* -.*/dist/.* diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..30ca63bbc0 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,42 @@ +# git-blame ignored revisions +# To configure, run +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# Requires Git > 2.23 +# See https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt + +# Prettier bump after release +# 2.3.1 +a34b4a711ed9928f8809bed3f4c8572dc3a40efb +# 2.3.0 +3d8dc612b54cef741a1c31da1011a2d48748a1dd +# 2.2.1 +80961835a68e3de1b14819a7b77583a54d2b63d7 +# 2.2.0 +cf354c205de9841a2d306387473dac369359ca2b +# 2.1.2 +c4d3014b95122f4ad19c319a9b3f5f9625d6003f +# 2.1.1 +a8363197118e530d948978da6e5c414a765ba9c0 +# 2.1.0 +cef4bcafc7867050582d3107632bde7e722575d1 +# 2.0.5 +d33f8a3e2c0a59cb9f383ddec5bbf8d296bb1a23 +# 2.0.4 +592149791e4fea656d8c5fa34c25d4d19076a07a +# 2.0.3 +64b3ac9e8e933a09f049b7cace540ee526f4d5a4 +# 2.0.2 +c1dd17cf383b78fd8fd43442bb5db59b51900410 +# 2.0.1 +f56d620be529b60c13032681446c1eb76e0fb088 +# 2.0.0 +9dad95b35f935edce4c3d6cfa45c79a0b9c82b9f + +# Restructure test files (#10415) +ece93681f1010796e7d8eb4394196ccaef0cbc9c + +# Categorize tests (#8239 #8248 #8249 #8251) +b585bd6fa4d750a98e277303c428edfc48fea3f4 +f8c5b1fd1da4d67bc09d12bc3411b70d0fa4f4a1 +b6225788966a4a6b49e652044337436642dcd627 +7ad515111e79a3f304d5480d6586314222052333 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b79df98b9c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 🤔 Support question + url: https://stackoverflow.com/questions/ask?tags=prettier + about: Issues are dedicated to development purposes. If you have questions, please use Stack Overflow. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..07b4c921c0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: weekly + day: sunday + time: "01:00" + open-pull-requests-limit: 20 + + - package-ecosystem: "npm" + directory: "/website" + schedule: + interval: weekly + day: sunday + time: "01:00" + + - package-ecosystem: "npm" + directory: "/scripts/release" + schedule: + interval: weekly + day: sunday + time: "01:00" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + day: sunday + time: "01:00" diff --git a/.github/workflows/dev-package-test.yml b/.github/workflows/dev-package-test.yml new file mode 100644 index 0000000000..27b3ae34e3 --- /dev/null +++ b/.github/workflows/dev-package-test.yml @@ -0,0 +1,48 @@ +name: Dev_Package_Test + +on: + schedule: + - cron: "0 0 * * 1" + pull_request: + paths: + - "package.json" + - ".github/workflows/dev-package-test.yml" + +jobs: + test: + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: + - "ubuntu-latest" + node: + # Run tests on minimal version we support + - "12" + NPM_CLIENT: + - "yarn" + - "npm" + - "pnpm" + env: + INSTALL_PACKAGE: true + NPM_CLIENT: ${{ matrix.NPM_CLIENT }} + name: Test with ${{ matrix.NPM_CLIENT }} + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Setup Node.js + uses: actions/setup-node@v2.1.5 + with: + node-version: ${{ matrix.node }} + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Install Client Package + if: matrix.NPM_CLIENT == 'pnpm' + run: npm install --global pnpm@5 + + - name: Run Tests + run: yarn test:dev-package --maxWorkers=2 diff --git a/.github/workflows/dev-test.yml b/.github/workflows/dev-test.yml index 369b3b0ba6..f526dcaed4 100644 --- a/.github/workflows/dev-test.yml +++ b/.github/workflows/dev-test.yml @@ -4,7 +4,7 @@ on: # [prettierx ...] push: branches: - # [prettierx ...] + - main # - master # - patch-release - dev @@ -14,6 +14,7 @@ on: jobs: test: + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -22,59 +23,67 @@ jobs: - "macos-latest" - "windows-latest" node: + - "16" + - "14" - "12" - - "10" - # [prettierx ...] include: - # [prettierx] code coverage not enabled (...) + # only enable coverage on the fastest job - os: "ubuntu-latest" node: "16" - # [prettierx] NOT ENABLED: + # [prettierx] code coverage not enabled: # ENABLE_CODE_COVERAGE: true + FULL_TEST: true + CHECK_TEST_PARSERS: true exclude: - os: "macos-latest" - node: "13" + node: "14" + - os: "windows-latest" + node: "14" env: ENABLE_CODE_COVERAGE: ${{ matrix.ENABLE_CODE_COVERAGE }} + FULL_TEST: ${{ matrix.FULL_TEST }} + CHECK_TEST_PARSERS: ${{ matrix.CHECK_TEST_PARSERS }} name: Node.js ${{ matrix.node }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v2.3.4 + # `codecov/codecov-action` require depth to be at least `2`, see #10219 with: - fetch-depth: 1 + fetch-depth: 2 - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v2.1.5 with: node-version: ${{ matrix.node }} - name: Install Dependencies run: yarn install --frozen-lockfile - - name: Run Tests + - name: Run Tests (macOS) + if: matrix.os == 'macos-latest' run: yarn test --maxWorkers=4 + - name: Run Tests (Linux and Windows) + if: matrix.os != 'macos-latest' + run: yarn test --maxWorkers=2 + # [prettierx] code coverage not enabled (see above) - name: Upload Coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v1.5.0 if: matrix.ENABLE_CODE_COVERAGE with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage/lcov.info fail_ci_if_error: true # #8073 test - name: Run Tests (PRETTIER_FALLBACK_RESOLVE) - run: yarn test "tests_integration/__tests__/(config|plugin)" + run: yarn test "tests/integration/__tests__/(config|plugin)" env: PRETTIER_FALLBACK_RESOLVE: true # [prettierx] code coverage not enabled (see above) - name: Upload Coverage (PRETTIER_FALLBACK_RESOLVE) - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v1.5.0 if: matrix.ENABLE_CODE_COVERAGE with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage/lcov.info fail_ci_if_error: true diff --git a/.github/workflows/eslint-rules.yml b/.github/workflows/eslint-rules.yml new file mode 100644 index 0000000000..12e0f4d533 --- /dev/null +++ b/.github/workflows/eslint-rules.yml @@ -0,0 +1,28 @@ +name: Internal_ESLint_Rules_Test + +on: + push: + paths: + - "scripts/tools/eslint-plugin-prettier-internal-rules/**" + - ".github/workflows/eslint-rules.yml" + pull_request: + paths: + - "scripts/tools/eslint-plugin-prettier-internal-rules/**" + - ".github/workflows/eslint-rules.yml" + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Setup Node.js + uses: actions/setup-node@v2.1.5 + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Test + run: cd scripts/tools/eslint-plugin-prettier-internal-rules && yarn test-coverage diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f9afb84e49..c848f248aa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,6 +4,7 @@ on: # [prettierx ...] push: branches: + - main # [prettierx ...] # - master # - patch-release @@ -18,14 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 1 + uses: actions/checkout@v2.3.4 - name: Setup Node.js - uses: actions/setup-node@v1 - with: - node-version: "12" + uses: actions/setup-node@v2.1.5 - name: Install Dependencies run: yarn install --frozen-lockfile diff --git a/.github/workflows/prod-test.yml b/.github/workflows/prod-test.yml index 602bd3749e..40478783cb 100644 --- a/.github/workflows/prod-test.yml +++ b/.github/workflows/prod-test.yml @@ -4,6 +4,7 @@ on: # [prettierx ...] push: branches: + - main # [prettierx ...] # - master # - patch-release @@ -18,47 +19,61 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 1 + uses: actions/checkout@v2.3.4 - name: Setup Node.js - uses: actions/setup-node@v1 - with: - node-version: "12" + uses: actions/setup-node@v2.1.5 - name: Install Dependencies run: yarn install --frozen-lockfile + - name: Cache Build Results + id: build-cache + uses: actions/cache@v2.1.6 + with: + path: .cache + key: v2-build-cache-${{ hashFiles('yarn.lock') }}-${{ hashFiles('scripts/build/**/*') }}-${{ github.ref }}- + restore-keys: | + v2-build-cache-${{ hashFiles('yarn.lock') }}-${{ hashFiles('scripts/build/**/*') }}-${{ github.ref }}- + v2-build-cache-${{ hashFiles('yarn.lock') }}-${{ hashFiles('scripts/build/**/*') }}-refs/heads/${{ github.base_ref }}- + v2-build-cache-${{ hashFiles('yarn.lock') }}-${{ hashFiles('scripts/build/**/*') }}-refs/heads/main- + - name: Build Package + # [prettierx] run: yarn build-extra-dist - name: Upload Artifact - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v2 with: name: dist path: dist + # This step calls `git reset` + # It should be the last step + # The cache step might saving the result of main branch, need investigate + - name: Check Sizes + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'dependabot/npm_and_yarn/') + uses: preactjs/compressed-size-action@2.1.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + compression: none + lint: name: Lint runs-on: ubuntu-latest needs: [build] steps: - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 1 + uses: actions/checkout@v2.3.4 - name: Setup Node.js - uses: actions/setup-node@v1 - with: - node-version: "12" + uses: actions/setup-node@v2.1.5 - name: Install Dependencies run: yarn install --frozen-lockfile - name: Download Artifact - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v2 with: name: dist path: dist @@ -67,6 +82,7 @@ jobs: run: yarn lint:dist test: + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -75,30 +91,46 @@ jobs: - "macos-latest" - "windows-latest" node: + - "16" + - "14" - "12" - "10" + include: + - os: "ubuntu-latest" + node: "16" + FULL_TEST: true exclude: - os: "macos-latest" - node: "13" + node: "14" + - os: "macos-latest" + node: "12" + - os: "windows-latest" + node: "14" + - os: "windows-latest" + node: "12" + env: + FULL_TEST: ${{ matrix.FULL_TEST }} name: Node.js ${{ matrix.node }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} needs: [build] steps: - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 1 + uses: actions/checkout@v2.3.4 - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v2.1.5 with: node-version: ${{ matrix.node }} + - name: Config `ignore-engines=true` (Node.js 10) + if: matrix.node == '10' + run: yarn config set ignore-engines true + - name: Install Dependencies run: yarn install --frozen-lockfile - name: Download Artifact - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v2 with: name: dist path: dist @@ -121,7 +153,7 @@ jobs: # #8073 test - name: Run Tests (PRETTIER_FALLBACK_RESOLVE) - run: yarn test "tests_integration/__tests__/(config|plugin)" + run: yarn test "tests/integration/__tests__/(config|plugin)" env: NODE_ENV: production PRETTIER_FALLBACK_RESOLVE: true diff --git a/.gitignore b/.gitignore index 7a9a181d7c..824eebe1c7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,15 @@ /website/static/playground.js /website/static/lib .DS_Store -coverage +/coverage .idea package-lock.json +.yarn/* +!.yarn/releases +!.yarn/plugins +!.yarn/sdks +!.yarn/versions +.pnp.* +.nyc_output +/scripts/tools/eslint-plugin-prettier-internal-rules/node_modules +.devcontainer diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 3261b0e458..510d631cbd 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,16 +1,7 @@ +# Remove this file after year 2020 + - id: prettier name: prettier - entry: prettier --write - language: node - files: "\\.(\ - css|less|scss\ - |graphql|gql\ - |html\ - |js|jsx\ - |json\ - |md|markdown|mdown|mkdn\ - |mdx\ - |ts|tsx\ - |vue\ - |yaml|yml\ - )$" + entry: Prettier support for pre-commit has been moved to https://github.com/pre-commit/mirrors-prettier, please use the new repository. + language: fail + pass_filenames: false diff --git a/.prettierignore b/.prettierignore index 37f2fa04ea..37b755dc4c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,11 +1,13 @@ dist/ .cache/ coverage/ -/tests/**/*.* -!/tests/**/jsfmt.spec.js -/tests_integration/cli/ -/tests_integration/plugins/ +/tests/format/**/*.* +!/tests/format/**/jsfmt.spec.js +/tests/integration/cli/ +/tests/integration/plugins/ +/tests/integration/custom-parsers/ /website/build/ /website/static/lib/ /website/static/playground.js cspell.json +.nyc_output diff --git a/.prettierrc b/.prettierrc index 0967ef424b..af25a135e4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1 +1,4 @@ -{} +overrides: + - files: "**/*.{js,mjs}" + options: + parser: meriyah diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dba827485..181ec8d31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,493 @@ $ git log --pretty=format:"- %s" rev1..rev2 | sed 's/#\([0-9]*\)/\[prettier\/pre - ci-info -> 3.2.0 - find-parent-dir -> 0.3.1 +## prettier 2.3.1 + +[diff](https://github.com/prettier/prettier/compare/2.3.0...2.3.1) + +#### Support TypeScript 4.3 (#10945 by @sosukesuzuki) + +##### [`override` modifiers in class elements](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#override) + +```ts +class Foo extends { + override method() {} +} +``` + +##### [static index signatures (`[key: KeyType]: ValueType`) in classes](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#static-index-signatures) + +```ts +class Foo { + static [key: string]: Bar; +} +``` + +##### [`get` / `set` in type declarations](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#separate-write-types) + +```ts +interface Foo { + set foo(value); + get foo(): string; +} +``` + +#### Preserve attributes order for element node (#10958 by @dcyriller) + + +```handlebars +{{!-- Input --}} + +{{!-- Prettier stable --}} + +{{!-- Prettier main --}} + +``` + +### Track cursor position properly when it’s at the end of the range to format (#10938 by @j-f1) + +Previously, if the cursor was at the end of the range to format, it would simply be placed back at the end of the updated range. +Now, it will be repositioned if Prettier decides to add additional code to the end of the range (such as a semicolon). + + +```jsx +// Input (<|> represents the cursor) +const someVariable = myOtherVariable<|> +// range to format: ^^^^^^^^^^^^^^^ + +// Prettier stable +const someVariable = myOtherVariable;<|> +// range to format: ^^^^^^^^^^^^^^^ + +// Prettier main +const someVariable = myOtherVariable<|>; +// range to format: ^^^^^^^^^^^^^^^ +``` + +### Break the LHS of type alias that has complex type parameters (#10901 by @sosukesusuzki) + + +```ts +// Input +type FieldLayoutWith< + T extends string, + S extends unknown = { width: string } +> = { + type: T; + code: string; + size: S; +}; + +// Prettier stable +type FieldLayoutWith = + { + type: T; + code: string; + size: S; + }; + +// Prettier main +type FieldLayoutWith< + T extends string, + S extends unknown = { width: string } +> = { + type: T; + code: string; + size: S; +}; + +``` + +### Break the LHS of assignments that has complex type parameters (#10916 by @sosukesuzuki) + + +```ts +// Input +const map: Map< + Function, + Map +> = new Map(); + +// Prettier stable +const map: Map> = + new Map(); + +// Prettier main +const map: Map< + Function, + Map +> = new Map(); + +``` + +### Fix incorrectly wrapped arrow functions with return types (#10940 by @thorn0) + + +```ts +// Input +longfunctionWithCall12("bla", foo, (thing: string): complex> => { + code(); +}); + +// Prettier stable +longfunctionWithCall12("bla", foo, (thing: string): complex< + type +> => { + code(); +}); + +// Prettier main +longfunctionWithCall12( + "bla", + foo, + (thing: string): complex> => { + code(); + } +); +``` + +#### Avoid breaking call expressions after assignments with complex type arguments (#10949 by @sosukesuzuki) + + +```ts +// Input +const foo = call<{ + prop1: string; + prop2: string; + prop3: string; +}>(); + +// Prettier stable +const foo = + call<{ + prop1: string; + prop2: string; + prop3: string; + }>(); + +// Prettier main +const foo = call<{ + prop1: string; + prop2: string; + prop3: string; +}>(); + +``` + +### Fix order of `override` modifiers (#10961 by @sosukesuzuki) + +```ts +// Input +class Foo extends Bar { + abstract override foo: string; +} + +// Prettier stable +class Foo extends Bar { + abstract override foo: string; +} + +// Prettier main +class Foo extends Bar { + abstract override foo: string; +} +``` + +# prettier 2.3.0 + +[diff](https://github.com/prettier/prettier/compare/2.2.1...2.3.0) + +🔗 [Release Notes](https://prettier.io/blog/2021/05/09/2.3.0.html) + +### prettier 2.2.1 + +[diff](https://github.com/prettier/prettier/compare/2.2.0...2.2.1) + +#### Fix formatting for AssignmentExpression with ClassExpression ([#9741](https://github.com/prettier/prettier/pull/9741) by [@sosukesuzuki](https://github.com/sosukesuzuki)) + + +```js +// Input +module.exports = class A extends B { + method() { + console.log("foo"); + } +}; + +// Prettier 2.2.0 +module.exports = class A extends ( + B +) { + method() { + console.log("foo"); + } +}; + +// Prettier 2.2.1 +module.exports = class A extends B { + method() { + console.log("foo"); + } +}; +``` + +### prettier 2.2.0 + +[diff](https://github.com/prettier/prettier/compare/2.1.2...2.2.0) + +🔗 [Release Notes](https://prettier.io/blog/2020/11/20/2.2.0.html) + +### prettier 2.1.2 + +[diff](https://github.com/prettier/prettier/compare/2.1.1...2.1.2) + +#### Fix formatting for directives in fields ([#9116](https://github.com/prettier/prettier/pull/9116) by [@sosukesuzuki](https://github.com/sosukesuzuki)) + + +```graphql +# Input +type Query { + someQuery(id: ID!, someOtherData: String!): String! @deprecated @isAuthenticated + versions: Versions! +} + + +# Prettier stable +type Query { + someQuery(id: ID!, someOtherData: String!): String! + @deprecated + @isAuthenticated + versions: Versions! +} + +# Prettier master +type Query { + someQuery(id: ID!, someOtherData: String!): String! + @deprecated + @isAuthenticated + versions: Versions! +} + +``` + +#### Fix line breaks for CSS in JS ([#9136](https://github.com/prettier/prettier/pull/9136) by [@sosukesuzuki](https://github.com/sosukesuzuki)) + + +```js +// Input +styled.div` + // prettier-ignore + @media (aaaaaaaaaaaaa) { + z-index: ${(props) => (props.isComplete ? '1' : '0')}; + } +`; +styled.div` + ${props => getSize(props.$size.xs)} + ${props => getSize(props.$size.sm, 'sm')} + ${props => getSize(props.$size.md, 'md')} +`; + +// Prettier stable +styled.div` + // prettier-ignore + @media (aaaaaaaaaaaaa) { + z-index: ${(props) => + props.isComplete ? "1" : "0"}; + } +`; +styled.div` + ${(props) => getSize(props.$size.xs)} + ${(props) => getSize(props.$size.sm, "sm")} + ${(props) => + getSize(props.$size.md, "md")} +`; + +// Prettier master +styled.div` + // prettier-ignore + @media (aaaaaaaaaaaaa) { + z-index: ${(props) => (props.isComplete ? "1" : "0")}; + } +`; +styled.div` + ${(props) => getSize(props.$size.xs)} + ${(props) => getSize(props.$size.sm, "sm")} + ${(props) => getSize(props.$size.md, "md")} +`; + +``` + +#### Fix comment printing in mapping and sequence ([#9143](https://github.com/prettier/prettier/pull/9143), [#9169](https://github.com/prettier/prettier/pull/9169) by [@sosukesuzuki](https://github.com/sosukesuzuki), [@fisker](https://github.com/fisker), fix in `yaml-unist-parser` by [@ikatyang](https://github.com/ikatyang)) + + +```yaml +# Input +- a + # Should indent +- bb + +--- +- a: a + b: b + + # Should print one empty line before +- another + +# Prettier stable +- a +# Should indent +- bb + +--- +- a: a + b: b + + + # Should print one empty line before +- another + +# Prettier master +- a + # Should indent +- bb + +--- +- a: a + b: b + + # Should print one empty line before +- another +``` + +### prettier 2.1.1 + +[diff](https://github.com/prettier/prettier/compare/2.1.0...2.1.1) + +#### Fix format on html with frontMatter ([#9043](https://github.com/prettier/prettier/pull/9043) by [@fisker](https://github.com/fisker)) + + +```html + +--- +layout: foo +--- + +Test abc. + + +TypeError: Cannot read property 'end' of undefined + ... + + +--- +layout: foo +--- + +Test abc. +``` + +#### Fix broken format for `...infer T` ([#9044](https://github.com/prettier/prettier/pull/9044) by [@fisker](https://github.com/fisker)) + + +```typescript +// Input +type Tail = T extends [infer U, ...infer R] ? R : never; + +// Prettier stable +type Tail = T extends [infer U, ...(infer R)] ? R : never; + +// Prettier master +type Tail = T extends [infer U, ...infer R] ? R : never; +``` + +#### Fix format on `style[lang="sass"]` ([#9051](https://github.com/prettier/prettier/pull/9051) by [@fisker](https://github.com/fisker)) + + +```jsx + + + + + + + + +``` + +#### Fix self-closing blocks and blocks with `src` attribute format ([#9052](https://github.com/prettier/prettier/pull/9052), [#9055](https://github.com/prettier/prettier/pull/9055) by [@fisker](https://github.com/fisker)) + + +```vue + + + + + + + + + + + + + + + + +``` + +### prettier 2.1.0 + +[diff](https://github.com/prettier/prettier/compare/2.0.5...2.1.0) + +🔗 [Release Notes](https://prettier.io/blog/2020/08/24/2.1.0.html) + ## prettierx 0.18.1 [compare prettierx-0.18.0...prettierx-0.18.1](https://github.com/brodybits/prettierx/compare/prettierx-0.18.0...prettierx-0.18.1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f04070f0c..18a36da17b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,27 +4,76 @@ To get up and running, install the dependencies and run the tests: ```bash yarn -yarn lint:eslint yarn test ``` -Here's what you need to know about the tests: +## Tests -- The tests use [Jest snapshots](https://facebook.github.io/jest/docs/en/snapshot-testing.html). -- You can make changes and run `jest -u` (or `yarn test -u`) to update the snapshots. Then run `git diff` to take a look at what changed. Always update the snapshots when opening a PR. -- You can run `AST_COMPARE=1 DEEP_COMPARE=1 jest` for a more robust test run. - - `AST_COMPARE` That formats each file, re-parses it, and compares the new AST with the original one and makes sure they are semantically equivalent. - - `DEEP_COMPARE` That formats each file, then formats the output again, and checks that the second output is the same as the first. -- Each test folder has a `jsfmt.spec.js` that runs the tests. For JavaScript files, generally you can just put `run_spec(__dirname, ["babel", "flow", "typescript"]);` there. This will verify that the output using each parser is the same. You can also pass options as the third argument, like this: `run_spec(__dirname, ["babel"], { trailingComma: "es5" });` -- `tests/flow/` contains the Flow test suite, and is not supposed to be edited by hand. To update it, clone the Flow repo next to the prettierX repo and run: `node scripts/sync-flow-tests.js ../flow/tests/`. -- If you would like to debug prettierX locally, you can ~~either~~ debug it in node ~~or the browser~~. ~~The easiest way to debug it in the browser is to run the interactive `docs` REPL locally.~~ The easiest way to debug it in node, is to create a local test file with some example code you want formatted and either run it in an editor like VS Code or run it directly via `./bin/prettierx.js `. +The tests use [Jest snapshots](https://facebook.github.io/jest/docs/en/snapshot-testing.html). You can make changes and run `jest -u` (or `yarn test -u`) to update the snapshots. Then run `git diff` to take a look at what changed. Always update the snapshots when opening a PR. -Run `yarn lint:eslint --fix` to automatically format files. +Each test directory in `tests/format` has a `jsfmt.spec.js` file that controls how exactly the rest of the files in the directory are used for tests. This file must contain one or more calls to the `run_spec` global function. For example, in directories with JavaScript formatting tests, `jsfmt.spec.js` generally looks like this: -If you can, take look at [commands.md](commands.md) and check out [Wadler's paper](http://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf) to understand how Prettier works. +```js +run_spec(__dirname, ["babel", "flow", "typescript"]); +``` + +This verifies that for each file in the directory, the output matches the snapshot and is the same for each listed parser. + +You can also pass options as the third argument: + +```js +run_spec(__dirname, ["babel"], { trailingComma: "es5" }); +``` + +Signature: + +```ts +function run_spec( + fixtures: + | string + | { + dirname: string; + snippets?: Array< + | string + | { code: string; name?: string; filename?: string; output?: string } + >; + }, + parsers: string[], + options?: PrettierOptions & { + errors: true | { [parserName: string]: true | string[] }; + } +): void; +``` + +Parameters: + +- **`fixtures`**: Must be set to `__dirname` or to an object of the shape `{ dirname: __dirname, ... }`. The object may have the `snippets` property to specify an array of extra input entries in addition to the files in the current directory. For each input entry (a file or a snippet), `run_spec` configures and runs a number of tests. The main check is that for a given input the output should match the snapshot (for snippets, the expected output can also be specified directly). [Additional checks](#deeper-testing) are controlled by options and environment variables. +- **`parsers`**: A list of parser names. The tests verify that the parsers in this list produce the same output. If the list includes `typescript`, then `babel-ts` is included implicitly. If the list includes `babel`, and the current directory is inside `tests/format/js`, then `espree` and `meriyah` are included implicitly. +- **`options`**: In addition to Prettier's formatting options, can contain the `errors` property to specify that it's expected that the formatting shouldn't be successful and an error should be thrown for all (`errors: true`) or some combinations of input entries and parsers. + +The implementation of `run_spec` can be found in [`tests/config/format-test.js`](tests/config/format-test.js). + +`tests/format/flow-repo/` contains the Flow test suite and is not supposed to be edited by hand. To update it, clone the Flow repo next to the Prettier repo and run: `node scripts/sync-flow-tests.js ../flow/tests/`. + +## Debugging + +To debug prettierX locally, you can either debug it in Node (recommended) or the browser. + +- The easiest way to debug it in Node is to create a local test file with some example code you want formatted and either run it in an editor like VS Code or run it directly via `./bin/prettierx.js `. +- The easiest way to debug it in the browser is to build Prettier's website locally (see [`website/README.md`](website/README.md)). + +## Other + +The project uses ESLint for linting and Prettier for formatting. If your editor isn't set up to work with them, you can lint and format all files from the command line using `yarn fix`. + +After opening a PR, describe your changes in a file in the `changelog_unreleased` directory following the template [`changelog_unreleased/TEMPLATE.md`](changelog_unreleased/TEMPLATE.md) and commit this file to your PR. + +Take a look at [`commands.md`](commands.md) and, if you know Haskell, check out [Wadler's paper](http://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf) to understand how Prettier works. ~~If you want to know more about prettier(X)'s GitHub labels, see the [Prettier Issue Labels](https://github.com/prettier/prettier/wiki/Issue-Labels) page on the Wiki.~~ +# Advanced topics + ## Performance If you're contributing a performance improvement, the following prettier(X) CLI options can help: @@ -49,3 +98,18 @@ In the above commands: - `> /dev/null` ensures the formatted output is discarded. In addition to the options above, you can use [`node --prof` and `node --prof-process`](https://nodejs.org/en/docs/guides/simple-profiling/), as well as `node --trace-opt --trace-deopt`, to get more advanced performance insights. + +## Regression testing + +We have a cool tool for regression testing that runs on GitHub Actions. Have a look: https://github.com/prettier/prettier-regression-testing + +## Deeper testing + +You can run `FULL_TEST=1 jest` for a more robust test run, which includes the following additional checks: + +- **compare AST** - re-parses the output and makes sure the new AST is equivalent to the original one. +- **second format** - formats the output again and checks that the second output is the same as the first. +- **EOL '\r\n'** and **EOL '\r'** - check that replacing line endings with `\r\n` or `\r` in the input doesn't affect the output. +- **BOM** - checks that adding BOM (`U+FEFF`) to the input affects the output in only one way: the BOM is preserved. + +Usually there is no need to run these extra checks locally, since they're run on the CI anyway. diff --git a/README.md b/README.md index 60d216cd89..57320dca76 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,11 @@ This is the branch containing code for Prettier’s 2.0 release. See [the `maste

- + Github Actions Build Status - + Github Actions Build Status - + Github Actions Build Status Codecov Coverage Status diff --git a/bin/prettierx.js b/bin/prettierx.js index 79fe9b2370..7f3f91780b 100755 --- a/bin/prettierx.js +++ b/bin/prettierx.js @@ -2,4 +2,4 @@ "use strict"; -require("../src/cli").run(process.argv.slice(2)); +module.exports = require("../src/cli").run(process.argv.slice(2)); diff --git a/changelog_unreleased/blog-post-intro.md b/changelog_unreleased/BLOG_POST_INTRO_TEMPLATE.md similarity index 100% rename from changelog_unreleased/blog-post-intro.md rename to changelog_unreleased/BLOG_POST_INTRO_TEMPLATE.md diff --git a/changelog_unreleased/TEMPLATE.md b/changelog_unreleased/TEMPLATE.md index a7ebe3c0aa..24d07c16da 100644 --- a/changelog_unreleased/TEMPLATE.md +++ b/changelog_unreleased/TEMPLATE.md @@ -6,7 +6,7 @@ - For TypeScript specific syntax, choose `typescript/`. - If your PR applies to multiple languages, such as TypeScript/Flow, choose one folder and mention which languages it applies to. -2. In your chosen folder, create a file with your PR number: `pr-XXXX.md`. For example: `typescript/pr-6728.md`. +2. In your chosen folder, create a file with your PR number: `XXXX.md`. For example: `typescript/6728.md`. 3. Copy the content below and paste it in your new file. @@ -22,9 +22,9 @@ --> -#### Title ([#XXXX](https://github.com/prettier/prettier/pull/XXXX) by [@user](https://github.com/user)) +#### Title (#XXXX by @user) -Optional description if it makes sense. + ```jsx @@ -34,6 +34,6 @@ Optional description if it makes sense. // Prettier stable foo ?? baz || baz; -// Prettier master +// Prettier main (foo ?? baz) || baz; ``` diff --git a/changelog_unreleased/handlebars/10958.md b/changelog_unreleased/handlebars/10958.md new file mode 100644 index 0000000000..1890c5a777 --- /dev/null +++ b/changelog_unreleased/handlebars/10958.md @@ -0,0 +1,44 @@ +#### Preserve attributes order for element node (#10958 by @dcyriller) + + +```handlebars +{{!-- Input --}} + +{{!-- Prettier stable --}} + +{{!-- Prettier main --}} + +``` diff --git a/changelog_unreleased/html/10906.md b/changelog_unreleased/html/10906.md new file mode 100644 index 0000000000..54d664af9c --- /dev/null +++ b/changelog_unreleased/html/10906.md @@ -0,0 +1,29 @@ +#### Allow `:` as class prefix delimiter (#10906 by @tkalmar) + + +```html + +

+ + +
+ + +
+``` diff --git a/changelog_unreleased/javascript/10938.md b/changelog_unreleased/javascript/10938.md new file mode 100644 index 0000000000..bd98f47d85 --- /dev/null +++ b/changelog_unreleased/javascript/10938.md @@ -0,0 +1,19 @@ +#### Track cursor position properly when it’s at the end of the range to format (#10938 by @j-f1) + +Previously, if the cursor was at the end of the range to format, it would simply be placed back at the end of the updated range. +Now, it will be repositioned if Prettier decides to add additional code to the end of the range (such as a semicolon). + + +```jsx +// Input (<|> represents the cursor) +const someVariable = myOtherVariable<|> +// range to format: ^^^^^^^^^^^^^^^ + +// Prettier stable +const someVariable = myOtherVariable;<|> +// range to format: ^^^^^^^^^^^^^^^ + +// Prettier main +const someVariable = myOtherVariable<|>; +// range to format: ^^^^^^^^^^^^^^^ +``` diff --git a/changelog_unreleased/typescript/10901.md b/changelog_unreleased/typescript/10901.md new file mode 100644 index 0000000000..4ab300e845 --- /dev/null +++ b/changelog_unreleased/typescript/10901.md @@ -0,0 +1,33 @@ +#### Break the LHS of type alias that has complex type parameters (#10901 by @sosukesusuzki) + + +```ts +// Input +type FieldLayoutWith< + T extends string, + S extends unknown = { width: string } +> = { + type: T; + code: string; + size: S; +}; + +// Prettier stable +type FieldLayoutWith = + { + type: T; + code: string; + size: S; + }; + +// Prettier main +type FieldLayoutWith< + T extends string, + S extends unknown = { width: string } +> = { + type: T; + code: string; + size: S; +}; + +``` diff --git a/changelog_unreleased/typescript/10916.md b/changelog_unreleased/typescript/10916.md new file mode 100644 index 0000000000..68f41577b5 --- /dev/null +++ b/changelog_unreleased/typescript/10916.md @@ -0,0 +1,21 @@ +#### Break the LHS of assignments that has complex type parameters (#10916 by @sosukesuzuki) + + +```ts +// Input +const map: Map< + Function, + Map +> = new Map(); + +// Prettier stable +const map: Map> = + new Map(); + +// Prettier main +const map: Map< + Function, + Map +> = new Map(); + +``` diff --git a/changelog_unreleased/typescript/10940.md b/changelog_unreleased/typescript/10940.md new file mode 100644 index 0000000000..e74b638add --- /dev/null +++ b/changelog_unreleased/typescript/10940.md @@ -0,0 +1,25 @@ +#### Fix incorrectly wrapped arrow functions with return types (#10940 by @thorn0) + + +```ts +// Input +longfunctionWithCall12("bla", foo, (thing: string): complex> => { + code(); +}); + +// Prettier stable +longfunctionWithCall12("bla", foo, (thing: string): complex< + type +> => { + code(); +}); + +// Prettier main +longfunctionWithCall12( + "bla", + foo, + (thing: string): complex> => { + code(); + } +); +``` diff --git a/changelog_unreleased/typescript/10945.md b/changelog_unreleased/typescript/10945.md new file mode 100644 index 0000000000..07c4719098 --- /dev/null +++ b/changelog_unreleased/typescript/10945.md @@ -0,0 +1,26 @@ +#### Support TypeScript 4.3 (#10945 by @sosukesuzuki) + +##### [`override` modifiers in class elements](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#override) + +```ts +class Foo extends { + override method() {} +} +``` + +##### [static index signatures (`[key: KeyType]: ValueType`) in classes](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#static-index-signatures) + +```ts +class Foo { + static [key: string]: Bar; +} +``` + +##### [`get` / `set` in type declarations](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#separate-write-types) + +```ts +interface Foo { + set foo(value); + get foo(): string; +} +``` diff --git a/changelog_unreleased/typescript/10949.md b/changelog_unreleased/typescript/10949.md new file mode 100644 index 0000000000..973b8b7eb7 --- /dev/null +++ b/changelog_unreleased/typescript/10949.md @@ -0,0 +1,27 @@ +#### Avoid breaking call expressions after assignments with complex type arguments (#10949 by @sosukesuzuki) + + +```ts +// Input +const foo = call<{ + prop1: string; + prop2: string; + prop3: string; +}>(); + +// Prettier stable +const foo = + call<{ + prop1: string; + prop2: string; + prop3: string; + }>(); + +// Prettier main +const foo = call<{ + prop1: string; + prop2: string; + prop3: string; +}>(); + +``` diff --git a/changelog_unreleased/typescript/10961.md b/changelog_unreleased/typescript/10961.md new file mode 100644 index 0000000000..84c6042399 --- /dev/null +++ b/changelog_unreleased/typescript/10961.md @@ -0,0 +1,18 @@ +#### Fix order of `override` modifiers (#10961 by @sosukesuzuki) + +```ts +// Input +class Foo extends Bar { + abstract override foo: string; +} + +// Prettier stable +class Foo extends Bar { + abstract override foo: string; +} + +// Prettier main +class Foo extends Bar { + abstract override foo: string; +} +``` diff --git a/changelog_unreleased/yaml/10874.md b/changelog_unreleased/yaml/10874.md new file mode 100644 index 0000000000..c73e119eca --- /dev/null +++ b/changelog_unreleased/yaml/10874.md @@ -0,0 +1,66 @@ +#### Don't switch to explicit mappings needlessly (#10874 by @pdavies) + +Previously, Prettier printed long YAML keys using explicit mapping syntax. The intent was to use that syntax to accommodate multiline keys, but it had the unintended effect of using it even for long keys that couldn’t be made multiline – for example, because `--prose-wrap` was set to `preserve` or the key didn’t contain whitespace. This is bad because: + +- Doing so has the effect of further increasing the line length. +- Explicit mappings are obscure and most people have never seen them. It causes confusion to introduce them needlessly. + + +```yaml +# Input +- stage: Process + jobs: + - template: Process.yml + parameters: + ${{ if in(parameters.BuildType, 'a') }}: + BuildArtifacts: + - input: Build.Release.x86 + output: Processed.Release.x86 + ${{ if in(parameters.BuildType, 'b ') }}: + BuildArtifacts: + - input: Build.Release + output: Processed.Release + +# Prettier stable +- stage: Process + jobs: + - template: Process.yml + parameters: + ${{ if in(parameters.BuildType, 'a') }}: + BuildArtifacts: + - input: Build.Release.x86 + output: Processed.Release.x86 + ? ${{ if in(parameters.BuildType, 'b ') }} + : BuildArtifacts: + - input: Build.Release + output: Processed.Release + +# Prettier main +- stage: Process + jobs: + - template: Process.yml + parameters: + ${{ if in(parameters.BuildType, 'a') }}: + BuildArtifacts: + - input: Build.Release.x86 + output: Processed.Release.x86 + ${{ if in(parameters.BuildType, 'b ') }}: + BuildArtifacts: + - input: Build.Release + output: Processed.Release + +# Prettier main (with --prose-wrap always) +- stage: Process + jobs: + - template: Process.yml + parameters: + ${{ if in(parameters.BuildType, 'a') }}: + BuildArtifacts: + - input: Build.Release.x86 + output: Processed.Release.x86 + ? ${{ if in(parameters.BuildType, 'b ') + }} + : BuildArtifacts: + - input: Build.Release + output: Processed.Release +``` diff --git a/commands.md b/commands.md index 76452e1efc..b1bc31ed06 100644 --- a/commands.md +++ b/commands.md @@ -1,28 +1,30 @@ -The core of the algorithm is implemented in `doc-{printer,builders,utils,debug}.js`. The printer should use the basic formatting abstractions provided to construct a format when printing a node. Parts of the API only exist to be compatible with recast's previous API to ease migration, but over time we can clean it up. +The core of the algorithm is implemented in `src/document/doc-{printer,builders,utils}.js`. The printer uses the basic formatting abstractions provided to construct a format when printing a node. -The following commands are available: +## Prettier's intermediate representation: `Doc` -### concat +A doc can be a string, an array of docs, or a command. ```ts -declare function concat(docs: Doc[]): Doc; +type Doc = string | Doc[] | DocCommand; ``` -Combine an array into a single string. +- _strings_ are printed directly as is (however for the algorithm to work properly they shouldn't contain line break characters) +- _arrays_ are used to concatenate a list of docs to be printed sequentially into a single doc +- `DocCommand` is any of the following: -### group +### `group` ```ts -type GroupOpts = { +type GroupOptions = { shouldBreak?: boolean; - expandedStates?: Doc[]; + id?: symbol; }; -declare function group(doc: Doc, opts?: GroupOpts): Doc; +declare function group(doc: Doc, options?: GroupOptions): Doc; ``` Mark a group of items which the printer should try to fit on one line. This is the basic command to tell the printer when to break. Groups are usually nested, and the printer will try to fit everything on one line, but if it doesn't fit it will break the outermost group first and try again. It will continue breaking groups until everything fits (or there are no more groups to break). -A document can force parent groups to break by including `breakParent` (see below). A hard and literal line automatically include this so they always break parent groups. Breaks are propagated to all parent groups, so if a deeply nested expression has a hard break, everything will break. This only matters for "hard" breaks, i.e. newlines that are printed no matter what and can be statically analyzed. +A group is forced to break if it's created with the `shouldBreak` option set to `true` or if it includes [`breakParent`](#breakParent). A [hard](#hardline) and [literal](#literalline) line breaks automatically include this so they always break parent groups. Breaks are propagated to all parent groups, so if a deeply nested expression has a hard break, everything will break. This only matters for "hard" breaks, i.e. newlines that are printed no matter what and can be statically analyzed. For example, an array will try to fit on one line: @@ -42,114 +44,121 @@ However, if any of the items inside the array have a hard break, the array will ]; ``` -Functions always break after the opening curly brace no matter what, so the array breaks as well for consistent formatting. See the implementation of `ArrayExpression` for an example. +Functions always break after the opening curly brace no matter what, so the array breaks as well for consistent formatting. See [the implementation of `ArrayExpression`](#example) for an example. + +The `id` option can be used in [`ifBreak`](#ifBreak) checks. -### conditionalGroup +### `conditionalGroup` This should be used as **last resort** as it triggers an exponential complexity when nested. ```ts -type ConditionalGroupOpts = { - shouldBreak?: boolean; -}; declare function conditionalGroup( alternatives: Doc[], - opts?: ConditionalGroupOpts + options?: GroupOptions ): Doc; ``` -This will try to print the first argument, if it fit use it, otherwise go to the next one and so on. +This will try to print the first alternative, if it fit use it, otherwise go to the next one and so on. The alternatives is an array of documents going from the least expanded (most flattened) representation first to the most expanded. ```js conditionalGroup([a, b, c]); ``` -### fill +### `fill` ```ts declare function fill(docs: Doc[]): Doc; ``` -This is an alternative type of group which behaves like text layout: it's going to add a break whenever the next element doesn't fit in the line anymore. The difference with a typical group is that it's not going to break all the separators, just the ones that are at the end of lines. +This is an alternative type of group which behaves like text layout: it's going to add a break whenever the next element doesn't fit in the line anymore. The difference with [`group`](#group) is that it's not going to break all the separators, just the ones that are at the end of lines. ```js -fill(["I", line, "love", line, "prettier"]); +fill(["I", line, "love", line, "Prettier"]); ``` -### ifBreak +Expects the `docs` argument to be an array of alternating content and line breaks. In other words, elements with odd indices must be line breaks (e.g., [`softline`](#softline)). + +### `ifBreak` ```ts -declare function ifBreak(ifBreak: Doc, noBreak: Doc): Doc; +declare function ifBreak( + ifBreak: Doc, + noBreak?: Doc, + options?: { groupId?: symbol } +): Doc; ``` -Prints something if the current group breaks and something else if it doesn't. +Print something if the current `group` or the current element of `fill` breaks and something else if it doesn't. ```js ifBreak(";", " "); ``` -### breakParent +`groupId` can be used to check another _already printed_ group instead of the current group. + +### `breakParent` ```ts -declare var breakParent: Doc; +declare const breakParent: Doc; ``` -Include this anywhere to force all parent groups to break. See `group` for more info. Example: +Include this anywhere to force all parent groups to break. See [`group`](#group) for more info. Example: ```js -group(concat([" ", expr, " ", breakParent])); +group([" ", expr, " ", breakParent]); ``` -### join +### `join` ```ts declare function join(sep: Doc, docs: Doc[]): Doc; ``` -Join an array of items with a separator. +Join an array of docs with a separator. -### line +### `line` ```ts -declare var line: Doc; +declare const line: Doc; ``` Specify a line break. If an expression fits on one line, the line break will be replaced with a space. Line breaks always indent the next line with the current level of indentation. -### softline +### `softline` ```ts -declare var softline: Doc; +declare const softline: Doc; ``` Specify a line break. The difference from `line` is that if the expression fits on one line, it will be replaced with nothing. -### hardline +### `hardline` ```ts -declare var hardline: Doc; +declare const hardline: Doc; ``` Specify a line break that is **always** included in the output, no matter if the expression fits on one line or not. -### literalline +### `literalline` ```ts -declare var literalline: Doc; +declare const literalline: Doc; ``` -Specify a line break that is **always** included in the output, and don't indent the next line. This is used for template literals. +Specify a line break that is **always** included in the output and doesn't indent the next line. Also, unlike `hardline`, this kind of line break preserves trailing whitespace on the line it ends. This is used for template literals. -### lineSuffix +### `lineSuffix` ```ts declare function lineSuffix(suffix: Doc): Doc; ``` -This is used to implement trailing comments. In practice, it is not practical to find where the line ends and you don't want to accidentally print some code at the end of the comment. `lineSuffix` will buffer the output and flush it before any new line. +This is used to implement trailing comments. It's not practical to constantly check where the line ends to avoid accidentally printing some code at the end of a comment. `lineSuffix` buffers docs passed to it and flushes them before any new line. ```js -concat(["a", lineSuffix(" // comment"), ";", hardline]); +["a", lineSuffix(" // comment"), ";", hardline]; ``` will output @@ -158,16 +167,16 @@ will output a; // comment ``` -### lineSuffixBoundary +### `lineSuffixBoundary` ```ts -declare var lineSuffixBoundary: Doc; +declare const lineSuffixBoundary: Doc; ``` -In cases where you embed code inside of templates, comments shouldn't be able to leave the code part. lineSuffixBoundary is an explicit marker you can use to flush code in addition to newlines. +In cases where you embed code inside of templates, comments shouldn't be able to leave the code part. `lineSuffixBoundary` is an explicit marker you can use to flush the [`lineSuffix`](#lineSuffix) buffer in addition to line breaks. ```js -concat(["{", lineSuffix(" // comment"), lineSuffixBoundary, "}", hardline]); +["{", lineSuffix(" // comment"), lineSuffixBoundary, "}", hardline]; ``` will output @@ -185,7 +194,7 @@ and **not** {} // comment ``` -### indent +### `indent` ```ts declare function indent(doc: Doc): Doc; @@ -193,7 +202,7 @@ declare function indent(doc: Doc): Doc; Increase the level of indentation. -### dedent +### `dedent` ```ts declare function dedent(doc: Doc): Doc; @@ -201,15 +210,15 @@ declare function dedent(doc: Doc): Doc; Decrease the level of indentation. (Each `align` is considered one level of indentation.) -### align +### `align` ```ts -declare function align(n: number | string, doc: Doc): Doc; +declare function align(widthOrString: number | string, doc: Doc): Doc; ``` -This is similar to indent but it increases the level of indentation by a fixed number or a string. -Trailing alignments in indentation are still spaces, but middle ones are transformed into one tab per `align` when `useTabs` enabled. -If it's using in a whitespace-sensitive language, e.g. markdown, you should use `n` with string value to force print it. +Increase the indentation by a fixed number of spaces or a string. A variant of [`indent`](#indent). + +When `useTabs` is enabled, trailing alignments in indentation are still spaces, but middle ones are transformed one tab per `align`. In a whitespace-sensitive context (e.g., Markdown), you should pass spaces to `align` as strings to prevent their replacement with tabs. For example: @@ -221,38 +230,93 @@ For example: - `` -> `<2 space>` - `` -> `<2 space>` -### markAsRoot +### `markAsRoot` ```ts declare function markAsRoot(doc: Doc): Doc; ``` -This marks the current indentation as root for `dedentToRoot` and `literalline`s. +Mark the current indentation as root for [`dedentToRoot`](#dedentToRoot) and [`literalline`](#literalline)s. -#### dedentToRoot +### `dedentToRoot` ```ts declare function dedentToRoot(doc: Doc): Doc; ``` -This will dedent the current indentation to the root marked by `markAsRoot`. +Decrease the current indentation to the root marked by [`markAsRoot`](#markAsRoot). + +### `trim` + +```ts +declare const trim: Doc; +``` + +Trim all the indentation on the current line. This can be used for preprocessor directives. Should be placed after a line break. -### trim +### `indentIfBreak` + +_Added in v2.3.0_ ```ts -declare var trim: Doc; +declare function indentIfBreak( + doc: Doc, + opts: { groupId: symbol; negate?: boolean } +): Doc; ``` -This will trim any whitespace or tab character on the current line. This is used for preprocessor directives. +An optimized version of `ifBreak(indent(doc), doc, { groupId })`. -### cursor +With `negate: true`, corresponds to `ifBreak(doc, indent(doc), { groupId })` + +It doesn't make sense to apply `indentIfBreak` to the current group because "indent if the current group is broken" is the normal behavior of `indent`. That's why `groupId` is required. + +### `label` + +_Added in v2.3.0_ ```ts -declare var cursor: Doc; +declare function label(label: string, doc: Doc): Doc; +``` + +Mark a doc with a string label. This doesn't affect how the doc is printed, but can be useful for heuristics based on doc introspection. + +E.g., to decide how to print an assignment expression, we might want to know whether its right-hand side has been printed as a method call chain, not as a plain function call. If the method chain printing code uses `label` to mark its result, checking that condition can be as easy as `rightHandSideDoc.label === 'method-chain'`. + +### `hardlineWithoutBreakParent` and `literallineWithoutBreakParent` + +_Added in v2.3.0_ + +```ts +declare const hardlineWithoutBreakParent: Doc; +declare const literallineWithoutBreakParent: Doc; +``` + +These are used very rarely, for advanced formatting tricks. Unlike their "normal" counterparts, they don't include an implicit [`breakParent`](#breakParent). + +Examples: + +- `hardlineWithoutBreakParent` is used for printing tables in Prettier's Markdown printer. With `proseWrap` set to `never`, the columns are aligned only if none of the rows exceeds `printWidth`. +- `literallineWithoutBreakParent` is used in the [Ruby plugin](https://github.com/prettier/plugin-ruby) for [printing heredoc syntax](https://github.com/prettier/plugin-ruby/blob/b6e7bd6bc3f70de8f146aa58ad0c8310518bf467/src/ruby/nodes/heredocs.js). + +### `cursor` + +```ts +declare const cursor: Doc; ``` This is a placeholder value where the cursor is in the original input in order to find where it would be printed. +### [Deprecated] `concat` + +_This command has been deprecated in v2.3.0, use `Doc[]` instead_ + +```ts +declare function concat(docs: Doc[]): Doc; +``` + +Combine an array into a single doc. + ## Example For an example, here's the implementation of the `ArrayExpression` node type: @@ -260,20 +324,20 @@ For an example, here's the implementation of the `ArrayExpression` node type: ```js group( - concat([ + [ "[", indent( - concat([ + [ line, join( - concat([",", line]), + [",", line], path.map(print, "elements") ) - ]) + ] ), line, "]" - ]) + ] ); ``` diff --git a/cspell.json b/cspell.json index 493a649b78..86868d0ae6 100644 --- a/cspell.json +++ b/cspell.json @@ -2,13 +2,16 @@ "version": "0.1", "words": [ "ACMR", + "Alexa", "algolia", "Amjad", "Andrey", "animationend", + "ansible", + "Apheleia", "apos", - "aquibm", "arduner", + "arity", "arrayify", "Artem", "Ascher", @@ -23,16 +26,16 @@ "autogenerated", "autolink", "autolinks", + "autoload", + "autoloaded", "autoloading", "Azzola", "backport", "backticks", - "bakkot", "behaviour", "Bekkelund", - "belochub", - "benjie", "Bento", + "bfnrt", "bigint", "binaryish", "bindon", @@ -40,8 +43,7 @@ "blenda", "blockquote's", "bookmarklet", - "bopomofo", - "brainkim", + "Bopomofo", "Breakell", "Brevik", "bugfix", @@ -51,10 +53,11 @@ "camelcase", "camelified", "chedeau", + "cherow", + "Cheung", "chrzosel", "Clemmons", "cliify", - "cloudflare", "cmds", "codebases", "codeblock", @@ -67,88 +70,96 @@ "commonmark", "concating", "cond", + "corejs", "cosmiconfig", "CRLFs", + "crossorigin", "daleroy", "danez", - "dangmai", "Dara", "dashify", - "dcyriller", "declarators", "dedent", "defun", + "Deisz", + "Deloise", + "deltice", "deopt", + "dependabot", "deps", + "dessant", "destructured", "desugared", "devs", "docblock", "docblocks", + "doctag", + "Docusaurus’s", "Dodds", "Dolzhykov", "dotfile", "dotfiles", + "downlevel", "duailibe", "Duperron", "editorconfig", + "eemeli", "ekkhus", "elektronik", "Eneman", "ENOENT", - "ericsakmar", + "EOTP", + "eqeqeq", "Ericsburgh", "errored", "Esben", "esbenp", - "eslint's", + "eslintignore", "eslump", + "espree", "esproposal", "estree", "esutils", "eval", - "evilebottnawi", "execa", "fbglyph", + "FBID", "Ficarra", "filepath", "Filipe", "finalizer", "Fiorini", - "flxwu", + "Fisker", "fnames", + "foldgutter", "formatprg", "Friedly", + "frobble", "fuzzer", - "gavinjoyce", "Georgii", "gettin", "git's", + "git’s", "gitattributes", "githook", - "gitignore", "gitkeep", - "Gitter", + "gitter", "glimmerjs", "globbing", "globby", "gofmt", - "googlegroups", - "googlemaps", "Gregor", - "hackily", - "haggholm", + "gtag", "Hampus", "hardcoded", "hardline", "hardlines", - "harel", "hashbang", "Hawken", "Hengles", "Hersevoort", + "hljs", "hlsson", - "hongrich", "Horky", "hotpink", "hsla", @@ -159,15 +170,14 @@ "iarna", "ICSS", "idempotence", - "IIFE", + "iife", "IIFEs", "ikatyang", "Ilya", "impltype", "importee", "importmap", - "indentable", - "indexof", + "Indentable", "infc", "instanceof", "Intelli", @@ -175,59 +185,60 @@ "jackyho", "Jakefile", "jakegavin", - "jbrown", "jetbrains", "jlongster", - "jnwng", "Joar", "josephfrazier", - "Joun", - "jounqin", - "jridgewell", "jscodefmt", "jsesc", "jsfmt", + "jsonl", "JSXs", - "junit", - "jwbay", - "kachkaev", + "judgements", "kalmanb", "Karimov", "Kassens", "Kasturi", + "kddeisz", + "kddnewton", "Kearney", + "keyframes", "keyof", "Khatri", - "koba", + "Konstantin", + "l’objectif", + "lcov", "libdef", "linearize", "linebreak", "linebreaks", + "linenumbers", "literalline", - "literallines", - "lockfile", + "Literallines", "loglevel", "lowercased", "lowercasing", "lydell", - "malcolmsgroves", + "macos", "Marek", "Masad", "Matejka", "Mateusz", - "mattiaerre", - "matzkoh", - "MDAST", - "memberish", + "mdast", + "Memberish", "memoized", + "meriyah", "Michał", - "microsyntax", + "Microsyntax", "Mikael", + "minimalistic", "minimise", "miniprettier", "mitermayer", "mixins", "mjml", + "mkdir", + "mobx", "Moeller", "Monteiro", "Morrell", @@ -235,23 +246,25 @@ "mousedown", "mouseup", "mprettier", + "msapplication", "multiparser", "Muntean", "nargs", + "navbutton", "neoclide", "neoformat", "neovim", + "netrc", "nicolo", "nnoremap", - "nodir", "noncharacters", "nonenumerable", - "nonspacing", + "Nonspacing", "noopener", "noreferrer", "normalise", "normalised", - "nrvtbfux", + "npmrc", "nullability", "nullish", "Nuno", @@ -259,16 +272,15 @@ "octicon", "Okazaki", "Okonetchnikov", - "oneth", - "onurtemizkan", "onwarn", "Oopsy", + "outdent", "overparenthesization", "overscroll", "packagejson", "Panasenko", + "Pandoc", "Pangsakulyanont", - "papayawhip", "paren", "parens", "parentless", @@ -276,25 +288,27 @@ "pcss", "Pierzchała", "Pieter", - "pomber", + "pnpm", "postprocess", "postprocessor", + "preactjs", "precache", "precommit", "prefetch", "preorder", - "Prettier's", + "prettier's", "Prettier’s", "prettierformatsource", "prettiergetsupportinfo", "prettierignore", "prettierrc", - "prettylint", + "prettierx", + "probot", "progid", "promisify", "proto", + "Pschera", "quasis", - "quux", "Raghuvir", "Rasmus", "Rattray", @@ -304,19 +318,21 @@ "readlines", "rebalance", "rebeccapurple", + "Rects", "recurse", "recurses", + "Redeclaration", "refmt", "regexes", - "reimplement", - "repo", + "Reimplement", "repo's", + "repo’s", + "REPONAME", "repos", - "reselect", "rhengles", "ribaudo", - "roadmap", - "rreverser", + "Roadmap", + "Rubocop", "ruleset", "rulesets", "sandhose", @@ -324,55 +340,54 @@ "sbdchd", "scandir", "schemastore", - "serializer", - "serviceworker", + "Serializers", "setlocal", "setq", - "shellscape", "shellsession", "Shigeaki", - "Shinigami", "Simen", - "simonhaenisch", "singleline", "skratchdot", - "smirea", + "Skyscanner", "socio", "softline", "softlines", "Sorin", - "sosukesuzuki", - "squidfunk", + "Sosuke", "srcset", "Stachowiak", "staged's", "standalones", "Stankiewicz", - "stringify", - "stubailo", + "starturl", + "stringifier", + "stylefmt", "styleguides", "stylelint", "stylelintrc", + "stylesheet", + "subfolder", "subvalue", "suchipi", "superset", "supertypes", - "swac", - "systemjs", - "tdeekens", + "Supprimer", "templating", "tempy", - "tgriesser", + "testname", "tidelift", - "tidelift’s", + "Tidelift’s", "tldr", "Tomasek", "Tradeshift", "Transloadit", + "trippable", "TSAs", "tsep", + "TSESTree", + "TSESTreeOptions", "TSJS", - "Typeahead", + "typeahead", "typecasted", "typecheck", "typeof", @@ -382,21 +397,18 @@ "uffff", "Umidbek", "unaries", - "uncheck", + "Uncheck", "uncook", + "unibeautify", "unignore", - "uniqby", "unist", - "unmount", + "Unmount", "unparenthesised", "unparenthesized", "unparseable", "unpause", - "unpkg", - "unrestrict", - "unstage", + "Unrestrict", "unstaged", - "untracked", "valourous", "Vanderwerff", "vanguarding", @@ -409,9 +421,12 @@ "Vue's", "Wadler", "Wadler's", - "warrenseine", + "wcwidth", + "webcompat", "webstorm", + "Weixin", "whitespaces", + "wxss", "xargs", "yamafaktory", "Yatharth", @@ -432,21 +447,31 @@ "semistandard", "typecheck", "Zatorski", - "Zeit", - "zimme", "Zosel" ], "ignoreRegExpList": [ "\\n(`{3,})\\w*\\n[\\s\\S]+?\\1", - "\\[@\\w+?\\]", - "\\[`\\w+`\\]", + "\\[(\\*{2})?@[-\\w]+?\\1\\]", + "by @[-\\w]+(?:, @[-\\w]+)?", "ve{2,}r{2,}y", - "ve+r+y+long\\w*" + "ve+r+y+long\\w*", + "\\(https?://.*?\\)", + "author: \".*?\"", + "authorURL: \".*?\"", + "\"author\": \".*?\"" ], "ignorePaths": [ + "cspell.json", "**/node_modules/**", + "**/yarn.lock", + "{coverage,dist,.cache,.git,.vscode,.DS_Store,tests}/**/*", + "!tests/**/jsfmt.spec.js", + "*.{log,svg,snap,png}", + "test*.*", + "website/data/users.yml", "website/build/**", "website/playground/codeSamples.js", + "website/pages/googlefe164a33bda4034b.html", "website/static/lib/**", "website/static/playground.js" ] diff --git a/docs/api.md b/docs/api.md index a0f11070db..57462399ba 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3,13 +3,15 @@ id: api title: API --- +If you want to run Prettier programmatically, check this page out. + ```js const prettier = require("prettier"); ``` -## `prettier.format(source [, options])` +## `prettier.format(source, options)` -`format` is used to format text using Prettier. [Options](options.md) may be provided to override the defaults. Set `options.parser` according to the language you are formatting (see the [list of available parsers](options.md#parser)). +`format` is used to format text using Prettier. `options.parser` must be set according to the language you are formatting (see the [list of available parsers](options.md#parser)). Alternatively, `options.filepath` can be specified for Prettier to infer the parser from the file extension. Other [options](options.md) may be provided to override the defaults. ```js prettier.format("foo ( );", { semi: false, parser: "babel" }); @@ -33,7 +35,7 @@ prettier.formatWithCursor(" 1", { cursorOffset: 2, parser: "babel" }); ## `prettier.resolveConfig(filePath [, options])` -`resolveConfig` can be used to resolve configuration for a given source file, passing its path as the first argument. The config search will start at the file path and continue to search up the directory (you can use `process.cwd()` to start searching from the current directory). Or you can pass directly the path of the config file as `options.config` if you don't wish to search for it. A promise is returned which will resolve to: +`resolveConfig` can be used to resolve configuration for a given source file, passing its path as the first argument. The config search will start at the file path and continue to search up the directory (you can use `process.cwd()` to start searching from the current directory). Or you can pass directly the path of the config file as `options.config` if you don’t wish to search for it. A promise is returned which will resolve to: - An options object, providing a [config file](configuration.md) was found. - `null`, if no file was found. @@ -49,14 +51,14 @@ prettier.resolveConfig(filePath).then((options) => { }); ``` -If `options.editorconfig` is `true` and an [`.editorconfig` file](https://editorconfig.org/) is in your project, Prettier will parse it and convert its properties to the corresponding prettier configuration. This configuration will be overridden by `.prettierrc`, etc. Currently, the following EditorConfig properties are supported: +If `options.editorconfig` is `true` and an [`.editorconfig` file](https://editorconfig.org/) is in your project, Prettier will parse it and convert its properties to the corresponding Prettier configuration. This configuration will be overridden by `.prettierrc`, etc. Currently, the following EditorConfig properties are supported: - `end_of_line` - `indent_style` - `indent_size`/`tab_width` - `max_line_length` -Use `prettier.resolveConfig.sync(filePath [, options])` if you'd like to use sync version. +Use `prettier.resolveConfig.sync(filePath [, options])` if you’d like to use sync version. ## `prettier.resolveConfigFile([filePath])` @@ -70,14 +72,12 @@ The promise will be rejected if there was an error parsing the configuration fil The search starts at `process.cwd()`, or at `filePath` if provided. Please see the [cosmiconfig docs](https://github.com/davidtheclark/cosmiconfig#explorersearch) for details on how the resolving works. ```js -prettier.resolveConfigFile().then((filePath) => { - prettier.resolveConfig(filePath).then((options) => { - const formatted = prettier.format(text, options); - }); +prettier.resolveConfigFile(filePath).then((configFile) => { + // you got the path of the configuration file }); ``` -Use `prettier.resolveConfigFile.sync([filePath])` if you'd like to use sync version. +Use `prettier.resolveConfigFile.sync([filePath])` if you’d like to use sync version. ## `prettier.clearConfigCache()` @@ -98,11 +98,13 @@ The promise will be rejected if the type of `filePath` is not `string`. Setting `options.ignorePath` (`string`) and `options.withNodeModules` (`boolean`) influence the value of `ignored` (`false` by default). +If the given `filePath` is ignored, the `inferredParser` is always `null`. + Providing [plugin](plugins.md) paths in `options.plugins` (`string[]`) helps extract `inferredParser` for files that are not supported by Prettier core. When setting `options.resolveConfig` (`boolean`, default `false`), Prettier will resolve the configuration for the given `filePath`. This is useful, for example, when the `inferredParser` might be overridden for a subset of files. -Use `prettier.getFileInfo.sync(filePath [, options])` if you'd like to use sync version. +Use `prettier.getFileInfo.sync(filePath [, options])` if you’d like to use sync version. ## `prettier.getSupportInfo()` @@ -138,7 +140,7 @@ If you need to make modifications to the AST (such as codemods), or you want to (text: string, parsers: object, options: object) => AST; ``` -Prettier's built-in parsers are exposed as properties on the `parsers` argument. +Prettier’s built-in parsers are exposed as properties on the `parsers` argument. ```js prettier.format("lodash ( )", { diff --git a/docs/assets/webstorm/file-watcher-prettier.png b/docs/assets/webstorm/file-watcher-prettier.png deleted file mode 100644 index 3fd6b548ae..0000000000 Binary files a/docs/assets/webstorm/file-watcher-prettier.png and /dev/null differ diff --git a/docs/browser.md b/docs/browser.md index e1d89e6858..cb7aee8496 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -3,49 +3,72 @@ id: browser title: Browser --- -Run Prettier in the browser with the `standalone.js` UMD bundle shipped in the NPM package (starting in version 1.13). The UMD bundle only formats the code and has no support for config files, ignore files, CLI usage, or automatic loading of plugins. +Run Prettier in the browser using its **standalone** version. This version doesn’t depend on Node.js. It only formats the code and has no support for config files, ignore files, CLI usage, or automatic loading of plugins. + +The standalone version comes as: + +- ES modules: `esm/standalone.mjs`, starting in version 2.2 +- UMD: `standalone.js`, starting in version 1.13 + +The [`browser` field](https://github.com/defunctzombie/package-browser-field-spec) in Prettier’s `package.json` points to `standalone.js`. That’s why you can just `import` or `require` the `prettier` module to access Prettier’s API, and your code can stay compatible with both Node and the browser as long as webpack or another bundler that supports the `browser` field is used. This is especially convenient for [plugins](plugins.md). ### `prettier.format(code, options)` -Unlike the `format` function from the [main API](api.md#prettierformatsource--options), this function does not load plugins automatically, so a `plugins` property is required if you want to load plugins. Additionally, the parsers included in the Prettier package won't be loaded automatically, so you need to load them before using them. +Required options: + +- **[`parser`](options.md#parser) (or [`filepath`](options.md#file-path))**: One of these options has to be specified for Prettier to know which parser to use. -See [Usage](#usage) below for examples. +- **`plugins`**: Unlike the `format` function from the [Node.js-based API](api.md#prettierformatsource--options), this function doesn’t load plugins automatically. The `plugins` option is required because all the parsers included in the Prettier package come as plugins (for reasons of file size). These plugins are files named + + - `parser-*.js` in and + - `parser-*.mjs` in + + You need to load the ones that you’re going to use and pass them to `prettier.format` using the `plugins` option. + +See below for examples. ## Usage ### Global ```html - - + + ``` +Note that the [`unpkg` field](https://unpkg.com/#examples) in Prettier’s `package.json` points to `standalone.js`, that’s why `https://unpkg.com/prettier` can also be used instead of `https://unpkg.com/prettier/standalone.js`. + ### ES Modules -```js -import prettier from "prettier/standalone"; -import parserGraphql from "prettier/parser-graphql"; +```html + ``` ### AMD ```js define([ - "https://unpkg.com/prettier@2.0.5/standalone.js", - "https://unpkg.com/prettier@2.0.5/parser-graphql.js", + "https://unpkg.com/prettier@2.3.1/standalone.js", + "https://unpkg.com/prettier@2.3.1/parser-graphql.js", ], (prettier, ...plugins) => { - prettier.format("query { }", { parser: "graphql", plugins }); + prettier.format("type Query { hello: String }", { + parser: "graphql", + plugins, + }); }); ``` @@ -54,15 +77,58 @@ define([ ```js const prettier = require("prettier/standalone"); const plugins = [require("prettier/parser-graphql")]; -prettier.format("query { }", { parser: "graphql", plugins }); +prettier.format("type Query { hello: String }", { + parser: "graphql", + plugins, +}); ``` -This syntax doesn't necessarily work in the browser, but it can be used when bundling the code with browserify, Rollup, webpack, or another bundler. +This syntax doesn’t necessarily work in the browser, but it can be used when bundling the code with browserify, Rollup, webpack, or another bundler. ### Worker ```js -importScripts("https://unpkg.com/prettier@2.0.5/standalone.js"); -importScripts("https://unpkg.com/prettier@2.0.5/parser-graphql.js"); -prettier.format("query { }", { parser: "graphql", plugins: prettierPlugins }); +importScripts("https://unpkg.com/prettier@2.3.1/standalone.js"); +importScripts("https://unpkg.com/prettier@2.3.1/parser-graphql.js"); +prettier.format("type Query { hello: String }", { + parser: "graphql", + plugins: prettierPlugins, +}); +``` + +## Parser plugins for embedded code + +If you want to format [embedded code](options.md#embedded-language-formatting), you need to load related plugins too. For example: + +```html + +``` + +The HTML code embedded in JavaScript stays unformatted because the `html` parser hasn’t been loaded. Correct usage: + +```html + ``` diff --git a/docs/cli.md b/docs/cli.md index e8dac38e8f..fa5e420605 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,14 +3,16 @@ id: cli title: CLI --- -Use the `prettier` command to run Prettier from the command line. Run it without any arguments to see the [options](options.md). - -To format a file in-place, use `--write`. You may want to consider committing your code before doing that, just in case. +Use the `prettier` command to run Prettier from the command line. ```bash prettier [options] [file/dir/glob ...] ``` +> To run your locally installed version of Prettier, prefix the command with `npx` or `yarn` (if you use Yarn), i.e. `npx prettier --help`, or `yarn prettier --help`. + +To format a file in-place, use `--write`. (Note: This overwrites your files!) + In practice, this may look something like: ```bash @@ -19,27 +21,31 @@ prettier --write . This command formats all files supported by Prettier in the current directory and its subdirectories. +It’s recommended to always make sure that `prettier --write .` only formats what you want in your project. Use a [`.prettierignore`](ignore.md) file to ignore things that should not be formatted. + A more complicated example: ```bash prettier --single-quote --trailing-comma all --write docs package.json "{app,__{tests,mocks}__}/**/*.js" ``` -> Don't forget the **quotes** around the globs! The quotes make sure that Prettier CLI expands the globs rather than your shell, which is important for cross-platform usage. +> Don’t forget the **quotes** around the globs! The quotes make sure that Prettier CLI expands the globs rather than your shell, which is important for cross-platform usage. + +> It’s better to use a [configuration file](configuration.md) for formatting options like `--single-quote` and `--trailing-comma` instead of passing them as CLI flags. This way the Prettier CLI, [editor integrations](editors.md), and other tooling can all know what options you use. -> It's usually better to use a [configuration file](configuration.md) for formatting options like `--single-quote` and `--trailing-comma` instead of passing them as CLI flags. This allows sharing those settings across different ways to run Prettier (CLI, [editor integrations](editors.md), etc.). +## File patterns -Given a list of paths/patterns, Prettier CLI first treats every entry in it as a literal path. +Given a list of paths/patterns, the Prettier CLI first treats every entry in it as a literal path. -- If the path points to an existing file, Prettier CLI proceeds with that file and doesn't resolve the path as a glob pattern. +- If the path points to an existing file, Prettier CLI proceeds with that file and doesn’t resolve the path as a glob pattern. - If the path points to an existing directory, Prettier CLI recursively finds supported files in that directory. This resolution process is based on file extensions and well-known file names that Prettier and its [plugins](plugins.md) associate with supported languages. - Otherwise, the entry is resolved as a glob pattern using the [glob syntax from the `fast-glob` module](https://github.com/mrmlnc/fast-glob#pattern-syntax). -Prettier CLI will ignore files located in `node_modules` directory. To opt out from this behavior use `--with-node-modules` flag. +Prettier CLI will ignore files located in `node_modules` directory. To opt out from this behavior, use `--with-node-modules` flag. -To escape special characters in globs, one of the two escaping syntaxes can be used: `prettier "\[my-dir]/*.js"` or `prettier "[[]my-dir]/*.js"`. Both match all JS files in a directory named `[my-dir]`, however the latter syntax is preferable as the former doesn't work on Windows, where backslashes are treated as path separators. +To escape special characters in globs, one of the two escaping syntaxes can be used: `prettier "\[my-dir]/*.js"` or `prettier "[[]my-dir]/*.js"`. Both match all JS files in a directory named `[my-dir]`, however the latter syntax is preferable as the former doesn’t work on Windows, where backslashes are treated as path separators. ## `--check` @@ -61,12 +67,12 @@ Console output if some of the files require re-formatting: ```console Checking formatting... -src/fileA.js -src/fileB.js -Code style issues found in the above file(s). Forgot to run Prettier? +[warn] src/fileA.js +[warn] src/fileB.js +[warn] Code style issues found in the above file(s). Forgot to run Prettier? ``` -The command will return exit code 1 in the second case, which is helpful inside the CI pipelines. +The command will return exit code `1` in the second case, which is helpful inside the CI pipelines. Human-friendly status messages help project contributors react on possible problems. To minimise the number of times `prettier --check` finds unformatted files, you may be interested in configuring a [pre-commit hook](precommit.md) in your repo. Applying this practice will minimise the number of times the CI fails because of code formatting problems. @@ -77,9 +83,9 @@ If you need to pipe the list of unformatted files to another command, you can u | Code | Information | | ---- | ----------------------------------- | -| 0 | Everything formatted properly | -| 1 | Something wasn't formatted properly | -| 2 | Something's wrong with Prettier | +| `0` | Everything formatted properly | +| `1` | Something wasn’t formatted properly | +| `2` | Something’s wrong with Prettier | ## `--debug-check` @@ -87,7 +93,7 @@ If you're worried that Prettier will change the correctness of your code, add `- ## `--find-config-path` and `--config` -If you are repeatedly formatting individual files with `prettier`, you will incur a small performance cost when prettier attempts to look up a [configuration file](configuration.md). In order to skip this, you may ask prettier to find the config file once, and re-use it later on. +If you are repeatedly formatting individual files with `prettier`, you will incur a small performance cost when Prettier attempts to look up a [configuration file](configuration.md). In order to skip this, you may ask Prettier to find the config file once, and re-use it later on. ```bash prettier --find-config-path ./my/file.js @@ -100,29 +106,13 @@ This will provide you with a path to the configuration file, which you can pass prettier --config ./my/.prettierrc --write ./my/file.js ``` -You can also use `--config` if your configuration file lives somewhere where prettier cannot find it, such as a `config/` directory. +You can also use `--config` if your configuration file lives somewhere where Prettier cannot find it, such as a `config/` directory. -If you don't have a configuration file, or want to ignore it if it does exist, you can pass `--no-config` instead. +If you don’t have a configuration file, or want to ignore it if it does exist, you can pass `--no-config` instead. ## `--ignore-path` -Path to a file containing patterns that describe files to ignore. By default, prettier looks for `./.prettierignore`. - -## `--require-pragma` - -Require a special comment, called a pragma, to be present in the file's first docblock comment in order for prettier to format it. - -```js -/** - * @prettier - */ -``` - -Valid pragmas are `@prettier` and `@format`. - -## `--insert-pragma` - -Insert a `@format` pragma to the top of formatted files when pragma is absent. Works well when used in tandem with `--require-pragma`. +Path to a file containing patterns that describe files to ignore. By default, Prettier looks for `./.prettierignore`. ## `--list-different` @@ -152,21 +142,21 @@ Config file take precedence over CLI options **prefer-file** -If a config file is found will evaluate it and ignore other CLI options. If no config file is found CLI options will evaluate as normal. +If a config file is found will evaluate it and ignore other CLI options. If no config file is found, CLI options will evaluate as normal. This option adds support to editor integrations where users define their default configuration but want to respect project specific configuration. ## `--no-editorconfig` -Don't take .editorconfig into account when parsing configuration. See the [`prettier.resolveConfig` docs](api.md) for details. +Don’t take `.editorconfig` into account when parsing configuration. See the [`prettier.resolveConfig` docs](api.md) for details. ## `--with-node-modules` -Prettier CLI will ignore files located in `node_modules` directory. To opt-out from this behavior use `--with-node-modules` flag. +Prettier CLI will ignore files located in `node_modules` directory. To opt out from this behavior, use `--with-node-modules` flag. ## `--write` -This rewrites all processed files in place. This is comparable to the `eslint --fix` workflow. +This rewrites all processed files in place. This is comparable to the `eslint --fix` workflow. You can also use `-w` alias. ## `--loglevel` @@ -198,3 +188,15 @@ $ cat abc.css | prettier --stdin-filepath abc.css display: none; } ``` + +## `--ignore-unknown` + +With `--ignore-unknown` (or `-u`), prettier will ignore unknown files matched by patterns. + +```console +$ prettier "**/*" --write --ignore-unknown +``` + +## `--no-error-on-unmatched-pattern` + +Prevent errors when pattern is unmatched. diff --git a/docs/comparison.md b/docs/comparison.md index 89579bddee..882ca74f5a 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -9,8 +9,10 @@ Linters have two categories of rules: **Formatting rules**: eg: [max-len](https://eslint.org/docs/rules/max-len), [no-mixed-spaces-and-tabs](https://eslint.org/docs/rules/no-mixed-spaces-and-tabs), [keyword-spacing](https://eslint.org/docs/rules/keyword-spacing), [comma-style](https://eslint.org/docs/rules/comma-style)… -Prettier alleviates the need for this whole category of rules! Prettier is going to reprint the entire program from scratch in a consistent way, so it's not possible for the programmer to make a mistake there anymore :) +Prettier alleviates the need for this whole category of rules! Prettier is going to reprint the entire program from scratch in a consistent way, so it’s not possible for the programmer to make a mistake there anymore :) **Code-quality rules**: eg [no-unused-vars](https://eslint.org/docs/rules/no-unused-vars), [no-extra-bind](https://eslint.org/docs/rules/no-extra-bind), [no-implicit-globals](https://eslint.org/docs/rules/no-implicit-globals), [prefer-promise-reject-errors](https://eslint.org/docs/rules/prefer-promise-reject-errors)… Prettier does nothing to help with those kind of rules. They are also the most important ones provided by linters as they are likely to catch real bugs with your code! + +In other words, use **Prettier for formatting** and **linters for catching bugs!** diff --git a/docs/configuration.md b/docs/configuration.md index 5651e690af..ca89213f0c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,16 +3,19 @@ id: configuration title: Configuration File --- -Prettier uses [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) for configuration file support. This means you can configure prettier via (in order of precedence): +Prettier uses [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) for configuration file support. This means you can configure Prettier via (in order of precedence): - A `"prettier"` key in your `package.json` file. -- A `.prettierrc` file, written in JSON or YAML, with optional extensions: `.json/.yaml/.yml` (without extension takes precedence). -- A `.prettierrc.js` or `prettier.config.js` file that exports an object. -- A `.prettierrc.toml` file, written in TOML (the `.toml` extension is _required_). +- A `.prettierrc` file written in JSON or YAML. +- A `.prettierrc.json`, `.prettierrc.yml`, `.prettierrc.yaml`, or `.prettierrc.json5` file. +- A `.prettierrc.js`, `.prettierrc.cjs`, `prettier.config.js`, or `prettier.config.cjs` file that exports an object using `module.exports`. +- A `.prettierrc.toml` file. -The configuration file will be resolved starting from the location of the file being formatted, and searching up the file tree until a config file is (or isn't) found. +The configuration file will be resolved starting from the location of the file being formatted, and searching up the file tree until a config file is (or isn’t) found. -The options to the configuration file are the same as the [API options](options.md). +Prettier intentionally doesn’t support any kind of global configuration. This is to make sure that when a project is copied to another computer, Prettier’s behavior stays the same. Otherwise, Prettier wouldn’t be able to guarantee that everybody in a team gets the same consistent results. + +The options you can use in the configuration file are the same as the [API options](options.md). ## Basic Configuration @@ -116,7 +119,7 @@ Sharing a Prettier configuration is simple: just publish a module that exports a } ``` -If you don't want to use `package.json`, you can use any of the supported extensions to export a string, e.g. `.prettierrc.json`: +If you don’t want to use `package.json`, you can use any of the supported extensions to export a string, e.g. `.prettierrc.json`: ```json "@company/prettier-config" @@ -165,8 +168,8 @@ You can also switch to the `flow` parser instead of the default `babel` for .js } ``` -**Note:** _Never_ put the `parser` option at the top level of your configuration. _Only_ use it inside `overrides`. Otherwise you effectively disable Prettier's automatic file extension based parser inference. This forces Prettier to use the parser you specified for _all_ types of files – even when it doesn't make sense, such as trying to parse a CSS file as JavaScript. +**Note:** _Never_ put the `parser` option at the top level of your configuration. _Only_ use it inside `overrides`. Otherwise you effectively disable Prettier’s automatic file extension based parser inference. This forces Prettier to use the parser you specified for _all_ types of files – even when it doesn’t make sense, such as trying to parse a CSS file as JavaScript. ## Configuration Schema -If you'd like a JSON schema to validate your configuration, one is available here: http://json.schemastore.org/prettierrc. +If you’d like a JSON schema to validate your configuration, one is available here: http://json.schemastore.org/prettierrc. diff --git a/docs/editors.md b/docs/editors.md index 93d7003a00..dbf2bd9ddd 100644 --- a/docs/editors.md +++ b/docs/editors.md @@ -3,41 +3,45 @@ id: editors title: Editor Integration --- -## Atom +To get the most out of Prettier, it’s recommended to run it from your editor. + +If your editor does not support Prettier, you can instead [run Prettier with a file watcher](watching-files.md). -Atom users can simply install the [prettier-atom] package and use `Ctrl+Alt+F` to format a file (or format on save if enabled). +**Note!** It’s important to [install](install.md) Prettier locally in every project, so each project gets the correct Prettier version. + +## Visual Studio Code -Alternatively, you can use one the packages below, which behave similarly to [prettier-atom] but have a focus on minimalism. +`prettier-vscode` can be installed using the extension sidebar – it’s called “Prettier - Code formatter.” [Check its repository for configuration and shortcuts](https://github.com/prettier/prettier-vscode). -- [mprettier](https://github.com/t9md/atom-mprettier) -- [miniprettier](https://github.com/duailibe/atom-miniprettier) +If you’d like to toggle the formatter on and off, install [`vscode-status-bar-format-toggle`](https://marketplace.visualstudio.com/items?itemName=tombonnike.vscode-status-bar-format-toggle). ## Emacs -Emacs users should see [this repository](https://github.com/prettier/prettier-emacs) for on-demand formatting. +Check out the [prettier-emacs](https://github.com/prettier/prettier-emacs) repo, or [prettier.el](https://github.com/jscheid/prettier.el). The package [Apheleia](https://github.com/raxod502/apheleia) supports multiple code formatters, including Prettier. ## Vim -Vim users can install either [vim-prettier](https://github.com/prettier/vim-prettier), which is Prettier specific, or [Neoformat](https://github.com/sbdchd/neoformat) or [ALE](https://github.com/w0rp/ale) which are generalized lint/format engines with support for Prettier. +[vim-prettier](https://github.com/prettier/vim-prettier) is a Prettier-specific Vim plugin. [Neoformat](https://github.com/sbdchd/neoformat), [ALE](https://github.com/w0rp/ale), and [coc-prettier](https://github.com/neoclide/coc-prettier) are multi-language Vim linter/formatter plugins that support Prettier. For more details see [the Vim setup guide](vim.md). -## Visual Studio Code +## Sublime Text + +Sublime Text support is available through Package Control and the [JsPrettier](https://packagecontrol.io/packages/JsPrettier) plug-in. -`prettier-vscode` can be installed using the extension sidebar. Search for `Prettier - Code formatter`. It can also be installed using `ext install esbenp.prettier-vscode` in the command palette. [Check its repository for configuration and shortcuts](https://github.com/prettier/prettier-vscode). +## JetBrains WebStorm, PHPStorm, PyCharm... -If you'd like to toggle the formatter on and off, install [`vscode-status-bar-format-toggle`](https://marketplace.visualstudio.com/items?itemName=tombonnike.vscode-status-bar-format-toggle). +See the [WebStorm setup guide](webstorm.md). ## Visual Studio Install the [JavaScript Prettier extension](https://github.com/madskristensen/JavaScriptPrettier). -## Sublime Text - -Sublime Text support is available through Package Control and the [JsPrettier](https://packagecontrol.io/packages/JsPrettier) plug-in. +## Atom -## JetBrains WebStorm, PHPStorm, PyCharm... +Atom users can install the [prettier-atom](https://github.com/prettier/prettier-atom) package, or one of the more minimalistic [mprettier](https://github.com/t9md/atom-mprettier) and +[miniprettier](https://github.com/duailibe/atom-miniprettier) packages. -See the [WebStorm setup guide](webstorm.md). +## Espresso -[prettier-atom]: https://github.com/prettier/prettier-atom +Espresso users can install the [espresso-prettier](https://github.com/eablokker/espresso-prettier) plugin. diff --git a/docs/ignore.md b/docs/ignore.md index 9706b8d23f..1cca65e411 100644 --- a/docs/ignore.md +++ b/docs/ignore.md @@ -3,11 +3,28 @@ id: ignore title: Ignoring Code --- -Prettier offers an escape hatch to ignore a block of code or prevent entire files from being formatted. +Use `.prettierignore` to ignore (i.e. not reformat) certain files and folders completely. -## Ignoring Files +Use “prettier-ignore” comments to ignore parts of files. -To exclude files from formatting, add entries to a `.prettierignore` file in the project root or set the [`--ignore-path` CLI option](cli.md#--ignore-path). `.prettierignore` uses [gitignore syntax](https://git-scm.com/docs/gitignore#_pattern_format). +## Ignoring Files: .prettierignore + +To exclude files from formatting, create a `.prettierignore` file in the root of your project. `.prettierignore` uses [gitignore syntax](https://git-scm.com/docs/gitignore#_pattern_format). + +Example: + +``` +# Ignore artifacts: +build +coverage + +# Ignore all HTML files: +*.html +``` + +It’s recommended to have a `.prettierignore` in your project! This way you can run `prettier --write .` to make sure that everything is formatted (without mangling files you don’t want, or choking on generated files). And – your editor will know which files _not_ to format! + +(See also the [`--ignore-path` CLI option](cli.md#--ignore-path).) ## JavaScript @@ -107,6 +124,16 @@ This type of ignore is only allowed to be used in top-level and aimed to disable ``` +## YAML + +To ignore a part of a YAML file, `# prettier-ignore` should be placed on the line immediately above the ignored node: + +```yaml +# prettier-ignore +key : value +hello: world +``` + ## GraphQL ```graphql @@ -132,3 +159,13 @@ This type of ignore is only allowed to be used in top-level and aimed to disable {{/my-crazy-component}} ``` + +## Command Line File Patterns + +For one-off commands, when you want to exclude some files without adding them to `.prettierignore`, negative patterns can come in handy: + +```bash +prettier --write . '!**/*.{js,jsx,vue}' +``` + +See [fast-glob](https://prettier.io/docs/en/cli.html#file-patterns) to learn more about advanced glob syntax. diff --git a/docs/index.md b/docs/index.md index 27f3750abf..fb5d926150 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ title: What is Prettier? Prettier is an opinionated code formatter with support for: -- JavaScript, including [ES2017](https://github.com/tc39/proposals/blob/master/finished-proposals.md) +- JavaScript (including experimental features) - [JSX](https://facebook.github.io/jsx/) - [Angular](https://angular.io/) - [Vue](https://vuejs.org/) @@ -13,7 +13,7 @@ Prettier is an opinionated code formatter with support for: - [TypeScript](https://www.typescriptlang.org/) - CSS, [Less](http://lesscss.org/), and [SCSS](https://sass-lang.com) - [HTML](https://en.wikipedia.org/wiki/HTML) -- [JSON](http://json.org/) +- [JSON](https://json.org/) - [GraphQL](https://graphql.org/) - [Markdown](https://commonmark.org/), including [GFM](https://github.github.com/gfm/) and [MDX](https://mdxjs.com/) - [YAML](https://yaml.org/) @@ -28,7 +28,7 @@ For example, take the following code: foo(arg1, arg2, arg3, arg4); ``` -It fits in a single line so it's going to stay as is. However, we've all run into this situation: +It fits in a single line so it’s going to stay as is. However, we've all run into this situation: ```js @@ -46,7 +46,7 @@ foo( ); ``` -Prettier enforces a consistent code **style** (i.e. code formatting that won't affect the AST) across your entire codebase because it disregards the original styling[\*](#footnotes) by parsing it away and re-printing the parsed AST with its own rules that take the maximum line length into account, wrapping code when necessary. +Prettier enforces a consistent code **style** (i.e. code formatting that won’t affect the AST) across your entire codebase because it disregards the original styling[\*](#footnotes) by parsing it away and re-printing the parsed AST with its own rules that take the maximum line length into account, wrapping code when necessary. If you want to learn more, these two conference talks are great introductions: diff --git a/docs/install.md b/docs/install.md index c7a08347c2..f54dfce805 100644 --- a/docs/install.md +++ b/docs/install.md @@ -3,26 +3,145 @@ id: install title: Install --- -Install with `yarn`: +First, install Prettier locally: + + + ```bash -yarn add prettier --dev --exact -# or globally -yarn global add prettier +npm install --save-dev --save-exact prettier ``` -_We're using `yarn` but you can use `npm` if you like:_ + ```bash -npm install --save-dev --save-exact prettier -# or globally -npm install --global prettier +yarn add --dev --exact prettier +``` + + + +Then, create an empty config file to let editors and other tools know you are using Prettier: + + + +```bash +echo {}> .prettierrc.json ``` -> We recommend pinning an exact version of prettier in your `package.json` as we introduce stylistic changes in patch releases. +Next, create a [.prettierignore](ignore.md) file to let the Prettier CLI and editors know which files to _not_ format. Here’s an example: + +``` +# Ignore artifacts: +build +coverage +``` + +> Tip! Base your .prettierignore on .gitignore and .eslintignore (if you have one). + +> Another tip! If your project isn’t ready to format, say, HTML files yet, add `*.html`. + +Now, format all files with Prettier: + + + + +```bash +npx prettier --write . +``` + +> What is that `npx` thing? `npx` ships with `npm` and lets you run locally installed tools. We’ll leave off the `npx` part for brevity throughout the rest of this file! +> +> Note: If you forget to install Prettier first, `npx` will temporarily download the latest version. That’s not a good idea when using Prettier, because we change how code is formatted in each release! It’s important to have a locked down version of Prettier in your `package.json`. And it’s faster, too. + + + +```bash +yarn prettier --write . +``` + +> What is `yarn` doing at the start? `yarn prettier` runs the locally installed version of Prettier. We’ll leave off the `yarn` part for brevity throughout the rest of this file! + + + +`prettier --write .` is great for formatting everything, but for a big project it might take a little while. You may run `prettier --write app/` to format a certain directory, or `prettier --write app/components/Button.js` to format a certain file. Or use a _glob_ like `prettier --write "app/**/*.test.js"` to format all tests in a directory (see [fast-glob](https://github.com/mrmlnc/fast-glob#pattern-syntax) for supported glob syntax). -If you use `npx` to run Prettier, the version should be pinned like this: +If you have a CI setup, run the following as part of it to make sure that everyone runs Prettier. This avoids merge conflicts and other collaboration issues! ```bash -npx prettier@2.0.5 . --write +npx prettier --check . ``` + +`--check` is like `--write`, but only checks that files are already formatted, rather than overwriting them. `prettier --write` and `prettier --check` are the most common ways to run Prettier. + +## Set up your editor + +Formatting from the command line is a good way to get started, but you get the most from Prettier by running it from your editor, either via a keyboard shortcut or automatically whenever you save a file. When a line has gotten so long while coding that it won’t fit your screen, just hit a key and watch it magically be wrapped into multiple lines! Or when you paste some code and the indentation gets all messed up, let Prettier fix it up for you without leaving your editor. + +See [Editor Integration](editors.md) for how to set up your editor. If your editor does not support Prettier, you can instead [run Prettier with a file watcher](watching-files.md). + +> **Note:** Don’t skip the regular local install! Editor plugins will pick up your local version of Prettier, making sure you use the correct version in every project. (You wouldn’t want your editor accidentally causing lots of changes because it’s using a newer version of Prettier than your project!) +> +> And being able to run Prettier from the command line is still a good fallback, and needed for CI setups. + +## ESLint (and other linters) + +If you use ESLint, install [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier#installation) to make ESLint and Prettier play nice with each other. It turns off all ESLint rules that are unnecessary or might conflict with Prettier. There’s a similar config for Stylelint: [stylelint-config-prettier](https://github.com/prettier/stylelint-config-prettier) + +(See [Prettier vs. Linters](comparison.md) to learn more about formatting vs linting, [Integrating with Linters](integrating-with-linters.md) for more in-depth information on configuring your linters, and [Related projects](related-projects.md) for even more integration possibilities, if needed.) + +## Git hooks + +In addition to running Prettier from the command line (`prettier --write`), checking formatting in CI, and running Prettier from your editor, many people like to run Prettier as a pre-commit hook as well. This makes sure all your commits are formatted, without having to wait for your CI build to finish. + +For example, you can do the following to have Prettier run before each commit: + +1. Install [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged): + + + + + ```bash + npm install --save-dev husky lint-staged + npx husky install + npm set-script prepare "husky install" + npx husky add .husky/pre-commit "npx lint-staged" + ``` + + + + ```bash + yarn add --dev husky lint-staged + npx husky install + npm set-script prepare "husky install" + npx husky add .husky/pre-commit "npx lint-staged" + ``` + + > If you use Yarn 2, see https://typicode.github.io/husky/#/?id=yarn-2 + + + +2. Add the following to your `package.json`: + +```json +{ + "lint-staged": { + "**/*": "prettier --write --ignore-unknown" + } +} +``` + +> Note: If you use ESLint, make sure lint-staged runs it before Prettier, not after. + +See [Pre-commit Hook](precommit.md) for more information. + +## Summary + +To summarize, we have learned to: + +- Install an exact version of Prettier locally in your project. This makes sure that everyone in the project gets the exact same version of Prettier. Even a patch release of Prettier can result in slightly different formatting, so you wouldn’t want different team members using different versions and formatting each other’s changes back and forth. +- Add a `.prettierrc.json` to let your editor know that you are using Prettier. +- Add a `.prettierignore` to let your editor know which files _not_ to touch, as well as for being able to run `prettier --write .` to format the entire project (without mangling files you don’t want, or choking on generated files). +- Run `prettier --check .` in CI to make sure that your project stays formatted. +- Run Prettier from your editor for the best experience. +- Use [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) to make Prettier and ESLint play nice together. +- Set up a pre-commit hook to make sure that every commit is formatted. diff --git a/docs/integrating-with-linters.md b/docs/integrating-with-linters.md index 3264d4293c..fe2107104f 100644 --- a/docs/integrating-with-linters.md +++ b/docs/integrating-with-linters.md @@ -3,174 +3,38 @@ id: integrating-with-linters title: Integrating with Linters --- -Prettier can be integrated into workflows with existing linting tools. -This allows you to use Prettier for code formatting concerns, while letting your linter focus on code-quality concerns as outlined in our [comparison with linters](comparison.md). +Linters usually contain not only code quality rules, but also stylistic rules. Most stylistic rules are unnecessary when using Prettier, but worse – they might conflict with Prettier! Use Prettier for code formatting concerns, and linters for code-quality concerns, as outlined in [Prettier vs. Linters](comparison.md). -Whatever linting tool you wish to integrate with, the steps are broadly similar. -First disable any existing formatting rules in your linter that may conflict with how Prettier wishes to format your code. Then you can either add an extension to your linting tool to format your file with Prettier - so that you only need a single command for format a file, or run your linter then Prettier as separate steps. +Luckily it’s easy to turn off rules that conflict or are unnecessary with Prettier, by using these pre-made configs: -All these instructions assume you have already installed `prettier` in your [`devDependencies`]. +- [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) +- [tslint-config-prettier](https://github.com/alexjoverm/tslint-config-prettier) +- [stylelint-config-prettier](https://github.com/prettier/stylelint-config-prettier) -## ESLint +Check out the above links for instructions on how to install and set things up. -### Disable formatting rules +## Notes -[`eslint-config-prettier`](https://github.com/prettier/eslint-config-prettier) is a config that disables rules that conflict with Prettier. Add it to your [`devDependencies`], then extend from it within your `.eslintrc` configuration. Make sure to put it last in the `extends` array, so it gets the chance to override other configs. +When searching for both Prettier and your linter on the Internet you’ll probably find more related projects. These are **generally not recommended,** but can be useful in certain circumstances. -```bash -yarn add --dev eslint-config-prettier -``` +First, we have plugins that let you run Prettier as if it was a linter rule: -Then in `.eslintrc.json`: +- [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) +- [tslint-plugin-prettier](https://github.com/ikatyang/tslint-plugin-prettier) +- [stylelint-prettier](https://github.com/prettier/stylelint-prettier) -```json -{ - "extends": ["prettier"] -} -``` +These plugins were especially useful when Prettier was new. By running Prettier inside your linters, you didn’t have to set up any new infrastructure and you could re-use your editor integrations for the linters. But these days you can run `prettier --check .` and most editors have Prettier support. -### Use ESLint to run Prettier +The downsides of those plugins are: -[`eslint-plugin-prettier`](https://github.com/prettier/eslint-plugin-prettier) is a plugin that adds a rule that formats content using Prettier. Add it to your [`devDependencies`], then enable the plugin and rule. +- You end up with a lot of red squiggly lines in your editor, which gets annoying. Prettier is supposed to make you forget about formatting – and not be in your face about it! +- They are slower than running Prettier directly. +- They’re yet one layer of indirection where things may break. -```bash -yarn add --dev eslint-plugin-prettier -``` +Finally, we have tools that runs `prettier` and then immediately for example `eslint --fix` on files. -Then in `.eslintrc.json`: +- [prettier-eslint](https://github.com/prettier/prettier-eslint) +- [prettier-tslint](https://github.com/azz/prettier-tslint) +- [prettier-stylelint](https://github.com/hugomrdias/prettier-stylelint) -```json -{ - "plugins": ["prettier"], - "rules": { - "prettier/prettier": "error" - } -} -``` - -### Recommended configuration - -`eslint-plugin-prettier` exposes a "recommended" configuration that configures both `eslint-plugin-prettier` and `eslint-config-prettier` in a single step. Add both `eslint-plugin-prettier` and `eslint-config-prettier` as developer dependencies, then extend the recommended config: - -```bash -yarn add --dev eslint-config-prettier eslint-plugin-prettier -``` - -Then in `.eslintrc.json`: - -```json -{ - "extends": ["plugin:prettier/recommended"] -} -``` - -## TSLint - -### Disable formatting rules - -[`tslint-config-prettier`](https://github.com/alexjoverm/tslint-config-prettier) is a config that disables rules that conflict with Prettier. Add it to your [`devDependencies`], then extend from it within your `tslint.json` configuration. Make sure to put it last in the `extends` array, so it gets the chance to override other configs. - -```bash -yarn add --dev tslint-config-prettier -``` - -Then in `tslint.json`: - -```json -{ - "extends": ["tslint-config-prettier"] -} -``` - -### Use TSLint to run Prettier - -[`tslint-plugin-prettier`](https://github.com/ikatyang/tslint-plugin-prettier) is a plugin that adds a rule that formats content using Prettier. Add it to your [`devDependencies`], then enable the plugin and rule. - -```bash -yarn add --dev tslint-plugin-prettier -``` - -Then in `tslint.json`: - -```json -{ - "extends": ["tslint-plugin-prettier"], - "rules": { - "prettier": true - } -} -``` - -### Recommended configuration - -`tslint-plugin-prettier` does not expose a recommended configuration. You should combine the two steps above. Add both `tslint-plugin-prettier` and `tslint-config-prettier` as developer dependencies, then add both sets of config. - -```bash -yarn add --dev tslint-config-prettier tslint-plugin-prettier -``` - -Then in `tslint.json`: - -```json -{ - "extends": ["tslint-plugin-prettier", "tslint-config-prettier"], - "rules": { - "prettier": true - } -} -``` - -## Stylelint - -### Disable formatting rules - -[`stylelint-config-prettier`](https://github.com/prettier/stylelint-config-prettier) is a config that disables rules that conflict with Prettier. Add it to your [`devDependencies`], then extend from it within your `.stylelintrc` configuration. Make sure to put it last in the `extends` array, so it gets the chance to override other configs. - -```bash -yarn add --dev stylelint-config-prettier -``` - -Then in `.stylelintrc`: - -```json -{ - "extends": ["stylelint-config-prettier"] -} -``` - -### Use Stylelint to run Prettier - -[`stylelint-prettier`](https://github.com/prettier/stylelint-prettier) is a plugin that adds a rule that formats content using Prettier. Add it to your [`devDependencies`], then enable the plugin and rule. - -```bash -yarn add --dev stylelint-prettier -``` - -Then in `.stylelintrc`: - -```json -{ - "plugins": ["stylelint-prettier"], - "rules": { - "prettier/prettier": true - } -} -``` - -### Recommended configuration - -`stylelint-prettier` exposes a "recommended" configuration that configures both `stylelint-prettier` and `stylelint-config-prettier` in a single step. Add both `stylelint-prettier` and `stylelint-config-prettier` as developer dependencies, then extend the recommended config: - -```bash -yarn add --dev stylelint-config-prettier stylelint-prettier -``` - -Then in `.stylelintrc`: - -```json -{ - "extends": ["stylelint-prettier/recommended"] -} -``` - -[`devdependencies`]: https://docs.npmjs.com/specifying-dependencies-and-devdependencies-in-a-package-json-file +Those are useful if some aspect of Prettier’s output makes Prettier completely unusable to you. Then you can have for example `eslint --fix` fix that up for you. The downside is that these tools are much slower than just running Prettier. diff --git a/docs/option-philosophy.md b/docs/option-philosophy.md index 02f8e821a7..c2f9d33f8f 100644 --- a/docs/option-philosophy.md +++ b/docs/option-philosophy.md @@ -17,17 +17,17 @@ Note that changes from [`arijs/prettier-miscellaneous`](https://github.com/arijs ## Original Prettier option philosophy -> Prettier has a few options because of history. **But we don’t want more of them.** +> Prettier has a few options because of history. **But we won’t add more of them.** > > Read on to learn more. Prettier is not a kitchen-sink code formatter that attempts to print your code in any way you wish. It is _opinionated._ Quoting the [Why Prettier?](why-prettier.md) page: -> By far the biggest reason for adopting Prettier is to stop all the on-going debates over styles. +> By far the biggest reason for adopting Prettier is to stop all the ongoing debates over styles. -The more options Prettier has, the further from the above goal it gets. **The debates over styles just turn into debates over which Prettier options to use.** +Yet the more options Prettier has, the further from the above goal it gets. **The debates over styles just turn into debates over which Prettier options to use.** Formatting wars break out with renewed vigour: “Which option values are better? Why? Did we make the right choices?” -The issue about [resisting adding configuration](https://github.com/prettier/prettier/issues/40) has more 👍s than any option request issue. +And it’s not the only cost options have. To learn more about their downsides, see the [issue about resisting adding configuration](https://github.com/prettier/prettier/issues/40), which has more 👍s than any option request issue. So why are there any options at all? @@ -35,27 +35,18 @@ So why are there any options at all? - A couple were added after “great demand.” 🤔 - Some were added for compatibility reasons. 👍 -What we’ve learned during the years is that it’s really hard to measure demand. Prettier has grown _a lot_ in usage. What was “great demand” back in the day is not as much today. How many is many? What about all silent users? - -It’s so easy to add “just one more“ option. But where do we stop? When is one too many? There will always be a “top issue” in the issue tracker. Even if we add just that one final option. - -The downside of options is that they open up for debate within teams. Which options should we use? Why? Did we make the right choices? - -Every option also makes it much harder to say no to new ones. If _those_ options exist, why can’t this one? - -We’ve had several users open up option requests only to close them themselves a couple of months later. They had realized that they don’t care at all about that little syntax choice they used to feel so strongly about. Examples: [#3101](https://github.com/prettier/prettier/issues/3101#issuecomment-500927917) and [#5501](https://github.com/prettier/prettier/issues/5501#issuecomment-487025417). - -All of this makes the topic of options in Prettier very difficult. And mentally tiring for maintainers. What do people want? What do people _really_ want in 6 months? Are we spending time and energy on the right things? - -Some options are easier to motivate: +Options that are easier to motivate include: - `--trailing-comma es5` lets you use trailing commas in most environments without having to transpile (trailing function commas were added in ES2017). -- `--prose-wrap` is important to support all quirky markdown renderers in the wild. +- `--prose-wrap` is important to support all quirky Markdown renderers in the wild. - `--html-whitespace-sensitivity` is needed due to the unfortunate whitespace rules of HTML. - `--end-of-line` makes it easier for teams to keep CRLFs out of their git repositories. - `--quote-props` is important for advanced usage of the Google Closure Compiler. -But others are harder to motivate in hindsight, and usually end up with bike shedding. `--arrow-parens`, -`--jsx-single-quote`, `--jsx-bracket-same-line` and `--no-bracket-spacing` are not the type of options we want more of. They exist (and are difficult to remove now), but should not motivate adding more options like them. +But other options are harder to motivate in hindsight: `--arrow-parens`, `--jsx-single-quote`, `--jsx-bracket-same-line` and `--no-bracket-spacing` are not the type of options we’re happy to have. They cause a lot of [bike-shedding](https://en.wikipedia.org/wiki/Law_of_triviality) in teams, and we’re sorry for that. Difficult to remove now, these options exist as a historical artifact and should not motivate adding more options (“If _those_ options exist, why can’t this one?”). + +For a long time, we left option requests open in order to let discussions play out and collect feedback. What we’ve learned during those years is that it’s really hard to measure demand. Prettier has grown a lot in usage. What was “great demand” back in the day is not as much today. GitHub reactions and Twitter polls became unrepresentative. What about all silent users? It looked easy to add “just one more” option. But where should we have stopped? When is one too many? Even after adding “that one final option”, there would always be a “top issue” in the issue tracker. + +However, the time to stop has come. Now that Prettier is mature enough and we see it adopted by so many organizations and projects, the research phase is over. We have enough confidence to conclude that Prettier reached a point where the set of options should be “frozen”. **Option requests aren’t accepted anymore.** We’re thankful to everyone who participated in this difficult journey. -Feel free to open issues! Prettier isn’t perfect. Many times things can be improved without adding options. But if the issue _does_ seem to need a new option, we’ll generally keep it open, to let people 👍 it and add comments. +Please note that as option requests are out of scope for Prettier, they will be closed without discussion. The same applies to requests to preserve elements of input formatting (e.g. line breaks) since that’s nothing else but an option in disguise with all the downsides of “real” options. There may be situations where adding an option can’t be avoided because of technical necessity (e.g. compatibility), but for formatting-related options, this is final. diff --git a/docs/options.md b/docs/options.md index 098654d8d9..5a73080834 100644 --- a/docs/options.md +++ b/docs/options.md @@ -3,7 +3,11 @@ id: options title: Options --- -Prettier ships with a handful of customizable format options, usable in both the CLI and API. +Prettier ships with a handful of format options. + +**To learn more about Prettier’s stance on options – see the [Option Philosophy](option-philosophy.md).** + +If you change any options, it’s recommended to do it via a [configuration file](configuration.md). This way the Prettier CLI, [editor integrations](editors.md) and other tooling knows what options you use. ## Print Width @@ -11,17 +15,19 @@ Specify the line length that the printer will wrap on. > **For readability we recommend against using more than 80 characters:** > -> In code styleguides, maximum line length rules are often set to 100 or 120. However, when humans write code, they don't strive to reach the maximum number of columns on every line. Developers often use whitespace to break up long lines for readability. In practice, the average line length often ends up well below the maximum. +> In code styleguides, maximum line length rules are often set to 100 or 120. However, when humans write code, they don’t strive to reach the maximum number of columns on every line. Developers often use whitespace to break up long lines for readability. In practice, the average line length often ends up well below the maximum. +> +> Prettier’s printWidth option does not work the same way. It is not the hard upper allowed line length limit. It is a way to say to Prettier roughly how long you’d like lines to be. Prettier will make both shorter and longer lines, but generally strive to meet the specified printWidth. > -> Prettier, on the other hand, strives to fit the most code into every line. With the print width set to 120, prettier may produce overly compact, or otherwise undesirable code. +> Remember, computers are dumb. You need to explicitly tell them what to do, while humans can make their own (implicit) judgements, for example on when to break a line. > -> See the [print width rationale](rationale.md#print-width) for more information. +> In other words, don’t try to use printWidth as if it was ESLint’s [max-len](https://eslint.org/docs/rules/max-len) – they’re not the same. max-len just says what the maximum allowed line length is, but not what the generally preferred length is – which is what printWidth specifies. | Default | CLI Override | API Override | | ------- | --------------------- | ------------------- | | `80` | `--print-width ` | `printWidth: ` | -(If you don't want line wrapping when formatting Markdown, you can set the [Prose Wrap](#prose-wrap) option to disable it.) +(If you don’t want line wrapping when formatting Markdown, you can set the [Prose Wrap](#prose-wrap) option to disable it.) ## Tab Width @@ -92,9 +98,18 @@ Valid options: - `"consistent"` - If at least one property in an object requires quotes, quote all properties. - `"preserve"` - Respect the input use of quotes in object properties. -| Default | CLI Override | API Override | -| ------------- | ----------------------------------------------- | ----------------------------------------------- | -| `"as-needed"` | `--quote-props ` | `quoteProps: ""` | +| Default | CLI Override | API Override | +| ------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `"as-needed"` | --quote-props | quoteProps: "" | + +Note that Prettier never unquotes numeric property names in Angular expressions, TypeScript, and Flow because the distinction between string and numeric keys is significant in these languages. See: [Angular][quote-props-angular], [TypeScript][quote-props-typescript], [Flow][quote-props-flow]. Also Prettier doesn’t unquote numeric properties for Vue (see the [issue][quote-props-vue] about that). + +[quote-props-angular]: https://codesandbox.io/s/hungry-morse-foj87?file=/src/app/app.component.html +[quote-props-typescript]: https://www.typescriptlang.org/play?#code/DYUwLgBAhhC8EG8IEYBcKA0EBM7sQF8AoUSAIzkQgHJlr1ktrt6dCiiATEAY2CgBOICKWhR0AaxABPAPYAzCGGkAHEAugBuLr35CR4CGTKSZG5Wo1ltRKDHjHtQA +[quote-props-flow]: https://flow.org/try/#0PQKgBAAgZgNg9gdzCYAoVBjOA7AzgFzAA8wBeMAb1TDAAYAuMARlQF8g +[quote-props-vue]: https://github.com/prettier/prettier/issues/10127 + +If this option is set to `preserve`, `singleQuote` to `false` (default value), and `parser` to `json5`, double quotes are always used for strings. This effectively allows using the `json5` parser for “JSON with comments and trailing commas”. ## JSX Quotes @@ -108,17 +123,17 @@ Use single quotes instead of double quotes in JSX. _Default value changed from `none` to `es5` in v2.0.0_ -Print trailing commas wherever possible when multi-line. (A single-line array, for example, never gets trailing commas.) +Print trailing commas wherever possible in multi-line comma-separated syntactic structures. (A single-line array, for example, never gets trailing commas.) Valid options: -- `"es5"` - Trailing commas where valid in ES5 (objects, arrays, etc.) +- `"es5"` - Trailing commas where valid in ES5 (objects, arrays, etc.). No trailing commas in type parameters in TypeScript. - `"none"` - No trailing commas. -- `"all"` - Trailing commas wherever possible (including function arguments). This requires node 8 or a [transform](https://babeljs.io/docs/plugins/syntax-trailing-function-commas/). +- `"all"` - Trailing commas wherever possible (including [function parameters and calls](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Trailing_commas#Trailing_commas_in_functions)). To run, JavaScript code formatted this way needs an engine that supports ES2017 (Node.js 8+ or a modern browser) or [downlevel compilation](https://babeljs.io/docs/en/index). This also enables trailing commas in type parameters in TypeScript (supported since TypeScript 2.7 released in January 2018). -| Default | CLI Override | API Override | -| ------- | --------------------------------- | --------------------------------- | -| `"es5"` | `--trailing-comma ` | `trailingComma: ""` | +| Default | CLI Override | API Override | +| ------- | ------------------------------------------------------ | ------------------------------------------------------ | +| `"es5"` | --trailing-comma | trailingComma: "" | ## Object curly spacing @@ -179,9 +194,9 @@ Valid options: - `"always"` - Always include parens. Example: `(x) => x` - `"avoid"` - Omit parens when possible. Example: `x => x` -| Default | CLI Override | API Override | -| ---------- | ------------------------------- | ------------------------------- | -| `"always"` | `--arrow-parens ` | `arrowParens: ""` | +| Default | CLI Override | API Override | +| ---------- | ----------------------------------------------- | ----------------------------------------------- | +| `"always"` | --arrow-parens | arrowParens: "" | At first glance, avoiding parentheses may look like a better choice because of less visual noise. However, when Prettier removes parentheses, it becomes harder to add type annotations, extra arguments or default values as well as making other changes. @@ -388,17 +403,19 @@ Put or disable spaces between type curly braces. Specify which parser to use. -Prettier automatically infers the parser from the input file path, so you shouldn't have to change this setting. +Prettier automatically infers the parser from the input file path, so you shouldn’t have to change this setting. -Both the `babel` and `flow` parsers support the same set of JavaScript features (including Flow type annotations). They might differ in some edge cases, so if you run into one of those you can try `flow` instead of `babel`. Almost the same applies to `typescript` and `babel-ts`. `babel-ts` might support JavaScript features (proposals) not yet supported by TypeScript, but it's less permissive when it comes to invalid code and less battle-tested than the `typescript` parser. +Both the `babel` and `flow` parsers support the same set of JavaScript features (including Flow type annotations). They might differ in some edge cases, so if you run into one of those you can try `flow` instead of `babel`. Almost the same applies to `typescript` and `babel-ts`. `babel-ts` might support JavaScript features (proposals) not yet supported by TypeScript, but it’s less permissive when it comes to invalid code and less battle-tested than the `typescript` parser. Valid options: -- `"babel"` (via [@babel/parser](https://github.com/babel/babel/tree/master/packages/babel-parser)) _Named `"babylon"` until v1.16.0_ +- `"babel"` (via [@babel/parser](https://github.com/babel/babel/tree/main/packages/babel-parser)) _Named `"babylon"` until v1.16.0_ - `"babel-flow"` (same as `"babel"` but enables Flow parsing explicitly to avoid ambiguity) _First available in v1.16.0_ - `"babel-ts"` (similar to `"typescript"` but uses Babel and its TypeScript plugin) _First available in v2.0.0_ - `"flow"` (via [flow-parser](https://github.com/facebook/flow/tree/master/src/parser)) - `"typescript"` (via [@typescript-eslint/typescript-estree](https://github.com/typescript-eslint/typescript-eslint)) _First available in v1.4.0_ +- `"espree"` (via [espree](https://github.com/eslint/espree)) _First available in v2.2.0_ +- `"meriyah"` (via [meriyah](https://github.com/meriyah/meriyah)) _First available in v2.2.0_ - `"css"` (via [postcss-scss](https://github.com/postcss/postcss-scss) and [postcss-less](https://github.com/shellscape/postcss-less), autodetects which to use) _First available in v1.7.1_ - `"scss"` (same parsers as `"css"`, prefers postcss-scss) _First available in v1.7.1_ - `"less"` (same parsers as `"css"`, prefers postcss-less) _First available in v1.7.1_ @@ -406,8 +423,8 @@ Valid options: - `"json5"` (same parser as `"json"`, but outputs as [json5](https://json5.org/)) _First available in v1.13.0_ - `"json-stringify"` (same parser as `"json"`, but outputs like `JSON.stringify`) _First available in v1.13.0_ - `"graphql"` (via [graphql/language](https://github.com/graphql/graphql-js/tree/master/src/language)) _First available in v1.5.0_ -- `"markdown"` (via [remark-parse](https://github.com/wooorm/remark/tree/master/packages/remark-parse)) _First available in v1.8.0_ -- `"mdx"` (via [remark-parse](https://github.com/wooorm/remark/tree/master/packages/remark-parse) and [@mdx-js/mdx](https://github.com/mdx-js/mdx/tree/master/packages/mdx)) _First available in v1.15.0_ +- `"markdown"` (via [remark-parse](https://github.com/wooorm/remark/tree/main/packages/remark-parse)) _First available in v1.8.0_ +- `"mdx"` (via [remark-parse](https://github.com/wooorm/remark/tree/main/packages/remark-parse) and [@mdx-js/mdx](https://github.com/mdx-js/mdx/tree/master/packages/mdx)) _First available in v1.15.0_ - `"html"` (via [angular-html-parser](https://github.com/ikatyang/angular-html-parser/tree/master/packages/angular-html-parser)) _First available in 1.15.0_ - `"vue"` (same parser as `"html"`, but also formats vue-specific syntax) _First available in 1.10.0_ - `"angular"` (same parser as `"html"`, but also formats angular-specific syntax via [angular-estree-parser](https://github.com/ikatyang/angular-estree-parser)) _First available in 1.15.0_ @@ -434,17 +451,19 @@ For example, the following will use the CSS parser: cat foo | prettier --stdin-filepath foo.css ``` +This option is only useful in the CLI and API. It doesn’t make sense to use it in a configuration file. + | Default | CLI Override | API Override | | ------- | --------------------------- | ---------------------- | | None | `--stdin-filepath ` | `filepath: ""` | -## Require pragma +## Require Pragma _First available in v1.7.0_ -Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the file. This is very useful when gradually transitioning large, unformatted codebases to prettier. +Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the file. This is very useful when gradually transitioning large, unformatted codebases to Prettier. -For example, a file with the following as its first comment will be formatted when `--require-pragma` is supplied: +A file with the following as its first comment will be formatted when `--require-pragma` is supplied: ```js /** @@ -468,12 +487,16 @@ or _First available in v1.8.0_ -Prettier can insert a special @format marker at the top of files specifying that the file has been formatted with prettier. This works well when used in tandem with the `--require-pragma` option. If there is already a docblock at the top of the file then this option will add a newline to it with the @format marker. +Prettier can insert a special `@format` marker at the top of files specifying that the file has been formatted with Prettier. This works well when used in tandem with the `--require-pragma` option. If there is already a docblock at the top of the file then this option will add a newline to it with the `@format` marker. + +Note that “in tandem” doesn’t mean “at the same time”. When the two options are used simultaneously, `--require-pragma` has priority, so `--insert-pragma` is ignored. The idea is that during an incremental adoption of Prettier in a big codebase, the developers participating in the transition process use `--insert-pragma` whereas `--require-pragma` is used by the rest of the team and automated tooling to process only files already transitioned. The feature has been inspired by Facebook’s [adoption strategy]. | Default | CLI Override | API Override | | ------- | ----------------- | ---------------------- | | `false` | `--insert-pragma` | `insertPragma: ` | +[adoption strategy]: https://prettier.io/blog/2017/05/03/1.3.0.html#facebook-adoption-update + ## Prose Wrap _First available in v1.8.2_ @@ -486,27 +509,27 @@ Valid options: - `"never"` - Do not wrap prose. - `"preserve"` - Wrap prose as-is. _First available in v1.9.0_ -| Default | CLI Override | API Override | -| ------------ | -------------------------------------- | -------------------------------------- | -| `"preserve"` | `--prose-wrap ` | `proseWrap: ""` | +| Default | CLI Override | API Override | +| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- | +| `"preserve"` | --prose-wrap | proseWrap: "" | ## HTML Whitespace Sensitivity -_First available in v1.15.0_ +_First available in v1.15.0. First available for Handlebars in 2.3.0_ -Specify the global whitespace sensitivity for HTML files, see [whitespace-sensitive formatting] for more info. +Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars. See [whitespace-sensitive formatting] for more info. [whitespace-sensitive formatting]: https://prettier.io/blog/2018/11/07/1.15.0.html#whitespace-sensitive-formatting Valid options: -- `"css"` - Respect the default value of CSS `display` property. -- `"strict"` - Whitespaces are considered sensitive. -- `"ignore"` - Whitespaces are considered insensitive. +- `"css"` - Respect the default value of CSS `display` property. For Handlebars treated same as `strict`. +- `"strict"` - Whitespace (or the lack of it) around all tags is considered significant. +- `"ignore"` - Whitespace (or the lack of it) around all tags is considered insignificant. -| Default | CLI Override | API Override | -| ------- | --------------------------------------------------- | -------------------------------------------------- | -| `"css"` | `--html-whitespace-sensitivity ` | `htmlWhitespaceSensitivity: ""` | +| Default | CLI Override | API Override | +| ------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| `"css"` | --html-whitespace-sensitivity | htmlWhitespaceSensitivity: "" | ## Vue files script and style tags indentation @@ -541,7 +564,7 @@ If you want to make sure that your entire git repository only contains Linux-sty 1. Ensure Prettier’s `endOfLine` option is set to `lf` (this is a default value since v2.0.0) 1. Configure [a pre-commit hook](precommit.md) that will run Prettier 1. Configure Prettier to run in your CI pipeline using [`--check` flag](cli.md#--check). If you use Travis CI, set [the `autocrlf` option](https://docs.travis-ci.com/user/customizing-the-build#git-end-of-line-conversion-control) to `input` in `.travis.yml`. -1. Add `* text=auto eol=lf` to the repo's `.gitattributes` file. +1. Add `* text=auto eol=lf` to the repo’s `.gitattributes` file. You may need to ask Windows users to re-clone your repo after this change to ensure git has not converted `LF` to `CRLF` on checkout. All modern text editors in all operating systems are able to correctly display line endings when `\n` (`LF`) is used. @@ -553,8 +576,27 @@ Valid options: - `"crlf"` - Carriage Return + Line Feed characters (`\r\n`), common on Windows - `"cr"` - Carriage Return character only (`\r`), used very rarely - `"auto"` - Maintain existing line endings - (mixed values within one file are normalised by looking at what's used after the first line) + (mixed values within one file are normalised by looking at what’s used after the first line) + +| Default | CLI Override | API Override | +| ------- | ----------------------------------------------------------- | ---------------------------------------------------------- | +| `"lf"` | --end-of-line | endOfLine: "" | + +## Embedded Language Formatting + +_First available in v2.1.0_ + +Control whether Prettier formats quoted code embedded in the file. + +When Prettier identifies cases where it looks like you've placed some code it knows how to format within a string in another file, like in a tagged template in JavaScript with a tag named `html` or in code blocks in Markdown, it will by default try to format that code. + +Sometimes this behavior is undesirable, particularly in cases where you might not have intended the string to be interpreted as code. This option allows you to switch between the default behavior (`auto`) and disabling this feature entirely (`off`). + +Valid options: + +- `"auto"` – Format embedded code if Prettier can automatically identify it. +- `"off"` - Never automatically format embedded code. -| Default | CLI Override | API Override | -| ------- | --------------------------------- | -------------------------------- | -| `"lf"` | `--end-of-line ` | `endOfLine: ""` | +| Default | CLI Override | API Override | +| -------- | ------------------------------------ | ----------------------------------- | +| `"auto"` | `--embedded-language-formatting=off` | `embeddedLanguageFormatting: "off"` | diff --git a/docs/plugins.md b/docs/plugins.md index 710aec1437..2562c33293 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -3,7 +3,7 @@ id: plugins title: Plugins --- -Plugins are ways of adding new languages to Prettier. Prettier's own implementations of all languages are expressed using the plugin API. The core `prettier` package contains JavaScript and other web-focused languages built in. For additional languages you'll need to install a plugin. +Plugins are ways of adding new languages to Prettier. Prettier’s own implementations of all languages are expressed using the plugin API. The core `prettier` package contains JavaScript and other web-focused languages built in. For additional languages you’ll need to install a plugin. ## Using Plugins @@ -40,22 +40,20 @@ Providing at least one path to `--plugin-search-dir`/`pluginSearchDirs` turns of - [`@prettier/plugin-php`](https://github.com/prettier/plugin-php) - [`@prettier/plugin-pug`](https://github.com/prettier/plugin-pug) by [**@Shinigami92**](https://github.com/Shinigami92) - [`@prettier/plugin-ruby`](https://github.com/prettier/plugin-ruby) -- [`@prettier/plugin-swift`](https://github.com/prettier/plugin-swift) - [`@prettier/plugin-xml`](https://github.com/prettier/plugin-xml) ## Community Plugins - [`prettier-plugin-apex`](https://github.com/dangmai/prettier-plugin-apex) by [**@dangmai**](https://github.com/dangmai) - [`prettier-plugin-elm`](https://github.com/gicentre/prettier-plugin-elm) by [**@giCentre**](https://github.com/gicentre) +- [`prettier-plugin-go-template`](https://github.com/NiklasPor/prettier-plugin-go-template) by [**@NiklasPor**](https://github.com/NiklasPor) - [`prettier-plugin-java`](https://github.com/jhipster/prettier-java) by [**@JHipster**](https://github.com/jhipster) - [`prettier-plugin-kotlin`](https://github.com/Angry-Potato/prettier-plugin-kotlin) by [**@Angry-Potato**](https://github.com/Angry-Potato) -- [`prettier-plugin-package`](https://github.com/shellscape/prettier-plugin-package) by [**@shellscape**](https://github.com/shellscape) -- [`prettier-plugin-packagejson`](https://github.com/matzkoh/prettier-plugin-packagejson) by [**@matzkoh**](https://github.com/matzkoh) -- [`prettier-plugin-pg`](https://github.com/benjie/prettier-plugin-pg) by [**@benjie**](https://github.com/benjie) +- [`prettier-plugin-properties`](https://github.com/eemeli/prettier-plugin-properties) by [**@eemeli**](https://github.com/eemeli) - [`prettier-plugin-solidity`](https://github.com/prettier-solidity/prettier-plugin-solidity) by [**@mattiaerre**](https://github.com/mattiaerre) - [`prettier-plugin-svelte`](https://github.com/UnwrittenFun/prettier-plugin-svelte) by [**@UnwrittenFun**](https://github.com/UnwrittenFun) - [`prettier-plugin-toml`](https://github.com/bd82/toml-tools/tree/master/packages/prettier-plugin-toml) by [**@bd82**](https://github.com/bd82) -- [`prettier-plugin-organize-imports`](https://github.com/simonhaenisch/prettier-plugin-organize-imports) by [**@simonhaenisch**](https://github.com/simonhaenisch) +- [`prettier-plugin-sh`](https://github.com/rx-ts/prettier/tree/master/packages/sh) by [**@JounQin**](https://github.com/JounQin) ## Developing Plugins @@ -133,46 +131,108 @@ function preprocess(text: string, options: object): string; Printers convert ASTs into a Prettier intermediate representation, also known as a Doc. -The key must match the `astFormat` that the parser produces. The value contains an object with a `print` function and (optionally) an `embed` function. +The key must match the `astFormat` that the parser produces. The value contains an object with a `print` function. All other properties (`embed`, `preprocess`, etc.) are optional. ```js export const printers = { "dance-ast": { print, embed, + preprocess, insertPragma, + canAttachComment, + isBlockComment, + printComment, + handleComments: { + ownLine, + endOfLine, + remaining, + }, }, }; ``` -Printing is a recursive process of converting an AST node (represented by a path to that node) into a doc. The doc is constructed using the [builder commands](https://github.com/prettier/prettier/blob/master/commands.md): +#### The printing process + +Prettier uses an intermediate representation, called a Doc, which Prettier then turns into a string (based on options like `printWidth`). A _printer_'s job is to take the AST generated by `parsers[].parse` and return a Doc. A Doc is constructed using [builder commands](https://github.com/prettier/prettier/blob/main/commands.md): ```js -const { concat, join, line, ifBreak, group } = require("prettier").doc.builders; +const { join, line, ifBreak, group } = require("prettier").doc.builders; ``` -The signature of the `print` function is: +The printing process works as follows: + +1. `preprocess(ast: AST, options: object): AST`, if available, is called. It is passed the AST from the _parser_. The AST returned by `preprocess` will be used by Prettier. If `preprocess` is not defined, the AST returned from the _parser_ will be used. +2. Comments are attached to the AST (see _Handling comments in a printer_ for details). +3. A Doc is recursively constructed from the AST. i) `embed(path: AstPath, print, textToDoc, options: object): Doc | null` is called on each AST node. If `embed` returns a Doc, that Doc is used. ii) If `embed` is undefined or returns a falsy value, `print(path: AstPath, options: object, print): Doc` is called on each AST node. + +#### `print` + +Most of the work of a plugin's printer will take place in its `print` function, whose signature is: ```ts function print( // Path to the AST node to print - path: FastPath, + path: AstPath, options: object, // Recursively print a child node - print: (path: FastPath) => Doc + print: (selector?: string | number | Array | AstPath) => Doc ): Doc; ``` -Check out [prettier-python's printer](https://github.com/prettier/prettier-python/blob/034ba8a9551f3fa22cead41b323be0b28d06d13b/src/printer.js#L174) as an example. +The `print` function is passed the following parameters: + +- **`path`**: An object, which can be used to access nodes in the AST. It’s a stack-like data structure that maintains the current state of the recursion. It is called “path” because it represents the path to the current node from the root of the AST. The current node is returned by `path.getValue()`. +- **`options`**: A persistent object, which contains global options and which a plugin may mutate to store contextual data. +- **`print`**: A callback for printing sub-nodes. This function contains the core printing logic that consists of steps whose implementation is provided by plugins. In particular, it calls the printer’s `print` function and passes itself to it. Thus, the two `print` functions – the one from the core and the one from the plugin – call each other while descending down the AST recursively. + +Here’s a simplified example to give an idea of what a typical implementation of `print` looks like: + +```js +const { + builders: { group, indent, join, line, softline }, +} = require("prettier").doc; + +function print(path, options, print) { + const node = path.getValue(); + + switch (node.type) { + case "list": + return group([ + "(", + indent([softline, join(line, path.map(print, "elements"))]), + softline, + ")", + ]); + + case "pair": + return group([ + "(", + indent([softline, print("left"), line, ". ", print("right")]), + softline, + ")", + ]); + + case "symbol": + return node.name; + } + + throw new Error(`Unknown node type: ${node.type}`); +} +``` + +Check out [prettier-python's printer](https://github.com/prettier/prettier-python/blob/034ba8a9551f3fa22cead41b323be0b28d06d13b/src/printer.js#L174) for some examples of what is possible. -Embedding refers to printing one language inside another. Examples of this are CSS-in-JS and Markdown code blocks. Plugins can switch to alternate languages using the `embed` function. Its signature is: +#### (optional) `embed` + +The `embed` function is called when the plugin needs to print one language inside another. Examples of this are printing CSS-in-JS or fenced code blocks in Markdown. Its signature is: ```ts function embed( // Path to the current AST node - path: FastPath, + path: AstPath, // Print a node with the current printer - print: (path: FastPath) => Doc, + print: (selector?: string | number | Array | AstPath) => Doc, // Parse and print some text using a different parser. // You should set `options.parser` to specify which parser to use. textToDoc: (text: string, options: object) => Doc, @@ -181,7 +241,29 @@ function embed( ): Doc | null; ``` -If you don't want to switch to a different parser, simply return `null` or `undefined`. +The `embed` function acts like the `print` function, except that it is passed an additional `textToDoc` function, which can be used to render a doc using a different plugin. The `embed` function returns a Doc or a falsy value. If a falsy value is returned, the `print` function is called with the current `path`. If a Doc is returned, that Doc is used in printing and the `print` function is not called. + +For example, a plugin that had nodes with embedded JavaScript might have the following `embed` function: + +```js +function embed(path, print, textToDoc, options) { + const node = path.getValue(); + if (node.type === "javascript") { + return textToDoc(node.javaScriptText, { ...options, parser: "babel" }); + } + return false; +} +``` + +#### (optional) `preprocess` + +The preprocess function can process the AST from parser before passing into `print` function. + +```ts +function preprocess(ast: AST, options: object): AST; +``` + +#### (optional) `insertPragma` A plugin can implement how a pragma comment is inserted in the resulting code when the `--insert-pragma` option is used, in the `insertPragma` function. Its signature is: @@ -189,12 +271,101 @@ A plugin can implement how a pragma comment is inserted in the resulting code wh function insertPragma(text: string): string; ``` -_(Optional)_ The preprocess function can process the ast from parser before passing into `print` function. +#### Handling comments in a printer + +Comments are often not part of a language's AST and present a challenge for pretty printers. A Prettier plugin can either print comments itself in its `print` function or rely on Prettier's comment algorithm. + +By default, if the AST has a top-level `comments` property, Prettier assumes that `comments` stores an array of comment nodes. Prettier will then use the provided `parsers[].locStart`/`locEnd` functions to search for the AST node that each comment "belongs" to. Comments are then attached to these nodes **mutating the AST in the process**, and the `comments` property is deleted from the AST root. The `*Comment` functions are used to adjust Prettier's algorithm. Once the comments are attached to the AST, Prettier will automatically call the `printComment(path, options): Doc` function and insert the returned doc into the (hopefully) correct place. + +#### (optional) `printComment` + +Called whenever a comment node needs to be printed. It has the signature: ```ts -function preprocess(ast: AST, options: object): AST; +function printComment( + // Path to the current comment node + commentPath: AstPath, + // Current options + options: object +): Doc; ``` +#### (optional) `canAttachComment` + +```ts +function canAttachComment(node: AST): boolean; +``` + +This function is used for deciding whether a comment can be attached to a particular AST node. By default, _all_ AST properties are traversed searching for nodes that comments can be attached to. This function is used to prevent comments from being attached to a particular node. A typical implementation looks like + +```js +function canAttachComment(node) { + return node.type && node.type !== "comment"; +} +``` + +#### (optional) `isBlockComment` + +```ts +function isBlockComment(node: AST): boolean; +``` + +Returns whether or not the AST node is a block comment. + +#### (optional) `handleComments` + +The `handleComments` object contains three optional functions, each with signature + +```ts +function( + // The AST node corresponding to the comment + comment: AST, + // The full source code text + text: string, + // The global options object + options: object, + // The AST + ast: AST, + // Whether this comment is the last comment + isLastComment: boolean +): boolean +``` + +These functions are used to override Prettier's default comment attachment algorithm. `ownLine`/`endOfLine`/`remaining` is expected to either manually attach a comment to a node and return `true`, or return `false` and let Prettier attach the comment. + +Based on the text surrounding a comment node, Prettier dispatches: + +- `ownLine` if a comment has only whitespace preceding it and a newline afterwards, +- `endOfLine` if a comment has a newline afterwards but some non-whitespace preceding it, +- `remaining` in all other cases. + +At the time of dispatching, Prettier will have annotated each AST comment node (i.e., created new properties) with at least one of `enclosingNode`, `precedingNode`, or `followingNode`. These can be used to aid a plugin's decision process (of course the entire AST and original text is also passed in for making more complicated decisions). + +#### Manually attaching a comment + +The `util.addTrailingComment`/`addLeadingComment`/`addDanglingComment` functions can be used to manually attach a comment to an AST node. An example `ownLine` function that ensures a comment does not follow a "punctuation" node (made up for demonstration purposes) might look like: + +```js +const { util } = require("prettier"); + +function ownLine(comment, text, options, ast, isLastComment) { + const { precedingNode } = comment; + if (precedingNode && precedingNode.type === "punctuation") { + util.addTrailingComment(precedingNode, comment); + return true; + } + return false; +} +``` + +Nodes with comments are expected to have a `comments` property containing an array of comments. Each comment is expected to have the following properties: `leading`, `trailing`, `printed`. + + + +The example above uses `util.addTrailingComment`, which automatically sets `comment.leading`/`trailing`/`printed` to appropriate values and adds the comment to the AST node's `comments` array. + +The `--debug-print-comments` CLI flag can help with debugging comment attachment issues. It prints a detailed list of comments, which includes information on how every comment was classified (`ownLine`/`endOfLine`/`remaining`, `leading`/`trailing`/`dangling`) and to which node it was attached. For Prettier’s built-in languages, this information is also available on the Playground (the 'show comments' checkbox in the Debug section). + ### `options` `options` is an object containing the custom options your plugin supports. @@ -214,7 +385,7 @@ options: { ### `defaultOptions` -If your plugin requires different default values for some of Prettier's core options, you can specify them in `defaultOptions`: +If your plugin requires different default values for some of Prettier’s core options, you can specify them in `defaultOptions`: ``` defaultOptions: { diff --git a/docs/precommit.md b/docs/precommit.md index 9e76c99051..9a22010a4b 100644 --- a/docs/precommit.md +++ b/docs/precommit.md @@ -3,7 +3,7 @@ id: precommit title: Pre-commit Hook --- -You can use Prettier with a pre-commit tool. This can re-format your files that are marked as "staged" via `git add` before you commit. +You can use Prettier with a pre-commit tool. This can re-format your files that are marked as “staged” via `git add` before you commit. ## Option 1. [lint-staged](https://github.com/okonet/lint-staged) @@ -17,7 +17,7 @@ npx mrm lint-staged This will install [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged), then add a configuration to the project’s `package.json` that will automatically format supported files in a pre-commit hook. -See https://github.com/okonet/lint-staged#configuration for more details about how you can configure lint-staged. +Read more at the [lint-staged](https://github.com/okonet/lint-staged#configuration) repo. ## Option 2. [pretty-quick](https://github.com/azz/pretty-quick) @@ -25,23 +25,26 @@ See https://github.com/okonet/lint-staged#configuration for more details about h Install it along with [husky](https://github.com/typicode/husky): + + + ```bash -yarn add pretty-quick husky --dev +npx husky-init +npm install --save-dev pretty-quick +npx husky set .husky/pre-commit "npx pretty-quick --staged" ``` -and add this config to your `package.json`: + -```json -{ - "husky": { - "hooks": { - "pre-commit": "pretty-quick --staged" - } - } -} +```bash +npx husky-init # add --yarn2 for Yarn 2 +yarn add --dev pretty-quick +yarn husky set .husky/pre-commit "npx pretty-quick --staged" ``` -Find more info from [here](https://github.com/azz/pretty-quick). + + +Read more at the [pretty-quick](https://github.com/azz/pretty-quick) repo. ## Option 3. [pre-commit](https://github.com/pre-commit/pre-commit) @@ -50,41 +53,15 @@ Find more info from [here](https://github.com/azz/pretty-quick). Copy the following config into your `.pre-commit-config.yaml` file: ```yaml -- repo: https://github.com/prettier/prettier +- repo: https://github.com/pre-commit/mirrors-prettier rev: "" # Use the sha or tag you want to point at hooks: - id: prettier ``` -Find more info from [here](https://pre-commit.com). - -## Option 4. [precise-commits](https://github.com/JamesHenry/precise-commits) - -**Use Case:** Great for when you want partial file formatting on your changed/staged files. - -Install it along with [husky](https://github.com/typicode/husky): - -```bash -yarn add precise-commits husky --dev -``` - -and add this config to your `package.json`: - -```json -{ - "husky": { - "hooks": { - "pre-commit": "precise-commits" - } - } -} -``` - -**Note:** This is currently the only tool that will format only staged lines rather than the entire file. See more information [here](https://github.com/JamesHenry/precise-commits#why-precise-commits) - -Read more about this tool [here](https://github.com/JamesHenry/precise-commits#2-precommit-hook). +Read more at [mirror of prettier package for pre-commit](https://github.com/pre-commit/mirrors-prettier) and the [pre-commit](https://pre-commit.com) website. -## Option 5. [git-format-staged](https://github.com/hallettj/git-format-staged) +## Option 4. [git-format-staged](https://github.com/hallettj/git-format-staged) **Use Case:** Great for when you want to format partially-staged files, and other options do not provide a good fit for your project. @@ -92,42 +69,45 @@ Git-format-staged is used to run any formatter that can accept file content via 1. Changes in commits are always formatted. 2. Unstaged changes are never, under any circumstances staged during the formatting process. -3. If there are conflicts between formatted, staged changes and unstaged changes then your working tree files are left untouched - your work won't be overwritten, and there are no stashes to clean up. +3. If there are conflicts between formatted, staged changes and unstaged changes then your working tree files are left untouched - your work won’t be overwritten, and there are no stashes to clean up. 4. Unstaged changes are not formatted. Git-format-staged requires Python v3 or v2.7. Python is usually pre-installed on Linux and macOS, but not on Windows. Use git-format-staged with [husky](https://github.com/typicode/husky): + + + ```bash -yarn add --dev husky prettier git-format-staged +npx husky-init +npm install --save-dev git-format-staged +npx husky set .husky/pre-commit "git-format-staged -f 'prettier --ignore-unknown --stdin --stdin-filepath \"{}\"' ." ``` -and add this config to your `package.json`: + -```json -{ - "husky": { - "hooks": { - "pre-commit": "git-format-staged -f 'prettier --stdin --stdin-filepath \"{}\"' '*.js' '*.jsx' '*.ts' '*.tsx' '*.css' '*.json' '*.gql'" - } - } -} +```bash +npx husky-init # add --yarn2 for Yarn 2 +yarn add --dev git-format-staged +yarn husky set .husky/pre-commit "git-format-staged -f 'prettier --ignore-unknown --stdin --stdin-filepath \"{}\"' ." ``` + + Add or remove file extensions to suit your project. Note that regardless of which extensions you list formatting will respect any `.prettierignore` files in your project. To read about how git-format-staged works see [Automatic Code Formatting for Partially-Staged Files](https://www.olioapps.com/blog/automatic-code-formatting/). -## Option 6. bash script +## Option 5. Shell script Alternately you can save this script as `.git/hooks/pre-commit` and give it execute permission: -```bash +```sh #!/bin/sh -FILES=$(git diff --cached --name-only --diff-filter=ACMR "*.js" "*.jsx" | sed 's| |\\ |g') +FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') [ -z "$FILES" ] && exit 0 # Prettify all selected files -echo "$FILES" | xargs ./node_modules/.bin/prettier --write +echo "$FILES" | xargs ./node_modules/.bin/prettier --ignore-unknown --write # Add back the modified/prettified files to staging echo "$FILES" | xargs git add @@ -135,11 +115,11 @@ echo "$FILES" | xargs git add exit 0 ``` -If git is reporting that your prettified files are still modified after committing, you may need to add a post-commit script to update git's index as described in [this issue](https://github.com/prettier/prettier/issues/2978#issuecomment-334408427). +If git is reporting that your prettified files are still modified after committing, you may need to add a [post-commit script to update git’s index](https://github.com/prettier/prettier/issues/2978#issuecomment-334408427). Add something like the following to `.git/hooks/post-commit`: -```bash +```sh #!/bin/sh git update-index -g ``` diff --git a/docs/rationale.md b/docs/rationale.md index af9cba2e23..4df64fa24d 100644 --- a/docs/rationale.md +++ b/docs/rationale.md @@ -9,16 +9,16 @@ Prettier is an opinionated code formatter. This document explains some of its ch ### Correctness -The first requirement of Prettier is to output valid code that has the exact same behavior as before formatting. Please report any code where Prettier fails to follow these correctness rules — that's a bug which needs to be fixed! +The first requirement of Prettier is to output valid code that has the exact same behavior as before formatting. Please report any code where Prettier fails to follow these correctness rules — that’s a bug which needs to be fixed! ### Strings -Double or single quotes? Prettier chooses the one which results in the fewest number of escapes. `"It's gettin' better!"`, not `'It\'s gettin\' better!'`. In case of a tie, Prettier defaults to double quotes (but that can be changed via the [`--single-quote`](options.html#quotes) option). +Double or single quotes? Prettier chooses the one which results in the fewest number of escapes. `"It's gettin' better!"`, not `'It\'s gettin\' better!'`. In case of a tie or the string not containing any quotes, Prettier defaults to double quotes (but that can be changed via the [singleQuote](options.html#quotes) option). -JSX has its own option for quotes: [`--jsx-single-quote`](options.html#jsx-quotes). +JSX has its own option for quotes: [jsxSingleQuote](options.html#jsx-quotes). JSX takes its roots from HTML, where the dominant use of quotes for attributes is double quotes. Browser developer tools also follow this convention by always displaying HTML with double quotes, even if the source code uses single quotes. A separate option allows using single quotes for JS and double quotes for "HTML" (JSX). -Prettier maintains the way your string is escaped. For example, `"🙂"` won't be formatted into `"\uD83D\uDE42"` and vice versa. +Prettier maintains the way your string is escaped. For example, `"🙂"` won’t be formatted into `"\uD83D\uDE42"` and vice versa. ### Empty lines @@ -29,9 +29,9 @@ It turns out that empty lines are very hard to automatically generate. The appro ### Multi-line objects -By default, Prettier’s printing algorithm prints expressions on a single line if they fit. Objects are used for a lot of different things in JavaScript, though, and sometimes it really helps readability if they stay multiline. See [object lists], [nested configs], [stylesheets] and [keyed methods], for example. We haven't been able to find a good rule for all those cases, so Prettier instead keeps objects multiline if there's a newline between the `{` and the first key in the original source code. A consequence of this is that long singleline objects are automatically expanded, but short multiline objects are never collapsed. +By default, Prettier’s printing algorithm prints expressions on a single line if they fit. Objects are used for a lot of different things in JavaScript, though, and sometimes it really helps readability if they stay multiline. See [object lists], [nested configs], [stylesheets] and [keyed methods], for example. We haven’t been able to find a good rule for all those cases, so Prettier instead keeps objects multiline if there’s a newline between the `{` and the first key in the original source code. A consequence of this is that long singleline objects are automatically expanded, but short multiline objects are never collapsed. -**Tip:** If you have a multiline object that you'd like to join up into a single line: +**Tip:** If you have a multiline object that you’d like to join up into a single line: ```js const user = { @@ -55,7 +55,7 @@ const user = { name: "John Doe", const user = { name: "John Doe", age: 30 }; ``` -And if you'd like to go multiline again, add in a newline after `{`: +And if you’d like to go multiline again, add in a newline after `{`: ```js @@ -77,9 +77,15 @@ const user = { [stylesheets]: https://github.com/prettier/prettier/issues/74#issuecomment-275262094 [keyed methods]: https://github.com/prettier/prettier/pull/495#issuecomment-275745434 +> #### ♻️ A note on formatting reversibility +> +> The semi-manual formatting for object literals is in fact a workaround, not a feature. It was implemented only because at the time a good heuristic wasn’t found and an urgent fix was needed. However, as a general strategy, Prettier avoids _non-reversible_ formatting like that, so the team is still looking for heuristics that would allow either to remove this behavior completely or at least to reduce the number of situations where it’s applied. +> +> What does **reversible** mean? Once an object literal becomes multiline, Prettier won’t collapse it back. If in Prettier-formatted code, we add a property to an object literal, run Prettier, then change our mind, remove the added property, and then run Prettier again, we might end up with a formatting not identical to the initial one. This useless change might even get included in a commit, which is exactly the kind of situation Prettier was created to prevent. + ### Decorators -Just like with objects, decorators are used for a lot of different things. Sometimes it makes sense to write decorators _above_ the line they're decorating, sometimes it's nicer if they're on the _same_ line. We haven't been able to find a good rule for this, so Prettier keeps your decorator positioned like you wrote them (if they fit on the line). This isn't ideal, but a pragmatic solution to a difficult problem. +Just like with objects, decorators are used for a lot of different things. Sometimes it makes sense to write decorators _above_ the line they're decorating, sometimes it’s nicer if they're on the _same_ line. We haven’t been able to find a good rule for this, so Prettier keeps your decorator positioned like you wrote them (if they fit on the line). This isn’t ideal, but a pragmatic solution to a difficult problem. ```js @Component({ @@ -99,7 +105,7 @@ class HeroButtonComponent { } ``` -There's one exception: classes. We don't think it ever makes sense to inline the decorators for them, so they are always moved to their own line. +There’s one exception: classes. We don’t think it ever makes sense to inline the decorators for them, so they are always moved to their own line. ```js @@ -139,7 +145,7 @@ export @decorator class Foo {} ### Semicolons -This is about using the [`--no-semi`](options.md#semicolons) option. +This is about using the [noSemi](options.md#semicolons) option. Consider this piece of code: @@ -185,7 +191,7 @@ This practice is also common in [standard] which uses a semicolon-free style. ### Print width -The [`--print-width`](options.md#print-width) is more of a guideline to Prettier than a hard rule. It generally means “try to make lines this long, go shorter if needed and longer in special cases.” +The [printWidth](options.md#print-width) option is more of a guideline to Prettier than a hard rule. It is not the upper allowed line length limit. It is a way to say to Prettier roughly how long you’d like lines to be. Prettier will make both shorter and longer lines, but generally strive to meet the specified print width. There are some edge cases, such as really long string literals, regexps, comments and variable names, which cannot be broken across lines (without using code transforms which [Prettier doesn’t do](#what-prettier-is-_not_-concerned-about)). Or if you nest your code 50 levels deep your lines are of course going to be mostly indentation :) @@ -202,7 +208,7 @@ import { } from "../components/collections/collection-dashboard/main"; ``` -The following example doesn't fit within the print width, but Prettier prints it in a single line anyway: +The following example doesn’t fit within the print width, but Prettier prints it in a single line anyway: ```js import { CollectionDashboard } from "../components/collections/collection-dashboard/main"; @@ -262,7 +268,7 @@ Secondly, [the alternate formatting makes it easier to edit the JSX](https://git ### Comments -When it comes to the _contents_ of comments, Prettier can’t do much really. Comments can contain everything from prose to commented out code and ASCII diagrams. Since they can contain anything, Prettier can’t know how to format or wrap them. So they are left as-is. The only exception to this are JSDoc-style comments (block comments where every line starts with a `*`), which Prettier can fix the indentation of. +When it comes to the _content_ of comments, Prettier can’t do much really. Comments can contain everything from prose to commented out code and ASCII diagrams. Since they can contain anything, Prettier can’t know how to format or wrap them. So they are left as-is. The only exception to this are JSDoc-style comments (block comments where every line starts with a `*`), which Prettier can fix the indentation of. Then there’s the question of _where_ to put the comments. Turns out this is a really difficult problem. Prettier tries its best to keep your comments roughly where they were, but it’s no easy task because comments can be placed almost anywhere. @@ -293,7 +299,7 @@ const result = safeToEval && settings.allowNativeEval ? eval(input) : fallback(input); ``` -Which means that the `eslint-disable` comment is no longer effective. In this case you need to move the comment: +Which means that the `eslint-disable-next-line` comment is no longer effective. In this case you need to move the comment: ```js const result = @@ -301,9 +307,15 @@ const result = safeToEval && settings.allowNativeEval ? eval(input) : fallback(input); ``` +If possible, prefer comments that operate on line ranges (e.g. `eslint-disable` and `eslint-enable`) or on the statement level (e.g. `/* istanbul ignore next */`), they are even safer. It’s possible to disallow using `eslint-disable-line` and `eslint-disable-next-line` comments using [`eslint-plugin-eslint-comments`](https://github.com/mysticatea/eslint-plugin-eslint-comments). + +## Disclaimer about non-standard syntax + +Prettier is often able to recognize and format non-standard syntax such as ECMAScript early-stage proposals and Markdown syntax extensions not defined by any specification. The support for such syntax is considered best-effort and experimental. Incompatibilities may be introduced in any release and should not be viewed as breaking changes. + ## What Prettier is _not_ concerned about -Prettier only _prints_ code. It does not transform it. This is to limit the scope of Prettier. Let's focus on the printing and do it really well! +Prettier only _prints_ code. It does not transform it. This is to limit the scope of Prettier. Let’s focus on the printing and do it really well! Here are a few examples of things that are out of scope for Prettier: diff --git a/docs/related-projects.md b/docs/related-projects.md index 457ec69079..0f31a297f4 100644 --- a/docs/related-projects.md +++ b/docs/related-projects.md @@ -5,37 +5,33 @@ title: Related Projects ## ESLint Integrations -- [`eslint-plugin-prettier`](https://github.com/prettier/eslint-plugin-prettier) plugs Prettier into your ESLint workflow -- [`eslint-config-prettier`](https://github.com/prettier/eslint-config-prettier) turns off all ESLint rules that are unnecessary or might conflict with Prettier -- [`prettier-eslint`](https://github.com/prettier/prettier-eslint) passes `prettier` output to `eslint --fix` -- [`prettier-standard`](https://github.com/sheerun/prettier-standard) uses `prettier` and `prettier-eslint` to format code with standard rules -- [`prettier-standard-formatter`](https://github.com/dtinth/prettier-standard-formatter) passes `prettier` output to `standard --fix` +- [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) turns off all ESLint rules that are unnecessary or might conflict with Prettier +- [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) runs Prettier as an ESLint rule and reports differences as individual ESLint issues +- [prettier-eslint](https://github.com/prettier/prettier-eslint) passes `prettier` output to `eslint --fix` +- [prettier-standard](https://github.com/sheerun/prettier-standard) uses `prettierx` and `prettier-eslint` to format code with `standard` rules -## TSLint Integrations +## stylelint Integrations -- [`tslint-plugin-prettier`](https://github.com/ikatyang/tslint-plugin-prettier) runs Prettier as a TSLint rule and reports differences as individual TSLint issues -- [`tslint-config-prettier`](https://github.com/alexjoverm/tslint-config-prettier) use TSLint with Prettier without any conflict -- [`prettier-tslint`](https://github.com/azz/prettier-tslint) passes `prettier` output to `tslint --fix` +- [stylelint-config-prettier](https://github.com/prettier/stylelint-config-prettier) turns off all rules that are unnecessary or might conflict with Prettier. +- [stylelint-prettier](https://github.com/prettier/stylelint-prettier) runs Prettier as a stylelint rule and reports differences as individual stylelint issues +- [prettier-stylelint](https://github.com/hugomrdias/prettier-stylelint) passes `prettier` output to `stylelint --fix` -## stylelint Integrations +## TSLint Integrations -- [`stylelint-prettier`](https://github.com/prettier/stylelint-prettier) runs Prettier as a stylelint rule and reports differences as individual stylelint issues -- [`stylelint-config-prettier`](https://github.com/prettier/stylelint-config-prettier) turns off all rules that are unnecessary or might conflict with Prettier. -- [`prettier-stylelint`](https://github.com/hugomrdias/prettier-stylelint) passes `prettier` output to `stylelint --fix` +- [tslint-config-prettier](https://github.com/alexjoverm/tslint-config-prettier) use TSLint with Prettier without any conflict +- [tslint-plugin-prettier](https://github.com/ikatyang/tslint-plugin-prettier) runs Prettier as a TSLint rule and reports differences as individual TSLint issues +- [prettier-tslint](https://github.com/azz/prettier-tslint) passes `prettier` output to `tslint --fix` ## Forks -- [`prettier-miscellaneous`](https://github.com/arijs/prettier-miscellaneous) `prettier` with a few minor extra options +- [prettierx](https://github.com/brodybits/prettierx) less opinionated fork of Prettier ## Misc -- [`neutrino-preset-prettier`](https://github.com/SpencerCDixon/neutrino-preset-prettier) allows you to use Prettier as a Neutrino preset -- [`prettier_d`](https://github.com/josephfrazier/prettier_d.js) runs Prettier as a server to avoid Node.js startup delay. It also supports configuration via `.prettierrc`, `package.json`, and `.editorconfig`. -- [`Prettier Bookmarklet`](https://prettier.glitch.me/) provides a bookmarklet and exposes a REST API for Prettier that allows to format CodeMirror editor in your browser -- [`prettier-github`](https://github.com/jgierer12/prettier-github) formats code in GitHub comments -- [`rollup-plugin-prettier`](https://github.com/mjeanroy/rollup-plugin-prettier) allows you to use Prettier with Rollup -- [`markdown-magic-prettier`](https://github.com/camacho/markdown-magic-prettier) allows you to use Prettier to format JS [codeblocks](https://help.github.com/articles/creating-and-highlighting-code-blocks/) in Markdown files via [Markdown Magic](https://github.com/DavidWells/markdown-magic) -- [`pretty-quick`](https://github.com/azz/pretty-quick) formats your changed files with Prettier -- [`prettier-chrome`](https://github.com/u3u/prettier-chrome) an extension that can be formatted using Prettier in Chrome -- [`prettylint`](https://github.com/ikatyang/prettylint) run Prettier as a linter -- [`jest-runner-prettier`](https://github.com/keplersj/jest-runner-prettier) run Prettier as a Jest runner +- [parallel-prettier](https://github.com/microsoft/parallel-prettier) is an alternative CLI that formats files in parallel to speed up large projects +- [prettier_d](https://github.com/josephfrazier/prettier_d.js) runs Prettier as a server to avoid Node.js startup delay +- [pretty-quick](https://github.com/azz/pretty-quick) formats changed files with Prettier +- [rollup-plugin-prettier](https://github.com/mjeanroy/rollup-plugin-prettier) allows you to use Prettier with Rollup +- [jest-runner-prettier](https://github.com/keplersj/jest-runner-prettier) is Prettier as a Jest runner +- [prettier-chrome](https://github.com/u3u/prettier-chrome) is an extension that runs Prettier in the browser +- [spotless](https://github.com/diffplug/spotless) lets you run prettier from [gradle](https://github.com/diffplug/spotless/tree/main/plugin-gradle#prettier) or [maven](https://github.com/diffplug/spotless/tree/main/plugin-maven#prettier). diff --git a/docs/technical-details.md b/docs/technical-details.md index 19b3f2bdc2..1a6b44ae5b 100644 --- a/docs/technical-details.md +++ b/docs/technical-details.md @@ -3,10 +3,10 @@ id: technical-details title: Technical Details --- -This printer is a fork of [recast](https://github.com/benjamn/recast)'s printer with its algorithm replaced by the one described by Wadler in "[A prettier printer](https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf)". There still may be leftover code from recast that needs to be cleaned up. +This printer is a fork of [recast](https://github.com/benjamn/recast)’s printer with its algorithm replaced by the one described by Wadler in "[A prettier printer](https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf)". There still may be leftover code from recast that needs to be cleaned up. The basic idea is that the printer takes an AST and returns an intermediate representation of the output, and the printer uses that to generate a string. The advantage is that the printer can "measure" the IR and see if the output is going to fit on a line, and break if not. -This means that most of the logic of printing an AST involves generating an abstract representation of the output involving certain commands. For example, `concat(["(", line, arg, line, ")"])` would represent a concatenation of opening parens, an argument, and closing parens. But if that doesn't fit on one line, the printer can break where `line` is specified. +This means that most of the logic of printing an AST involves generating an abstract representation of the output involving certain commands. For example, `["(", line, arg, line, ")"]` would represent a concatenation of opening parens, an argument, and closing parens. But if that doesn’t fit on one line, the printer can break where `line` is specified. -More (rough) details can be found in [commands.md](https://github.com/prettier/prettier/blob/master/commands.md). +More (rough) details can be found in [commands.md](https://github.com/prettier/prettier/blob/main/commands.md). diff --git a/docs/vim.md b/docs/vim.md index dc5f02b29d..ed9edb6d72 100644 --- a/docs/vim.md +++ b/docs/vim.md @@ -38,7 +38,7 @@ autocmd BufWritePre,TextChanged,InsertLeave *.js Neoformat See `:help autocmd-events` in Vim for details. -It's recommended to use a [config file](configuration.md), but you can also add options in your `.vimrc`: +It’s recommended to use a [config file](configuration.md), but you can also add options in your `.vimrc`: ```vim autocmd FileType javascript setlocal formatprg=prettier\ --single-quote\ --trailing-comma\ es5 @@ -46,7 +46,7 @@ autocmd FileType javascript setlocal formatprg=prettier\ --single-quote\ --trail let g:neoformat_try_formatprg = 1 ``` -Each space in prettier options should be escaped with `\`. +Each space in Prettier options should be escaped with `\`. ## [ALE](https://github.com/dense-analysis/ale) @@ -71,7 +71,7 @@ let g:ale_fixers = { \} ``` -ALE supports both _linters_ and _fixers_. If you don't specify which _linters_ to run, **all available tools for all supported languages will be run**, and you might get a correctly formatted file with a bunch of lint errors. To disable this behavior you can tell ALE to run only linters you've explicitly configured (more info in the [FAQ](https://github.com/dense-analysis/ale/blob/ed8104b6ab10f63c78e49b60d2468ae2656250e9/README.md#faq-disable-linters)): +ALE supports both _linters_ and _fixers_. If you don’t specify which _linters_ to run, **all available tools for all supported languages will be run,** and you might get a correctly formatted file with a bunch of lint errors. To disable this behavior you can tell ALE to run only linters you've explicitly configured (more info in the [FAQ](https://github.com/dense-analysis/ale/blob/ed8104b6ab10f63c78e49b60d2468ae2656250e9/README.md#faq-disable-linters)): ```vim let g:ale_linters_explicit = 1 @@ -85,10 +85,10 @@ To have ALE run Prettier on save: let g:ale_fix_on_save = 1 ``` -It's recommended to use a [config file](configuration.md), but you can also add options in your `.vimrc`: +It’s recommended to use a [config file](configuration.md), but you can also add options in your `.vimrc`: ```vim -let g:ale_javascript_prettier_options = '--single-quote --trailing-comma es5' +let g:ale_javascript_prettier_options = '--single-quote --trailing-comma all' ``` ## [coc-prettier](https://github.com/neoclide/coc-prettier) @@ -97,7 +97,7 @@ Prettier extension for [coc.nvim](https://github.com/neoclide/coc.nvim) which re Install coc.nvim with your favorite plugin manager, such as [vim-plug](https://github.com/junegunn/vim-plug): ```vim -Plug 'neoclide/coc.nvim', {'do': { -> coc#util#install()}} +Plug 'neoclide/coc.nvim', {'branch': 'release'} ``` And install coc-prettier by command: @@ -116,7 +116,7 @@ Update your `coc-settings.json` for languages that you want format on save. ```json { - "coc.preferences.formatOnSaveFiletypes": ["css", "Markdown"] + "coc.preferences.formatOnSaveFiletypes": ["css", "markdown"] } ``` @@ -127,9 +127,9 @@ Update your `coc-settings.json` for languages that you want format on save. If you want something really bare-bones, you can create a custom key binding. In this example, `gp` (mnemonic: "get pretty") is used to run prettier (with options) in the currently active buffer: ```vim -nnoremap gp :silent %!prettier --stdin-filepath % --trailing-comma all --single-quote +nnoremap gp :silent %!prettier --stdin-filepath % ``` -Note that if there's a syntax error in your code, the whole buffer will be replaced with an error message. You'll need to press `u` to get your code back. +Note that if there’s a syntax error in your code, the whole buffer will be replaced with an error message. You’ll need to press `u` to get your code back. -Another disadvantage of this approach is that the cursor position won't be preserved. +Another disadvantage of this approach is that the cursor position won’t be preserved. diff --git a/docs/watching-files.md b/docs/watching-files.md index e41a4cfd49..b4cb93d701 100644 --- a/docs/watching-files.md +++ b/docs/watching-files.md @@ -3,18 +3,18 @@ id: watching-files title: Watching For Changes --- -If you prefer to have prettier watch for changes from the command line you can use a package like [onchange](https://www.npmjs.com/package/onchange). For example: +You can have Prettier watch for changes from the command line by using [onchange](https://www.npmjs.com/package/onchange). For example: ```bash -npx onchange '**/*.js' -- npx prettier --write {{changed}} +npx onchange "**/*" -- npx prettier --write --ignore-unknown {{changed}} ``` -or add the following to your `package.json` +Or add the following to your `package.json`: ```json { "scripts": { - "prettier-watch": "onchange '**/*.js' -- prettier --write {{changed}}" + "prettier-watch": "onchange \"**/*\" -- prettier --write --ignore-unknown {{changed}}" } } ``` diff --git a/docs/webstorm.md b/docs/webstorm.md index a32acfb57f..96dd45bbfc 100644 --- a/docs/webstorm.md +++ b/docs/webstorm.md @@ -3,19 +3,23 @@ id: webstorm title: WebStorm Setup --- -## WebStorm 2018.1 and above +## Using Prettier in WebStorm -Use the `Reformat with Prettier` action (`Alt-Shift-Cmd-P` on macOS or `Alt-Shift-Ctrl-P` on Windows and Linux) to format the selected code, a file, or a whole directory. +Use the `Reformat with Prettier` action (`Opt-Shift-Cmd-P` on macOS or `Alt-Shift-Ctrl-P` on Windows and Linux) to format the selected code, a file, or a whole directory. -Don't forget to install `prettier` first. +To run Prettier on save in WebStorm 2020.1 or above, open _Preferences | Languages & Frameworks | JavaScript | Prettier_ and enable the option `Run on save for files`. + +By default, only JavaScript and TypeScript files will be formatted automatically. You can further configure what files will be updated using the [glob pattern](https://github.com/isaacs/node-glob#glob-primer). + +Don’t forget to install Prettier first. To use Prettier in IntelliJ IDEA, PhpStorm, PyCharm, and other JetBrains IDEs, please install this [plugin](https://plugins.jetbrains.com/plugin/10456-prettier). -For older IDE versions, please follow the instructions below. +To run Prettier on save in older IDE versions, you can set up a file watcher following the instructions below. ## Running Prettier on save using File Watcher -To automatically format your files using `prettier` on save, you can use a [File Watcher](https://plugins.jetbrains.com/plugin/7177-file-watchers). +To automatically format your files using Prettier on save in WebStorm 2019.\* or earlier, you can use a [File Watcher](https://plugins.jetbrains.com/plugin/7177-file-watchers). Go to _Preferences | Tools | File Watchers_ and click **+** to add a new watcher. @@ -24,31 +28,27 @@ In Webstorm 2018.2, select Prettier from the list, review the configuration, add In older IDE versions, select Custom and do the following configuration: - **Name**: _Prettier_ or any other name -- **File Type**: _JavaScript_ (or _Any_ if you want to run `prettier` on all files) +- **File Type**: _JavaScript_ (or _Any_ if you want to run Prettier on all files) - **Scope**: _Project Files_ -- **Program**: full path to `.bin/prettier` or `.bin\prettier.cmd` in the project's `node_module` folder. Or, if Prettier is installed globally, select `prettier` on macOS and Linux or `C:\Users\user_name\AppData\Roaming\npm\prettier.cmd` on Windows (or whatever `npm prefix -g` returns). -- **Arguments**: `--write [other options] $FilePathRelativeToProjectRoot$` +- **Program**: full path to `.bin/prettier` or `.bin\prettier.cmd` in the project’s `node_module` folder. Or, if Prettier is installed globally, select `prettier` on macOS and Linux or `C:\Users\user_name\AppData\Roaming\npm\prettier.cmd` on Windows (or whatever `npm prefix -g` returns). +- **Arguments**: `--write [other options] $FilePath$` - **Output paths to refresh**: `$FilePathRelativeToProjectRoot$` - **Working directory**: `$ProjectFileDir$` -- **Environment variables**: add `COMPILE_PARTIAL=true` if you want to run `prettier` on partials (like `_component.scss`) +- **Environment variables**: add `COMPILE_PARTIAL=true` if you want to run Prettier on partials (like `_component.scss`) - **Auto-save edited files to trigger the watcher**: Uncheck to reformat on Save only. -![Example](/docs/assets/webstorm/file-watcher-prettier.png) - -## WebStorm 2017.3 or earlier - -### Using Prettier with ESLint +## Using Prettier with ESLint If you are using ESLint with [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier), use the `Fix ESLint Problems` action to reformat the current file – find it using _Find Action_ (`Cmd/Ctrl-Shift-A`) or [add a keyboard shortcut](https://www.jetbrains.com/help/webstorm/configuring-keyboard-shortcuts.html) to it in _Preferences | Keymap_ and then use it. Make sure that the ESLint integration is enabled in _Preferences | Languages & Frameworks | JavaScript | Code Quality Tools | ESLint_. -### Using Prettier as External Tool +## Using Prettier as External Tool Go to _Preferences | Tools | External Tools_ and click **+** to add a new tool. Let’s name it **Prettier**. - **Program**: `prettier` on macOS and Linux or `C:\Users\user_name\AppData\Roaming\npm\prettier.cmd` on Windows (or whatever `npm prefix -g` returns), if Prettier is installed globally -- **Parameters**: `--write [other options] $FilePathRelativeToProjectRoot$` +- **Parameters**: `--write [other options] $FilePath$` - **Working directory**: `$ProjectFileDir$` > If Prettier is installed locally in your project, replace the path in **Program** with `$ProjectFileDir$/node_modules/.bin/prettier` on macOS and Linux or `$ProjectFileDir$\node_modules\.bin\prettier.cmd` on Windows. @@ -57,6 +57,6 @@ Go to _Preferences | Tools | External Tools_ and click **+** to add a new tool. Press `Cmd/Ctrl-Shift-A` (_Find Action_), search for _Prettier_, and then hit `Enter`. -It will run `prettier` for the current file. +It will run Prettier for the current file. You can [add a keyboard shortcut](https://www.jetbrains.com/help/webstorm/configuring-keyboard-shortcuts.html) to run this External tool configuration in _Preferences | Keymap_. diff --git a/docs/why-prettier.md b/docs/why-prettier.md index bee7c307ce..ec2f49bd18 100644 --- a/docs/why-prettier.md +++ b/docs/why-prettier.md @@ -7,22 +7,22 @@ title: Why Prettier? By far the biggest reason for adopting Prettier is to stop all the on-going debates over styles. [It is generally accepted that having a common style guide is valuable for a project and team](https://www.smashingmagazine.com/2012/10/why-coding-style-matters/) but getting there is a very painful and unrewarding process. People get very emotional around particular ways of writing code and nobody likes spending time writing and receiving nits. -So why choose the "Prettier style guide" over any other random style guide? Because Prettier is the only "style guide" that is fully automatic. Even if Prettier does not format all code 100% the way you'd like, it's worth the "sacrifice" given the unique benefits of Prettier, don't you think? +So why choose the “Prettier style guide” over any other random style guide? Because Prettier is the only “style guide” that is fully automatic. Even if Prettier does not format all code 100% the way you’d like, it’s worth the “sacrifice” given the unique benefits of Prettier, don’t you think? - “We want to free mental threads and end discussions around style. While sometimes fruitful, these discussions are for the most part wasteful.” -- “Literally had an engineer go through a huge effort of cleaning up all of our code because we were debating ternary style for the longest time and were inconsistent about it. It was dumb, but it was a weird on-going "great debate" that wasted lots of little back and forth bits. It's far easier for us all to agree now: just run Prettier, and go with that style.” +- “Literally had an engineer go through a huge effort of cleaning up all of our code because we were debating ternary style for the longest time and were inconsistent about it. It was dumb, but it was a weird on-going “great debate” that wasted lots of little back and forth bits. It’s far easier for us all to agree now: just run Prettier, and go with that style.” - “Getting tired telling people how to style their product code.” - “Our top reason was to stop wasting our time debating style nits.” - “Having a githook set up has reduced the amount of style issues in PRs that result in broken builds due to ESLint rules or things I have to nit-pick or clean up later.” -- “I don't want anybody to nitpick any other person ever again.” -- “It reminds me of how Steve Jobs used to wear the same clothes every day because he has a million decisions to make and he didn't want to be bothered to make trivial ones like picking out clothes. I think Prettier is like that.” +- “I don’t want anybody to nitpick any other person ever again.” +- “It reminds me of how Steve Jobs used to wear the same clothes every day because he has a million decisions to make and he didn’t want to be bothered to make trivial ones like picking out clothes. I think Prettier is like that.” ## Helping Newcomers -Prettier is usually introduced by people with experience in the current codebase and JavaScript but the people that disproportionally benefit from it are newcomers to the codebase. One may think that it's only useful for people with very limited programming experience, but we've seen it quicken the ramp up time from experienced engineers joining the company, as they likely used a different coding style before, and developers coming from a different programming language. +Prettier is usually introduced by people with experience in the current codebase and JavaScript but the people that disproportionally benefit from it are newcomers to the codebase. One may think that it’s only useful for people with very limited programming experience, but we've seen it quicken the ramp up time from experienced engineers joining the company, as they likely used a different coding style before, and developers coming from a different programming language. - “My motivations for using Prettier are: appearing that I know how to write JavaScript well.” -- “I always put spaces in the wrong place, now I don't have to worry about it anymore.” +- “I always put spaces in the wrong place, now I don’t have to worry about it anymore.” - “When you're a beginner you're making a lot of mistakes caused by the syntax. Thanks to Prettier, you can reduce these mistakes and save a lot of time to focus on what really matters.” - “As a teacher, I will also tell to my students to install Prettier to help them to learn the JS syntax and have readable files.” @@ -32,16 +32,16 @@ What usually happens once people are using Prettier is that they realize that th - “I want to write code. Not spend cycles on formatting.” - “It removed 5% that sucks in our daily life - aka formatting” -- “We're in 2017 and it's still painful to break a call into multiple lines when you happen to add an argument that makes it go over the 80 columns limit :(“ +- “We're in 2017 and it’s still painful to break a call into multiple lines when you happen to add an argument that makes it go over the 80 columns limit :(“ ## Easy to adopt We've worked very hard to use the least controversial coding styles, went through many rounds of fixing all the edge cases and polished the getting started experience. When you're ready to push Prettier into your codebase, not only should it be painless for you to do it technically but the newly formatted codebase should not generate major controversy and be accepted painlessly by your co-workers. -- “It's low overhead. We were able to throw Prettier at very different kinds of repos without much work.” -- “It's been mostly bug free. Had there been major styling issues during the course of implementation we would have been wary about throwing this at our JS codebase. I'm happy to say that's not the case.” +- “It’s low overhead. We were able to throw Prettier at very different kinds of repos without much work.” +- “It’s been mostly bug free. Had there been major styling issues during the course of implementation we would have been wary about throwing this at our JS codebase. I’m happy to say that’s not the case.” - “Everyone runs it as part of their pre commit scripts, a couple of us use the editor on save extensions as well.” -- “It's fast, against one of our larger JS codebases we were able to run Prettier in under 13 seconds.” +- “It’s fast, against one of our larger JS codebases we were able to run Prettier in under 13 seconds.” - “The biggest benefit for Prettier for us was being able to format the entire code base at once.” ## Clean up an existing codebase @@ -53,7 +53,7 @@ Since coming up with a coding style and enforcing it is a big undertaking, it of ## Ride the hype train -Purely technical aspects of the projects aren't the only thing people look into when choosing to adopt Prettier. Who built and uses it and how quickly it spreads through the community has a non-trivial impact. +Purely technical aspects of the projects aren’t the only thing people look into when choosing to adopt Prettier. Who built and uses it and how quickly it spreads through the community has a non-trivial impact. - “The amazing thing, for me, is: 1) Announced 2 months ago. 2) Already adopted by, it seems, every major JS project. 3) 7000 stars, 100,000 npm downloads/mo” - “Was built by the same people as React & React Native.” diff --git a/jest.config.js b/jest.config.js index cfa8f90a9e..822c28f74b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,42 +1,82 @@ "use strict"; +const path = require("path"); + // [prettierx] const installPrettier = require("./scripts/install-prettierx"); -const ENABLE_CODE_COVERAGE = !!process.env.ENABLE_CODE_COVERAGE; -if (process.env.NODE_ENV === "production") { - // [prettierx] - process.env.PRETTIERX_DIR = installPrettier(); +const PROJECT_ROOT = __dirname; +const isProduction = process.env.NODE_ENV === "production"; +const ENABLE_CODE_COVERAGE = Boolean(process.env.ENABLE_CODE_COVERAGE); +const TEST_STANDALONE = Boolean(process.env.TEST_STANDALONE); +const INSTALL_PACKAGE = Boolean(process.env.INSTALL_PACKAGE); + +// [prettierx] +let PRETTIERX_DIR = isProduction + ? path.join(PROJECT_ROOT, "dist") + : PROJECT_ROOT; +if (INSTALL_PACKAGE || (isProduction && !TEST_STANDALONE)) { + PRETTIERX_DIR = installPrettier(PRETTIERX_DIR); +} +process.env.PRETTIERX_DIR = PRETTIERX_DIR; + +const testPathIgnorePatterns = []; +let transform = {}; +if (TEST_STANDALONE) { + testPathIgnorePatterns.push("/tests/integration/"); +} +if (isProduction) { + // `esm` bundles need transform + transform = { + "(?:\\.mjs|codeSamples\\.js)$": [ + "babel-jest", + { + presets: [ + [ + "@babel/env", + { + targets: { node: "current" }, + exclude: [ + "transform-async-to-generator", + "transform-classes", + "proposal-async-generator-functions", + "transform-regenerator", + ], + }, + ], + ], + }, + ], + }; +} else { + // Only test bundles for production + testPathIgnorePatterns.push( + "/tests/integration/__tests__/bundle.js" + ); } module.exports = { - setupFiles: ["/tests_config/run_spec.js"], + setupFiles: ["/tests/config/setup.js"], snapshotSerializers: [ "jest-snapshot-serializer-raw", "jest-snapshot-serializer-ansi", ], testRegex: "jsfmt\\.spec\\.js$|__tests__/.*\\.js$", + testPathIgnorePatterns, collectCoverage: ENABLE_CODE_COVERAGE, - collectCoverageFrom: ["src/**/*.js", "index.js", "!/node_modules/"], + collectCoverageFrom: ["/src/**/*.js", "/bin/**/*.js"], coveragePathIgnorePatterns: [ - "/standalone.js", + "/src/standalone.js", "/src/document/doc-debug.js", - "/src/main/massage-ast.js", ], coverageReporters: ["text", "lcov"], moduleNameMapper: { - // Jest wires `fs` to `graceful-fs`, which causes a memory leak when - // `graceful-fs` does `require('fs')`. - // Ref: https://github.com/facebook/jest/issues/2179#issuecomment-355231418 - // If this is removed, see also scripts/build/build.js. - "graceful-fs": "/tests_config/fs.js", - - // [prettierx merge from prettier@2.0.5] - "prettier/local": "/tests_config/require_prettierx.js", - "prettier/standalone": "/tests_config/require_standalone.js", + // [prettierx] + "prettier-local": "/tests/config/require-prettierx.js", + "prettier-standalone": "/tests/config/require-standalone.js", }, - testEnvironment: "node", - transform: {}, + modulePathIgnorePatterns: ["/dist", "/website"], + transform, watchPlugins: [ "jest-watch-typeahead/filename", "jest-watch-typeahead/testname", diff --git a/netlify.toml b/netlify.toml index da2384873b..ccb7c5f55b 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,2 +1,3 @@ [build.environment] - NODE_VERSION = "10" + NODE_VERSION = "14" + YARN_VERSION = "1.22.10" diff --git a/package.json b/package.json index 0c050612c0..a962bf413b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "prettierx", - "version": "0.18.3-dev", + "version": "0.19.0-dev", + "prettier-version": "2.3.1", "description": "prettierx - less opinionated code formatter fork of prettier", "bin": "./bin/prettierx.js", "repository": "brodybits/prettierx", @@ -8,57 +9,60 @@ "author": "James Long", "license": "MIT", "main": "./index.js", + "browser": "./standalone.js", + "unpkg": "./standalone.js", "engines": { - "node": ">=10.13.0" + "node": ">=12.17.0" }, "files": [ "CHANGELOG.md", "LICENSE", "README.md", - "bin", "index.js", - "src" + "standalone.js", + "src", + "bin" ], "// prettierx dependencies notes": [ "* installing dependencies from GitHub is not desired in prettierx", " due to a possible issue with firewalls in certain organizations", + " TEMPORARY EXCEPTION: parse-srcset", + " (as needed for a test to pass due to an update only available from GitHub)", "* using @brodybits/remark-parse fork of remark-parse with", " updated trim sub-dependency as recommended by:", " https://www.npmjs.com/advisories/1700", "* flow-parser & typescript are moved to devDependencies, and", " they are specified as optional in peerDependenciesMeta", - "* tslib@1 is added to dependencies to avoid a peerDependencies warning", - " with @angular/compiler version 9", - " (newer tslib seems to lead to warning due to version mismatch)", - " Note that this should not be an issue with @angular/compiler >= 10.", "* codecov is removed from devDependencies", "" ], "dependencies": { - "@angular/compiler": "9.0.5", + "@angular/compiler": "12.0.3", "@babel/code-frame": "7.12.13", - "@babel/parser": "7.12.11", - "@brodybits/remark-parse": "5.0.1", - "@glimmer/syntax": "0.56.2", + "@babel/parser": "7.14.4", + "@brodybits/remark-parse": "8.0.5", + "@glimmer/syntax": "0.79.3", "@iarna/toml": "2.2.5", - "@typescript-eslint/typescript-estree": "2.34.0", - "angular-estree-parser": "1.3.1", - "angular-html-parser": "1.7.0", + "@typescript-eslint/typescript-estree": "4.26.1", + "angular-estree-parser": "2.4.0", + "angular-html-parser": "1.8.0", "camelcase": "6.2.0", "chalk": "4.1.1", "ci-info": "3.2.0", "cjk-regex": "2.0.1", "cosmiconfig": "7.0.0", "dashify": "2.0.0", - "dedent": "0.7.0", "diff": "5.0.0", "editorconfig": "0.15.3", - "editorconfig-to-prettier": "0.1.1", + "editorconfig-to-prettier": "0.2.0", "escape-string-regexp": "4.0.0", + "espree": "7.3.1", "esutils": "2.0.3", "fast-glob": "3.2.5", + "fast-json-stable-stringify": "2.1.0", "find-parent-dir": "0.3.1", "find-project-root": "1.1.1", + "get-stdin": "8.0.0", "get-stream": "6.0.1", "globby": "11.0.3", "graphql": "15.5.0", @@ -67,16 +71,19 @@ "html-tag-names": "1.1.5", "html-void-elements": "1.0.5", "ignore": "4.0.6", - "jest-docblock": "26.0.0", - "json-stable-stringify": "1.0.1", + "jest-docblock": "27.0.1", + "json5": "2.2.0", "leven": "3.1.0", "lines-and-columns": "1.1.6", - "linguist-languages": "7.10.0", + "linguist-languages": "7.15.0", "lodash": "4.17.21", "mem": "8.1.1", + "meriyah": "4.1.5", "minimatch": "3.0.4", "minimist": "1.2.5", "n-readlines": "1.0.1", + "outdent": "0.8.0", + "parse-srcset": "ikatyang/parse-srcset#54eb9c1cb21db5c62b4d0e275d7249516df6f0ee", "please-upgrade-node": "3.2.0", "postcss-less": "4.0.1", "postcss-media-query-parser": "0.2.3", @@ -84,57 +91,67 @@ "postcss-selector-parser": "2.2.3", "postcss-values-parser": "2.0.1", "regexp-util": "1.2.2", - "remark-math": "1.0.6", + "remark-footnotes": "2.0.0", + "remark-math": "3.0.1", "resolve": "1.20.0", "semver": "7.3.5", - "srcset": "3.0.0", "string-width": "4.2.2", - "tslib": "1.14.1", + "strip-ansi": "6.0.0", "unicode-regex": "3.0.0", "unified": "9.2.1", "vnopts": "1.0.2", + "wcwidth": "1.0.1", "yaml-unist-parser": "1.3.1" }, "devDependencies": { - "@babel/core": "7.13.15", - "@babel/preset-env": "7.13.15", + "@babel/core": "7.14.3", + "@babel/preset-env": "7.14.4", + "@babel/types": "7.14.4", + "@glimmer/reference": "0.79.3", "@rollup/plugin-alias": "3.1.2", - "@rollup/plugin-commonjs": "14.0.0", + "@rollup/plugin-babel": "5.3.0", + "@rollup/plugin-commonjs": "18.1.0", "@rollup/plugin-json": "4.1.0", - "@rollup/plugin-node-resolve": "7.1.3", + "@rollup/plugin-node-resolve": "13.0.0", "@rollup/plugin-replace": "2.4.2", + "@types/estree": "0.0.48", + "babel-jest": "27.0.2", "babel-loader": "8.2.2", "benchmark": "2.1.4", "builtin-modules": "3.2.0", + "core-js": "3.14.0", "cross-env": "7.0.3", "cspell": "4.2.8", "eslint": "7.28.0", "eslint-config-prettier": "8.3.0", "eslint-formatter-friendly": "7.0.0", "eslint-plugin-import": "2.23.4", - "eslint-plugin-prettier": "3.4.0", - "eslint-plugin-react": "7.23.2", - "eslint-plugin-unicorn": "29.0.0", + "eslint-plugin-jest": "24.3.6", + "eslint-plugin-prettier-internal-rules": "file:./scripts/tools/eslint-plugin-prettier-internal-rules", + "eslint-plugin-react": "7.24.0", + "eslint-plugin-regexp": "0.11.0", + "eslint-plugin-unicorn": "33.0.1", + "esm-utils": "1.1.0", "execa": "5.1.1", - "flow-parser": "0.122.0", + "flow-parser": "0.152.0", "jest": "27.0.4", "jest-snapshot-serializer-ansi": "1.0.0", "jest-snapshot-serializer-raw": "1.2.0", "jest-watch-typeahead": "0.6.4", - "prettier": "2.0.5", + "npm-run-all": "4.1.5", + "path-browserify": "1.0.1", + "prettier": "2.3.1", + "pretty-bytes": "5.6.0", "rimraf": "3.0.2", "rollup": "2.51.2", - "rollup-plugin-babel": "4.4.0", - "rollup-plugin-node-globals": "1.4.0", + "rollup-plugin-polyfill-node": "0.6.2", "rollup-plugin-terser": "7.0.2", "shelljs": "0.8.4", "snapshot-diff": "0.8.1", - "strip-ansi": "6.0.0", - "synchronous-promise": "2.0.15", "tempy": "1.0.1", - "terser-webpack-plugin": "4.2.3", - "typescript": "3.9.9", - "webpack": "4.46.0" + "terser-webpack-plugin": "5.1.3", + "typescript": "4.3.2", + "webpack": "5.38.1" }, "peerDependenciesMeta": { "flow-parser": { @@ -144,6 +161,10 @@ "optional": true } }, + "resolutions": { + "postcss-scss/postcss": "7.0.36", + "**/fast-glob/glob-parent": "5.1.2" + }, "// prettierx release scripts notes": [ "* prepublishOnly is changed to just log some info", "* prepare-release script is normally not run on prettierx,", @@ -160,21 +181,26 @@ "prepare-release": "echo 'use prepare-extra-release for prettierx' && exit 1", "prepare-extra-release": "yarn && yarn build-extra-dist && yarn test:dist", "test": "jest", + "test:dev-package": "cross-env INSTALL_PACKAGE=1 jest", "test:dist": "cross-env NODE_ENV=production jest", - "test:dist-standalone": "cross-env NODE_ENV=production TEST_STANDALONE=1 jest tests/", - "test:integration": "jest tests_integration", - "perf:repeat": "yarn && yarn build && cross-env NODE_ENV=production node ./dist/bin-prettier.js --debug-repeat ${PERF_REPEAT:-1000} --loglevel debug ${PERF_FILE:-./index.js} > /dev/null", - "perf:repeat-inspect": "yarn && yarn build && cross-env NODE_ENV=production node --inspect-brk ./dist/bin-prettier.js --debug-repeat ${PERF_REPEAT:-1000} --loglevel debug ${PERF_FILE:-./index.js} > /dev/null", - "perf:benchmark": "yarn && yarn build && cross-env NODE_ENV=production node ./dist/bin-prettier.js --debug-benchmark --loglevel debug ${PERF_FILE:-./index.js} > /dev/null", + "test:dist-standalone": "cross-env NODE_ENV=production TEST_STANDALONE=1 jest", + "test:integration": "jest tests/integration", + "perf:repeat": "yarn && yarn build-extra-dist && cross-env NODE_ENV=production node ./dist/bin-prettierx.js --debug-repeat ${PERF_REPEAT:-1000} --loglevel debug ${PERF_FILE:-./index.js} > /dev/null", + "perf:repeat-inspect": "yarn && yarn build-extra-dist && cross-env NODE_ENV=production node --inspect-brk ./dist/bin-prettierx.js --debug-repeat ${PERF_REPEAT:-1000} --loglevel debug ${PERF_FILE:-./index.js} > /dev/null", + "perf:benchmark": "yarn && yarn build-extra-dist && cross-env NODE_ENV=production node ./dist/bin-prettierx.js --debug-benchmark --loglevel debug ${PERF_FILE:-./index.js} > /dev/null", + "lint": "run-p lint:*", "lint:typecheck": "tsc", "lint:eslint": "cross-env EFF_NO_LINK_RULES=true eslint . --format friendly", - "lint:changelog": "node ./scripts/lint-changelog.js", - "lint:prettier": "prettier \"**/*.{md,json,yml,html,css}\" --check", - "lint:dist": "eslint --no-eslintrc --no-ignore --env=es6,browser --parser-options=ecmaVersion:2016 \"dist/!(bin-prettierx|index|third-party).js\"", - "lint:spellcheck": "cspell *.md {bin,scripts,src,website}/**/*.js {docs,website/blog,changelog_unreleased}/**/*.md", - "lint:deps": "node ./scripts/check-deps.js", - "build-extra-dist": "node --max-old-space-size=8192 ./scripts/build/build.js", + "lint:changelog": "node ./scripts/lint-changelog.mjs", + "lint:prettier": "prettier . \"!test*\" --check", + "lint:dist": "eslint --no-eslintrc --no-ignore --no-inline-config --env=es6,browser --parser-options=ecmaVersion:2019 \"dist/!(bin-prettierx|index|third-party).js\"", + "lint:spellcheck": "cspell \"**/*\" \".github/**/*\"", + "lint:deps": "node ./scripts/check-deps.mjs", + "fix": "run-s fix:eslint fix:prettier", + "fix:eslint": "yarn lint:eslint --fix", + "fix:prettier": "yarn lint:prettier --write", "build": "echo 'use build-extra-dist for prettierx' && exit 1", - "build-docs": "node ./scripts/build-docs.js" + "build-docs": "node ./scripts/build-docs.mjs", + "build-extra-dist": "node --max-old-space-size=3072 ./scripts/build/build.mjs" } } diff --git a/scripts/build-docs.js b/scripts/build-docs.js deleted file mode 100644 index 4b31464c37..0000000000 --- a/scripts/build-docs.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node - -"use strict"; - -const path = require("path"); -const shell = require("shelljs"); - -shell.config.fatal = true; - -const rootDir = path.join(__dirname, ".."); -const docs = path.join(rootDir, "website/static/lib"); - -function pipe(string) { - return new shell.ShellString(string); -} - -const isPullRequest = process.env.PULL_REQUEST === "true"; -const prettierPath = isPullRequest ? "dist" : "node_modules/prettier"; - -shell.mkdir("-p", docs); - -if (isPullRequest) { - // --- Build prettier for PR --- - const pkg = require("../package.json"); - const newPkg = { ...pkg, version: `999.999.999-pr.${process.env.REVIEW_ID}` }; - pipe(JSON.stringify(newPkg, null, 2)).to("package.json"); - shell.exec("yarn build"); - pipe(JSON.stringify(pkg, null, 2)).to("package.json"); // restore -} -shell.cp(`${prettierPath}/standalone.js`, `${docs}/`); -shell.cp(`${prettierPath}/parser-*.js`, `${docs}/`); - -// --- Site --- -shell.cd("website"); -shell.echo("Building website..."); -shell.exec("yarn install"); - -shell.exec("yarn build"); - -shell.echo(); diff --git a/scripts/build-docs.mjs b/scripts/build-docs.mjs new file mode 100644 index 0000000000..65463c5f2e --- /dev/null +++ b/scripts/build-docs.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import path from "node:path"; +import fs from "node:fs"; +import shell from "shelljs"; +import globby from "globby"; +import prettier from "prettier"; +import createEsmUtils from "esm-utils"; + +shell.config.fatal = true; + +const { __dirname, require } = createEsmUtils(import.meta); +const rootDir = path.join(__dirname, ".."); +const docs = path.join(rootDir, "website/static/lib"); + +function pipe(string) { + return new shell.ShellString(string); +} + +const isPullRequest = process.env.PULL_REQUEST === "true"; +const prettierPath = path.join( + rootDir, + isPullRequest ? "dist" : "node_modules/prettier" +); + +shell.mkdir("-p", docs); + +if (isPullRequest) { + // --- Build prettier for PR --- + const pkg = require("../package.json"); + const newPkg = { ...pkg, version: `999.999.999-pr.${process.env.REVIEW_ID}` }; + pipe(JSON.stringify(newPkg, null, 2)).to("package.json"); + // [prettierx] + shell.exec("yarn build-extra-dist --playground"); + pipe(JSON.stringify(pkg, null, 2) + "\n").to("package.json"); // restore +} + +shell.cp(`${prettierPath}/standalone.js`, `${docs}/`); +shell.cp(`${prettierPath}/parser-*.js`, `${docs}/`); + +const parserModules = globby.sync(["parser-*.js"], { cwd: prettierPath }); +const parsers = {}; +for (const file of parserModules) { + const plugin = require(path.join(prettierPath, file)); + const property = file.replace(/\.js$/, "").split("-")[1]; + parsers[file] = { + parsers: Object.keys(plugin.parsers), + property, + }; +} + +fs.writeFileSync( + `${docs}/parsers-location.js`, + prettier.format( + ` + "use strict"; + + const parsersLocation = ${JSON.stringify(parsers)}; + `, + { parser: "babel" } + ) +); + +// --- Site --- +shell.cd("website"); +shell.echo("Building website..."); +shell.exec("yarn install"); + +// [prettierx] +shell.exec("yarn build-extra-dist"); + +shell.echo(); diff --git a/scripts/build/babel-plugins/transform-custom-require.js b/scripts/build/babel-plugins/transform-custom-require.js deleted file mode 100644 index 2092bcc698..0000000000 --- a/scripts/build/babel-plugins/transform-custom-require.js +++ /dev/null @@ -1,55 +0,0 @@ -"use strict"; - -// -// BEFORE: -// eval("require")("./path/to/file") -// eval("require")(identifier) -// eval("require").cache -// -// AFTER: -// require("./file") -// require(identifier) -// require.cache -// - -module.exports = function (babel) { - const t = babel.types; - - return { - visitor: { - CallExpression(path) { - const { node } = path; - if (isEvalRequire(node.callee) && node.arguments.length === 1) { - let arg = node.arguments[0]; - if (t.isLiteral(arg) && arg.value.startsWith(".")) { - const value = "." + arg.value.slice(arg.value.lastIndexOf("/")); - arg = t.stringLiteral(value); - } - path.replaceWith(t.callExpression(t.identifier("require"), [arg])); - } - }, - MemberExpression(path) { - const { node } = path; - if (isEvalRequire(node.object)) { - path.replaceWith( - t.memberExpression( - t.identifier("require"), - node.property, - node.compute, - node.optional - ) - ); - } - }, - }, - }; - - function isEvalRequire(node) { - return ( - t.isCallExpression(node) && - t.isIdentifier(node.callee, { name: "eval" }) && - node.arguments.length === 1 && - t.isLiteral(node.arguments[0], { value: "require" }) - ); - } -}; diff --git a/scripts/build/build.js b/scripts/build/build.js deleted file mode 100644 index 45154f373b..0000000000 --- a/scripts/build/build.js +++ /dev/null @@ -1,125 +0,0 @@ -"use strict"; - -const chalk = require("chalk"); -const execa = require("execa"); -const minimist = require("minimist"); -const path = require("path"); -const stringWidth = require("string-width"); - -const bundler = require("./bundler"); -const bundleConfigs = require("./config"); -const util = require("./util"); -const Cache = require("./cache"); - -// Errors in promises should be fatal. -const loggedErrors = new Set(); -process.on("unhandledRejection", (err) => { - // No need to print it twice. - if (!loggedErrors.has(err)) { - console.error(err); - } - process.exit(1); -}); - -const CACHED = chalk.bgYellow.black(" CACHED "); -const OK = chalk.bgGreen.black(" DONE "); -const FAIL = chalk.bgRed.black(" FAIL "); - -function fitTerminal(input) { - const columns = Math.min(process.stdout.columns || 40, 80); - const WIDTH = columns - stringWidth(OK) + 1; - if (input.length < WIDTH) { - input += chalk.dim(".").repeat(WIDTH - input.length - 1); - } - return input; -} - -async function createBundle(bundleConfig, cache) { - const { output } = bundleConfig; - process.stdout.write(fitTerminal(output)); - - return bundler(bundleConfig, cache) - .catch((error) => { - console.log(FAIL + "\n"); - handleError(error); - }) - .then((result) => { - if (result.cached) { - console.log(CACHED); - } else { - console.log(OK); - } - }); -} - -function handleError(error) { - loggedErrors.add(error); - console.error(error); - throw error; -} - -async function cacheFiles() { - // Copy built files to .cache - try { - await execa("rm", ["-rf", path.join(".cache", "files")]); - await execa("mkdir", ["-p", path.join(".cache", "files")]); - for (const bundleConfig of bundleConfigs) { - await execa("cp", [ - path.join("dist", bundleConfig.output), - path.join(".cache", "files"), - ]); - } - } catch (err) { - // Don't fail the build - } -} - -async function preparePackage() { - const pkg = await util.readJson("package.json"); - // [prettierx merge ...] - // pkg.bin = "./bin-prettier.js"; - // [prettierx] - pkg.bin = "./bin-prettierx.js"; - // [prettierx] use line like this to specify a different minimum - // Node.js version in release build, if needed someday: - // pkg.engines.node = ">=8"; - delete pkg.dependencies; - delete pkg.devDependencies; - pkg.scripts = { - prepublishOnly: - "node -e \"assert.equal(require('.').version, require('..').version)\"", - }; - pkg.files = ["*.js"]; - await util.writeJson("dist/package.json", pkg); - - await util.copyFile("./README.md", "./dist/README.md"); - await util.copyFile("./LICENSE", "./dist/LICENSE"); -} - -async function run(params) { - await execa("rm", ["-rf", "dist"]); - await execa("mkdir", ["-p", "dist"]); - - if (params["purge-cache"]) { - await execa("rm", ["-rf", ".cache"]); - } - - const bundleCache = new Cache(".cache/", "v21"); - await bundleCache.load(); - - console.log(chalk.inverse(" Building packages ")); - for (const bundleConfig of bundleConfigs) { - await createBundle(bundleConfig, bundleCache); - } - - await bundleCache.save(); - await cacheFiles(); - - await preparePackage(); -} - -run( - minimist(process.argv.slice(2), { - boolean: ["purge-cache"], - }) -); diff --git a/scripts/build/build.mjs b/scripts/build/build.mjs new file mode 100644 index 0000000000..855338aaca --- /dev/null +++ b/scripts/build/build.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import path from "node:path"; +import fs from "node:fs/promises"; +import readline from "node:readline"; +import chalk from "chalk"; +import execa from "execa"; +import minimist from "minimist"; +import prettyBytes from "pretty-bytes"; +import bundler from "./bundler.mjs"; +import bundleConfigs from "./config.mjs"; +import * as utils from "./utils.mjs"; +import Cache from "./cache.mjs"; + +// Errors in promises should be fatal. +const loggedErrors = new Set(); +process.on("unhandledRejection", (err) => { + // No need to print it twice. + if (!loggedErrors.has(err)) { + console.error(err); + } + process.exit(1); +}); + +const CACHE_VERSION = "v35"; // This need update when updating build scripts +const statusConfig = [ + { color: "bgYellow", text: "CACHED" }, + { color: "bgGreen", text: "DONE" }, + { color: "bgRed", text: "FAIL" }, + { color: "bgGray", text: "SKIPPED" }, +]; +const maxLength = Math.max(...statusConfig.map(({ text }) => text.length)) + 2; +const padStatusText = (text) => { + while (text.length < maxLength) { + text = text.length % 2 ? `${text} ` : ` ${text}`; + } + return text; +}; +const status = {}; +for (const { color, text } of statusConfig) { + status[text] = chalk[color].black(padStatusText(text)); +} + +function fitTerminal(input, suffix = "") { + const columns = Math.min(process.stdout.columns || 40, 80); + const WIDTH = columns - maxLength + 1; + if (input.length < WIDTH) { + const repeatCount = WIDTH - input.length - 1 - suffix.length; + input += chalk.dim(".").repeat(repeatCount) + suffix; + } + return input; +} + +async function createBundle(bundleConfig, cache, options) { + const { output, target, format, type } = bundleConfig; + process.stdout.write(fitTerminal(output)); + try { + const { cached, skipped } = await bundler(bundleConfig, cache, options); + + if (skipped) { + console.log(status.SKIPPED); + return; + } + + if (cached) { + console.log(status.CACHED); + return; + } + + const file = path.join("dist", output); + + // Files including U+FFEE can't load in Chrome Extension + // `prettier-chrome-extension` https://github.com/prettier/prettier-chrome-extension + // details https://github.com/prettier/prettier/pull/8534 + if (target === "universal") { + const content = await fs.readFile(file, "utf8"); + if (content.includes("\ufffe")) { + throw new Error("Bundled umd file should not have U+FFFE character."); + } + } + + if (options["print-size"]) { + // Clear previous line + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0, null); + + const getSizeText = async (file) => + prettyBytes((await fs.stat(file)).size); + const sizeTexts = [await getSizeText(file)]; + if ( + type !== "core" && + format !== "esm" && + bundleConfig.bundler !== "webpack" && + target === "universal" + ) { + const esmFile = path.join("dist/esm", output.replace(".js", ".mjs")); + sizeTexts.push(`esm ${await getSizeText(esmFile)}`); + } + process.stdout.write(fitTerminal(output, `${sizeTexts.join(", ")} `)); + } + + console.log(status.DONE); + } catch (error) { + console.log(status.FAIL + "\n"); + handleError(error); + } +} + +function handleError(error) { + loggedErrors.add(error); + console.error(error); + throw error; +} + +async function cacheFiles(cache) { + // Copy built files to .cache + try { + await execa("rm", ["-rf", path.join(".cache", "files")]); + await execa("mkdir", ["-p", path.join(".cache", "files")]); + await execa("mkdir", ["-p", path.join(".cache", "files", "esm")]); + const manifest = cache.updated; + + for (const file of Object.keys(manifest.files)) { + await execa("cp", [ + file, + path.join(".cache", file.replace("dist", "files")), + ]); + } + } catch { + // Don't fail the build + } +} + +async function preparePackage() { + const pkg = await utils.readJson("package.json"); + // [prettierx merge ...] + // pkg.bin = "./bin-prettier.js"; + // [prettierx] + pkg.bin = "./bin-prettierx.js"; + pkg.engines.node = ">=10.13.0"; + delete pkg.dependencies; + delete pkg.devDependencies; + pkg.scripts = { + prepublishOnly: + "node -e \"assert.equal(require('.').version, require('..').version)\"", + }; + pkg.files = ["*.js", "esm/*.mjs"]; + await utils.writeJson("dist/package.json", pkg); + + await utils.copyFile("./README.md", "./dist/README.md"); + await utils.copyFile("./LICENSE", "./dist/LICENSE"); +} + +async function run(params) { + await execa("rm", ["-rf", "dist"]); + await execa("mkdir", ["-p", "dist"]); + if (!params.playground) { + await execa("mkdir", ["-p", "dist/esm"]); + } + + if (params["purge-cache"]) { + await execa("rm", ["-rf", ".cache"]); + } + + const bundleCache = new Cache(".cache/", CACHE_VERSION); + await bundleCache.load(); + + console.log(chalk.inverse(" Building packages ")); + for (const bundleConfig of bundleConfigs) { + await createBundle(bundleConfig, bundleCache, params); + } + + await cacheFiles(bundleCache); + await bundleCache.save(); + + if (!params.playground) { + await preparePackage(); + } +} + +run( + minimist(process.argv.slice(2), { + boolean: ["purge-cache", "playground", "print-size"], + }) +); diff --git a/scripts/build/bundler.js b/scripts/build/bundler.js deleted file mode 100644 index 871e7c4399..0000000000 --- a/scripts/build/bundler.js +++ /dev/null @@ -1,251 +0,0 @@ -"use strict"; - -const execa = require("execa"); -const path = require("path"); -const { rollup } = require("rollup"); -const webpack = require("webpack"); -const resolve = require("@rollup/plugin-node-resolve"); -const alias = require("@rollup/plugin-alias"); -const commonjs = require("@rollup/plugin-commonjs"); -const nodeGlobals = require("rollup-plugin-node-globals"); -const json = require("@rollup/plugin-json"); -const replace = require("@rollup/plugin-replace"); -const { terser } = require("rollup-plugin-terser"); -const babel = require("rollup-plugin-babel"); -const nativeShims = require("./rollup-plugins/native-shims"); -const executable = require("./rollup-plugins/executable"); -const evaluate = require("./rollup-plugins/evaluate"); -const externals = require("./rollup-plugins/externals"); - -const EXTERNALS = [ - "assert", - "buffer", - "constants", - "crypto", - "events", - "fs", - "module", - "os", - "path", - "stream", - "url", - "util", - "readline", - "tty", - - // See comment in jest.config.js - "graceful-fs", -]; - -function getBabelConfig(bundle) { - const config = { - babelrc: false, - plugins: bundle.babelPlugins || [], - compact: bundle.type === "plugin" ? false : "auto", - }; - if (bundle.type === "core") { - config.plugins.push( - require.resolve("./babel-plugins/transform-custom-require") - ); - } - const targets = { node: "10" }; - if (bundle.target === "universal") { - targets.browsers = [ - ">0.5%", - "not ie 11", - "not safari 5.1", - "not op_mini all", - ]; - } - config.presets = [ - [ - require.resolve("@babel/preset-env"), - { - targets, - exclude: ["transform-async-to-generator"], - modules: false, - }, - ], - ]; - config.plugins.push([ - require.resolve("@babel/plugin-proposal-object-rest-spread"), - { loose: true, useBuiltIns: true }, - ]); - return config; -} - -function getRollupConfig(bundle) { - const config = { - input: bundle.input, - - onwarn(warning) { - if ( - // We use `eval("require")` to enable dynamic requires in the - // custom parser API - warning.code === "EVAL" || - // ignore `MIXED_EXPORTS` warn - warning.code === "MIXED_EXPORTS" || - (warning.code === "CIRCULAR_DEPENDENCY" && - warning.importer.startsWith("node_modules")) - ) { - return; - } - - // web bundle can't have external requires - if ( - warning.code === "UNRESOLVED_IMPORT" && - bundle.target === "universal" - ) { - throw new Error( - `Unresolved dependency in universal bundle: ${warning.source}` - ); - } - - console.warn(warning); - }, - }; - - const replaceStrings = { - "process.env.PRETTIER_TARGET": JSON.stringify(bundle.target), - "process.env.NODE_ENV": JSON.stringify("production"), - }; - if (bundle.target === "universal") { - // We can't reference `process` in UMD bundles and this is - // an undocumented "feature" - replaceStrings["process.env.PRETTIER_DEBUG"] = "global.PRETTIER_DEBUG"; - } - Object.assign(replaceStrings, bundle.replace); - - const babelConfig = getBabelConfig(bundle); - - config.plugins = [ - replace({ - values: replaceStrings, - delimiters: ["", ""], - }), - executable(), - evaluate(), - json(), - bundle.alias && alias(bundle.alias), - bundle.target === "universal" && - nativeShims(path.resolve(__dirname, "shims")), - resolve({ - extensions: [".js", ".json"], - preferBuiltins: bundle.target === "node", - }), - commonjs({ - ignoreGlobal: bundle.target === "node", - ...bundle.commonjs, - }), - externals(bundle.externals), - bundle.target === "universal" && nodeGlobals(), - babelConfig && babel(babelConfig), - bundle.type === "plugin" && terser(), - ].filter(Boolean); - - if (bundle.target === "node") { - config.external = EXTERNALS; - } - - return config; -} - -function getRollupOutputOptions(bundle) { - const options = { - file: `dist/${bundle.output}`, - strict: typeof bundle.strict === "undefined" ? true : bundle.strict, - paths: [{ "graceful-fs": "fs" }], - }; - - if (bundle.target === "node") { - options.format = "cjs"; - } else if (bundle.target === "universal") { - options.format = "umd"; - options.name = - bundle.type === "plugin" ? `prettierPlugins.${bundle.name}` : bundle.name; - } - return options; -} - -function getWebpackConfig(bundle) { - if (bundle.type !== "plugin" || bundle.target !== "universal") { - throw new Error("Must use rollup for this bundle"); - } - - const root = path.resolve(__dirname, "..", ".."); - const config = { - entry: path.resolve(root, bundle.input), - module: { - rules: [ - { - test: /\.js$/, - use: { - loader: "babel-loader", - options: getBabelConfig(bundle), - }, - }, - ], - }, - output: { - path: path.resolve(root, "dist"), - filename: bundle.output, - library: ["prettierPlugins", bundle.name], - libraryTarget: "umd", - // https://github.com/webpack/webpack/issues/6642 - globalObject: 'new Function("return this")()', - }, - }; - - if (bundle.terserOptions) { - const TerserPlugin = require("terser-webpack-plugin"); - - config.optimization = { - minimizer: [new TerserPlugin(bundle.terserOptions)], - }; - } - - return config; -} - -function runWebpack(config) { - return new Promise((resolve, reject) => { - webpack(config, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - -module.exports = async function createBundle(bundle, cache) { - const inputOptions = getRollupConfig(bundle); - const outputOptions = getRollupOutputOptions(bundle); - - const useCache = await cache.checkBundle( - bundle.output, - inputOptions, - outputOptions - ); - if (useCache) { - try { - await execa("cp", [ - path.join(cache.cacheDir, "files", bundle.output), - "dist", - ]); - return { cached: true }; - } catch (err) { - // Proceed to build - } - } - - if (bundle.bundler === "webpack") { - await runWebpack(getWebpackConfig(bundle)); - } else { - const result = await rollup(inputOptions); - await result.write(outputOptions); - } - - return { bundled: true }; -}; diff --git a/scripts/build/bundler.mjs b/scripts/build/bundler.mjs new file mode 100644 index 0000000000..844b817dc5 --- /dev/null +++ b/scripts/build/bundler.mjs @@ -0,0 +1,379 @@ +import path from "node:path"; +import fs from "node:fs"; +import { rollup } from "rollup"; +import webpack from "webpack"; +import { nodeResolve as rollupPluginNodeResolve } from "@rollup/plugin-node-resolve"; +import rollupPluginAlias from "@rollup/plugin-alias"; +import rollupPluginCommonjs from "@rollup/plugin-commonjs"; +import rollupPluginPolyfillNode from "rollup-plugin-polyfill-node"; +import rollupPluginJson from "@rollup/plugin-json"; +import rollupPluginReplace from "@rollup/plugin-replace"; +import { terser as rollupPluginTerser } from "rollup-plugin-terser"; +import { babel as rollupPluginBabel } from "@rollup/plugin-babel"; +import WebpackPluginTerser from "terser-webpack-plugin"; +import createEsmUtils from "esm-utils"; +import builtinModules from "builtin-modules"; +import rollupPluginExecutable from "./rollup-plugins/executable.mjs"; +import rollupPluginEvaluate from "./rollup-plugins/evaluate.mjs"; +import rollupPluginReplaceModule from "./rollup-plugins/replace-module.mjs"; +import bundles from "./config.mjs"; + +const { __dirname, require } = createEsmUtils(import.meta); +const PROJECT_ROOT = path.join(__dirname, "../.."); + +const entries = [ + // Force using the CJS file, instead of ESM; i.e. get the file + // from `"main"` instead of `"module"` (rollup default) of package.json + { + find: "outdent", + replacement: require.resolve("outdent"), + }, + { + find: "lines-and-columns", + replacement: require.resolve("lines-and-columns"), + }, + { + find: "@angular/compiler/src", + replacement: path.resolve( + `${PROJECT_ROOT}/node_modules/@angular/compiler/esm2015/src` + ), + }, + // Avoid rollup `SOURCEMAP_ERROR` and `THIS_IS_UNDEFINED` error + { + find: "@glimmer/syntax", + replacement: require.resolve("@glimmer/syntax"), + }, +]; + +function webpackNativeShims(config, modules) { + if (!config.resolve) { + config.resolve = {}; + } + const { resolve } = config; + resolve.alias = resolve.alias || {}; + resolve.fallback = resolve.fallback || {}; + for (const module of modules) { + if (module in resolve.alias || module in resolve.fallback) { + throw new Error(`fallback/alias for "${module}" already exists.`); + } + const file = path.join(__dirname, `shims/${module}.mjs`); + if (fs.existsSync(file)) { + resolve.alias[module] = file; + } else { + resolve.fallback[module] = false; + } + } + return config; +} + +function getBabelConfig(bundle) { + const config = { + babelrc: false, + assumptions: { + setSpreadProperties: true, + }, + sourceType: "unambiguous", + plugins: bundle.babelPlugins || [], + compact: bundle.type === "plugin" ? false : "auto", + exclude: [/\/core-js\//], + }; + const targets = { node: "10" }; + if (bundle.target === "universal") { + targets.browsers = [ + ">0.5%", + "not ie 11", + "not safari 5.1", + "not op_mini all", + ]; + } + config.presets = [ + [ + "@babel/preset-env", + { + targets, + exclude: [ + "es.array.unscopables.flat-map", + "es.promise", + "es.promise.finally", + "es.string.replace", + "es.symbol.description", + "es.typed-array.*", + "web.*", + ], + modules: false, + useBuiltIns: "usage", + corejs: { + version: 3, + }, + debug: false, + }, + ], + ]; + config.plugins.push([ + "@babel/plugin-proposal-object-rest-spread", + { useBuiltIns: true }, + ]); + return config; +} + +function getRollupConfig(bundle) { + const config = { + input: bundle.input, + onwarn(warning) { + if ( + // ignore `MIXED_EXPORTS` warn + warning.code === "MIXED_EXPORTS" || + (warning.code === "CIRCULAR_DEPENDENCY" && + (warning.importer.startsWith("node_modules") || + warning.importer.startsWith("\x00polyfill-node:"))) || + warning.code === "SOURCEMAP_ERROR" || + warning.code === "THIS_IS_UNDEFINED" + ) { + return; + } + + // web bundle can't have external requires + if ( + warning.code === "UNRESOLVED_IMPORT" && + bundle.target === "universal" + ) { + throw new Error( + `Unresolved dependency in universal bundle: ${warning.source}` + ); + } + + console.warn(warning); + }, + external: [], + }; + + const replaceStrings = { + "process.env.PRETTIER_TARGET": JSON.stringify(bundle.target), + "process.env.NODE_ENV": JSON.stringify("production"), + }; + if (bundle.target === "universal") { + // We can't reference `process` in UMD bundles and this is + // an undocumented "feature" + replaceStrings["process.env.PRETTIER_DEBUG"] = "global.PRETTIER_DEBUG"; + // `rollup-plugin-node-globals` replace `__dirname` with the real dirname + // `parser-typescript.js` will contain a path of working directory + // See #8268 + replaceStrings.__filename = JSON.stringify( + "/prettier-security-filename-placeholder.js" + ); + replaceStrings.__dirname = JSON.stringify( + "/prettier-security-dirname-placeholder" + ); + } + Object.assign(replaceStrings, bundle.replace); + + const babelConfig = { babelHelpers: "bundled", ...getBabelConfig(bundle) }; + + const alias = { ...bundle.alias }; + alias.entries = [...entries, ...(alias.entries || [])]; + + const replaceModule = {}; + // Replace other bundled files + if (bundle.target === "node") { + for (const item of bundles) { + if (item.input !== bundle.input) { + replaceModule[path.join(PROJECT_ROOT, item.input)] = `./${item.output}`; + } + } + replaceModule[path.join(PROJECT_ROOT, "./package.json")] = "./package.json"; + } + Object.assign(replaceModule, bundle.replaceModule); + + config.plugins = [ + rollupPluginReplace({ + values: replaceStrings, + delimiters: ["", ""], + preventAssignment: true, + }), + rollupPluginExecutable(), + rollupPluginEvaluate(), + rollupPluginJson({ + exclude: Object.keys(replaceModule) + .filter((file) => file.endsWith(".json")) + .map((file) => path.relative(PROJECT_ROOT, file)), + }), + rollupPluginAlias(alias), + rollupPluginNodeResolve({ + extensions: [".js", ".json"], + preferBuiltins: bundle.target === "node", + }), + rollupPluginCommonjs({ + ignoreGlobal: bundle.target === "node", + ignore: + bundle.type === "plugin" + ? undefined + : (id) => /\.\/parser-.*?/.test(id), + requireReturnsDefault: "preferred", + ignoreDynamicRequires: true, + ignoreTryCatch: bundle.target === "node", + ...bundle.commonjs, + }), + replaceModule && rollupPluginReplaceModule(replaceModule), + bundle.target === "universal" && rollupPluginPolyfillNode(), + rollupPluginBabel(babelConfig), + ].filter(Boolean); + + if (bundle.target === "node") { + config.external.push(...builtinModules); + } + if (bundle.external) { + config.external.push(...bundle.external); + } + + return config; +} + +function getRollupOutputOptions(bundle, buildOptions) { + const options = { + // Avoid warning form #8797 + exports: "auto", + file: `dist/${bundle.output}`, + name: bundle.name, + plugins: [ + bundle.minify !== false && + bundle.target === "universal" && + rollupPluginTerser({ + output: { + ascii_only: true, + }, + }), + ], + }; + + if (bundle.target === "node") { + options.format = "cjs"; + } else if (bundle.target === "universal") { + if (!bundle.format && bundle.bundler !== "webpack") { + return [ + { + ...options, + format: "umd", + }, + !buildOptions.playground && { + ...options, + format: "esm", + file: `dist/esm/${bundle.output.replace(".js", ".mjs")}`, + }, + ].filter(Boolean); + } + options.format = bundle.format; + } + + if (buildOptions.playground && bundle.bundler !== "webpack") { + return { skipped: true }; + } + return [options]; +} + +function getWebpackConfig(bundle) { + if (bundle.type !== "plugin" || bundle.target !== "universal") { + throw new Error("Must use rollup for this bundle"); + } + + const root = path.resolve(__dirname, "..", ".."); + const config = { + mode: "production", + performance: { hints: false }, + entry: path.resolve(root, bundle.input), + module: { + rules: [ + { + test: /\.js$/, + use: { + loader: "babel-loader", + options: getBabelConfig(bundle), + }, + }, + ], + }, + output: { + path: path.resolve(root, "dist"), + filename: bundle.output, + library: { + type: "umd", + name: bundle.name.split("."), + }, + // https://github.com/webpack/webpack/issues/6642 + globalObject: 'new Function("return this")()', + }, + optimization: {}, + resolve: { + // Webpack@5 can't resolve "postcss/lib/parser" and "postcss/lib/stringifier"" imported by `postcss-scss` + // Ignore `exports` field to fix bundle script + exportsFields: [], + }, + }; + + if (bundle.terserOptions) { + config.optimization.minimizer = [ + new WebpackPluginTerser(bundle.terserOptions), + ]; + } + // config.optimization.minimize = false; + + return webpackNativeShims(config, ["os", "path", "util", "url", "fs"]); +} + +function runWebpack(config) { + return new Promise((resolve, reject) => { + webpack(config, (error, stats) => { + if (error) { + reject(error); + return; + } + + if (stats.hasErrors()) { + const { errors } = stats.toJson(); + const error = new Error(errors[0].message); + error.errors = errors; + reject(error); + return; + } + + if (stats.hasWarnings()) { + const { warnings } = stats.toJson(); + console.warn(warnings); + } + + resolve(); + }); + }); +} + +async function createBundle(bundle, cache, options) { + const inputOptions = getRollupConfig(bundle); + const outputOptions = getRollupOutputOptions(bundle, options); + + if (!Array.isArray(outputOptions) && outputOptions.skipped) { + return { skipped: true }; + } + + if ( + !options["purge-cache"] && + ( + await Promise.all( + outputOptions.map((outputOption) => + cache.isCached(inputOptions, outputOption) + ) + ) + ).every((cached) => cached) + ) { + return { cached: true }; + } + + if (bundle.bundler === "webpack") { + await runWebpack(getWebpackConfig(bundle)); + } else { + const result = await rollup(inputOptions); + await Promise.all(outputOptions.map((option) => result.write(option))); + } + + return { bundled: true }; +} + +export default createBundle; diff --git a/scripts/build/cache.js b/scripts/build/cache.js deleted file mode 100644 index 1bcb061a96..0000000000 --- a/scripts/build/cache.js +++ /dev/null @@ -1,129 +0,0 @@ -"use strict"; - -const util = require("util"); -const assert = require("assert"); -const crypto = require("crypto"); -const fs = require("fs"); -const path = require("path"); -const { rollup } = require("rollup"); - -const readFile = util.promisify(fs.readFile); -const writeFile = util.promisify(fs.writeFile); - -const ROOT = path.join(__dirname, "..", ".."); - -function Cache(cacheDir, version) { - this.cacheDir = path.resolve(cacheDir || required("cacheDir")); - this.manifest = path.join(this.cacheDir, "manifest.json"); - this.version = version || required("version"); - this.checksums = {}; - this.files = {}; - this.updated = { - version: this.version, - checksums: {}, - files: {}, - }; -} - -// Loads the manifest.json file with the information from the last build -Cache.prototype.load = async function () { - // This should never throw, if it does, let it fail the build - const lockfile = await readFile("yarn.lock", "utf-8"); - const lockfileHash = hashString(lockfile); - this.updated.checksums["yarn.lock"] = lockfileHash; - - try { - const manifest = await readFile(this.manifest, "utf-8"); - const { version, checksums, files } = JSON.parse(manifest); - - // Ignore the cache if the version changed - assert.equal(this.version, version); - - assert.ok(typeof checksums === "object"); - // If yarn.lock changed, rebuild everything - assert.equal(lockfileHash, checksums["yarn.lock"]); - this.checksums = checksums; - - assert.ok(typeof files === "object"); - this.files = files; - - for (const files of Object.values(this.files)) { - assert.ok(Array.isArray(files)); - } - } catch (err) { - this.checksums = {}; - this.files = {}; - } -}; - -// Run rollup to get the list of files included in the bundle and check if -// any (or the list itself) have changed. -// This takes the same rollup config used for bundling to include files that are -// resolved by specific plugins. -Cache.prototype.checkBundle = async function (id, inputOptions, outputOptions) { - const files = new Set(this.files[id]); - const newFiles = (this.updated.files[id] = []); - - let dirty = false; - - const bundle = await rollup(getRollupConfig(inputOptions)); - const { output } = await bundle.generate(outputOptions); - - const modules = output - .filter((mod) => !/\0/.test(mod.facadeModuleId)) - .map((mod) => [path.relative(ROOT, mod.facadeModuleId), mod.code]); - - for (const [id, code] of modules) { - newFiles.push(id); - // If we already checked this file for another bundle, reuse the hash - if (!this.updated.checksums[id]) { - this.updated.checksums[id] = hashString(code); - } - const hash = this.updated.checksums[id]; - - // Check if this file changed - if (!this.checksums[id] || this.checksums[id] !== hash) { - dirty = true; - } - - // Check if this file is new - if (!files.delete(id)) { - dirty = true; - } - } - - // Final check: if any file was removed, `files` is not empty - return !dirty && files.size === 0; -}; - -Cache.prototype.save = async function () { - try { - await writeFile(this.manifest, JSON.stringify(this.updated, null, 2)); - } catch (err) { - // Don't fail the build - } -}; - -function required(name) { - throw new Error(name + " is required"); -} - -function hashString(string) { - return crypto.createHash("md5").update(string).digest("hex"); -} - -function getRollupConfig(rollupConfig) { - return { - ...rollupConfig, - onwarn() {}, - plugins: rollupConfig.plugins.filter( - (plugin) => - // We're not interested in dependencies, we already check `yarn.lock` - plugin.name !== "node-resolve" && - // This is really slow, we need this "preflight" to be fast - plugin.name !== "babel" - ), - }; -} - -module.exports = Cache; diff --git a/scripts/build/cache.mjs b/scripts/build/cache.mjs new file mode 100644 index 0000000000..6da2afb740 --- /dev/null +++ b/scripts/build/cache.mjs @@ -0,0 +1,149 @@ +import { strict as assert } from "node:assert"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import execa from "execa"; +import { rollup } from "rollup"; +import createEsmUtils from "esm-utils"; + +const { __dirname } = createEsmUtils(import.meta); +const ROOT = path.join(__dirname, "..", ".."); + +class Cache { + constructor(cacheDir, version) { + this.cacheDir = path.resolve(cacheDir || required("cacheDir")); + this.manifest = path.join(this.cacheDir, "manifest.json"); + this.version = version || required("version"); + this.checksums = {}; + this.files = {}; + this.updated = { + version: this.version, + checksums: {}, + files: {}, + }; + } + + // Loads the manifest.json file with the information from the last build + async load() { + // This should never throw, if it does, let it fail the build + const lockfile = await fs.readFile("yarn.lock", "utf-8"); + const lockfileHash = hashString(lockfile); + this.updated.checksums["yarn.lock"] = lockfileHash; + + try { + const manifest = await fs.readFile(this.manifest, "utf-8"); + const { version, checksums, files } = JSON.parse(manifest); + + // Ignore the cache if the version changed + assert.strictEqual(this.version, version); + + assert.ok(typeof checksums === "object"); + // If yarn.lock changed, rebuild everything + assert.strictEqual(lockfileHash, checksums["yarn.lock"]); + this.checksums = checksums; + + assert.ok(typeof files === "object"); + this.files = files; + + for (const files of Object.values(this.files)) { + assert.ok(Array.isArray(files)); + } + } catch { + this.checksums = {}; + this.files = {}; + } + } + + // Run rollup to get the list of files included in the bundle and check if + // any (or the list itself) have changed. + // This takes the same rollup config used for bundling to include files that are + // resolved by specific plugins. + async checkBundle(id, inputOptions, outputOptions) { + const files = new Set(this.files[id]); + const newFiles = (this.updated.files[id] = []); + + let dirty = false; + + const bundle = await rollup(getRollupConfig(inputOptions)); + const { output } = await bundle.generate(outputOptions); + + const modules = output + .filter((mod) => !/\0/.test(mod.facadeModuleId)) + .map((mod) => [path.relative(ROOT, mod.facadeModuleId), mod.code]); + + for (const [id, code] of modules) { + newFiles.push(id); + // If we already checked this file for another bundle, reuse the hash + if (!this.updated.checksums[id]) { + this.updated.checksums[id] = hashString(code); + } + const hash = this.updated.checksums[id]; + + // Check if this file changed + if (!this.checksums[id] || this.checksums[id] !== hash) { + dirty = true; + } + + // Check if this file is new + if (!files.delete(id)) { + dirty = true; + } + } + + // Final check: if any file was removed, `files` is not empty + return !dirty && files.size === 0; + } + + async save() { + try { + await fs.writeFile(this.manifest, JSON.stringify(this.updated, null, 2)); + } catch { + // Don't fail the build + } + } + + async isCached(inputOptions, outputOption) { + const useCache = await this.checkBundle( + outputOption.file, + inputOptions, + outputOption + ); + if (useCache) { + try { + await execa("cp", [ + path.join(this.cacheDir, outputOption.file.replace("dist", "files")), + outputOption.file, + ]); + return true; + } catch (err) { + console.log(err); + // Proceed to build + } + } + return false; + } +} + +function required(name) { + throw new Error(name + " is required"); +} + +function hashString(string) { + return crypto.createHash("md5").update(string).digest("hex"); +} + +function getRollupConfig(rollupConfig) { + return { + ...rollupConfig, + onwarn() {}, + plugins: rollupConfig.plugins.filter( + (plugin) => + // We're not interested in dependencies, we already check `yarn.lock` + plugin.name !== "node-resolve" && + // This is really slow, we need this "preflight" to be fast + plugin.name !== "babel" + ), + }; +} + +export default Cache; diff --git a/scripts/build/config.js b/scripts/build/config.js deleted file mode 100644 index 4b0b9f120d..0000000000 --- a/scripts/build/config.js +++ /dev/null @@ -1,189 +0,0 @@ -"use strict"; - -const path = require("path"); -const PROJECT_ROOT = path.resolve(__dirname, "../.."); - -/** - * @typedef {Object} Bundle - * @property {string} input - input of the bundle - * @property {string?} output - path of the output file in the `dist/` folder - * @property {string?} name - name for the UMD bundle (for plugins, it'll be `prettierPlugins.${name}`) - * @property {'node' | 'universal'} target - should generate a CJS only for node or UMD bundle - * @property {'core' | 'plugin'} type - it's a plugin bundle or core part of prettier - * @property {'rollup' | 'webpack'} [bundler='rollup'] - define which bundler to use - * @property {CommonJSConfig} [commonjs={}] - options for `rollup-plugin-commonjs` - * @property {string[]} externals - array of paths that should not be included in the final bundle - * @property {Object.} replace - map of strings to replace when processing the bundle - * @property {string[]} babelPlugins - babel plugins - * @property {Object?} terserOptions - options for `terser` - - * @typedef {Object} CommonJSConfig - * @property {Object} namedExports - for cases where rollup can't infer what's exported - * @property {string[]} ignore - paths of CJS modules to ignore - */ - -/** @type {Bundle[]} */ -const parsers = [ - { - input: "src/language-js/parser-babel.js", - }, - { - input: "src/language-js/parser-flow.js", - strict: false, - }, - { - input: "src/language-js/parser-typescript.js", - replace: { - 'require("@microsoft/typescript-etw")': "undefined", - }, - }, - { - input: "src/language-js/parser-angular.js", - alias: { - // Force using the CJS file, instead of ESM; i.e. get the file - // from `"main"` instead of `"module"` (rollup default) of package.json - entries: [ - { - find: "lines-and-columns", - replacement: require.resolve("lines-and-columns"), - }, - { - find: "@angular/compiler/src", - replacement: path.resolve( - `${PROJECT_ROOT}/node_modules/@angular/compiler/esm2015/src` - ), - }, - ], - }, - }, - { - input: "src/language-css/parser-postcss.js", - // postcss has dependency cycles that don't work with rollup - bundler: "webpack", - terserOptions: { - // prevent terser generate extra .LICENSE file - extractComments: false, - terserOptions: { - mangle: { - // postcss need keep_fnames when minify - keep_fnames: true, - // we don't transform class anymore, so we need keep_classnames too - keep_classnames: true, - }, - }, - }, - }, - { - input: "src/language-graphql/parser-graphql.js", - }, - { - input: "src/language-markdown/parser-markdown.js", - }, - { - input: "src/language-handlebars/parser-glimmer.js", - alias: { - entries: [ - // `handlebars` causes webpack warning by using `require.extensions` - // `dist/handlebars.js` also complaint on `window` variable - // use cjs build instead - // https://github.com/prettier/prettier/issues/6656 - { - find: "handlebars", - replacement: require.resolve("handlebars/dist/cjs/handlebars.js"), - }, - ], - }, - commonjs: { - namedExports: { - [require.resolve("handlebars/dist/cjs/handlebars.js")]: [ - "parse", - "parseWithoutProcessing", - ], - [require.resolve( - "@glimmer/syntax/dist/modules/es2017/index.js" - )]: "default", - }, - ignore: ["source-map"], - }, - }, - { - input: "src/language-html/parser-html.js", - }, - { - input: "src/language-yaml/parser-yaml.js", - alias: { - // Force using the CJS file, instead of ESM; i.e. get the file - // from `"main"` instead of `"module"` (rollup default) of package.json - entries: [ - { - find: "lines-and-columns", - replacement: require.resolve("lines-and-columns"), - }, - ], - }, - }, -].map((parser) => ({ - type: "plugin", - target: "universal", - name: getFileOutput(parser).replace(/\.js$/, "").split("-")[1], - ...parser, -})); - -/** @type {Bundle[]} */ -const coreBundles = [ - { - input: "index.js", - type: "core", - target: "node", - externals: [path.resolve("src/common/third-party.js")], - replace: { - // from @iarna/toml/parse-string - "eval(\"require('util').inspect\")": "require('util').inspect", - }, - }, - { - input: "src/document/index.js", - name: "doc", - type: "core", - output: "doc.js", - target: "universal", - }, - { - // [prettierx] - input: "standalone.js", - name: "prettierx", - type: "core", - target: "universal", - }, - { - // [prettierx] - input: "bin/prettierx.js", - type: "core", - output: "bin-prettierx.js", - target: "node", - externals: [path.resolve("src/common/third-party.js")], - }, - { - input: "src/common/third-party.js", - type: "core", - target: "node", - replace: { - // cosmiconfig@5 -> import-fresh uses `require` to resolve js config, which caused Error: - // Dynamic requires are not currently supported by rollup-plugin-commonjs. - "require(filePath)": "eval('require')(filePath)", - "parent.eval('require')(filePath)": "parent.require(filePath)", - "require.cache": "eval('require').cache", - // cosmiconfig@6 -> import-fresh can't find parentModule, since module is bundled - "parentModule(__filename)": "__filename", - }, - }, -]; - -function getFileOutput(bundle) { - return bundle.output || path.basename(bundle.input); -} - -module.exports = coreBundles.concat(parsers).map((bundle) => ({ - ...bundle, - output: getFileOutput(bundle), -})); diff --git a/scripts/build/config.mjs b/scripts/build/config.mjs new file mode 100644 index 0000000000..9a03ec5938 --- /dev/null +++ b/scripts/build/config.mjs @@ -0,0 +1,158 @@ +import path from "node:path"; + +/** + * @typedef {Object} Bundle + * @property {string} input - input of the bundle + * @property {string?} output - path of the output file in the `dist/` folder + * @property {string?} name - name for the UMD bundle (for plugins, it'll be `prettierPlugins.${name}`) + * @property {'node' | 'universal'} target - should generate a CJS only for node or universal bundle + * @property {'core' | 'plugin'} type - it's a plugin bundle or core part of prettier + * @property {'rollup' | 'webpack'} [bundler='rollup'] - define which bundler to use + * @property {CommonJSConfig} [commonjs={}] - options for `rollup-plugin-commonjs` + * @property {string[]} external - array of paths that should not be included in the final bundle + * @property {Object.} replaceModule - module replacement path or code + * @property {Object.} replace - map of strings to replace when processing the bundle + * @property {string[]} babelPlugins - babel plugins + * @property {Object?} terserOptions - options for `terser` + * @property {boolean?} minify - minify + + * @typedef {Object} CommonJSConfig + * @property {string[]} ignore - paths of CJS modules to ignore + */ + +/** @type {Bundle[]} */ +const parsers = [ + { + input: "src/language-js/parser-babel.js", + }, + { + input: "src/language-js/parser-flow.js", + replace: { + // `flow-parser` use this for `globalThis`, can't work in strictMode + "(function(){return this}())": '(new Function("return this")())', + }, + }, + { + input: "src/language-js/parser-typescript.js", + replace: { + // `typescript/lib/typescript.js` expose extra global objects + // `TypeScript`, `toolsVersion`, `globalThis` + 'typeof process === "undefined" || process.browser': "false", + 'typeof globalThis === "object"': "true", + // `@typescript-eslint/typescript-estree` v4 + 'require("globby")': "{}", + "extra.projects = prepareAndTransformProjects(": + "extra.projects = [] || prepareAndTransformProjects(", + "process.versions.node": "'999.999.999'", + // `rollup-plugin-polyfill-node` don't have polyfill for these modules + 'require("perf_hooks")': "{}", + 'require("inspector")': "{}", + }, + }, + { + input: "src/language-js/parser-espree.js", + }, + { + input: "src/language-js/parser-meriyah.js", + }, + { + input: "src/language-js/parser-angular.js", + }, + { + input: "src/language-css/parser-postcss.js", + // postcss has dependency cycles that don't work with rollup + bundler: "webpack", + terserOptions: { + // prevent terser generate extra .LICENSE file + extractComments: false, + terserOptions: { + // prevent U+FFFE in the output + output: { + ascii_only: true, + }, + mangle: { + // postcss need keep_fnames when minify + keep_fnames: true, + // we don't transform class anymore, so we need keep_classnames too + keep_classnames: true, + }, + }, + }, + }, + { + input: "dist/parser-postcss.js", + output: "esm/parser-postcss.mjs", + format: "esm", + }, + { + input: "src/language-graphql/parser-graphql.js", + }, + { + input: "src/language-markdown/parser-markdown.js", + }, + { + input: "src/language-handlebars/parser-glimmer.js", + commonjs: { + ignore: ["source-map"], + }, + }, + { + input: "src/language-html/parser-html.js", + }, + { + input: "src/language-yaml/parser-yaml.js", + }, +].map((bundle) => ({ + type: "plugin", + target: "universal", + name: `prettierPlugins.${ + bundle.input.match(/parser-(?.*?)\.js$/).groups.name + }`, + output: path.basename(bundle.input), + ...bundle, +})); + +/** @type {Bundle[]} */ +const coreBundles = [ + { + input: "src/index.js", + replace: { + // from @iarna/toml/parse-string + "eval(\"require('util').inspect\")": "require('util').inspect", + }, + }, + { + input: "src/document/index.js", + name: "doc", + output: "doc.js", + target: "universal", + format: "umd", + minify: false, + }, + { + // [prettierx] + input: "src/standalone.js", + name: "prettierx", + target: "universal", + }, + { + // [prettierx] + input: "bin/prettierx.js", + output: "bin-prettierx.js", + externals: ["benchmark"], + }, + { + input: "src/common/third-party.js", + replace: { + // cosmiconfig@6 -> import-fresh can't find parentModule, since module is bundled + "parentModule(__filename)": "__filename", + }, + }, +].map((bundle) => ({ + type: "core", + target: "node", + output: path.basename(bundle.input), + ...bundle, +})); + +export default [...coreBundles, ...parsers]; diff --git a/scripts/build/rollup-plugins/evaluate.js b/scripts/build/rollup-plugins/evaluate.js deleted file mode 100644 index 835422c5bb..0000000000 --- a/scripts/build/rollup-plugins/evaluate.js +++ /dev/null @@ -1,28 +0,0 @@ -"use strict"; - -module.exports = function () { - return { - name: "evaluate", - - transform(_text, id) { - if (!/\.evaluate\.js$/.test(id)) { - return null; - } - - const json = JSON.stringify( - require(id.replace(/^\0commonjs-proxy:/, "")), - (_, v) => { - if (typeof v === "function") { - throw new Error("Cannot evaluate functions."); - } - return v; - } - ); - - return { - code: `var json = ${json}; export default json;`, - map: { mappings: "" }, - }; - }, - }; -}; diff --git a/scripts/build/rollup-plugins/evaluate.mjs b/scripts/build/rollup-plugins/evaluate.mjs new file mode 100644 index 0000000000..d793380be0 --- /dev/null +++ b/scripts/build/rollup-plugins/evaluate.mjs @@ -0,0 +1,30 @@ +import createEsmUtils from "esm-utils"; + +const { require } = createEsmUtils(import.meta); + +export default function () { + return { + name: "evaluate", + + transform(_text, id) { + if (!/\.evaluate\.js$/.test(id)) { + return; + } + + const json = JSON.stringify( + require(id.replace(/^\0commonjs-proxy:/, "")), + (_, v) => { + if (typeof v === "function") { + throw new Error("Cannot evaluate functions."); + } + return v; + } + ); + + return { + code: `export default ${json};`, + map: { mappings: "" }, + }; + }, + }; +} diff --git a/scripts/build/rollup-plugins/executable.js b/scripts/build/rollup-plugins/executable.js deleted file mode 100644 index 7505c8ee6b..0000000000 --- a/scripts/build/rollup-plugins/executable.js +++ /dev/null @@ -1,49 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const path = require("path"); - -module.exports = function () { - let banner; - let entry; - let file; - - return { - name: "executable", - - options(inputOptions) { - entry = path.resolve(inputOptions.input); - }, - - generateBundle(outputOptions) { - file = outputOptions.file; - }, - - load(id) { - if (id !== entry) { - return; - } - const source = fs.readFileSync(id, "utf-8"); - const match = source.match(/^\s*(#!.*)/); - if (match) { - banner = match[1]; - return ( - source.slice(0, match.index) + - source.slice(match.index + banner.length) - ); - } - }, - - renderChunk(code) { - if (banner) { - return { code: banner + "\n" + code }; - } - }, - - writeBundle() { - if (banner && file) { - fs.chmodSync(file, 0o755 & ~process.umask()); - } - }, - }; -}; diff --git a/scripts/build/rollup-plugins/executable.mjs b/scripts/build/rollup-plugins/executable.mjs new file mode 100644 index 0000000000..10f35df4bd --- /dev/null +++ b/scripts/build/rollup-plugins/executable.mjs @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import path from "node:path"; + +export default function () { + let banner; + let entry; + let file; + + return { + name: "executable", + + options(inputOptions) { + entry = path.resolve(inputOptions.input); + }, + + generateBundle(outputOptions) { + file = outputOptions.file; + }, + + load(id) { + if (id !== entry) { + return; + } + const source = fs.readFileSync(id, "utf-8"); + const match = source.match(/^\s*(#!.*)/); + if (match) { + banner = match[1]; + return ( + source.slice(0, match.index) + + source.slice(match.index + banner.length) + ); + } + }, + + renderChunk(code) { + if (banner) { + return { code: banner + "\n" + code }; + } + }, + + writeBundle() { + if (banner && file) { + fs.chmodSync(file, 0o755 & ~process.umask()); + } + }, + }; +} diff --git a/scripts/build/rollup-plugins/externals.js b/scripts/build/rollup-plugins/externals.js deleted file mode 100644 index 24b1b51b44..0000000000 --- a/scripts/build/rollup-plugins/externals.js +++ /dev/null @@ -1,20 +0,0 @@ -"use strict"; - -const path = require("path"); - -module.exports = function (modules = []) { - const requires = modules.reduce((obj, mod) => { - obj[mod] = path.basename(mod).replace(/\.js$/, ""); - return obj; - }, {}); - - return { - name: "externals", - - load(importee) { - if (requires[importee]) { - return `export default eval("require")("./${requires[importee]}");`; - } - }, - }; -}; diff --git a/scripts/build/rollup-plugins/native-shims.js b/scripts/build/rollup-plugins/native-shims.js deleted file mode 100644 index bb931a54c0..0000000000 --- a/scripts/build/rollup-plugins/native-shims.js +++ /dev/null @@ -1,34 +0,0 @@ -"use strict"; - -const builtins = require("builtin-modules"); -const fs = require("fs"); -const path = require("path"); - -const EMPTY = "export default {};"; -const PREFIX = "\0shim:"; - -module.exports = function (dir) { - return { - resolveId(importee) { - if (importee.startsWith(PREFIX)) { - return importee; - } - - if (/\0/.test(importee) || !builtins.includes(importee)) { - return null; - } - - const shim = path.resolve(dir, importee + ".js"); - if (fs.existsSync(shim)) { - return shim; - } - return PREFIX + importee; - }, - - load(id) { - if (id.startsWith(PREFIX)) { - return EMPTY; - } - }, - }; -}; diff --git a/scripts/build/rollup-plugins/replace-module.mjs b/scripts/build/rollup-plugins/replace-module.mjs new file mode 100644 index 0000000000..935dbea8fa --- /dev/null +++ b/scripts/build/rollup-plugins/replace-module.mjs @@ -0,0 +1,19 @@ +export default function (replacements = {}) { + return { + name: "externals", + + load(importee) { + if (!Reflect.has(replacements, importee)) { + return; + } + + const replacement = replacements[importee]; + + if (typeof replacement === "string") { + return `export default require(${JSON.stringify(replacement)});`; + } + + return replacement.code; + }, + }; +} diff --git a/scripts/build/shims/assert.js b/scripts/build/shims/assert.js deleted file mode 100644 index a6eb7fec32..0000000000 --- a/scripts/build/shims/assert.js +++ /dev/null @@ -1,4 +0,0 @@ -function assert() {} -assert.ok = function() {}; -assert.strictEqual = function() {}; -export default assert; diff --git a/scripts/build/shims/assert.mjs b/scripts/build/shims/assert.mjs new file mode 100644 index 0000000000..88e0542d10 --- /dev/null +++ b/scripts/build/shims/assert.mjs @@ -0,0 +1,4 @@ +function assert() {} +assert.ok = function () {}; +assert.strictEqual = function () {}; +export default assert; diff --git a/scripts/build/shims/events.js b/scripts/build/shims/events.mjs similarity index 100% rename from scripts/build/shims/events.js rename to scripts/build/shims/events.mjs diff --git a/scripts/build/shims/fs.mjs b/scripts/build/shims/fs.mjs new file mode 100644 index 0000000000..26a5b26c2d --- /dev/null +++ b/scripts/build/shims/fs.mjs @@ -0,0 +1,3 @@ +export const existsSync = () => false; +export const readFileSync = () => ""; +export default { existsSync, readFileSync }; diff --git a/scripts/build/shims/os.js b/scripts/build/shims/os.js deleted file mode 100644 index f2a73b10ad..0000000000 --- a/scripts/build/shims/os.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - EOL: "\n" -}; diff --git a/scripts/build/shims/os.mjs b/scripts/build/shims/os.mjs new file mode 100644 index 0000000000..27e543d960 --- /dev/null +++ b/scripts/build/shims/os.mjs @@ -0,0 +1,5 @@ +export default { + EOL: "\n", + platform: () => "browser", + cpus: () => [{ model: "Prettier" }], +}; diff --git a/scripts/build/shims/path.js b/scripts/build/shims/path.js deleted file mode 100644 index e7c5c66940..0000000000 --- a/scripts/build/shims/path.js +++ /dev/null @@ -1,16 +0,0 @@ -const sep = /[\\/]/; - -export function extname(path) { - const filename = basename(path); - const dotIndex = filename.lastIndexOf("."); - if (dotIndex === -1) return ""; - return filename.slice(dotIndex); -} - -export function basename(path) { - return path.split(sep).pop(); -} - -export function isAbsolute() { - return true; -} diff --git a/scripts/build/shims/path.mjs b/scripts/build/shims/path.mjs new file mode 100644 index 0000000000..85c8fe123a --- /dev/null +++ b/scripts/build/shims/path.mjs @@ -0,0 +1,2 @@ +export * from "path-browserify"; +export { default } from "path-browserify"; diff --git a/scripts/build/shims/tty.js b/scripts/build/shims/tty.js deleted file mode 100644 index fbdbd67f2b..0000000000 --- a/scripts/build/shims/tty.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - isatty() { - return false - } -} diff --git a/scripts/build/shims/tty.mjs b/scripts/build/shims/tty.mjs new file mode 100644 index 0000000000..0fdd5b758d --- /dev/null +++ b/scripts/build/shims/tty.mjs @@ -0,0 +1,5 @@ +export default { + isatty() { + return false; + }, +}; diff --git a/scripts/build/util.js b/scripts/build/util.js deleted file mode 100644 index 3776858bce..0000000000 --- a/scripts/build/util.js +++ /dev/null @@ -1,30 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const { promisify } = require("util"); - -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); - -async function readJson(file) { - const data = await readFile(file); - return JSON.parse(data); -} - -function writeJson(file, content) { - content = JSON.stringify(content, null, 2); - return writeFile(file, content); -} - -async function copyFile(from, to) { - const data = await readFile(from); - return writeFile(to, data); -} - -module.exports = { - readJson, - writeJson, - copyFile, - readFile, - writeFile, -}; diff --git a/scripts/build/utils.mjs b/scripts/build/utils.mjs new file mode 100644 index 0000000000..b474be33f2 --- /dev/null +++ b/scripts/build/utils.mjs @@ -0,0 +1,18 @@ +import fs from "node:fs/promises"; + +async function readJson(file) { + const data = await fs.readFile(file); + return JSON.parse(data); +} + +function writeJson(file, content) { + content = JSON.stringify(content, null, 2); + return fs.writeFile(file, content); +} + +async function copyFile(from, to) { + const data = await fs.readFile(from); + return fs.writeFile(to, data); +} + +export { readJson, writeJson, copyFile }; diff --git a/scripts/check-deps.js b/scripts/check-deps.js deleted file mode 100644 index 85bfc539bf..0000000000 --- a/scripts/check-deps.js +++ /dev/null @@ -1,19 +0,0 @@ -"use strict"; - -const pkg = require("../package.json"); -const chalk = require("chalk"); - -validateDependencyObject(pkg.dependencies); -validateDependencyObject(pkg.devDependencies); - -function validateDependencyObject(object) { - Object.keys(object).forEach((key) => { - if (object[key][0] === "^" || object[key][0] === "~") { - console.error( - chalk.red("error"), - `Dependency "${chalk.bold.red(key)}" should be pinned.` - ); - process.exitCode = 1; - } - }); -} diff --git a/scripts/check-deps.mjs b/scripts/check-deps.mjs new file mode 100644 index 0000000000..60e8d1f2d3 --- /dev/null +++ b/scripts/check-deps.mjs @@ -0,0 +1,26 @@ +import fs from "node:fs/promises"; +import chalk from "chalk"; + +(async () => { + const packageJson = JSON.parse( + await fs.readFile(new URL("../package.json", import.meta.url)) + ); + + validateDependencyObject(packageJson.dependencies); + validateDependencyObject(packageJson.devDependencies); +})().catch((error) => { + console.error(error); + process.exit(1); +}); + +function validateDependencyObject(object) { + for (const key of Object.keys(object)) { + if (object[key][0] === "^" || object[key][0] === "~") { + console.error( + chalk.red("error"), + `Dependency "${chalk.bold.red(key)}" should be pinned.` + ); + process.exitCode = 1; + } + } +} diff --git a/scripts/clean-changelog-unreleased.mjs b/scripts/clean-changelog-unreleased.mjs new file mode 100644 index 0000000000..600fcbfd65 --- /dev/null +++ b/scripts/clean-changelog-unreleased.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import globby from "globby"; + +const changelogUnreleasedDir = fileURLToPath( + new URL("../changelog_unreleased", import.meta.url) +); + +const files = globby.sync(["blog-post-intro.md", "*/*.md"], { + cwd: changelogUnreleasedDir, + absolute: true, +}); +for (const file of files) { + fs.unlinkSync(file); +} diff --git a/scripts/clean-cspell.mjs b/scripts/clean-cspell.mjs new file mode 100644 index 0000000000..39511389f9 --- /dev/null +++ b/scripts/clean-cspell.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import execa from "execa"; + +const CSPELL_CONFIG_FILE = new URL("../cspell.json", import.meta.url); + +const updateConfig = async (config) => + await fs.writeFile(CSPELL_CONFIG_FILE, JSON.stringify(config, undefined, 4)); + +(async () => { + console.log("Empty words ..."); + const config = JSON.parse(await fs.readFile(CSPELL_CONFIG_FILE, "utf8")); + updateConfig({ ...config, words: [] }); + + console.log("Running spellcheck with empty words ..."); + try { + await execa("yarn lint:spellcheck"); + } catch ({ stdout }) { + let words = [...stdout.matchAll(/ - Unknown word \((.*?)\)/g)].map( + ([, word]) => word + ); + // Unique + words = [...new Set(words)]; + // Remove upper case word, if lower case one already exists + words = words.filter((word) => { + const lowerCased = word.toLowerCase(); + return lowerCased === word || !words.includes(lowerCased); + }); + // Compare function from https://github.com/streetsidesoftware/vscode-spell-checker/blob/2fde3bc5c658ee51da5a56580aa1370bf8174070/packages/client/src/settings/CSpellSettings.ts#L78 + words = words.sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()) + ); + config.words = words; + } + + console.log("Updating words ..."); + updateConfig(config); + + console.log("Running spellcheck with new words ..."); + const subprocess = execa("yarn lint:spellcheck"); + subprocess.stdout.pipe(process.stdout); + await subprocess; + + console.log("CSpell config file updated."); +})(); diff --git a/scripts/draft-blog-post.js b/scripts/draft-blog-post.js deleted file mode 100644 index bce3c86d9d..0000000000 --- a/scripts/draft-blog-post.js +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env node - -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const rimraf = require("rimraf"); - -const changelogUnreleasedDir = path.join(__dirname, "../changelog_unreleased"); -const blogDir = path.join(__dirname, "../website/blog"); -const introFile = path.join(changelogUnreleasedDir, "blog-post-intro.md"); -const version = require("../package.json").version.replace(/-.+/, ""); -const postGlob = path.join(blogDir, `????-??-??-${version}.md`); -const postFile = path.join( - blogDir, - `${new Date().toISOString().replace(/T.+/, "")}-${version}.md` -); - -const categories = [ - { dir: "javascript", title: "JavaScript" }, - { dir: "typescript", title: "TypeScript" }, - { dir: "flow", title: "Flow" }, - { dir: "json", title: "JSON" }, - { dir: "css", title: "CSS" }, - { dir: "scss", title: "SCSS" }, - { dir: "less", title: "Less" }, - { dir: "html", title: "HTML" }, - { dir: "vue", title: "Vue" }, - { dir: "angular", title: "Angular" }, - { dir: "lwc", title: "LWC" }, - { dir: "handlebars", title: "Handlebars (alpha)" }, - { dir: "graphql", title: "GraphQL" }, - { dir: "markdown", title: "Markdown" }, - { dir: "mdx", title: "MDX" }, - { dir: "yaml", title: "YAML" }, - { dir: "api", title: "API" }, - { dir: "cli", title: "CLI" }, -]; - -const categoriesByDir = categories.reduce((result, category) => { - result[category.dir] = category; - return result; -}, {}); - -const dirs = fs - .readdirSync(changelogUnreleasedDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()); - -for (const dir of dirs) { - const dirPath = path.join(changelogUnreleasedDir, dir.name); - const category = categoriesByDir[dir.name]; - - if (!category) { - throw new Error("Unknown category: " + dir.name); - } - - category.entries = fs - .readdirSync(path.join(changelogUnreleasedDir, dir.name)) - .filter((fileName) => /^pr-\d+\.md$/.test(fileName)) - .map((fileName) => { - const [title, ...rest] = fs - .readFileSync(path.join(dirPath, fileName), "utf8") - .trim() - .split("\n"); - return { - breaking: title.includes("[BREAKING]"), - highlight: title.includes("[HIGHLIGHT]"), - content: [ - title - .replace(/\[(BREAKING|HIGHLIGHT)\]/g, "") - .replace(/\s+/g, " ") - .replace(/^#### [a-z]/, (s) => s.toUpperCase()), - ...rest, - ].join("\n"), - }; - }); -} - -rimraf.sync(postGlob); - -fs.writeFileSync( - postFile, - [ - fs.readFileSync(introFile, "utf8").trim(), - "", - ...printEntries({ - title: "Highlights", - filter: (entry) => entry.highlight, - }), - ...printEntries({ - title: "Breaking changes", - filter: (entry) => entry.breaking && !entry.highlight, - }), - ...printEntries({ - title: "Other changes", - filter: (entry) => !entry.breaking && !entry.highlight, - }), - ].join("\n\n") + "\n" -); - -function printEntries({ title, filter }) { - const result = []; - - for (const { entries = [], title } of categories) { - const filteredEntries = entries.filter(filter); - if (filteredEntries.length) { - result.push("### " + title); - result.push(...filteredEntries.map((entry) => entry.content)); - } - } - - if (result.length) { - result.unshift("## " + title); - } - - return result; -} diff --git a/scripts/draft-blog-post.mjs b/scripts/draft-blog-post.mjs new file mode 100644 index 0000000000..2bc1a389c9 --- /dev/null +++ b/scripts/draft-blog-post.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import rimraf from "rimraf"; +import semver from "semver"; +import createEsmUtils from "esm-utils"; + +const { __dirname, require } = createEsmUtils(import.meta); +const changelogUnreleasedDir = path.join(__dirname, "../changelog_unreleased"); +const blogDir = path.join(__dirname, "../website/blog"); +const introTemplateFile = path.join( + changelogUnreleasedDir, + "BLOG_POST_INTRO_TEMPLATE.md" +); +const introFile = path.join(changelogUnreleasedDir, "blog-post-intro.md"); +if (!fs.existsSync(introFile)) { + fs.copyFileSync(introTemplateFile, introFile); +} +const previousVersion = require("prettier/package.json").version; +const version = require("../package.json").version.replace(/-.+/, ""); +const postGlob = path.join(blogDir, `????-??-??-${version}.md`); +const postFile = path.join( + blogDir, + `${new Date().toISOString().replace(/T.+/, "")}-${version}.md` +); + +const categories = [ + { dir: "javascript", title: "JavaScript" }, + { dir: "typescript", title: "TypeScript" }, + { dir: "flow", title: "Flow" }, + { dir: "json", title: "JSON" }, + { dir: "css", title: "CSS" }, + { dir: "scss", title: "SCSS" }, + { dir: "less", title: "Less" }, + { dir: "html", title: "HTML" }, + { dir: "vue", title: "Vue" }, + { dir: "angular", title: "Angular" }, + { dir: "lwc", title: "LWC" }, + { dir: "handlebars", title: "Ember / Handlebars" }, + { dir: "graphql", title: "GraphQL" }, + { dir: "markdown", title: "Markdown" }, + { dir: "mdx", title: "MDX" }, + { dir: "yaml", title: "YAML" }, + { dir: "api", title: "API" }, + { dir: "cli", title: "CLI" }, +]; + +const categoriesByDir = new Map( + categories.map((category) => [category.dir, category]) +); + +const dirs = fs + .readdirSync(changelogUnreleasedDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()); + +for (const dir of dirs) { + const dirPath = path.join(changelogUnreleasedDir, dir.name); + const category = categoriesByDir.get(dir.name); + + if (!category) { + throw new Error("Unknown category: " + dir.name); + } + + category.entries = fs + .readdirSync(dirPath) + .filter((fileName) => /^\d+\.md$/.test(fileName)) + .map((fileName) => { + const [title, ...rest] = fs + .readFileSync(path.join(dirPath, fileName), "utf8") + .trim() + .split("\n"); + + const improvement = title.match(/\[IMPROVEMENT(:(\d+))?]/); + + const section = title.includes("[HIGHLIGHT]") + ? "highlight" + : title.includes("[BREAKING]") + ? "breaking" + : improvement + ? "improvement" + : undefined; + + const order = + section === "improvement" && improvement[2] !== undefined + ? Number(improvement[2]) + : undefined; + + const content = [processTitle(title), ...rest].join("\n"); + + return { fileName, section, order, content }; + }); +} + +rimraf.sync(postGlob); + +fs.writeFileSync( + postFile, + replaceVersions( + [ + fs.readFileSync(introFile, "utf8").trim(), + "", + ...printEntries({ + title: "Highlights", + filter: (entry) => entry.section === "highlight", + }), + ...printEntries({ + title: "Breaking Changes", + filter: (entry) => entry.section === "breaking", + }), + ...printEntries({ + title: "Formatting Improvements", + filter: (entry) => entry.section === "improvement", + }), + ...printEntries({ + title: "Other Changes", + filter: (entry) => !entry.section, + }), + ].join("\n\n") + "\n" + ) +); + +function processTitle(title) { + return title + .replace(/\[(BREAKING|HIGHLIGHT|IMPROVEMENT(:\d+)?)]/g, "") + .replace(/\s+/g, " ") + .replace(/^#{4} [a-z]/, (s) => s.toUpperCase()) + .replace(/(? 0) { + filteredEntries.sort((a, b) => { + if (a.order !== undefined) { + return b.order === undefined ? 1 : a.order - b.order; + } + return a.fileName.localeCompare(b.fileName, "en", { numeric: true }); + }); + result.push( + "### " + title, + ...filteredEntries.map((entry) => entry.content) + ); + } + } + + if (result.length > 0) { + result.unshift("## " + title); + } + + return result; +} + +function formatVersion(version) { + return `${semver.major(version)}.${semver.minor(version)}`; +} + +function replaceVersions(data) { + return data + .replace(/prettier stable/gi, `Prettier ${formatVersion(previousVersion)}`) + .replace(/prettier main/gi, `Prettier ${formatVersion(version)}`); +} diff --git a/scripts/generate-schema.js b/scripts/generate-schema.js index 55fe541904..a9c35ecdb4 100755 --- a/scripts/generate-schema.js +++ b/scripts/generate-schema.js @@ -1,7 +1,6 @@ #!/usr/bin/env node "use strict"; -const fromPairs = require("lodash/fromPairs"); if (require.main !== module) { module.exports = generateSchema; @@ -22,7 +21,7 @@ function generateSchema(options) { definitions: { optionsDefinition: { type: "object", - properties: fromPairs( + properties: Object.fromEntries( options.map((option) => [option.name, optionToSchema(option)]) ), }, diff --git a/scripts/install-prettierx.js b/scripts/install-prettierx.js index ad211cb803..007f9467ce 100644 --- a/scripts/install-prettierx.js +++ b/scripts/install-prettierx.js @@ -4,20 +4,51 @@ const path = require("path"); const shell = require("shelljs"); const tempy = require("tempy"); +// [prettierx]: typescript/flow-parser optional dep support +const pkg = require("../package.json"); + shell.config.fatal = true; -const rootDir = path.join(__dirname, ".."); -const distDir = path.join(rootDir, "dist"); +const client = process.env.NPM_CLIENT || "yarn"; + +// [prettierx]: typescript/flow-parser optional dep support +function getInstallCommand(args) { + switch (client) { + case "npm": + // npm fails when engine requirement only with `--engine-strict` + return `npm install ${args} --engine-strict`; + case "pnpm": + // Note: current pnpm can't work with `--engine-strict` and engineStrict setting in `.npmrc` + return `pnpm add ${args}`; + default: + // yarn fails when engine requirement not compatible by default + return `yarn add ${args}`; + } +} -module.exports = () => { - const file = shell.exec("npm pack", { cwd: distDir }).stdout.trim(); - const tarPath = path.join(distDir, file); +module.exports = (packageDir) => { const tmpDir = tempy.directory(); + const file = shell.exec("npm pack", { cwd: packageDir }).stdout.trim(); + shell.mv(path.join(packageDir, file), tmpDir); + const tarPath = path.join(tmpDir, file); + + shell.exec(`${client} init -y`, { cwd: tmpDir, silent: true }); + + // [prettierx]: typescript/flow-parser optional dep support + shell.exec(getInstallCommand(`"${tarPath}"`), { cwd: tmpDir }); + + // [prettierx]: typescript/flow-parser optional dep support + const optionalDeps = new Set(); + + for (const dependency of Object.keys(pkg.peerDependenciesMeta)) { + if (pkg.peerDependenciesMeta[dependency].optional) { + optionalDeps.add(dependency); + } + } - shell.config.silent = true; - shell.exec("npm init -y", { cwd: tmpDir }); - shell.exec(`npm install "${tarPath}"`, { cwd: tmpDir }); - shell.config.silent = false; + if (optionalDeps.size > 0) { + shell.exec(getInstallCommand([...optionalDeps].join(" ")), { cwd: tmpDir }); + } // [prettierx] return path.join(tmpDir, "node_modules/prettierx"); diff --git a/scripts/lint-changelog.js b/scripts/lint-changelog.js deleted file mode 100644 index 26ff1c20a0..0000000000 --- a/scripts/lint-changelog.js +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env node -"use strict"; -const fs = require("fs"); -const path = require("path"); - -const CHANGELOG_DIR = "changelog_unreleased"; -const TEMPLATE_FILE = "TEMPLATE.md"; -const BLOG_POST_INTRO_FILE = "blog-post-intro.md"; -const CHANGELOG_CATEGORIES = [ - "angular", - "api", - "cli", - "css", - "flow", - "graphql", - "handlebars", - "html", - "javascript", - "json", - "less", - "lwc", - "markdown", - "mdx", - "scss", - "typescript", - "vue", - "yaml", -]; -const CHANGELOG_ROOT = path.join(__dirname, `../${CHANGELOG_DIR}`); -const showErrorMessage = (message) => { - console.error(message); - process.exitCode = 1; -}; - -const files = fs.readdirSync(CHANGELOG_ROOT); -for (const file of files) { - if ( - file !== TEMPLATE_FILE && - file !== BLOG_POST_INTRO_FILE && - !CHANGELOG_CATEGORIES.includes(file) - ) { - showErrorMessage(`Please remove "${file}" from "${CHANGELOG_DIR}".`); - } -} -for (const file of [ - TEMPLATE_FILE, - BLOG_POST_INTRO_FILE, - ...CHANGELOG_CATEGORIES, -]) { - if (!files.includes(file)) { - showErrorMessage(`Please don't remove "${file}" from "${CHANGELOG_DIR}".`); - } -} - -const authorRegex = /by \[@(.*?)\]\(https:\/\/github\.com\/\1\)/; -const titleRegex = /^#{4} (.*?)\(\[#\d{4,}]/; - -const template = fs.readFileSync( - path.join(CHANGELOG_ROOT, TEMPLATE_FILE), - "utf8" -); -const [templateComment] = template.match(//); -const [templateAuthorLink] = template.match(authorRegex); - -for (const category of CHANGELOG_CATEGORIES) { - const files = fs.readdirSync(path.join(CHANGELOG_ROOT, category)); - if (!files.includes(".gitkeep")) { - showErrorMessage( - `Please don't remove ".gitkeep" from "${CHANGELOG_DIR}/${category}".` - ); - } - - for (const prFile of files) { - if (prFile === ".gitkeep") { - continue; - } - - const match = prFile.match(/^pr-(\d{4,})\.md$/); - const displayPath = `${CHANGELOG_DIR}/${category}/${prFile}`; - - if (!match) { - showErrorMessage( - `[${displayPath}]: Filename is not in form of "pr-{PR_NUMBER}.md".` - ); - continue; - } - const [, prNumber] = match; - const content = fs.readFileSync( - path.join(CHANGELOG_DIR, category, prFile), - "utf8" - ); - const prLink = `[#${prNumber}](https://github.com/prettier/prettier/pull/${prNumber})`; - - if (!content.includes(prLink)) { - showErrorMessage(`[${displayPath}]: PR link "${prLink}" is missing.`); - } - if (!authorRegex.test(content)) { - showErrorMessage(`[${displayPath}]: Author link is missing.`); - } - if (content.includes(templateComment)) { - showErrorMessage( - `[${displayPath}]: Please remove template comments at top.` - ); - } - if (content.includes(templateAuthorLink)) { - showErrorMessage( - `[${displayPath}]: Please change author link to your github account.` - ); - } - if (!content.startsWith("#### ")) { - showErrorMessage(`[${displayPath}]: Please use h4("####") for title.`); - } - const titleMatch = content.match(titleRegex); - if (!titleMatch) { - showErrorMessage(`[${displayPath}]: Something wrong in title.`); - continue; - } - const [, title] = titleMatch; - const categoryInTitle = title.split(":").shift().trim(); - if (CHANGELOG_CATEGORIES.includes(categoryInTitle.toLowerCase())) { - showErrorMessage( - `[${displayPath}]: Please remove "${categoryInTitle}:" in title.` - ); - } - - if (title.startsWith(" ")) { - showErrorMessage( - `[${displayPath}]: Don't add extra space(s) at beginning of title.` - ); - } - - if (!title.endsWith(" ") || title.length - title.trimEnd().length !== 1) { - showErrorMessage( - `[${displayPath}]: Please put one space between title and PR link.` - ); - } - } -} diff --git a/scripts/lint-changelog.mjs b/scripts/lint-changelog.mjs new file mode 100644 index 0000000000..71fa017cbd --- /dev/null +++ b/scripts/lint-changelog.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +import path from "node:path"; +import fs from "node:fs"; +import { outdent } from "outdent"; +import createEsmUtils from "esm-utils"; + +const { __dirname } = createEsmUtils(import.meta); +const CHANGELOG_DIR = "changelog_unreleased"; +const TEMPLATE_FILE = "TEMPLATE.md"; +const BLOG_POST_INTRO_TEMPLATE_FILE = "BLOG_POST_INTRO_TEMPLATE.md"; +const BLOG_POST_INTRO_FILE = "blog-post-intro.md"; +const CHANGELOG_CATEGORIES = [ + "angular", + "api", + "cli", + "css", + "flow", + "graphql", + "handlebars", + "html", + "javascript", + "json", + "less", + "lwc", + "markdown", + "mdx", + "scss", + "typescript", + "vue", + "yaml", +]; +const CHANGELOG_ROOT = path.join(__dirname, `../${CHANGELOG_DIR}`); +const showErrorMessage = (message) => { + console.error(message); + process.exitCode = 1; +}; + +const files = fs.readdirSync(CHANGELOG_ROOT); +for (const file of files) { + if ( + file !== TEMPLATE_FILE && + file !== BLOG_POST_INTRO_FILE && + file !== BLOG_POST_INTRO_TEMPLATE_FILE && + !CHANGELOG_CATEGORIES.includes(file) + ) { + showErrorMessage(`Please remove "${file}" from "${CHANGELOG_DIR}".`); + } +} +for (const file of [ + TEMPLATE_FILE, + BLOG_POST_INTRO_TEMPLATE_FILE, + ...CHANGELOG_CATEGORIES, +]) { + if (!files.includes(file)) { + showErrorMessage(`Please don't remove "${file}" from "${CHANGELOG_DIR}".`); + } +} + +const authorRegex = /by @[\w-]+|by \[@([\w-]+)]\(https:\/\/github\.com\/\1\)/; +const titleRegex = /^#{4} (.*?)\((#\d{4,}|\[#\d{4,}])/; + +const template = fs.readFileSync( + path.join(CHANGELOG_ROOT, TEMPLATE_FILE), + "utf8" +); +const [templateComment] = template.match(//s); +const [templateAuthorLink] = template.match(authorRegex); +const checkedFiles = new Map(); + +for (const category of CHANGELOG_CATEGORIES) { + const files = fs.readdirSync(path.join(CHANGELOG_ROOT, category)); + if (!files.includes(".gitkeep")) { + showErrorMessage( + `Please don't remove ".gitkeep" from "${CHANGELOG_DIR}/${category}".` + ); + } + + for (const prFile of files) { + if (prFile === ".gitkeep") { + continue; + } + + const match = prFile.match(/^(\d{4,})\.md$/); + const displayPath = `${CHANGELOG_DIR}/${category}/${prFile}`; + + if (!match) { + showErrorMessage( + `[${displayPath}]: Filename is not in form of "{PR_NUMBER}.md".` + ); + continue; + } + const [, prNumber] = match; + const prLink = `#${prNumber}`; + if (checkedFiles.has(prNumber)) { + showErrorMessage( + outdent` + Duplicate files for ${prLink} found. + - ${checkedFiles.get(prNumber)} + - ${displayPath} + ` + ); + } + checkedFiles.set(prNumber, displayPath); + const content = fs.readFileSync( + path.join(CHANGELOG_DIR, category, prFile), + "utf8" + ); + + if (!content.includes(prLink)) { + showErrorMessage(`[${displayPath}]: PR link "${prLink}" is missing.`); + } + if (!authorRegex.test(content)) { + showErrorMessage(`[${displayPath}]: Author link is missing.`); + } + if (content.includes(templateComment)) { + showErrorMessage( + `[${displayPath}]: Please remove template comments at top.` + ); + } + if (content.includes(templateAuthorLink)) { + showErrorMessage( + `[${displayPath}]: Please change author link to your github account.` + ); + } + if (!content.startsWith("#### ")) { + showErrorMessage(`[${displayPath}]: Please use h4 ("####") for title.`); + } + const titleMatch = content.match(titleRegex); + if (!titleMatch) { + showErrorMessage(`[${displayPath}]: Something wrong in title.`); + continue; + } + const [, title] = titleMatch; + const categoryInTitle = title.split(":").shift().trim(); + if ( + [...CHANGELOG_CATEGORIES, "js"].includes(categoryInTitle.toLowerCase()) + ) { + showErrorMessage( + `[${displayPath}]: Please remove "${categoryInTitle}:" in title.` + ); + } + + if (!title.endsWith(" ") || title.length - title.trimEnd().length !== 1) { + showErrorMessage( + `[${displayPath}]: Please put one space between title and PR link.` + ); + } + + if (/prettier master/i.test(content)) { + showErrorMessage( + `[${displayPath}]: Please use "main" instead of "master".` + ); + } + } +} diff --git a/scripts/release/package.json b/scripts/release/package.json index e8d773156e..afe9f8e641 100644 --- a/scripts/release/package.json +++ b/scripts/release/package.json @@ -1,12 +1,12 @@ { "private": true, "dependencies": { - "chalk": "2.4.1", - "dedent": "0.7.0", - "execa": "0.10.0", - "minimist": "1.2.3", + "chalk": "4.1.1", + "execa": "5.1.1", + "minimist": "1.2.5", "node-fetch": "2.6.1", - "semver": "5.5.0", - "string-width": "2.1.1" + "outdent": "0.8.0", + "semver": "7.3.5", + "string-width": "4.2.0" } } diff --git a/scripts/release/release.js b/scripts/release/release.js index c6ca1c8ade..093ea240c0 100644 --- a/scripts/release/release.js +++ b/scripts/release/release.js @@ -2,15 +2,14 @@ "use strict"; -const { exec, execSync } = require("child_process"); +const { exec } = require("child_process"); async function run() { const chalk = require("chalk"); - const dedent = require("dedent"); const minimist = require("minimist"); const semver = require("semver"); - - const { readJson } = require("./utils"); + const { string: outdentString } = require("outdent"); + const { runGit, readJson } = require("./utils"); const params = minimist(process.argv.slice(2), { string: ["version"], @@ -18,15 +17,19 @@ async function run() { alias: { v: "version" }, }); - const previousVersion = execSync("git describe --tags --abbrev=0") - .toString() - .trim(); + const { stdout: previousVersion } = await runGit([ + "describe", + "--tags", + "--abbrev=0", + ]); if (semver.parse(previousVersion) === null) { throw new Error(`Unexpected previousVersion: ${previousVersion}`); } else { params.previousVersion = previousVersion; - params.previousVersionOnMaster = (await readJson("package.json")).version; + params.previousVersionOnDefaultBranch = ( + await readJson("package.json") + ).version; } const steps = [ @@ -40,6 +43,7 @@ async function run() { require("./steps/push-to-git"), require("./steps/publish-to-npm"), require("./steps/bump-prettier"), + require("./steps/update-dependents-count"), require("./steps/post-publish-steps"), ]; @@ -48,7 +52,7 @@ async function run() { await step(params); } } catch (error) { - const message = dedent(error.message.trim()); + const message = outdentString(error.message.trim()); const stack = error.stack.replace(message, ""); console.error(`${chalk.red("error")} ${message}\n${stack}`); process.exit(1); diff --git a/scripts/release/steps/bump-prettier.js b/scripts/release/steps/bump-prettier.js index 679e571490..96a4ec1603 100644 --- a/scripts/release/steps/bump-prettier.js +++ b/scripts/release/steps/bump-prettier.js @@ -1,27 +1,43 @@ "use strict"; -const execa = require("execa"); +const fs = require("fs"); const semver = require("semver"); -const { logPromise, readJson, writeJson } = require("../utils"); +const { + runYarn, + runGit, + logPromise, + readJson, + writeJson, +} = require("../utils"); async function format() { - await execa("yarn", ["lint:eslint", "--fix"]); - await execa("yarn", ["lint:prettier", "--write"]); + await runYarn(["lint:eslint", "--fix"]); + await runYarn(["lint:prettier", "--write"]); } async function commit(version) { - await execa("git", [ - "commit", - "-am", - `Bump Prettier dependency to ${version}`, - ]); - await execa("git", ["push"]); + await runGit(["commit", "-am", `Bump Prettier dependency to ${version}`]); + + // Add rev to `.git-blame-ignore-revs` file + const file = ".git-blame-ignore-revs"; + const mark = "# Prettier bump after release"; + const { stdout: rev } = await runGit(["rev-parse", "HEAD"]); + let text = fs.readFileSync(file, "utf8"); + text = text.replace(mark, `${mark}\n# ${version}\n${rev}`); + fs.writeFileSync(file, text); + await runGit(["commit", "-am", `Git blame ignore ${version}`]); + + await runGit(["push"]); } -async function bump({ version, previousVersion, previousVersionOnMaster }) { +async function bump({ + version, + previousVersion, + previousVersionOnDefaultBranch, +}) { const pkg = await readJson("package.json"); if (semver.diff(version, previousVersion) === "patch") { - pkg.version = previousVersionOnMaster; // restore the `-dev` version + pkg.version = previousVersionOnDefaultBranch; // restore the `-dev` version } else { pkg.version = semver.inc(version, "minor") + "-dev"; } @@ -37,10 +53,10 @@ module.exports = async function (params) { await logPromise( "Installing Prettier", - execa("yarn", ["add", "--dev", `prettier@${version}`]) + runYarn(["add", "--dev", `prettier@${version}`]) ); await logPromise("Updating files", format()); - await logPromise("Bump master version", bump(params)); + await logPromise("Bump default branch version", bump(params)); await logPromise("Committing changed files", commit(version)); }; diff --git a/scripts/release/steps/check-git-status.js b/scripts/release/steps/check-git-status.js index 7607a931e9..9c21067724 100644 --- a/scripts/release/steps/check-git-status.js +++ b/scripts/release/steps/check-git-status.js @@ -1,9 +1,9 @@ "use strict"; -const execa = require("execa"); +const { runGit } = require("../utils"); module.exports = async function () { - const status = await execa.stdout("git", ["status", "--porcelain"]); + const { stdout: status } = await runGit(["status", "--porcelain"]); if (status) { throw new Error( diff --git a/scripts/release/steps/install-dependencies.js b/scripts/release/steps/install-dependencies.js index 375b91bb6b..a304bcf792 100644 --- a/scripts/release/steps/install-dependencies.js +++ b/scripts/release/steps/install-dependencies.js @@ -1,13 +1,13 @@ "use strict"; const execa = require("execa"); -const { logPromise } = require("../utils"); +const { runYarn, runGit, logPromise } = require("../utils"); async function install() { await execa("rm", ["-rf", "node_modules"]); - await execa("yarn", ["install"]); + await runYarn(["install"]); - const status = await execa.stdout("git", ["ls-files", "-m"]); + const { stdout: status } = await runGit(["ls-files", "-m"]); if (status) { throw new Error( "The lockfile needs to be updated, commit it before making the release." diff --git a/scripts/release/steps/post-publish-steps.js b/scripts/release/steps/post-publish-steps.js index a9c70775b6..ff42b563bb 100644 --- a/scripts/release/steps/post-publish-steps.js +++ b/scripts/release/steps/post-publish-steps.js @@ -1,10 +1,9 @@ "use strict"; const chalk = require("chalk"); -const dedent = require("dedent"); -const fetch = require("node-fetch"); +const { string: outdentString } = require("outdent"); const execa = require("execa"); -const { logPromise } = require("../utils"); +const { fetchText, logPromise } = require("../utils"); const SCHEMA_REPO = "SchemaStore/schemastore"; const SCHEMA_PATH = "src/schemas/json/prettierrc.json"; @@ -14,19 +13,19 @@ const EDIT_URL = `https://github.com/${SCHEMA_REPO}/edit/master/${SCHEMA_PATH}`; // Any optional or manual step can be warned in this script. async function checkSchema() { - const schema = await execa.stdout("node", ["scripts/generate-schema.js"]); + const { stdout: schema } = await execa("node", [ + "scripts/generate-schema.js", + ]); const remoteSchema = await logPromise( "Checking current schema in SchemaStore", - fetch(RAW_URL) - .then((r) => r.text()) - .then((t) => t.trim()) + fetchText(RAW_URL) ); - if (schema === remoteSchema) { + if (schema === remoteSchema.trim()) { return; } - return dedent(chalk` + return outdentString(chalk` {bold.underline The schema in {yellow SchemaStore} needs an update.} - Open {cyan.underline ${EDIT_URL}} - Run {yellow node scripts/generate-schema.js} and copy the new schema @@ -36,7 +35,7 @@ async function checkSchema() { } function twitterAnnouncement() { - return dedent(chalk` + return outdentString(chalk` {bold.underline Announce on Twitter} - Open {cyan.underline https://tweetdeck.twitter.com} - Make sure you are tweeting from the {yellow @PrettierCode} account. @@ -54,7 +53,7 @@ module.exports = async function () { } console.log( - dedent(chalk` + outdentString(chalk` {yellow.bold The following ${ steps.length === 1 ? "step is" : "steps are" } optional.} diff --git a/scripts/release/steps/publish-to-npm.js b/scripts/release/steps/publish-to-npm.js index cef91b28dc..73db7fe5a3 100644 --- a/scripts/release/steps/publish-to-npm.js +++ b/scripts/release/steps/publish-to-npm.js @@ -1,25 +1,41 @@ "use strict"; const chalk = require("chalk"); -const dedent = require("dedent"); +const { string: outdentString } = require("outdent"); const execa = require("execa"); const { logPromise, waitForEnter } = require("../utils"); +/** + * Retry "npm publish" when to enter OTP is failed. + */ +async function retryNpmPublish() { + const runNpmPublish = () => + execa("npm", ["publish"], { + cwd: "./dist", + stdio: "inherit", // we need to input OTP if 2FA enabled + }); + for (let i = 5; i > 0; i--) { + try { + return await runNpmPublish(); + } catch (error) { + if (error.code === "EOTP" && i > 0) { + console.log(`To enter OTP is failed, you can retry it ${i} times.`); + continue; + } + throw error; + } + } +} + module.exports = async function ({ dry, version }) { if (dry) { return; } - await logPromise( - "Publishing to npm", - execa("npm", ["publish"], { - cwd: "./dist", - stdio: "inherit", // we need to input OTP if 2FA enabled - }) - ); + await logPromise("Publishing to npm", retryNpmPublish()); console.log( - dedent(chalk` + outdentString(chalk` {green.bold Prettier ${version} published!} {yellow.bold Some manual steps are necessary.} diff --git a/scripts/release/steps/push-to-git.js b/scripts/release/steps/push-to-git.js index 6599517151..e85f0612c8 100644 --- a/scripts/release/steps/push-to-git.js +++ b/scripts/release/steps/push-to-git.js @@ -1,13 +1,12 @@ "use strict"; -const execa = require("execa"); -const { logPromise } = require("../utils"); +const { runGit, logPromise } = require("../utils"); async function pushGit({ version }) { - await execa("git", ["commit", "-am", `Release ${version}`]); - await execa("git", ["tag", "-a", version, "-m", `Release ${version}`]); - await execa("git", ["push"]); - await execa("git", ["push", "--tags"]); + await runGit(["commit", "-am", `Release ${version}`]); + await runGit(["tag", "-a", version, "-m", `Release ${version}`]); + await runGit(["push"]); + await runGit(["push", "--tags"]); } module.exports = function (params) { diff --git a/scripts/release/steps/update-changelog.js b/scripts/release/steps/update-changelog.js index 0202ecc513..3f8adf1952 100644 --- a/scripts/release/steps/update-changelog.js +++ b/scripts/release/steps/update-changelog.js @@ -1,8 +1,8 @@ "use strict"; -const chalk = require("chalk"); -const dedent = require("dedent"); const fs = require("fs"); +const chalk = require("chalk"); +const { outdent, string: outdentString } = require("outdent"); const semver = require("semver"); const { waitForEnter, runYarn, logPromise } = require("../utils"); @@ -20,7 +20,7 @@ function getBlogPostInfo(version) { function writeChangelog({ version, previousVersion, releaseNotes }) { const changelog = fs.readFileSync("CHANGELOG.md", "utf-8"); - const newEntry = dedent` + const newEntry = outdent` # ${version} [diff](https://github.com/prettier/prettier/compare/${previousVersion}...${version}) @@ -45,7 +45,7 @@ module.exports = async function ({ version, previousVersion }) { return; } console.warn( - dedent(chalk` + outdentString(chalk` {yellow warning} The file {bold ${blogPost.file}} doesn't exist, but it will be referenced in {bold CHANGELOG.md}. Make sure to create it later. Press ENTER to continue. @@ -53,10 +53,10 @@ module.exports = async function ({ version, previousVersion }) { ); } else { console.log( - dedent(chalk` + outdentString(chalk` {yellow.bold A manual step is necessary.} - You can copy the entries from {bold changelog_unreleased/*/pr-*.md} to {bold CHANGELOG.md} + You can copy the entries from {bold changelog_unreleased/*/*.md} to {bold CHANGELOG.md} and update it accordingly. You don't need to commit the file, the script will take care of that. diff --git a/scripts/release/steps/update-dependents-count.js b/scripts/release/steps/update-dependents-count.js new file mode 100644 index 0000000000..73ee3ae6fd --- /dev/null +++ b/scripts/release/steps/update-dependents-count.js @@ -0,0 +1,82 @@ +"use strict"; + +const chalk = require("chalk"); +const { runGit, fetchText, logPromise, processFile } = require("../utils"); + +async function update() { + const npmPage = await logPromise( + "Fetching npm dependents count", + fetchText("https://www.npmjs.com/package/prettier") + ); + const dependentsCountNpm = Number( + npmPage.match(/"dependentsCount":(\d+),/)[1] + ); + if (Number.isNaN(dependentsCountNpm)) { + throw new TypeError( + "Invalid data from https://www.npmjs.com/package/prettier" + ); + } + + const githubPage = await logPromise( + "Fetching github dependents count", + fetchText("https://github.com/prettier/prettier/network/dependents") + ); + const dependentsCountGithub = Number( + githubPage + .replace(/\n/g, "") + .match( + /.*?<\/svg>\s*([\d,]+?)\s*Repositories\s*<\/a>/ + )[1] + .replace(/,/g, "") + ); + if (Number.isNaN(dependentsCountNpm)) { + throw new TypeError( + "Invalid data from https://github.com/prettier/prettier/network/dependents" + ); + } + + processFile("website/pages/en/index.js", (content) => + content + .replace( + /()(.*?)(<\/strong>)/, + `$1${formatNumber(dependentsCountNpm)}$3` + ) + .replace( + /()(.*?)(<\/strong>)/, + `$1${formatNumber(dependentsCountGithub)}$3` + ) + ); + + const isUpdated = await logPromise( + "Checking if dependents count has been updated", + async () => + (await runGit(["diff", "--name-only"])).stdout === + "website/pages/en/index.js" + ); + + if (isUpdated) { + await logPromise("Committing and pushing to remote", async () => { + await runGit(["add", "."]); + await runGit(["commit", "-m", "Update dependents count"]); + await runGit(["push"]); + }); + } +} + +function formatNumber(value) { + if (value < 1e4) { + return String(value).slice(0, 1) + "0".repeat(String(value).length - 1); + } + if (value < 1e6) { + return Math.floor(value / 1e2) / 10 + "k"; + } + return Math.floor(value / 1e5) / 10 + " million"; +} + +module.exports = async function () { + try { + await update(); + } catch (error) { + console.log(chalk.red.bold(error.message)); + } +}; diff --git a/scripts/release/steps/update-version.js b/scripts/release/steps/update-version.js index 6f01681685..1df08da79b 100644 --- a/scripts/release/steps/update-version.js +++ b/scripts/release/steps/update-version.js @@ -1,7 +1,12 @@ "use strict"; -const execa = require("execa"); -const { logPromise, readJson, writeJson, processFile } = require("../utils"); +const { + runYarn, + logPromise, + readJson, + writeJson, + processFile, +} = require("../utils"); async function bump({ version }) { const pkg = await readJson("package.json"); @@ -21,10 +26,10 @@ async function bump({ version }) { // Update unpkg link in docs processFile("docs/browser.md", (content) => - content.replace(/(\/\/unpkg\.com\/prettier@)(?:.*?)\//g, `$1${version}/`) + content.replace(/(\/\/unpkg\.com\/prettier@).*?\//g, `$1${version}/`) ); - await execa("yarn", ["update-stable-docs"], { + await runYarn(["update-stable-docs"], { cwd: "./website", }); } diff --git a/scripts/release/utils.js b/scripts/release/utils.js index 66f35fee77..7f52e4d0a0 100644 --- a/scripts/release/utils.js +++ b/scripts/release/utils.js @@ -2,10 +2,11 @@ require("readline").emitKeypressEvents(process.stdin); -const chalk = require("chalk"); const fs = require("fs"); +const chalk = require("chalk"); const execa = require("execa"); const stringWidth = require("string-width"); +const fetch = require("node-fetch"); const OK = chalk.bgGreen.black(" DONE "); const FAIL = chalk.bgRed.black(" FAIL "); @@ -19,27 +20,37 @@ function fitTerminal(input) { return input; } -function logPromise(name, promise) { +async function logPromise(name, promiseOrAsyncFunction) { + const promise = + typeof promiseOrAsyncFunction === "function" + ? promiseOrAsyncFunction() + : promiseOrAsyncFunction; + process.stdout.write(fitTerminal(name)); - return promise - .then((result) => { - process.stdout.write(`${OK}\n`); - return result; - }) - .catch((err) => { - process.stdout.write(`${FAIL}\n`); - throw err; - }); + try { + const result = await promise; + process.stdout.write(`${OK}\n`); + return result; + } catch (error) { + process.stdout.write(`${FAIL}\n`); + throw error; + } } -function runYarn(script) { - if (typeof script === "string") { - script = [script]; +async function runYarn(args, options) { + args = Array.isArray(args) ? args : [args]; + + try { + return await execa("yarn", ["--silent", ...args], options); + } catch (error) { + throw new Error(`\`yarn ${args.join(" ")}\` failed\n${error.stdout}`); } - return execa("yarn", ["--silent"].concat(script)).catch((error) => { - throw new Error(`\`yarn ${script}\` failed\n${error.stdout}`); - }); +} + +function runGit(args, options) { + args = Array.isArray(args) ? args : [args]; + return execa("git", args, options); } function waitForEnter() { @@ -75,8 +86,15 @@ function processFile(filename, fn) { fs.writeFileSync(filename, fn(content)); } +async function fetchText(url) { + const response = await fetch(url); + return response.text(); +} + module.exports = { runYarn, + runGit, + fetchText, logPromise, processFile, readJson, diff --git a/scripts/release/yarn.lock b/scripts/release/yarn.lock index ebc21c8bca..bb3fbe9a28 100644 --- a/scripts/release/yarn.lock +++ b/scripts/release/yarn.lock @@ -2,155 +2,207 @@ # yarn lockfile v1 -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: - color-convert "^1.9.0" + color-convert "^2.0.1" -chalk@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" +chalk@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad" + integrity sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg== dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" + ansi-styles "^4.1.0" + supports-color "^7.1.0" -color-convert@^1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== dependencies: - color-name "^1.1.1" + color-name "~1.1.4" -color-name@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -cross-spawn@^6.0.0: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" +cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -dedent@0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - -execa@0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +execa@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== dependencies: - cross-spawn "^6.0.0" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +get-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" + integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -has-flag@^3.0.0: +is-fullwidth-code-point@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-fullwidth-code-point@^2.0.0: +is-stream@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - -is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" + integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" -minimist@1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.3.tgz#3db5c0765545ab8637be71f333a104a965a9ca3f" - integrity sha512-+bMdgqjMN/Z77a6NlY/I3U5LlRDbnmaAk6lDveAPKwSpcPM4tKAuYsvYF8xjhOPXhOYGe/73vVLVez5PW+jqhw== +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -nice-try@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4" +minimist@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== node-fetch@2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== dependencies: - path-key "^2.0.0" - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + path-key "^3.0.0" -path-key@^2.0.0, path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - -semver@5.5.0, semver@^5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab" - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - -signal-exit@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + mimic-fn "^2.1.0" + +outdent@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/outdent/-/outdent-0.8.0.tgz#2ebc3e77bf49912543f1008100ff8e7f44428eb0" + integrity sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +semver@7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" -string-width@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" + shebang-regex "^3.0.0" -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +string-width@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== dependencies: - ansi-regex "^3.0.0" + ansi-regex "^5.0.0" -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== -supports-color@^5.3.0: - version "5.4.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54" +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: - has-flag "^3.0.0" + has-flag "^4.0.0" -which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== dependencies: isexe "^2.0.0" + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== diff --git a/scripts/run-external-tests.js b/scripts/run-external-tests.js deleted file mode 100644 index a318bee710..0000000000 --- a/scripts/run-external-tests.js +++ /dev/null @@ -1,89 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const globby = require("globby"); -const { format } = require("../src/cli-util"); - -function tryFormat(file) { - const content = fs.readFileSync(file, "utf8"); - - try { - format({ "debug-check": true }, content, { - // Allow specifying the parser via an environment variable: - parser: process.env.PARSER, - // Use file extension detection otherwise: - filepath: file, - }); - } catch (error) { - return error; - } - return null; -} - -function runExternalTests(patterns) { - const testFiles = globby.sync(patterns); - - if (testFiles.length === 0) { - throw new Error(`No matching files. Patterns tried: ${patterns.join(" ")}`); - } - - const results = { - good: [], - skipped: [], - bad: [], - }; - - testFiles.forEach((file) => { - const error = tryFormat(file); - - if (error instanceof SyntaxError) { - results.skipped.push({ file, error }); - } else if (error) { - results.bad.push({ file, error }); - } else { - results.good.push({ file }); - } - - process.stderr.write( - `\r${results.good.length} good, ${results.skipped.length} skipped, ${results.bad.length} bad` - ); - }); - - return results; -} - -function run(argv) { - if (argv.length === 0) { - console.error( - [ - "You must provide at least one file or glob for test files!", - "Examples:", - ' node scripts/run-external-tests.js "../TypeScript/tests/**/*.ts"', - ' node scripts/run-external-tests.js "../flow/tests/**/*.js"', - ' PARSER=flow node scripts/run-external-tests.js "../flow/tests/**/*.js"', - ].join("\n") - ); - return 1; - } - - let results = null; - - try { - results = runExternalTests(argv); - } catch (error) { - console.error(`Failed to run external tests.\n${error}`); - return 1; - } - - console.log(""); - console.log( - results.bad.map((data) => `${data.file}\n${data.error}`).join("\n\n\n") - ); - - return 0; -} - -if (require.main === module) { - const exitCode = run(process.argv.slice(2)); - process.exit(exitCode); -} diff --git a/scripts/sync-flow-tests.js b/scripts/sync-flow-tests.js index 94c29fddff..68b0ef0d8c 100644 --- a/scripts/sync-flow-tests.js +++ b/scripts/sync-flow-tests.js @@ -1,14 +1,14 @@ "use strict"; const fs = require("fs"); +const path = require("path"); const flowParser = require("flow-parser"); const globby = require("globby"); -const path = require("path"); const rimraf = require("rimraf"); const DEFAULT_SPEC_CONTENT = "run_spec(__dirname);\n"; const SPEC_FILE_NAME = "jsfmt.spec.js"; -const FLOW_TESTS_DIR = path.join(__dirname, "..", "tests", "flow"); +const FLOW_TESTS_DIR = path.join(__dirname, "..", "tests", "flow-repo"); function tryParse(file, content) { const ast = flowParser.parse(content, { @@ -50,13 +50,13 @@ function syncTests(syncDir) { rimraf.sync(FLOW_TESTS_DIR); - filesToCopy.forEach((file) => { + for (const file of filesToCopy) { const content = fs.readFileSync(file, "utf8"); const parseError = tryParse(file, content); if (parseError) { skipped.push(parseError); - return; + continue; } const newFile = path.join(FLOW_TESTS_DIR, path.relative(syncDir, file)); @@ -67,7 +67,7 @@ function syncTests(syncDir) { fs.mkdirSync(dirname, { recursive: true }); fs.writeFileSync(newFile, content); fs.writeFileSync(specFile, specContent); - }); + } return skipped; } @@ -101,9 +101,9 @@ function run(argv) { "but that's not interesting for Prettier's tests.", "This is the skipped stuff:", "", - ] - .concat(skipped, "") - .join("\n") + ...skipped, + "", + ].join("\n") ); } diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/better-parent-property-check-in-needs-parens.js b/scripts/tools/eslint-plugin-prettier-internal-rules/better-parent-property-check-in-needs-parens.js new file mode 100644 index 0000000000..f39069033d --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/better-parent-property-check-in-needs-parens.js @@ -0,0 +1,98 @@ +"use strict"; + +const path = require("path"); + +const parentPropertyCheckSelector = [ + "FunctionDeclaration", + '[id.name="needsParens"]', + " ", + "BinaryExpression", + ":matches(", + [ + [ + '[left.type="MemberExpression"]', + '[left.object.type="Identifier"]', + '[left.object.name="parent"]', + '[right.type="Identifier"]', + '[right.name="node"]', + ], + [ + '[right.type="MemberExpression"]', + '[right.object.type="Identifier"]', + '[right.object.name="parent"]', + '[left.type="Identifier"]', + '[left.name="node"]', + ], + ] + .map((parts) => parts.join("")) + .join(", "), + ")", +].join(""); + +const nameCheckSelector = [ + "LogicalExpression", + '[right.type="BinaryExpression"]', + '[right.left.type="Identifier"]', + '[right.left.name="name"]', + ":not(", + '[left.type="BinaryExpression"]', + '[left.left.type="Identifier"]', + '[left.left.name="name"]', + ")", +].join(""); + +const MESSAGE_ID_PREFER_NAME_CHECK = "prefer-name-check"; +const MESSAGE_ID_NAME_CHECK_FIRST = "name-check-on-left"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/better-parent-property-check-in-needs-parens.js", + }, + messages: { + [MESSAGE_ID_PREFER_NAME_CHECK]: + "Prefer `name {{operator}} {{propertyText}}` over `parent.{{property}} {{operator}} node`.", + [MESSAGE_ID_NAME_CHECK_FIRST]: + "`name` comparison should be on left side.", + }, + fixable: "code", + }, + create(context) { + if (path.basename(context.getFilename()) !== "needs-parens.js") { + return {}; + } + const sourceCode = context.getSourceCode(); + + return { + [parentPropertyCheckSelector](node) { + const { operator, left, right } = node; + const { property } = [left, right].find( + ({ type }) => type === "MemberExpression" + ); + const propertyText = + property.type === "Identifier" + ? `"${property.name}"` + : sourceCode.getText(property); + + context.report({ + node, + messageId: MESSAGE_ID_PREFER_NAME_CHECK, + data: { + property: sourceCode.getText(property), + propertyText, + operator, + }, + fix: (fixer) => + fixer.replaceText(node, `name ${operator} ${propertyText}`), + }); + }, + [nameCheckSelector](node) { + context.report({ + node, + messageId: MESSAGE_ID_NAME_CHECK_FIRST, + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/consistent-negative-index-access.js b/scripts/tools/eslint-plugin-prettier-internal-rules/consistent-negative-index-access.js new file mode 100644 index 0000000000..50f3049414 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/consistent-negative-index-access.js @@ -0,0 +1,71 @@ +"use strict"; + +const selector = [ + "MemberExpression", + "[computed=true]", + "[optional=false]", + '[property.type="BinaryExpression"]', + '[property.operator="-"]', + '[property.left.type="MemberExpression"]', + "[property.left.optional=false]", + "[property.left.computed=false]", + '[property.left.property.type="Identifier"]', + '[property.left.property.name="length"]', + '[property.right.type="Literal"]', + `:not(${[ + "AssignmentExpression > .left", + "UpdateExpression > .argument", + // Ignore `getPenultimate` and `getLast` function self + 'VariableDeclarator[id.name="getPenultimate"] > ArrowFunctionExpression.init *', + 'VariableDeclarator[id.name="getLast"] > ArrowFunctionExpression.init *', + ].join(", ")})`, +].join(""); + +const messageId = "consistent-negative-index-access"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/consistent-negative-index-access.js", + }, + messages: { + [messageId]: "Prefer `{{method}}(…)` over `…[….length - {{index}}]`.", + }, + fixable: "code", + }, + create(context) { + const sourceCode = context.getSourceCode(); + + return { + [selector](node) { + const { value: index } = node.property.right; + + if (index !== 1 && index !== 2) { + return; + } + + const { object } = node; + const lengthObject = node.property.left.object; + + const objectText = sourceCode.getText(object); + // Simply use text to compare object + if (sourceCode.getText(lengthObject) !== objectText) { + return; + } + + const method = ["getLast", "getPenultimate"][index - 1]; + + context.report({ + node, + messageId, + data: { + index, + method, + }, + fix: (fixer) => fixer.replaceText(node, `${method}(${objectText})`), + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/directly-loc-start-end.js b/scripts/tools/eslint-plugin-prettier-internal-rules/directly-loc-start-end.js new file mode 100644 index 0000000000..024cbc1310 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/directly-loc-start-end.js @@ -0,0 +1,37 @@ +"use strict"; + +const selector = [ + "MemberExpression", + "[computed=false]", + '[property.type="Identifier"]', + ':matches([property.name="locStart"], [property.name="locEnd"])', +].join(""); + +const MESSAGE_ID = "directly-loc-start-end"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/directly-loc-start-end.js", + }, + messages: { + [MESSAGE_ID]: + "Please import `{{function}}` function and use it directly.", + }, + fixable: "code", + }, + create(context) { + return { + [selector](node) { + context.report({ + node, + messageId: MESSAGE_ID, + data: { function: node.property.name }, + fix: (fixer) => + fixer.replaceTextRange([node.range[0], node.property.range[0]], ""), + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/flat-ast-path-call.js b/scripts/tools/eslint-plugin-prettier-internal-rules/flat-ast-path-call.js new file mode 100644 index 0000000000..1352748180 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/flat-ast-path-call.js @@ -0,0 +1,127 @@ +"use strict"; + +// This rule only work for nested `AstPath#call()` for now + +function astPathCallSelector(path) { + const prefix = path ? `${path}.` : ""; + return [ + `[${prefix}type="CallExpression"]`, + `[${prefix}callee.type="MemberExpression"]`, + `[${prefix}callee.property.type="Identifier"]`, + `[${prefix}callee.property.name="call"]`, + `[${prefix}arguments.length>1]`, + `[${prefix}arguments.0.type!="SpreadElement"]`, + ].join(""); +} + +// Matches: +// ``` +// path.call((childPath) => childPath.call(print, "b"), "a") +// ``` +const selector = [ + astPathCallSelector(), + '[arguments.0.type="ArrowFunctionExpression"]', + "[arguments.0.params.length=1]", + '[arguments.0.params.0.type="Identifier"]', + astPathCallSelector("arguments.0.body"), + '[arguments.0.body.callee.object.type="Identifier"]', +].join(""); + +const MESSAGE_ID = "flat-ast-path-call"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/flat-ast-path-call.js", + }, + messages: { + [MESSAGE_ID]: "Do not use nested `AstPath#{{method}}(…)`.", + }, + fixable: "code", + }, + create(context) { + const sourceCode = context.getSourceCode(); + + return { + [selector](outerCall) { + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + const outerCallback = outerCall.arguments[0]; + + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^^^^^^^^^ + const outerCallbackParameterName = outerCallback.params[0].name; + + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^^^^^^^^^^^^^^^^^^^^^^^^^^ + const innerCall = outerCallback.body; + + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^^^^^^^^^ + const innerCallCalleeObjectName = innerCall.callee.object.name; + + if (outerCallbackParameterName !== innerCallCalleeObjectName) { + return; + } + + context.report({ + node: innerCall, + messageId: MESSAGE_ID, + data: { method: "call" }, + *fix(fixer) { + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^^^^^ + const innerCallback = innerCall.arguments[0]; + + yield fixer.replaceTextRange( + [ + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^ + outerCallback.range[0], + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^ + innerCallback.range[0], + ], + "" + ); + + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^ + const innerNamesStart = innerCallback.range[1]; + + // path.call((childPath) => childPath.call(print, "b"), "a") + // ^ + const innerNamesEnd = innerCall.range[1] - 1; + + let innerNamesText = sourceCode.text.slice( + innerNamesStart, + innerNamesEnd + ); + + yield fixer.replaceTextRange( + [innerNamesStart, innerNamesEnd + 1], + "" + ); + + const [penultimateToken, lastToken] = sourceCode.getLastTokens( + outerCall, + 2 + ); + + // `outer` call has `trailing comma` + if ( + penultimateToken && + penultimateToken.type === "Punctuator" && + penultimateToken.value === "," + ) { + innerNamesText = innerNamesText.slice(1); + } + + yield fixer.insertTextBefore(lastToken, innerNamesText); + }, + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/index.js b/scripts/tools/eslint-plugin-prettier-internal-rules/index.js new file mode 100644 index 0000000000..7f941fb016 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/index.js @@ -0,0 +1,21 @@ +"use strict"; + +module.exports = { + rules: { + "better-parent-property-check-in-needs-parens": require("./better-parent-property-check-in-needs-parens"), + "consistent-negative-index-access": require("./consistent-negative-index-access"), + "directly-loc-start-end": require("./directly-loc-start-end"), + "flat-ast-path-call": require("./flat-ast-path-call"), + "jsx-identifier-case": require("./jsx-identifier-case"), + "no-conflicting-comment-check-flags": require("./no-conflicting-comment-check-flags"), + "no-doc-builder-concat": require("./no-doc-builder-concat"), + "no-empty-flat-contents-for-if-break": require("./no-empty-flat-contents-for-if-break"), + "no-identifier-n": require("./no-identifier-n"), + "no-node-comments": require("./no-node-comments"), + "no-unnecessary-ast-path-call": require("./no-unnecessary-ast-path-call"), + "prefer-ast-path-each": require("./prefer-ast-path-each"), + "prefer-indent-if-break": require("./prefer-indent-if-break"), + "prefer-is-non-empty-array": require("./prefer-is-non-empty-array"), + "require-json-extensions": require("./require-json-extensions"), + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/jsx-identifier-case.js b/scripts/tools/eslint-plugin-prettier-internal-rules/jsx-identifier-case.js new file mode 100644 index 0000000000..392a5a329c --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/jsx-identifier-case.js @@ -0,0 +1,45 @@ +"use strict"; + +const MESSAGE_ID = "jsx-identifier-case"; + +// To ignore variables, config eslint like this +// {'prettier-internal-rules/jsx-identifier-case': ['error', 'name1', ... 'nameN']} + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/jsx-identifier-case.js", + }, + messages: { + [MESSAGE_ID]: "Please rename '{{name}}' to '{{fixed}}'.", + }, + fixable: "code", + }, + create(context) { + const ignored = new Set(context.options); + return { + "Identifier[name=/JSX/]:not(ObjectExpression > Property.properties > .key)"( + node + ) { + const { name } = node; + + if (ignored.has(name)) { + return; + } + + const fixed = name.replace(/JSX/g, "Jsx"); + context.report({ + node, + messageId: MESSAGE_ID, + data: { name, fixed }, + fix: (fixer) => fixer.replaceText(node, fixed), + }); + }, + }; + }, + schema: { + type: "array", + uniqueItems: true, + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/no-conflicting-comment-check-flags.js b/scripts/tools/eslint-plugin-prettier-internal-rules/no-conflicting-comment-check-flags.js new file mode 100644 index 0000000000..5b91b5a468 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/no-conflicting-comment-check-flags.js @@ -0,0 +1,86 @@ +"use strict"; +const MESSAGE_ID_UNIQUE = "unique"; +const MESSAGE_ID_CONFLICTING = "conflicting"; + +const conflictingFlags = [ + ["Leading", "Trailing", "Dangling"], + ["Block", "Line"], +]; + +const isCommentCheckFlags = (node) => + node.type === "MemberExpression" && + !node.computed && + !node.optional && + node.object.type === "Identifier" && + node.object.name === "CommentCheckFlags" && + node.property.type === "Identifier"; + +const flatFlags = (node) => { + const flags = []; + const binaryExpressions = [node]; + while (binaryExpressions.length > 0) { + const { left, right } = binaryExpressions.shift(); + for (const node of [left, right]) { + if (node.type === "BinaryExpression" && node.operator === "|") { + binaryExpressions.push(node); + continue; + } + + if (!isCommentCheckFlags(node)) { + return []; + } + + flags.push(node); + } + } + + return flags.map((node) => node.property.name); +}; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/no-conflicting-comment-check-flags.js", + }, + messages: { + [MESSAGE_ID_UNIQUE]: "Do not use same flag multiple times.", + [MESSAGE_ID_CONFLICTING]: "Do not use {{flags}} together.", + }, + }, + create(context) { + return { + ':not(BinaryExpression) > BinaryExpression[operator="|"]'(node) { + const flags = flatFlags(node); + + if (flags.length < 2) { + return; + } + + const uniqueFlags = new Set(flags); + if (uniqueFlags.size !== flags.length) { + context.report({ + node, + messageId: MESSAGE_ID_UNIQUE, + }); + return; + } + + for (const group of conflictingFlags) { + const presentFlags = group.filter((flag) => uniqueFlags.has(flag)); + if (presentFlags.length > 1) { + context.report({ + node, + messageId: MESSAGE_ID_CONFLICTING, + data: { + flags: presentFlags + .map((flag) => `'CommentCheckFlags.${flag}'`) + .join(", "), + }, + }); + } + } + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/no-doc-builder-concat.js b/scripts/tools/eslint-plugin-prettier-internal-rules/no-doc-builder-concat.js new file mode 100644 index 0000000000..b62696b92a --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/no-doc-builder-concat.js @@ -0,0 +1,34 @@ +"use strict"; + +const selector = [ + "CallExpression", + ">", + "Identifier.callee", + '[name="concat"]', +].join(""); + +const messageId = "no-doc-builder-concat"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/no-doc-builder-concat.js", + }, + messages: { + [messageId]: "Use array directly instead of `concat([])`", + }, + fixable: "code", + }, + create(context) { + return { + [selector](node) { + context.report({ + node, + messageId, + fix: (fixer) => fixer.replaceText(node, ""), + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/no-empty-flat-contents-for-if-break.js b/scripts/tools/eslint-plugin-prettier-internal-rules/no-empty-flat-contents-for-if-break.js new file mode 100644 index 0000000000..7c57839249 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/no-empty-flat-contents-for-if-break.js @@ -0,0 +1,41 @@ +"use strict"; + +const selector = [ + "CallExpression", + "[optional=false]", + '[callee.type="Identifier"]', + '[callee.name="ifBreak"]', + "[arguments.length=2]", + '[arguments.1.type="Literal"]', + '[arguments.1.value=""]', + ':not([arguments.0.type="SpreadElement"])', +].join(""); + +const messageId = "no-empty-flat-contents-for-if-break"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/no-empty-flat-contents-for-if-break.js", + }, + messages: { + [messageId]: + "Please don't pass an empty string to second parameter of ifBreak.", + }, + fixable: "code", + }, + create(context) { + return { + [selector](node) { + const [breakContents] = node.arguments; + context.report({ + node, + messageId, + fix: (fixer) => + fixer.removeRange([breakContents.range[1], node.range[1] - 1]), + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/no-identifier-n.js b/scripts/tools/eslint-plugin-prettier-internal-rules/no-identifier-n.js new file mode 100644 index 0000000000..839a2947d3 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/no-identifier-n.js @@ -0,0 +1,90 @@ +"use strict"; + +// eslint-disable-next-line import/no-extraneous-dependencies +const { findVariable } = require("eslint-utils"); +const ERROR = "error"; +const SUGGESTION = "suggestion"; +const selector = [ + "Identifier", + '[name="n"]', + `:not(${[ + "MemberExpression[computed=false] > .property", + "Property[shorthand=false][computed=false] > .key", + ].join(", ")})`, +].join(""); + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/no-identifier-n.js", + }, + messages: { + [ERROR]: "Please rename variable 'n'.", + [SUGGESTION]: "Rename to `node`.", + }, + fixable: "code", + }, + create(context) { + const variables = new Map(); + return { + [selector](node) { + const scope = context.getScope(); + const variable = findVariable(scope, node); + + /* istanbul ignore next */ + if (!variable) { + return; + } + + if (!variables.has(variable)) { + variables.set(variable, { fixable: true }); + } + + const data = variables.get(variable); + if (!data.fixable) { + return; + } + + const nodeVariable = findVariable(scope, "node"); + if (nodeVariable) { + data.fixable = false; + } + }, + "Program:exit"() { + for (const [variable, { fixable }] of variables.entries()) { + const [node] = variable.identifiers; + + const fix = function* (fixer) { + const identifiers = new Set([ + ...variable.identifiers, + ...variable.references.map((reference) => reference.identifier), + ]); + + for (const identifier of identifiers) { + const { parent } = identifier; + if ( + parent && + parent.type === "Property" && + parent.shorthand && + parent.key === identifier + ) { + yield fixer.replaceText(identifier, "n: node"); + } else { + yield fixer.replaceText(identifier, "node"); + } + } + }; + + const problem = { node, messageId: ERROR }; + if (fixable) { + problem.fix = fix; + } else { + problem.suggest = [{ messageId: SUGGESTION, fix }]; + } + context.report(problem); + } + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/no-node-comments.js b/scripts/tools/eslint-plugin-prettier-internal-rules/no-node-comments.js new file mode 100644 index 0000000000..e969d96d63 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/no-node-comments.js @@ -0,0 +1,97 @@ +"use strict"; +const path = require("path"); + +// `node.comments` +const memberExpressionSelector = [ + "MemberExpression[computed=false]", + "", + ">", + "Identifier.property", + '[name="comments"]', +].join(""); + +// `const {comments} = node` +// `const {comments: nodeComments} = node` +const objectPatternSelector = [ + "ObjectPattern", + ">", + "Property.properties", + ">", + "Identifier.key", + '[name="comments"]', +].join(""); + +const selector = `:matches(${memberExpressionSelector}, ${objectPatternSelector})`; + +const messageId = "no-node-comments"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/no-node-comments.js", + }, + messages: { + [messageId]: "Do not access node.comments.", + }, + }, + create(context) { + const fileName = context.getFilename(); + // [prettierx]: support npm for dev install + const parentDir = path.basename(path.resolve(__dirname, "..")); + const isLinked = parentDir !== "node_modules"; + const projectRoot = isLinked ? "../../.." : "../.."; + + const ignored = new Map( + context.options.map((option) => { + if (typeof option === "string") { + option = { file: option }; + } + const { file, functions } = option; + return [ + // [prettierx]: support npm for dev install + path.join(__dirname, projectRoot, file), + functions ? new Set(functions) : true, + ]; + }) + ); + // avoid report on `const {comments} = node` twice + const reported = new Set(); + return { + [selector](node) { + if (reported.has(node)) { + return; + } + + if (ignored.has(fileName)) { + const functionNames = ignored.get(fileName); + if (functionNames === true) { + return; + } + let isIgnored; + let currentNode = node.parent; + while (currentNode) { + if ( + currentNode.type === "FunctionDeclaration" && + currentNode.id && + currentNode.id.type === "Identifier" && + functionNames.has(currentNode.id.name) + ) { + isIgnored = true; + break; + } + currentNode = currentNode.parent; + } + if (isIgnored) { + return; + } + } + reported.add(node); + context.report({ + node, + messageId, + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/no-unnecessary-ast-path-call.js b/scripts/tools/eslint-plugin-prettier-internal-rules/no-unnecessary-ast-path-call.js new file mode 100644 index 0000000000..9b46fafea2 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/no-unnecessary-ast-path-call.js @@ -0,0 +1,56 @@ +"use strict"; + +const selector = [ + "CallExpression", + "[optional=false]", + '[callee.type="MemberExpression"]', + "[callee.computed=false]", + "[callee.optional=false]", + '[callee.property.type="Identifier"]', + '[callee.property.name="call"]', + "[arguments.length=1]", + '[arguments.0.type!="SpreadElement"]', +].join(""); + +const messageId = "no-unnecessary-ast-path-call"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/no-unnecessary-ast-path-call.js", + }, + messages: { + [messageId]: "Do not use `AstPath.call()` with one argument.", + }, + fixable: "code", + }, + create(context) { + const sourceCode = context.getSourceCode(); + + return { + [selector](node) { + const problem = { + node, + messageId, + }; + + const [callback] = node.arguments; + + // Don't fix to IIFE + if ( + callback.type !== "ArrowFunctionExpression" && + callback.type !== "FunctionExpression" + ) { + problem.fix = function (fixer) { + const callbackText = sourceCode.getText(callback); + const astPathText = sourceCode.getText(node.callee.object); + return fixer.replaceText(node, `${callbackText}(${astPathText})`); + }; + } + + context.report(problem); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/package.json b/scripts/tools/eslint-plugin-prettier-internal-rules/package.json new file mode 100644 index 0000000000..aaee2c7dcc --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/package.json @@ -0,0 +1,13 @@ +{ + "name": "eslint-plugin-prettier-internal-rules", + "version": "1.0.3", + "description": "Prettier internal eslint rules", + "private": true, + "author": "fisker", + "main": "./index.js", + "license": "MIT", + "scripts": { + "test": "node test.js", + "test-coverage": "npx nyc node test.js" + } +} diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-ast-path-each.js b/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-ast-path-each.js new file mode 100644 index 0000000000..b9685cf89f --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-ast-path-each.js @@ -0,0 +1,41 @@ +"use strict"; + +const selector = [ + "ExpressionStatement", + ">", + "CallExpression.expression", + "[optional=false]", + ">", + "MemberExpression.callee", + "[computed=false]", + "[optional=false]", + ">", + "Identifier.property", + '[name="map"]', +].join(""); + +const messageId = "prefer-ast-path-each"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/require-json-extensions.js", + }, + messages: { + [messageId]: "Prefer `AstPath#each()` over `AstPath#map()`.", + }, + fixable: "code", + }, + create(context) { + return { + [selector](node) { + context.report({ + node, + messageId, + fix: (fixer) => fixer.replaceText(node, "each"), + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-indent-if-break.js b/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-indent-if-break.js new file mode 100644 index 0000000000..7cf0ced687 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-indent-if-break.js @@ -0,0 +1,66 @@ +"use strict"; + +const selector = [ + "CallExpression", + "[optional=false]", + '[callee.type="Identifier"]', + '[callee.name="ifBreak"]', + "[arguments.length=3]", + '[arguments.0.type="CallExpression"]', + "[arguments.0.optional=false]", + '[arguments.0.callee.type="Identifier"]', + '[arguments.0.callee.name="indent"]', + "[arguments.0.arguments.length=1]", + '[arguments.0.arguments.0.type!="SpreadElement"]', + '[arguments.1.type!="SpreadElement"]', + '[arguments.2.type!="SpreadElement"]', +].join(""); + +const messageId = "prefer-indent-if-break"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-indent-if-break.js", + }, + messages: { + [messageId]: "Prefer `indentIfBreak(…)` over `ifBreak(indent(…), …)`.", + }, + fixable: "code", + }, + create(context) { + const sourceCode = context.getSourceCode(); + + return { + [selector](node) { + const indentDoc = node.arguments[0].arguments[0]; + const doc = node.arguments[1]; + + // Use text to compare same `doc` + if (sourceCode.getText(indentDoc) !== sourceCode.getText(doc)) { + return; + } + + context.report({ + node, + messageId, + *fix(fixer) { + yield fixer.replaceText(node.callee, "indentIfBreak"); + const openingParenthesisToken = sourceCode.getTokenAfter( + node.callee + ); + const commaToken = sourceCode.getTokenBefore( + doc, + ({ type, value }) => type === "Punctuator" && value === "," + ); + yield fixer.replaceTextRange( + [openingParenthesisToken.range[1], commaToken.range[1]], + "" + ); + }, + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-is-non-empty-array.js b/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-is-non-empty-array.js new file mode 100644 index 0000000000..6b4a8e81bf --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-is-non-empty-array.js @@ -0,0 +1,151 @@ +"use strict"; + +const getLengthSelector = (path) => + `[${path}.type="MemberExpression"][${path}.property.type="Identifier"][${path}.property.name="length"]`; +const selector = [ + "LogicalExpression", + ':not(FunctionDeclaration[id.name="isNonEmptyArray"] *)', + '[operator="&&"]', + `:matches(${[ + // `&& foo.length` + getLengthSelector("right"), + // `&& foo.length !== 0` + // `&& foo.length > 0` + [ + '[right.type="BinaryExpression"]', + ':matches([right.operator="!=="], [right.operator=">"])', + getLengthSelector("right.left"), + '[right.right.type="Literal"]', + '[right.right.raw="0"]', + ].join(""), + ].join(", ")})`, +].join(""); + +const negativeSelector = [ + "LogicalExpression", + '[operator="||"]', + `:matches(${[ + // `|| !foo.length` + [ + '[right.type="UnaryExpression"]', + '[right.operator="!"]', + getLengthSelector("right.argument"), + ].join(""), + // `|| foo.length === 0` + [ + '[right.type="BinaryExpression"]', + '[right.operator="==="]', + getLengthSelector("right.left"), + '[right.right.type="Literal"]', + '[right.right.raw="0"]', + ].join(""), + ].join(", ")})`, +].join(""); + +const isArrayIsArrayCall = (node) => + node.type === "CallExpression" && + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "Array" && + node.callee.property.type === "Identifier" && + node.callee.property.name === "isArray"; + +const MESSAGE_ID = "prefer-is-non-empty-array"; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/prefer-is-non-empty-array.js", + }, + messages: { + [MESSAGE_ID]: "Please use `isNonEmptyArray()`.", + }, + fixable: "code", + }, + create(context) { + const sourceCode = context.getSourceCode(); + + return { + [selector](node) { + let { left, right } = node; + + while (left.type === "LogicalExpression" && left.operator === "&&") { + left = left.right; + } + + let leftObject = left; + // `Array.isArray(foo)` + if (isArrayIsArrayCall(leftObject)) { + leftObject = leftObject.arguments[0]; + } + + const rightObject = + right.type === "BinaryExpression" ? right.left.object : right.object; + const objectText = sourceCode.getText(rightObject); + // Simple compare with code + if (sourceCode.getText(leftObject) !== objectText) { + return; + } + + const [start] = left.range; + const [, end] = node.range; + context.report({ + loc: { + start: sourceCode.getLocFromIndex(start), + end: sourceCode.getLocFromIndex(end), + }, + messageId: MESSAGE_ID, + fix(fixer) { + return fixer.replaceTextRange( + [start, end], + `isNonEmptyArray(${objectText})` + ); + }, + }); + }, + [negativeSelector](node) { + let { left, right } = node; + + while (left.type === "LogicalExpression" && left.operator === "||") { + left = left.right; + } + + if (left.type !== "UnaryExpression" || left.operator !== "!") { + return; + } + + const rightObject = + right.type === "UnaryExpression" + ? right.argument.object + : right.left.object; + let leftObject = left.argument; + if (isArrayIsArrayCall(leftObject)) { + leftObject = leftObject.arguments[0]; + } + + const objectText = sourceCode.getText(rightObject); + // Simple compare with code + if (sourceCode.getText(leftObject) !== objectText) { + return; + } + + const [start] = left.range; + const [, end] = node.range; + context.report({ + loc: { + start: sourceCode.getLocFromIndex(start), + end: sourceCode.getLocFromIndex(end), + }, + messageId: MESSAGE_ID, + fix(fixer) { + return fixer.replaceTextRange( + [start, end], + `!isNonEmptyArray(${objectText})` + ); + }, + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/require-json-extensions.js b/scripts/tools/eslint-plugin-prettier-internal-rules/require-json-extensions.js new file mode 100644 index 0000000000..80125b6e1c --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/require-json-extensions.js @@ -0,0 +1,77 @@ +"use strict"; + +const path = require("path"); + +const SELECTOR = [ + "CallExpression", + '[callee.type="Identifier"]', + '[callee.name="require"]', + "[arguments.length=1]", + '[arguments.0.type="Literal"]', + ">", + "Literal.arguments", +].join(""); + +const MESSAGE_ID = "require-json-extensions"; + +const resolveModuleInDirectory = (directory, id) => { + try { + return require.resolve(id, { paths: [directory] }); + } catch { + // noop + } +}; + +module.exports = { + meta: { + type: "suggestion", + docs: { + url: "https://github.com/prettier/prettier/blob/main/scripts/tools/eslint-plugin-prettier-internal-rules/require-json-extensions.js", + }, + messages: { + [MESSAGE_ID]: 'Missing file extension ".json" for "{{id}}".', + }, + fixable: "code", + }, + create(context) { + const filename = context.getFilename(); + const directory = path.dirname(filename); + const resolve = (id) => resolveModuleInDirectory(directory, id); + + return { + [SELECTOR](node) { + const id = node.value; + + if (id.endsWith(".json") || !id.includes("/")) { + return; + } + + const file = resolve(id); + + if (!file) { + return; + } + + const extension = path.extname(file); + if (extension !== ".json") { + return; + } + + let fix; + if (resolve(`${id}.json`) === file) { + fix = (fixer) => { + const [start, end] = node.range; + return fixer.replaceTextRange([start + 1, end - 1], `${id}.json`); + }; + } + + context.report({ + node, + messageId: MESSAGE_ID, + data: { id }, + fix, + }); + }, + }; + }, +}; diff --git a/scripts/tools/eslint-plugin-prettier-internal-rules/test.js b/scripts/tools/eslint-plugin-prettier-internal-rules/test.js new file mode 100644 index 0000000000..0938d27421 --- /dev/null +++ b/scripts/tools/eslint-plugin-prettier-internal-rules/test.js @@ -0,0 +1,498 @@ +/* eslint-disable import/no-extraneous-dependencies */ +"use strict"; + +const path = require("path"); +const { outdent } = require("outdent"); +const { RuleTester } = require("eslint"); +const { rules } = require("."); + +const test = (ruleId, tests) => { + new RuleTester({ parserOptions: { ecmaVersion: 2021 } }).run( + ruleId, + rules[ruleId], + tests + ); +}; + +test("better-parent-property-check-in-needs-parens", { + valid: ["function needsParens() {return parent.test === node;}"], + invalid: [ + { + code: 'return parent.type === "MemberExpression" && name === "object";', + errors: [{ message: "`name` comparison should be on left side." }], + }, + { + code: "return parent.test === node;", + output: 'return name === "test";', + errors: [ + { message: 'Prefer `name === "test"` over `parent.test === node`.' }, + ], + }, + { + code: "return parent.test !== node;", + output: 'return name !== "test";', + errors: [ + { message: 'Prefer `name !== "test"` over `parent.test !== node`.' }, + ], + }, + { + code: 'return parent["property"] === node;', + output: 'return name === "property";', + errors: [ + { + message: + 'Prefer `name === "property"` over `parent."property" === node`.', + }, + ], + }, + ].map((testCase) => ({ + ...testCase, + code: `function needsParens() {${testCase.code}}`, + output: `function needsParens() {${testCase.output || testCase.code}}`, + filename: "needs-parens.js", + })), +}); + +test("consistent-negative-index-access", { + valid: [ + "getLast(foo)", + "getPenultimate(foo)", + "foo[foo.length]", + "foo[foo.length - 3]", + "foo[foo.length + 1]", + "foo[foo.length + -1]", + "foo[foo.length * -1]", + "foo.length - 1", + "foo?.[foo.length - 1]", + "foo[foo?.length - 1]", + "foo[foo['length'] - 1]", + "foo[bar.length - 1]", + "foo.bar[foo. bar.length - 1]", + "foo[foo.length - 1]++", + "--foo[foo.length - 1]", + "foo[foo.length - 1] += 1", + "foo[foo.length - 1] = 1", + ], + invalid: [ + { + code: "foo[foo.length - 1]", + output: "getLast(foo)", + errors: 1, + }, + { + code: "foo[foo.length - 2]", + output: "getPenultimate(foo)", + errors: 1, + }, + { + code: "foo[foo.length - 0b10]", + output: "getPenultimate(foo)", + errors: 1, + }, + { + code: "foo()[foo().length - 1]", + output: "getLast(foo())", + errors: 1, + }, + ], +}); + +test("directly-loc-start-end", { + valid: [], + invalid: [ + { + code: "options.locStart(node)", + output: "locStart(node)", + errors: [ + { message: "Please import `locStart` function and use it directly." }, + ], + }, + { + code: "options.locEnd(node)", + output: "locEnd(node)", + errors: [ + { message: "Please import `locEnd` function and use it directly." }, + ], + }, + ], +}); + +test("flat-ast-path-call", { + valid: [ + 'path.call((childPath) => childPath.notCall(print, "b"), "a")', + 'path.notCall((childPath) => childPath.call(print, "b"), "a")', + 'path.call((childPath) => childPath.call(print, "b"))', + 'path.call((childPath) => childPath.call(print), "a")', + 'path.call((childPath) => notChildPath.call(print), "a")', + 'path.call(functionReference, "a")', + 'path.call((childPath) => notChildPath.call(print, "b"), "a")', + // Only check `arrow function` + 'path.call((childPath) => {return childPath.call(print, "b")}, "a")', + 'path.call(function(childPath) {return childPath.call(print, "b")}, "a")', + ], + invalid: [ + { + code: 'path.call((childPath) => childPath.call(print, "b"), "a")', + output: 'path.call(print, "a", "b")', + errors: [{ message: "Do not use nested `AstPath#call(…)`." }], + }, + { + // Trailing comma + code: 'path.call((childPath) => childPath.call(print, "b"), "a",)', + output: 'path.call(print, "a", "b")', + errors: 1, + }, + ], +}); + +test("jsx-identifier-case", { + valid: [ + { + code: "const isJSXNode = true", + options: ["isJSXNode"], + }, + ], + invalid: [ + { + code: "function isJSXNode(){}", + output: "function isJsxNode(){}", + errors: [{ message: "Please rename 'isJSXNode' to 'isJsxNode'." }], + }, + { + code: "const isJSXNode = true", + output: "const isJsxNode = true", + errors: [{ message: "Please rename 'isJSXNode' to 'isJsxNode'." }], + }, + ], +}); + +test("no-conflicting-comment-check-flags", { + valid: [ + "CommentCheckFlags.Leading", + "NotCommentCheckFlags.Leading | NotCommentCheckFlags.Trailing", + "CommentCheckFlags.Leading | CommentCheckFlags.Trailing | SOMETHING_ELSE", + "CommentCheckFlags.Leading & CommentCheckFlags.Trailing", + ], + invalid: [ + { + code: "CommentCheckFlags.Leading | CommentCheckFlags.Trailing", + output: null, + errors: [ + { + message: + "Do not use 'CommentCheckFlags.Leading', 'CommentCheckFlags.Trailing' together.", + }, + ], + }, + { + code: "(CommentCheckFlags.Leading | CommentCheckFlags.Trailing) | CommentCheckFlags.Dangling", + output: null, + errors: [ + { + message: + "Do not use 'CommentCheckFlags.Leading', 'CommentCheckFlags.Trailing', 'CommentCheckFlags.Dangling' together.", + }, + ], + }, + { + code: "CommentCheckFlags.Leading | CommentCheckFlags.Trailing | CommentCheckFlags.UNKNOWN", + output: null, + errors: [ + { + message: + "Do not use 'CommentCheckFlags.Leading', 'CommentCheckFlags.Trailing' together.", + }, + ], + }, + { + code: "CommentCheckFlags.Block | CommentCheckFlags.Line | CommentCheckFlags.UNKNOWN", + output: null, + errors: [ + { + message: + "Do not use 'CommentCheckFlags.Block', 'CommentCheckFlags.Line' together.", + }, + ], + }, + { + code: "CommentCheckFlags.Block | CommentCheckFlags.Block", + output: null, + errors: [ + { + message: "Do not use same flag multiple times.", + }, + ], + }, + ], +}); + +test("no-doc-builder-concat", { + valid: ["notConcat([])", "concat", "[].concat([])"], + invalid: [ + { + code: "concat(parts)", + output: "(parts)", + errors: 1, + }, + { + code: "concat(['foo', line])", + output: "(['foo', line])", + errors: 1, + }, + ], +}); + +test("no-identifier-n", { + valid: ["const a = {n: 1}", "const m = 1", "a.n = 1"], + invalid: [ + { + code: "const n = 1; alet(n)", + output: "const node = 1; alet(node)", + errors: 1, + }, + { + code: "const n = 1; alert({n})", + output: "const node = 1; alert({n: node})", + errors: 1, + }, + { + code: "const {n} = 1; alert(n)", + output: "const {n: node} = 1; alert(node)", + errors: 1, + }, + { + code: outdent` + const n = 1; + function a(node) { + alert(n, node) + } + function b() { + alert(n) + } + `, + output: outdent` + const n = 1; + function a(node) { + alert(n, node) + } + function b() { + alert(n) + } + `, + errors: [ + { + suggestions: [ + { + output: outdent` + const node = 1; + function a(node) { + alert(node, node) + } + function b() { + alert(node) + } + `, + }, + ], + }, + ], + }, + { + code: "const n = 1;const node = 2;", + output: "const n = 1;const node = 2;", + errors: [{ suggestions: [{ output: "const node = 1;const node = 2;" }] }], + }, + ], +}); + +test("no-node-comments", { + valid: [ + "const comments = node.notComments", + { + code: "function functionName() {return node.comments;}", + filename: path.join(__dirname, "../../..", "a.js"), + options: ["a.js"], + }, + { + code: "function functionName() {return node.comments;}", + filename: path.join(__dirname, "../../..", "a.js"), + options: [{ file: "a.js", functions: ["functionName"] }], + }, + ], + invalid: [ + ...[ + "function functionName() {return node.comments;}", + "const {comments} = node", + "const {comments: nodeComments} = node", + ].map((code) => ({ + code, + output: code, + errors: [{ message: "Do not access node.comments." }], + })), + { + code: "function notFunctionName() {return node.comments;}", + output: "function notFunctionName() {return node.comments;}", + filename: path.join(__dirname, "../../..", "a.js"), + options: [{ file: "a.js", functions: ["functionName"] }], + errors: [{ message: "Do not access node.comments." }], + }, + ], +}); + +test("prefer-ast-path-each", { + valid: ["const foo = path.map()"], + invalid: [ + { + code: "path.map()", + output: "path.each()", + errors: 1, + }, + ], +}); + +test("prefer-indent-if-break", { + valid: [ + "ifBreak(indent(doc))", + "notIfBreak(indent(doc), doc, options)", + "ifBreak(indent(doc), doc, )", + "ifBreak(...a, ...b, ...c)", + "ifBreak(notIndent(doc), doc, options)", + "ifBreak(indent(doc), notSameDoc, options)", + "ifBreak(indent(...a), a, options)", + "ifBreak(indent(a, b), a, options)", + ], + invalid: [ + { + code: "ifBreak(indent(doc), doc, options)", + output: "indentIfBreak( doc, options)", + errors: [ + { + message: "Prefer `indentIfBreak(…)` over `ifBreak(indent(…), …)`.", + }, + ], + }, + { + code: "ifBreak((indent(doc)), (doc), options)", + output: "indentIfBreak( (doc), options)", + errors: 1, + }, + ], +}); + +test("prefer-is-non-empty-array", { + valid: [ + // `isNonEmptyArray` self is ignored + outdent` + function isNonEmptyArray(object){ + return Array.isArray(object) && object.length; + } + `, + "a.b && a.c.length", + "a.b || !a.b.length", + '!a["b"] || !a.b.length', + ], + invalid: [ + ...[ + "a && a.b && a.b.length", + "a && a.b && a.b.length !== 0", + "a && a.b && a.b.length > 0", + "a && Array.isArray(a.b) && a.b.length", + "a && Array.isArray(a.b) && a.b.length !== 0", + "a && Array.isArray(a.b) && a.b.length > 0", + ].map((code) => ({ + code, + output: "a && isNonEmptyArray(a.b)", + errors: 1, + })), + ...[ + "!a || !a.b || !a.b.length", + "!a || !a.b || a.b.length === 0", + "!a || !Array.isArray(a.b) || !a.b.length", + "!a || !Array.isArray(a.b) || a.b.length === 0", + ].map((code) => ({ + code, + output: "!a || !isNonEmptyArray(a.b)", + errors: 1, + })), + ], +}); + +test("require-json-extensions", { + valid: ['require("./not-exists")', 'require("./index")'], + invalid: [ + { + code: 'require("./package")', + filename: __filename, + output: 'require("./package.json")', + errors: [{ message: 'Missing file extension ".json" for "./package".' }], + }, + ], +}); + +test("no-empty-flat-contents-for-if-break", { + valid: [ + "ifBreak('foo', 'bar')", + "ifBreak(doc1, doc2)", + "ifBreak(',')", + "ifBreak(doc)", + "ifBreak('foo', '', { groupId })", + "ifBreak(...foo, { groupId })", + ], + invalid: [ + { + code: "ifBreak('foo', '')", + output: "ifBreak('foo')", + errors: [ + { + message: + "Please don't pass an empty string to second parameter of ifBreak.", + }, + ], + }, + { + code: "ifBreak('foo' , '' )", + output: "ifBreak('foo')", + errors: [ + { + message: + "Please don't pass an empty string to second parameter of ifBreak.", + }, + ], + }, + { + code: "ifBreak(doc, '')", + output: "ifBreak(doc)", + errors: [ + { + message: + "Please don't pass an empty string to second parameter of ifBreak.", + }, + ], + }, + ], +}); + +test("no-unnecessary-ast-path-call", { + valid: [ + "call(foo)", + 'foo["call"](bar)', + "foo.call?.(bar)", + "foo?.call(bar)", + "foo.call(bar, name)", + "foo.notCall(bar)", + "foo.call(...bar)", + "foo.call()", + ], + invalid: [ + { + code: "foo.call(bar)", + output: "bar(foo)", + errors: 1, + }, + { + code: "foo.call(() => bar)", + output: "foo.call(() => bar)", + errors: 1, + }, + ], +}); diff --git a/src/cli/constant.js b/src/cli/constant.js index ec0bd9822d..e84d29beb5 100644 --- a/src/cli/constant.js +++ b/src/cli/constant.js @@ -1,7 +1,7 @@ "use strict"; -const dedent = require("dedent"); -const coreOptions = require("../main/core-options"); +const { outdent } = require("outdent"); +const { coreOptions } = require("./prettier-internal"); const categoryOrder = [ coreOptions.CATEGORY_OUTPUT, @@ -74,7 +74,7 @@ const options = { type: "boolean", category: coreOptions.CATEGORY_OUTPUT, alias: "c", - description: dedent` + description: outdent` Check if the given files are formatted, print a human-friendly summary message and paths to unformatted files (see also --list-different). `, @@ -112,7 +112,7 @@ const options = { }, { value: "prefer-file", - description: dedent` + description: outdent` If a config file is found will evaluate it and ignore other CLI options. If no config file is found CLI options will evaluate as normal. `, @@ -132,6 +132,9 @@ const options = { "debug-print-doc": { type: "boolean", }, + "debug-print-comments": { + type: "boolean", + }, "debug-repeat": { // Repeat the formatting a few times and measure the average duration. type: "int", @@ -145,6 +148,10 @@ const options = { "Don't take .editorconfig into account when parsing configuration.", default: true, }, + "error-on-unmatched-pattern": { + type: "boolean", + oppositeDescription: "Prevent errors when pattern is unmatched.", + }, "find-config-path": { type: "path", category: coreOptions.CATEGORY_CONFIG, @@ -153,7 +160,7 @@ const options = { }, "file-info": { type: "path", - description: dedent` + description: outdent` Extract the following info (as JSON) for a given file path. Reported fields: * ignored (boolean) - true if file path is filtered by --ignore-path * inferredParser (string | null) - name of parser inferred from file path @@ -162,7 +169,7 @@ const options = { help: { type: "flag", alias: "h", - description: dedent` + description: outdent` Show CLI usage, or details about the given flag. Example: --help write `, @@ -174,6 +181,11 @@ const options = { default: ".prettierignore", description: "Path to a file with patterns describing files to ignore.", }, + "ignore-unknown": { + type: "boolean", + alias: "u", + description: "Ignore unknown files.", + }, "list-different": { type: "boolean", category: coreOptions.CATEGORY_OUTPUT, @@ -203,13 +215,14 @@ const options = { }, write: { type: "boolean", + alias: "w", category: coreOptions.CATEGORY_OUTPUT, description: "Edit files in-place. (Beware!)", }, }; // [prettierx] -const usageSummary = dedent` +const usageSummary = outdent` Usage: prettierx [options] [file/dir/glob ...] By default, output is written to stdout. diff --git a/src/cli/context.js b/src/cli/context.js new file mode 100644 index 0000000000..284e30c2f7 --- /dev/null +++ b/src/cli/context.js @@ -0,0 +1,134 @@ +"use strict"; +const pick = require("lodash/pick"); + +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); +const { + optionsModule, + optionsNormalizer: { normalizeCliOptions }, + utils: { arrayify }, +} = require("./prettier-internal"); +const minimist = require("./minimist"); +const constant = require("./constant"); +const { + createDetailedOptionMap, + normalizeDetailedOptionMap, +} = require("./option-map"); +const createMinimistOptions = require("./create-minimist-options"); + +/** + * @typedef {Object} Context + * @property logger + * @property {string[]} rawArguments + * @property argv + * @property {string[]} filePatterns + * @property {any[]} supportOptions + * @property detailedOptions + * @property detailedOptionMap + * @property apiDefaultOptions + * @property languages + * @property {Partial[]} stack + * @property pushContextPlugins + * @property popContextPlugins + */ + +class Context { + constructor({ rawArguments, logger }) { + this.rawArguments = rawArguments; + this.logger = logger; + this.stack = []; + + const { plugin: plugins, "plugin-search-dir": pluginSearchDirs } = + parseArgvWithoutPlugins(rawArguments, logger, [ + "plugin", + "plugin-search-dir", + ]); + + this.pushContextPlugins(plugins, pluginSearchDirs); + + const argv = parseArgv(rawArguments, this.detailedOptions, logger); + this.argv = argv; + this.filePatterns = argv._.map((file) => String(file)); + } + + /** + * @param {string[]} plugins + * @param {string[]=} pluginSearchDirs + */ + pushContextPlugins(plugins, pluginSearchDirs) { + this.stack.push( + pick(this, [ + "supportOptions", + "detailedOptions", + "detailedOptionMap", + "apiDefaultOptions", + "languages", + ]) + ); + + Object.assign(this, getContextOptions(plugins, pluginSearchDirs)); + } + + popContextPlugins() { + Object.assign(this, this.stack.pop()); + } +} + +function getContextOptions(plugins, pluginSearchDirs) { + const { options: supportOptions, languages } = prettier.getSupportInfo({ + showDeprecated: true, + showUnreleased: true, + showInternal: true, + plugins, + pluginSearchDirs, + }); + const detailedOptionMap = normalizeDetailedOptionMap({ + ...createDetailedOptionMap(supportOptions), + ...constant.options, + }); + + const detailedOptions = arrayify(detailedOptionMap, "name"); + + const apiDefaultOptions = { + ...optionsModule.hiddenDefaults, + ...Object.fromEntries( + supportOptions + .filter(({ deprecated }) => !deprecated) + .map((option) => [option.name, option.default]) + ), + }; + + return { + supportOptions, + detailedOptions, + detailedOptionMap, + apiDefaultOptions, + languages, + }; +} + +function parseArgv(rawArguments, detailedOptions, logger, keys) { + const minimistOptions = createMinimistOptions(detailedOptions); + let argv = minimist(rawArguments, minimistOptions); + + if (keys) { + detailedOptions = detailedOptions.filter((option) => + keys.includes(option.name) + ); + argv = pick(argv, keys); + } + + return normalizeCliOptions(argv, detailedOptions, { logger }); +} + +const detailedOptionsWithoutPlugins = getContextOptions().detailedOptions; +function parseArgvWithoutPlugins(rawArguments, logger, keys) { + return parseArgv( + rawArguments, + detailedOptionsWithoutPlugins, + logger, + typeof keys === "string" ? [keys] : keys + ); +} + +module.exports = { Context, parseArgvWithoutPlugins }; diff --git a/src/cli/core.js b/src/cli/core.js new file mode 100644 index 0000000000..2b9ca19ed9 --- /dev/null +++ b/src/cli/core.js @@ -0,0 +1,59 @@ +"use strict"; + +const path = require("path"); + +const stringify = require("fast-json-stable-stringify"); + +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); + +const { format, formatStdin, formatFiles } = require("./format"); +const { Context, parseArgvWithoutPlugins } = require("./context"); +const { + normalizeDetailedOptionMap, + createDetailedOptionMap, +} = require("./option-map"); +const { createDetailedUsage, createUsage } = require("./usage"); +const { createLogger } = require("./logger"); + +async function logResolvedConfigPathOrDie(context) { + const file = context.argv["find-config-path"]; + const configFile = await prettier.resolveConfigFile(file); + if (configFile) { + context.logger.log(path.relative(process.cwd(), configFile)); + } else { + throw new Error(`Can not find configure file for "${file}"`); + } +} + +async function logFileInfoOrDie(context) { + const options = { + ignorePath: context.argv["ignore-path"], + withNodeModules: context.argv["with-node-modules"], + plugins: context.argv.plugin, + pluginSearchDirs: context.argv["plugin-search-dir"], + resolveConfig: context.argv.config !== false, + }; + + context.logger.log( + prettier.format( + stringify(await prettier.getFileInfo(context.argv["file-info"], options)), + { parser: "json" } + ) + ); +} + +module.exports = { + Context, + createDetailedOptionMap, + createDetailedUsage, + createUsage, + format, + formatFiles, + formatStdin, + logResolvedConfigPathOrDie, + logFileInfoOrDie, + normalizeDetailedOptionMap, + parseArgvWithoutPlugins, + createLogger, +}; diff --git a/src/cli/create-minimist-options.js b/src/cli/create-minimist-options.js new file mode 100644 index 0000000000..baaa4cf461 --- /dev/null +++ b/src/cli/create-minimist-options.js @@ -0,0 +1,35 @@ +"use strict"; + +const partition = require("lodash/partition"); + +module.exports = function createMinimistOptions(detailedOptions) { + const [boolean, string] = partition( + detailedOptions, + ({ type }) => type === "boolean" + ).map((detailedOptions) => + detailedOptions.flatMap(({ name, alias }) => + alias ? [name, alias] : [name] + ) + ); + + const defaults = Object.fromEntries( + detailedOptions + .filter( + (option) => + !option.deprecated && + (!option.forwardToApi || + option.name === "plugin" || + option.name === "plugin-search-dir") && + option.default !== undefined + ) + .map((option) => [option.name, option.default]) + ); + + return { + // we use vnopts' AliasSchema to handle aliases for better error messages + alias: {}, + boolean, + string, + default: defaults, + }; +}; diff --git a/src/cli/expand-patterns.js b/src/cli/expand-patterns.js index 846f831e79..8e2e4d7297 100644 --- a/src/cli/expand-patterns.js +++ b/src/cli/expand-patterns.js @@ -1,21 +1,20 @@ "use strict"; const path = require("path"); -const fs = require("fs"); +const { promises: fs } = require("fs"); const fastGlob = require("fast-glob"); -const flat = require("lodash/flatten"); -/** @typedef {import('./util').Context} Context */ +/** @typedef {import('./context').Context} Context */ /** * @param {Context} context */ -function* expandPatterns(context) { +async function* expandPatterns(context) { const cwd = process.cwd(); const seen = new Set(); let noResults = true; - for (const pathOrError of expandPatternsInternal(context)) { + for await (const pathOrError of expandPatternsInternal(context)) { noResults = false; if (typeof pathOrError !== "string") { yield pathOrError; @@ -33,7 +32,7 @@ function* expandPatterns(context) { yield relativePath; } - if (noResults) { + if (noResults && context.argv["error-on-unmatched-pattern"] !== false) { // If there was no files and no other errors, let's yield a general error. yield { error: `No matching files. Patterns: ${context.filePatterns.join(" ")}`, @@ -44,20 +43,15 @@ function* expandPatterns(context) { /** * @param {Context} context */ -function* expandPatternsInternal(context) { +async function* expandPatternsInternal(context) { // Ignores files in version control systems directories and `node_modules` - const silentlyIgnoredDirs = { - ".git": true, - ".svn": true, - ".hg": true, - node_modules: context.argv["with-node-modules"] !== true, - }; - + const silentlyIgnoredDirs = [".git", ".svn", ".hg"]; + if (context.argv["with-node-modules"] !== true) { + silentlyIgnoredDirs.push("node_modules"); + } const globOptions = { dot: true, - ignore: Object.keys(silentlyIgnoredDirs) - .filter((dir) => silentlyIgnoredDirs[dir]) - .map((dir) => "**/" + dir), + ignore: silentlyIgnoredDirs.map((dir) => "**/" + dir), }; let supportedFilesGlob; @@ -73,7 +67,7 @@ function* expandPatternsInternal(context) { continue; } - const stat = statSafeSync(absolutePath); + const stat = await statSafe(absolutePath); if (stat) { if (stat.isFile()) { entries.push({ @@ -107,14 +101,18 @@ function* expandPatternsInternal(context) { let result; try { - result = fastGlob.sync(glob, globOptions); + result = await fastGlob(glob, globOptions); } catch ({ message }) { + /* istanbul ignore next */ yield { error: `${errorMessages.globError[type]}: ${input}\n${message}` }; + /* istanbul ignore next */ continue; } if (result.length === 0) { - yield { error: `${errorMessages.emptyResults[type]}: "${input}".` }; + if (context.argv["error-on-unmatched-pattern"] !== false) { + yield { error: `${errorMessages.emptyResults[type]}: "${input}".` }; + } } else { yield* sortPaths(result); } @@ -122,15 +120,16 @@ function* expandPatternsInternal(context) { function getSupportedFilesGlob() { if (!supportedFilesGlob) { - const extensions = flat( - context.languages.map((lang) => lang.extensions || []) + const extensions = context.languages.flatMap( + (lang) => lang.extensions || [] ); - const filenames = flat( - context.languages.map((lang) => lang.filenames || []) + const filenames = context.languages.flatMap( + (lang) => lang.filenames || [] ); - supportedFilesGlob = `**/{${extensions - .map((ext) => "*" + (ext[0] === "." ? ext : "." + ext)) - .concat(filenames)}}`; + supportedFilesGlob = `**/{${[ + ...extensions.map((ext) => "*" + (ext[0] === "." ? ext : "." + ext)), + ...filenames, + ]}}`; } return supportedFilesGlob; } @@ -152,13 +151,13 @@ const errorMessages = { /** * @param {string} absolutePath * @param {string} cwd - * @param {Record} ignoredDirectories + * @param {string[]} ignoredDirectories */ function containsIgnoredPathSegment(absolutePath, cwd, ignoredDirectories) { return path .relative(cwd, absolutePath) .split(path.sep) - .some((dir) => ignoredDirectories[dir]); + .some((dir) => ignoredDirectories.includes(dir)); } /** @@ -171,11 +170,11 @@ function sortPaths(paths) { /** * Get stats of a given path. * @param {string} filePath The path to target file. - * @returns {fs.Stats | undefined} The stats. + * @returns {Promise} The stats. */ -function statSafeSync(filePath) { +async function statSafe(filePath) { try { - return fs.statSync(filePath); + return await fs.stat(filePath); } catch (error) { /* istanbul ignore next */ if (error.code !== "ENOENT") { @@ -212,4 +211,7 @@ function fixWindowsSlashes(pattern) { return isWindows ? pattern.replace(/\\/g, "/") : pattern; } -module.exports = expandPatterns; +module.exports = { + expandPatterns, + fixWindowsSlashes, +}; diff --git a/src/cli/format.js b/src/cli/format.js new file mode 100644 index 0000000000..4eda1037c3 --- /dev/null +++ b/src/cli/format.js @@ -0,0 +1,435 @@ +"use strict"; + +const { promises: fs } = require("fs"); +const path = require("path"); + +const chalk = require("chalk"); + +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); +// eslint-disable-next-line no-restricted-modules +const { getStdin } = require("../common/third-party"); + +const { createIgnorer, errors } = require("./prettier-internal"); +const { expandPatterns, fixWindowsSlashes } = require("./expand-patterns"); +const { getOptionsForFile } = require("./option"); +const isTTY = require("./is-tty"); + +function diff(a, b) { + return require("diff").createTwoFilesPatch("", "", a, b, "", "", { + context: 2, + }); +} + +function handleError(context, filename, error, printedFilename) { + if (error instanceof errors.UndefinedParserError) { + // Can't test on CI, `isTTY()` is always false, see ./is-tty.js + /* istanbul ignore next */ + if ( + (context.argv.write || context.argv["ignore-unknown"]) && + printedFilename + ) { + printedFilename.clear(); + } + if (context.argv["ignore-unknown"]) { + return; + } + if (!context.argv.check && !context.argv["list-different"]) { + process.exitCode = 2; + } + context.logger.error(error.message); + return; + } + + if (context.argv.write) { + // Add newline to split errors from filename line. + process.stdout.write("\n"); + } + + const isParseError = Boolean(error && error.loc); + const isValidationError = /^Invalid \S+ value\./.test(error && error.message); + + if (isParseError) { + // `invalid.js: SyntaxError: Unexpected token (1:1)`. + context.logger.error(`${filename}: ${String(error)}`); + } else if (isValidationError || error instanceof errors.ConfigError) { + // `Invalid printWidth value. Expected an integer, but received 0.5.` + context.logger.error(error.message); + // If validation fails for one file, it will fail for all of them. + process.exit(1); + } else if (error instanceof errors.DebugError) { + // `invalid.js: Some debug error message` + context.logger.error(`${filename}: ${error.message}`); + } else { + // `invalid.js: Error: Some unexpected error\n[stack trace]` + /* istanbul ignore next */ + context.logger.error(filename + ": " + (error.stack || error)); + } + + // Don't exit the process if one file failed + process.exitCode = 2; +} + +function writeOutput(context, result, options) { + // Don't use `console.log` here since it adds an extra newline at the end. + process.stdout.write( + context.argv["debug-check"] ? result.filepath : result.formatted + ); + + if (options && options.cursorOffset >= 0) { + process.stderr.write(result.cursorOffset + "\n"); + } +} + +function listDifferent(context, input, options, filename) { + if (!context.argv.check && !context.argv["list-different"]) { + return; + } + + try { + if (!options.filepath && !options.parser) { + throw new errors.UndefinedParserError( + "No parser and no file path given, couldn't infer a parser." + ); + } + if (!prettier.check(input, options)) { + if (!context.argv.write) { + context.logger.log(filename); + process.exitCode = 1; + } + } + } catch (error) { + context.logger.error(error.message); + } + + return true; +} + +async function format(context, input, opt) { + if (!opt.parser && !opt.filepath) { + throw new errors.UndefinedParserError( + "No parser and no file path given, couldn't infer a parser." + ); + } + + if (context.argv["debug-print-doc"]) { + const doc = prettier.__debug.printToDoc(input, opt); + return { formatted: prettier.__debug.formatDoc(doc) + "\n" }; + } + + if (context.argv["debug-print-comments"]) { + return { + formatted: prettier.format( + JSON.stringify(prettier.formatWithCursor(input, opt).comments || []), + { parser: "json" } + ), + }; + } + + if (context.argv["debug-check"]) { + const pp = prettier.format(input, opt); + const pppp = prettier.format(pp, opt); + if (pp !== pppp) { + throw new errors.DebugError( + "prettier(input) !== prettier(prettier(input))\n" + diff(pp, pppp) + ); + } else { + const stringify = (obj) => JSON.stringify(obj, null, 2); + const ast = stringify( + prettier.__debug.parse(input, opt, /* massage */ true).ast + ); + const past = stringify( + prettier.__debug.parse(pp, opt, /* massage */ true).ast + ); + + /* istanbul ignore next */ + if (ast !== past) { + const MAX_AST_SIZE = 2097152; // 2MB + const astDiff = + ast.length > MAX_AST_SIZE || past.length > MAX_AST_SIZE + ? "AST diff too large to render" + : diff(ast, past); + throw new errors.DebugError( + "ast(input) !== ast(prettier(input))\n" + + astDiff + + "\n" + + diff(input, pp) + ); + } + } + return { formatted: pp, filepath: opt.filepath || "(stdin)\n" }; + } + + /* istanbul ignore next */ + if (context.argv["debug-benchmark"]) { + let benchmark; + try { + // eslint-disable-next-line import/no-extraneous-dependencies + benchmark = require("benchmark"); + } catch { + context.logger.debug( + "'--debug-benchmark' requires the 'benchmark' package to be installed." + ); + process.exit(2); + } + context.logger.debug( + "'--debug-benchmark' option found, measuring formatWithCursor with 'benchmark' module." + ); + const suite = new benchmark.Suite(); + suite + .add("format", () => { + prettier.formatWithCursor(input, opt); + }) + .on("cycle", (event) => { + const results = { + benchmark: String(event.target), + hz: event.target.hz, + ms: event.target.times.cycle * 1000, + }; + context.logger.debug( + "'--debug-benchmark' measurements for formatWithCursor: " + + JSON.stringify(results, null, 2) + ); + }) + .run({ async: false }); + } else if (context.argv["debug-repeat"] > 0) { + const repeat = context.argv["debug-repeat"]; + context.logger.debug( + "'--debug-repeat' option found, running formatWithCursor " + + repeat + + " times." + ); + let totalMs = 0; + for (let i = 0; i < repeat; ++i) { + // should be using `performance.now()`, but only `Date` is cross-platform enough + const startMs = Date.now(); + prettier.formatWithCursor(input, opt); + totalMs += Date.now() - startMs; + } + const averageMs = totalMs / repeat; + const results = { + repeat, + hz: 1000 / averageMs, + ms: averageMs, + }; + context.logger.debug( + "'--debug-repeat' measurements for formatWithCursor: " + + JSON.stringify(results, null, 2) + ); + } + + return prettier.formatWithCursor(input, opt); +} + +async function createIgnorerFromContextOrDie(context) { + try { + return await createIgnorer( + context.argv["ignore-path"], + context.argv["with-node-modules"] + ); + } catch (e) { + context.logger.error(e.message); + process.exit(2); + } +} + +async function formatStdin(context) { + const filepath = context.argv["stdin-filepath"] + ? path.resolve(process.cwd(), context.argv["stdin-filepath"]) + : process.cwd(); + + const ignorer = await createIgnorerFromContextOrDie(context); + // If there's an ignore-path set, the filename must be relative to the + // ignore path, not the current working directory. + const relativeFilepath = context.argv["ignore-path"] + ? path.relative(path.dirname(context.argv["ignore-path"]), filepath) + : path.relative(process.cwd(), filepath); + + try { + const input = await getStdin(); + + if ( + relativeFilepath && + ignorer.ignores(fixWindowsSlashes(relativeFilepath)) + ) { + writeOutput(context, { formatted: input }); + return; + } + + const options = await getOptionsForFile(context, filepath); + + if (listDifferent(context, input, options, "(stdin)")) { + return; + } + + writeOutput(context, await format(context, input, options), options); + } catch (error) { + handleError(context, relativeFilepath || "stdin", error); + } +} + +async function formatFiles(context) { + // The ignorer will be used to filter file paths after the glob is checked, + // before any files are actually written + const ignorer = await createIgnorerFromContextOrDie(context); + + let numberOfUnformattedFilesFound = 0; + + if (context.argv.check) { + context.logger.log("Checking formatting..."); + } + + for await (const pathOrError of expandPatterns(context)) { + if (typeof pathOrError === "object") { + context.logger.error(pathOrError.error); + // Don't exit, but set the exit code to 2 + process.exitCode = 2; + continue; + } + + const filename = pathOrError; + // If there's an ignore-path set, the filename must be relative to the + // ignore path, not the current working directory. + const ignoreFilename = context.argv["ignore-path"] + ? path.relative(path.dirname(context.argv["ignore-path"]), filename) + : filename; + + const fileIgnored = ignorer.ignores(fixWindowsSlashes(ignoreFilename)); + if ( + fileIgnored && + (context.argv["debug-check"] || + context.argv.write || + context.argv.check || + context.argv["list-different"]) + ) { + continue; + } + + const options = { + ...(await getOptionsForFile(context, filename)), + filepath: filename, + }; + + let printedFilename; + if (isTTY()) { + printedFilename = context.logger.log(filename, { + newline: false, + clearable: true, + }); + } + + let input; + try { + input = await fs.readFile(filename, "utf8"); + } catch (error) { + // Add newline to split errors from filename line. + /* istanbul ignore next */ + context.logger.log(""); + + /* istanbul ignore next */ + context.logger.error( + `Unable to read file: ${filename}\n${error.message}` + ); + + // Don't exit the process if one file failed + /* istanbul ignore next */ + process.exitCode = 2; + + /* istanbul ignore next */ + continue; + } + + if (fileIgnored) { + writeOutput(context, { formatted: input }, options); + continue; + } + + const start = Date.now(); + + let result; + let output; + + try { + result = await format(context, input, options); + output = result.formatted; + } catch (error) { + handleError(context, filename, error, printedFilename); + continue; + } + + const isDifferent = output !== input; + + if (printedFilename) { + // Remove previously printed filename to log it with duration. + printedFilename.clear(); + } + + if (context.argv.write) { + // Don't write the file if it won't change in order not to invalidate + // mtime based caches. + if (isDifferent) { + if (!context.argv.check && !context.argv["list-different"]) { + context.logger.log(`${filename} ${Date.now() - start}ms`); + } + + try { + await fs.writeFile(filename, output, "utf8"); + } catch (error) { + /* istanbul ignore next */ + context.logger.error( + `Unable to write file: ${filename}\n${error.message}` + ); + + // Don't exit the process if one file failed + /* istanbul ignore next */ + process.exitCode = 2; + } + } else if (!context.argv.check && !context.argv["list-different"]) { + context.logger.log(`${chalk.grey(filename)} ${Date.now() - start}ms`); + } + } else if (context.argv["debug-check"]) { + /* istanbul ignore else */ + if (result.filepath) { + context.logger.log(result.filepath); + } else { + process.exitCode = 2; + } + } else if (!context.argv.check && !context.argv["list-different"]) { + writeOutput(context, result, options); + } + + if (isDifferent) { + if (context.argv.check) { + context.logger.warn(filename); + } else if (context.argv["list-different"]) { + context.logger.log(filename); + } + numberOfUnformattedFilesFound += 1; + } + } + + // Print check summary based on expected exit code + if (context.argv.check) { + if (numberOfUnformattedFilesFound === 0) { + context.logger.log("All matched files use Prettier code style!"); + } else { + context.logger.warn( + context.argv.write + ? "Code style issues fixed in the above file(s)." + : "Code style issues found in the above file(s). Forgot to run Prettier?" + ); + } + } + + // Ensure non-zero exitCode when using --check/list-different is not combined with --write + if ( + (context.argv.check || context.argv["list-different"]) && + numberOfUnformattedFilesFound > 0 && + !process.exitCode && + !context.argv.write + ) { + process.exitCode = 1; + } +} + +module.exports = { format, formatStdin, formatFiles }; diff --git a/src/cli/index.js b/src/cli/index.js index bba6368a35..bf858981b4 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -1,82 +1,96 @@ "use strict"; -require("please-upgrade-node")(require("../../package.json")); +// eslint-disable-next-line no-restricted-modules +const packageJson = require("../../package.json"); +require("please-upgrade-node")(packageJson); -const prettier = require("../../index"); -const stringify = require("json-stable-stringify"); -const util = require("./util"); +// eslint-disable-next-line import/order +const stringify = require("fast-json-stable-stringify"); +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); +const core = require("./core"); -function run(args) { - const context = util.createContext(args); +async function run(rawArguments) { + // Create a default level logger, so we can log errors during `logLevel` parsing + let logger = core.createLogger(); try { - util.initContext(context); + const logLevel = core.parseArgvWithoutPlugins( + rawArguments, + logger, + "loglevel" + ).loglevel; + if (logLevel !== logger.logLevel) { + logger = core.createLogger(logLevel); + } - context.logger.debug(`normalized argv: ${JSON.stringify(context.argv)}`); + await main(rawArguments, logger); + } catch (error) { + logger.error(error.message); + process.exitCode = 1; + } +} - if (context.argv.check && context.argv["list-different"]) { - context.logger.error("Cannot use --check and --list-different together."); - process.exit(1); - } +async function main(rawArguments, logger) { + const context = new core.Context({ rawArguments, logger }); - if (context.argv.write && context.argv["debug-check"]) { - context.logger.error("Cannot use --write and --debug-check together."); - process.exit(1); - } + logger.debug(`normalized argv: ${JSON.stringify(context.argv)}`); - if (context.argv["find-config-path"] && context.filePatterns.length) { - context.logger.error("Cannot use --find-config-path with multiple files"); - process.exit(1); - } + if (context.argv.check && context.argv["list-different"]) { + throw new Error("Cannot use --check and --list-different together."); + } - if (context.argv["file-info"] && context.filePatterns.length) { - context.logger.error("Cannot use --file-info with multiple files"); - process.exit(1); - } + if (context.argv.write && context.argv["debug-check"]) { + throw new Error("Cannot use --write and --debug-check together."); + } - if (context.argv.version) { - context.logger.log(prettier.version); - process.exit(0); - } + if (context.argv["find-config-path"] && context.filePatterns.length > 0) { + throw new Error("Cannot use --find-config-path with multiple files"); + } - if (context.argv.help !== undefined) { - context.logger.log( - typeof context.argv.help === "string" && context.argv.help !== "" - ? util.createDetailedUsage(context, context.argv.help) - : util.createUsage(context) - ); - process.exit(0); - } + if (context.argv["file-info"] && context.filePatterns.length > 0) { + throw new Error("Cannot use --file-info with multiple files"); + } - if (context.argv["support-info"]) { - context.logger.log( - prettier.format(stringify(prettier.getSupportInfo()), { - parser: "json", - }) - ); - process.exit(0); - } + if (context.argv.version) { + logger.log(prettier.version); + return; + } - const hasFilePatterns = context.filePatterns.length !== 0; - const useStdin = - !hasFilePatterns && - (!process.stdin.isTTY || context.args["stdin-filepath"]); - - if (context.argv["find-config-path"]) { - util.logResolvedConfigPathOrDie(context); - } else if (context.argv["file-info"]) { - util.logFileInfoOrDie(context); - } else if (useStdin) { - util.formatStdin(context); - } else if (hasFilePatterns) { - util.formatFiles(context); - } else { - context.logger.log(util.createUsage(context)); - process.exit(1); - } - } catch (error) { - context.logger.error(error.message); - process.exit(1); + if (context.argv.help !== undefined) { + logger.log( + typeof context.argv.help === "string" && context.argv.help !== "" + ? core.createDetailedUsage(context, context.argv.help) + : core.createUsage(context) + ); + return; + } + + if (context.argv["support-info"]) { + logger.log( + prettier.format(stringify(prettier.getSupportInfo()), { + parser: "json", + }) + ); + return; + } + + const hasFilePatterns = context.filePatterns.length > 0; + const useStdin = + !hasFilePatterns && + (!process.stdin.isTTY || context.argv["stdin-filepath"]); + + if (context.argv["find-config-path"]) { + await core.logResolvedConfigPathOrDie(context); + } else if (context.argv["file-info"]) { + await core.logFileInfoOrDie(context); + } else if (useStdin) { + await core.formatStdin(context); + } else if (hasFilePatterns) { + await core.formatFiles(context); + } else { + logger.log(core.createUsage(context)); + process.exitCode = 1; } } diff --git a/src/cli/is-tty.js b/src/cli/is-tty.js new file mode 100644 index 0000000000..0eb7ed1992 --- /dev/null +++ b/src/cli/is-tty.js @@ -0,0 +1,11 @@ +"use strict"; + +// eslint-disable-next-line no-restricted-modules +const { isCI } = require("../common/third-party"); + +// Some CI pipelines incorrectly report process.stdout.isTTY status, +// which causes unwanted lines in the output. An additional check for isCI() helps. +// See https://github.com/prettier/prettier/issues/5801 +module.exports = function isTTY() { + return process.stdout.isTTY && !isCI(); +}; diff --git a/src/cli/logger.js b/src/cli/logger.js new file mode 100644 index 0000000000..88c97e820c --- /dev/null +++ b/src/cli/logger.js @@ -0,0 +1,90 @@ +"use strict"; + +const readline = require("readline"); +const chalk = require("chalk"); +const stripAnsi = require("strip-ansi"); +const wcwidth = require("wcwidth"); + +const countLines = (stream, text) => { + const columns = stream.columns || 80; + let lineCount = 0; + for (const line of stripAnsi(text).split("\n")) { + lineCount += Math.max(1, Math.ceil(wcwidth(line) / columns)); + } + return lineCount; +}; + +const clear = (stream, text) => () => { + const lineCount = countLines(stream, text); + + for (let line = 0; line < lineCount; line++) { + if (line > 0) { + readline.moveCursor(stream, 0, -1); + } + + readline.clearLine(stream, 0); + readline.cursorTo(stream, 0); + } +}; + +const emptyLogResult = { clear() {} }; +function createLogger(logLevel = "log") { + return { + logLevel, + warn: createLogFunc("warn", "yellow"), + error: createLogFunc("error", "red"), + debug: createLogFunc("debug", "blue"), + log: createLogFunc("log"), + }; + + function createLogFunc(loggerName, color) { + if (!shouldLog(loggerName)) { + return () => emptyLogResult; + } + + const prefix = color ? `[${chalk[color](loggerName)}] ` : ""; + const stream = process[loggerName === "log" ? "stdout" : "stderr"]; + + return (message, options) => { + options = { + newline: true, + clearable: false, + ...options, + }; + message = message.replace(/^/gm, prefix) + (options.newline ? "\n" : ""); + stream.write(message); + + if (options.clearable) { + return { + clear: clear(stream, message), + }; + } + }; + } + + function shouldLog(loggerName) { + switch (logLevel) { + case "silent": + return false; + case "debug": + if (loggerName === "debug") { + return true; + } + // fall through + case "log": + if (loggerName === "log") { + return true; + } + // fall through + case "warn": + if (loggerName === "warn") { + return true; + } + // fall through + case "error": + return loggerName === "error"; + } + } +} + +module.exports = { createLogger }; diff --git a/src/cli/minimist.js b/src/cli/minimist.js index 500bc96393..7556db18c2 100644 --- a/src/cli/minimist.js +++ b/src/cli/minimist.js @@ -1,7 +1,6 @@ "use strict"; const minimist = require("minimist"); -const fromPairs = require("lodash/fromPairs"); const PLACEHOLDER = null; @@ -15,12 +14,14 @@ module.exports = function (args, options) { const booleanWithoutDefault = boolean.filter((key) => !(key in defaults)); const newDefaults = { ...defaults, - ...fromPairs(booleanWithoutDefault.map((key) => [key, PLACEHOLDER])), + ...Object.fromEntries( + booleanWithoutDefault.map((key) => [key, PLACEHOLDER]) + ), }; const parsed = minimist(args, { ...options, default: newDefaults }); - return fromPairs( + return Object.fromEntries( Object.entries(parsed).filter(([, value]) => value !== PLACEHOLDER) ); }; diff --git a/src/cli/option-map.js b/src/cli/option-map.js new file mode 100644 index 0000000000..2565124508 --- /dev/null +++ b/src/cli/option-map.js @@ -0,0 +1,62 @@ +"use strict"; + +const dashify = require("dashify"); +const { coreOptions } = require("./prettier-internal"); + +function normalizeDetailedOption(name, option) { + return { + category: coreOptions.CATEGORY_OTHER, + ...option, + choices: + option.choices && + option.choices.map((choice) => { + const newChoice = { + description: "", + deprecated: false, + ...(typeof choice === "object" ? choice : { value: choice }), + }; + /* istanbul ignore next */ + if (newChoice.value === true) { + newChoice.value = ""; // backward compatibility for original boolean option + } + return newChoice; + }), + }; +} + +function normalizeDetailedOptionMap(detailedOptionMap) { + return Object.fromEntries( + Object.entries(detailedOptionMap) + .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) + .map(([name, option]) => [name, normalizeDetailedOption(name, option)]) + ); +} + +function createDetailedOptionMap(supportOptions) { + return Object.fromEntries( + supportOptions.map((option) => { + const newOption = { + ...option, + name: option.cliName || dashify(option.name), + description: option.cliDescription || option.description, + category: option.cliCategory || coreOptions.CATEGORY_FORMAT, + forwardToApi: option.name, + }; + + /* istanbul ignore next */ + if (option.deprecated) { + delete newOption.forwardToApi; + delete newOption.description; + delete newOption.oppositeDescription; + newOption.deprecated = true; + } + + return [newOption.name, newOption]; + }) + ); +} + +module.exports = { + normalizeDetailedOptionMap, + createDetailedOptionMap, +}; diff --git a/src/cli/option.js b/src/cli/option.js new file mode 100644 index 0000000000..13bd9662de --- /dev/null +++ b/src/cli/option.js @@ -0,0 +1,141 @@ +"use strict"; + +const dashify = require("dashify"); +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); +const minimist = require("./minimist"); +const { optionsNormalizer } = require("./prettier-internal"); +const createMinimistOptions = require("./create-minimist-options"); + +function getOptions(argv, detailedOptions) { + return Object.fromEntries( + detailedOptions + .filter(({ forwardToApi }) => forwardToApi) + .map(({ forwardToApi, name }) => [forwardToApi, argv[name]]) + ); +} + +function cliifyOptions(object, apiDetailedOptionMap) { + return Object.fromEntries( + Object.entries(object || {}).map(([key, value]) => { + const apiOption = apiDetailedOptionMap[key]; + const cliKey = apiOption ? apiOption.name : key; + + return [dashify(cliKey), value]; + }) + ); +} + +function createApiDetailedOptionMap(detailedOptions) { + return Object.fromEntries( + detailedOptions + .filter( + (option) => option.forwardToApi && option.forwardToApi !== option.name + ) + .map((option) => [option.forwardToApi, option]) + ); +} + +function parseArgsToOptions(context, overrideDefaults) { + const minimistOptions = createMinimistOptions(context.detailedOptions); + const apiDetailedOptionMap = createApiDetailedOptionMap( + context.detailedOptions + ); + return getOptions( + optionsNormalizer.normalizeCliOptions( + minimist(context.rawArguments, { + string: minimistOptions.string, + boolean: minimistOptions.boolean, + default: cliifyOptions(overrideDefaults, apiDetailedOptionMap), + }), + context.detailedOptions, + { logger: false } + ), + context.detailedOptions + ); +} + +async function getOptionsOrDie(context, filePath) { + try { + if (context.argv.config === false) { + context.logger.debug( + "'--no-config' option found, skip loading config file." + ); + return null; + } + + context.logger.debug( + context.argv.config + ? `load config file from '${context.argv.config}'` + : `resolve config from '${filePath}'` + ); + + const options = await prettier.resolveConfig(filePath, { + editorconfig: context.argv.editorconfig, + config: context.argv.config, + }); + + context.logger.debug("loaded options `" + JSON.stringify(options) + "`"); + return options; + } catch (error) { + context.logger.error( + `Invalid configuration file \`${filePath}\`: ` + error.message + ); + process.exit(2); + } +} + +function applyConfigPrecedence(context, options) { + try { + switch (context.argv["config-precedence"]) { + case "cli-override": + return parseArgsToOptions(context, options); + case "file-override": + return { ...parseArgsToOptions(context), ...options }; + case "prefer-file": + return options || parseArgsToOptions(context); + } + } catch (error) { + /* istanbul ignore next */ + context.logger.error(error.toString()); + + /* istanbul ignore next */ + process.exit(2); + } +} + +async function getOptionsForFile(context, filepath) { + const options = await getOptionsOrDie(context, filepath); + + const hasPlugins = options && options.plugins; + if (hasPlugins) { + context.pushContextPlugins(options.plugins); + } + + const appliedOptions = { + filepath, + ...applyConfigPrecedence( + context, + options && + optionsNormalizer.normalizeApiOptions(options, context.supportOptions, { + logger: context.logger, + }) + ), + }; + + context.logger.debug( + `applied config-precedence (${context.argv["config-precedence"]}): ` + + `${JSON.stringify(appliedOptions)}` + ); + + if (hasPlugins) { + context.popContextPlugins(); + } + + return appliedOptions; +} + +module.exports = { + getOptionsForFile, + createMinimistOptions, +}; diff --git a/src/cli/prettier-internal.js b/src/cli/prettier-internal.js new file mode 100644 index 0000000000..66f81d6dd5 --- /dev/null +++ b/src/cli/prettier-internal.js @@ -0,0 +1,4 @@ +"use strict"; + +// eslint-disable-next-line no-restricted-modules +module.exports = require("../index").__internal; diff --git a/src/cli/usage.js b/src/cli/usage.js new file mode 100644 index 0000000000..9ba5ab599d --- /dev/null +++ b/src/cli/usage.js @@ -0,0 +1,186 @@ +"use strict"; + +const groupBy = require("lodash/groupBy"); +const camelCase = require("camelcase"); +const constant = require("./constant"); + +const OPTION_USAGE_THRESHOLD = 25; +const CHOICE_USAGE_MARGIN = 3; +const CHOICE_USAGE_INDENTATION = 2; + +function indent(str, spaces) { + return str.replace(/^/gm, " ".repeat(spaces)); +} + +function createDefaultValueDisplay(value) { + return Array.isArray(value) + ? `[${value.map(createDefaultValueDisplay).join(", ")}]` + : value; +} + +function getOptionDefaultValue(context, optionName) { + // --no-option + if (!(optionName in context.detailedOptionMap)) { + return; + } + + const option = context.detailedOptionMap[optionName]; + + if (option.default !== undefined) { + return option.default; + } + + const optionCamelName = camelCase(optionName); + if (optionCamelName in context.apiDefaultOptions) { + return context.apiDefaultOptions[optionCamelName]; + } +} + +function createOptionUsageHeader(option) { + const name = `--${option.name}`; + const alias = option.alias ? `-${option.alias},` : null; + const type = createOptionUsageType(option); + return [alias, name, type].filter(Boolean).join(" "); +} + +function createOptionUsageRow(header, content, threshold) { + const separator = + header.length >= threshold + ? `\n${" ".repeat(threshold)}` + : " ".repeat(threshold - header.length); + + const description = content.replace(/\n/g, `\n${" ".repeat(threshold)}`); + + return `${header}${separator}${description}`; +} + +function createOptionUsageType(option) { + switch (option.type) { + case "boolean": + return null; + case "choice": + return `<${option.choices + .filter((choice) => !choice.deprecated && choice.since !== null) + .map((choice) => choice.value) + .join("|")}>`; + default: + return `<${option.type}>`; + } +} + +function createChoiceUsages(choices, margin, indentation) { + const activeChoices = choices.filter( + (choice) => !choice.deprecated && choice.since !== null + ); + const threshold = + Math.max(0, ...activeChoices.map((choice) => choice.value.length)) + margin; + return activeChoices.map((choice) => + indent( + createOptionUsageRow(choice.value, choice.description, threshold), + indentation + ) + ); +} + +function createOptionUsage(context, option, threshold) { + const header = createOptionUsageHeader(option); + const optionDefaultValue = getOptionDefaultValue(context, option.name); + return createOptionUsageRow( + header, + `${option.description}${ + optionDefaultValue === undefined + ? "" + : `\nDefaults to ${createDefaultValueDisplay(optionDefaultValue)}.` + }`, + threshold + ); +} + +function getOptionsWithOpposites(options) { + // Add --no-foo after --foo. + const optionsWithOpposites = options.map((option) => [ + option.description ? option : null, + option.oppositeDescription + ? { + ...option, + name: `no-${option.name}`, + type: "boolean", + description: option.oppositeDescription, + } + : null, + ]); + return optionsWithOpposites.flat().filter(Boolean); +} + +function createUsage(context) { + const options = getOptionsWithOpposites(context.detailedOptions).filter( + // remove unnecessary option (e.g. `semi`, `color`, etc.), which is only used for --help + (option) => + !( + option.type === "boolean" && + option.oppositeDescription && + !option.name.startsWith("no-") + ) + ); + + const groupedOptions = groupBy(options, (option) => option.category); + + const firstCategories = constant.categoryOrder.slice(0, -1); + const lastCategories = constant.categoryOrder.slice(-1); + const restCategories = Object.keys(groupedOptions).filter( + (category) => !constant.categoryOrder.includes(category) + ); + const allCategories = [ + ...firstCategories, + ...restCategories, + ...lastCategories, + ]; + + const optionsUsage = allCategories.map((category) => { + const categoryOptions = groupedOptions[category] + .map((option) => + createOptionUsage(context, option, OPTION_USAGE_THRESHOLD) + ) + .join("\n"); + return `${category} options:\n\n${indent(categoryOptions, 2)}`; + }); + + return [constant.usageSummary, ...optionsUsage, ""].join("\n\n"); +} + +function createDetailedUsage(context, flag) { + const option = getOptionsWithOpposites(context.detailedOptions).find( + (option) => option.name === flag || option.alias === flag + ); + + const header = createOptionUsageHeader(option); + const description = `\n\n${indent(option.description, 2)}`; + + const choices = + option.type !== "choice" + ? "" + : `\n\nValid options:\n\n${createChoiceUsages( + option.choices, + CHOICE_USAGE_MARGIN, + CHOICE_USAGE_INDENTATION + ).join("\n")}`; + + const optionDefaultValue = getOptionDefaultValue(context, option.name); + const defaults = + optionDefaultValue !== undefined + ? `\n\nDefault: ${createDefaultValueDisplay(optionDefaultValue)}` + : ""; + + const pluginDefaults = + option.pluginDefaults && Object.keys(option.pluginDefaults).length > 0 + ? `\nPlugin defaults:${Object.entries(option.pluginDefaults).map( + ([key, value]) => `\n* ${key}: ${createDefaultValueDisplay(value)}` + )}` + : ""; + return `${header}${description}${choices}${defaults}${pluginDefaults}`; +} + +module.exports = { + createUsage, + createDetailedUsage, +}; diff --git a/src/cli/util.js b/src/cli/util.js deleted file mode 100644 index 6bb6b46c19..0000000000 --- a/src/cli/util.js +++ /dev/null @@ -1,1003 +0,0 @@ -"use strict"; - -const path = require("path"); -const camelCase = require("camelcase"); -const dashify = require("dashify"); -const fs = require("fs"); - -const chalk = require("chalk"); -const readline = require("readline"); -const stringify = require("json-stable-stringify"); -const fromPairs = require("lodash/fromPairs"); -const pick = require("lodash/pick"); -const groupBy = require("lodash/groupBy"); -const flat = require("lodash/flatten"); - -const minimist = require("./minimist"); -const prettier = require("../../index"); -const createIgnorer = require("../common/create-ignorer"); -const expandPatterns = require("./expand-patterns"); -const errors = require("../common/errors"); -const constant = require("./constant"); -const coreOptions = require("../main/core-options"); -const optionsModule = require("../main/options"); -const optionsNormalizer = require("../main/options-normalizer"); -const thirdParty = require("../common/third-party"); -const arrayify = require("../utils/arrayify"); -const isTTY = require("../utils/is-tty"); - -const OPTION_USAGE_THRESHOLD = 25; -const CHOICE_USAGE_MARGIN = 3; -const CHOICE_USAGE_INDENTATION = 2; - -function getOptions(argv, detailedOptions) { - return fromPairs( - detailedOptions - .filter(({ forwardToApi }) => forwardToApi) - .map(({ forwardToApi, name }) => [forwardToApi, argv[name]]) - ); -} - -function cliifyOptions(object, apiDetailedOptionMap) { - return Object.keys(object || {}).reduce((output, key) => { - const apiOption = apiDetailedOptionMap[key]; - const cliKey = apiOption ? apiOption.name : key; - - output[dashify(cliKey)] = object[key]; - return output; - }, {}); -} - -function diff(a, b) { - return require("diff").createTwoFilesPatch("", "", a, b, "", "", { - context: 2, - }); -} - -function handleError(context, filename, error) { - if (error instanceof errors.UndefinedParserError) { - if (context.argv.write && isTTY()) { - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0, null); - } - if (!context.argv.check && !context.argv["list-different"]) { - process.exitCode = 2; - } - context.logger.error(error.message); - return; - } - - if (context.argv.write) { - // Add newline to split errors from filename line. - process.stdout.write("\n"); - } - - const isParseError = Boolean(error && error.loc); - const isValidationError = /^Invalid \S+ value\./.test(error && error.message); - - if (isParseError) { - // `invalid.js: SyntaxError: Unexpected token (1:1)`. - context.logger.error(`${filename}: ${String(error)}`); - } else if (isValidationError || error instanceof errors.ConfigError) { - // `Invalid printWidth value. Expected an integer, but received 0.5.` - context.logger.error(error.message); - // If validation fails for one file, it will fail for all of them. - process.exit(1); - } else if (error instanceof errors.DebugError) { - // `invalid.js: Some debug error message` - context.logger.error(`${filename}: ${error.message}`); - } else { - // `invalid.js: Error: Some unexpected error\n[stack trace]` - context.logger.error(filename + ": " + (error.stack || error)); - } - - // Don't exit the process if one file failed - process.exitCode = 2; -} - -function logResolvedConfigPathOrDie(context) { - const configFile = prettier.resolveConfigFile.sync( - context.argv["find-config-path"] - ); - if (configFile) { - context.logger.log(path.relative(process.cwd(), configFile)); - } else { - process.exit(1); - } -} - -function logFileInfoOrDie(context) { - const options = { - ignorePath: context.argv["ignore-path"], - withNodeModules: context.argv["with-node-modules"], - plugins: context.argv.plugin, - pluginSearchDirs: context.argv["plugin-search-dir"], - }; - context.logger.log( - prettier.format( - stringify(prettier.getFileInfo.sync(context.argv["file-info"], options)), - { parser: "json" } - ) - ); -} - -function writeOutput(context, result, options) { - // Don't use `console.log` here since it adds an extra newline at the end. - process.stdout.write( - context.argv["debug-check"] ? result.filepath : result.formatted - ); - - if (options && options.cursorOffset >= 0) { - process.stderr.write(result.cursorOffset + "\n"); - } -} - -function listDifferent(context, input, options, filename) { - if (!context.argv.check && !context.argv["list-different"]) { - return; - } - - try { - if (!options.filepath && !options.parser) { - throw new errors.UndefinedParserError( - "No parser and no file path given, couldn't infer a parser." - ); - } - if (!prettier.check(input, options)) { - if (!context.argv.write) { - context.logger.log(filename); - process.exitCode = 1; - } - } - } catch (error) { - context.logger.error(error.message); - } - - return true; -} - -function format(context, input, opt) { - if (!opt.parser && !opt.filepath) { - throw new errors.UndefinedParserError( - "No parser and no file path given, couldn't infer a parser." - ); - } - - if (context.argv["debug-print-doc"]) { - const doc = prettier.__debug.printToDoc(input, opt); - return { formatted: prettier.__debug.formatDoc(doc) }; - } - - if (context.argv["debug-check"]) { - const pp = prettier.format(input, opt); - const pppp = prettier.format(pp, opt); - if (pp !== pppp) { - throw new errors.DebugError( - "prettier(input) !== prettier(prettier(input))\n" + diff(pp, pppp) - ); - } else { - const stringify = (obj) => JSON.stringify(obj, null, 2); - const ast = stringify( - prettier.__debug.parse(input, opt, /* massage */ true).ast - ); - const past = stringify( - prettier.__debug.parse(pp, opt, /* massage */ true).ast - ); - - /* istanbul ignore next */ - if (ast !== past) { - const MAX_AST_SIZE = 2097152; // 2MB - const astDiff = - ast.length > MAX_AST_SIZE || past.length > MAX_AST_SIZE - ? "AST diff too large to render" - : diff(ast, past); - throw new errors.DebugError( - "ast(input) !== ast(prettier(input))\n" + - astDiff + - "\n" + - diff(input, pp) - ); - } - } - return { formatted: pp, filepath: opt.filepath || "(stdin)\n" }; - } - - /* istanbul ignore next */ - if (context.argv["debug-benchmark"]) { - let benchmark; - try { - benchmark = eval("require")("benchmark"); - } catch (err) { - context.logger.debug( - "'--debug-benchmark' requires the 'benchmark' package to be installed." - ); - process.exit(2); - } - context.logger.debug( - "'--debug-benchmark' option found, measuring formatWithCursor with 'benchmark' module." - ); - const suite = new benchmark.Suite(); - suite - .add("format", () => { - prettier.formatWithCursor(input, opt); - }) - .on("cycle", (event) => { - const results = { - benchmark: String(event.target), - hz: event.target.hz, - ms: event.target.times.cycle * 1000, - }; - context.logger.debug( - "'--debug-benchmark' measurements for formatWithCursor: " + - JSON.stringify(results, null, 2) - ); - }) - .run({ async: false }); - } else if (context.argv["debug-repeat"] > 0) { - const repeat = context.argv["debug-repeat"]; - context.logger.debug( - "'--debug-repeat' option found, running formatWithCursor " + - repeat + - " times." - ); - // should be using `performance.now()`, but only `Date` is cross-platform enough - const now = Date.now ? () => Date.now() : () => +new Date(); - let totalMs = 0; - for (let i = 0; i < repeat; ++i) { - const startMs = now(); - prettier.formatWithCursor(input, opt); - totalMs += now() - startMs; - } - const averageMs = totalMs / repeat; - const results = { - repeat, - hz: 1000 / averageMs, - ms: averageMs, - }; - context.logger.debug( - "'--debug-repeat' measurements for formatWithCursor: " + - JSON.stringify(results, null, 2) - ); - } - - return prettier.formatWithCursor(input, opt); -} - -function getOptionsOrDie(context, filePath) { - try { - if (context.argv.config === false) { - context.logger.debug( - "'--no-config' option found, skip loading config file." - ); - return null; - } - - context.logger.debug( - context.argv.config - ? `load config file from '${context.argv.config}'` - : `resolve config from '${filePath}'` - ); - - const options = prettier.resolveConfig.sync(filePath, { - editorconfig: context.argv.editorconfig, - config: context.argv.config, - }); - - context.logger.debug("loaded options `" + JSON.stringify(options) + "`"); - return options; - } catch (error) { - context.logger.error( - `Invalid configuration file \`${filePath}\`: ` + error.message - ); - process.exit(2); - } -} - -function getOptionsForFile(context, filepath) { - const options = getOptionsOrDie(context, filepath); - - const hasPlugins = options && options.plugins; - if (hasPlugins) { - pushContextPlugins(context, options.plugins); - } - - const appliedOptions = { - filepath, - ...applyConfigPrecedence( - context, - options && - optionsNormalizer.normalizeApiOptions(options, context.supportOptions, { - logger: context.logger, - }) - ), - }; - - context.logger.debug( - `applied config-precedence (${context.argv["config-precedence"]}): ` + - `${JSON.stringify(appliedOptions)}` - ); - - if (hasPlugins) { - popContextPlugins(context); - } - - return appliedOptions; -} - -function parseArgsToOptions(context, overrideDefaults) { - const minimistOptions = createMinimistOptions(context.detailedOptions); - const apiDetailedOptionMap = createApiDetailedOptionMap( - context.detailedOptions - ); - return getOptions( - optionsNormalizer.normalizeCliOptions( - minimist(context.args, { - string: minimistOptions.string, - boolean: minimistOptions.boolean, - default: cliifyOptions(overrideDefaults, apiDetailedOptionMap), - }), - context.detailedOptions, - { logger: false } - ), - context.detailedOptions - ); -} - -function applyConfigPrecedence(context, options) { - try { - switch (context.argv["config-precedence"]) { - case "cli-override": - return parseArgsToOptions(context, options); - case "file-override": - return { ...parseArgsToOptions(context), ...options }; - case "prefer-file": - return options || parseArgsToOptions(context); - } - } catch (error) { - context.logger.error(error.toString()); - process.exit(2); - } -} - -function formatStdin(context) { - const filepath = context.argv["stdin-filepath"] - ? path.resolve(process.cwd(), context.argv["stdin-filepath"]) - : process.cwd(); - - const ignorer = createIgnorerFromContextOrDie(context); - // If there's an ignore-path set, the filename must be relative to the - // ignore path, not the current working directory. - const relativeFilepath = context.argv["ignore-path"] - ? path.relative(path.dirname(context.argv["ignore-path"]), filepath) - : path.relative(process.cwd(), filepath); - - thirdParty - .getStream(process.stdin) - .then((input) => { - if (relativeFilepath && ignorer.filter([relativeFilepath]).length === 0) { - writeOutput(context, { formatted: input }); - return; - } - - const options = getOptionsForFile(context, filepath); - - if (listDifferent(context, input, options, "(stdin)")) { - return; - } - - writeOutput(context, format(context, input, options), options); - }) - .catch((error) => { - handleError(context, relativeFilepath || "stdin", error); - }); -} - -function createIgnorerFromContextOrDie(context) { - try { - return createIgnorer.sync( - context.argv["ignore-path"], - context.argv["with-node-modules"] - ); - } catch (e) { - context.logger.error(e.message); - process.exit(2); - } -} - -function formatFiles(context) { - // The ignorer will be used to filter file paths after the glob is checked, - // before any files are actually written - const ignorer = createIgnorerFromContextOrDie(context); - - let numberOfUnformattedFilesFound = 0; - - if (context.argv.check) { - context.logger.log("Checking formatting..."); - } - - for (const pathOrError of expandPatterns(context)) { - if (typeof pathOrError === "object") { - context.logger.error(pathOrError.error); - // Don't exit, but set the exit code to 2 - process.exitCode = 2; - continue; - } - - const filename = pathOrError; - // If there's an ignore-path set, the filename must be relative to the - // ignore path, not the current working directory. - const ignoreFilename = context.argv["ignore-path"] - ? path.relative(path.dirname(context.argv["ignore-path"]), filename) - : filename; - const fileIgnored = ignorer.filter([ignoreFilename]).length === 0; - if ( - fileIgnored && - (context.argv["debug-check"] || - context.argv.write || - context.argv.check || - context.argv["list-different"]) - ) { - continue; - } - - const options = { - ...getOptionsForFile(context, filename), - filepath: filename, - }; - - if (isTTY()) { - context.logger.log(filename, { newline: false }); - } - - let input; - try { - input = fs.readFileSync(filename, "utf8"); - } catch (error) { - // Add newline to split errors from filename line. - context.logger.log(""); - - context.logger.error( - `Unable to read file: ${filename}\n${error.message}` - ); - // Don't exit the process if one file failed - process.exitCode = 2; - continue; - } - - if (fileIgnored) { - writeOutput(context, { formatted: input }, options); - continue; - } - - const start = Date.now(); - - let result; - let output; - - try { - result = format(context, input, options); - output = result.formatted; - } catch (error) { - handleError(context, filename, error); - continue; - } - - const isDifferent = output !== input; - - if (isTTY()) { - // Remove previously printed filename to log it with duration. - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0, null); - } - - if (context.argv.write) { - // Don't write the file if it won't change in order not to invalidate - // mtime based caches. - if (isDifferent) { - if (!context.argv.check && !context.argv["list-different"]) { - context.logger.log(`${filename} ${Date.now() - start}ms`); - } - - try { - fs.writeFileSync(filename, output, "utf8"); - } catch (error) { - context.logger.error( - `Unable to write file: ${filename}\n${error.message}` - ); - // Don't exit the process if one file failed - process.exitCode = 2; - } - } else if (!context.argv.check && !context.argv["list-different"]) { - context.logger.log(`${chalk.grey(filename)} ${Date.now() - start}ms`); - } - } else if (context.argv["debug-check"]) { - if (result.filepath) { - context.logger.log(result.filepath); - } else { - process.exitCode = 2; - } - } else if (!context.argv.check && !context.argv["list-different"]) { - writeOutput(context, result, options); - } - - if ((context.argv.check || context.argv["list-different"]) && isDifferent) { - context.logger.log(filename); - numberOfUnformattedFilesFound += 1; - } - } - - // Print check summary based on expected exit code - if (context.argv.check) { - context.logger.log( - numberOfUnformattedFilesFound === 0 - ? "All matched files use Prettier code style!" - : context.argv.write - ? "Code style issues fixed in the above file(s)." - : "Code style issues found in the above file(s). Forgot to run Prettier?" - ); - } - - // Ensure non-zero exitCode when using --check/list-different is not combined with --write - if ( - (context.argv.check || context.argv["list-different"]) && - numberOfUnformattedFilesFound > 0 && - !process.exitCode && - !context.argv.write - ) { - process.exitCode = 1; - } -} - -function getOptionsWithOpposites(options) { - // Add --no-foo after --foo. - const optionsWithOpposites = options.map((option) => [ - option.description ? option : null, - option.oppositeDescription - ? { - ...option, - name: `no-${option.name}`, - type: "boolean", - description: option.oppositeDescription, - } - : null, - ]); - return flat(optionsWithOpposites).filter(Boolean); -} - -function createUsage(context) { - const options = getOptionsWithOpposites(context.detailedOptions).filter( - // remove unnecessary option (e.g. `semi`, `color`, etc.), which is only used for --help - (option) => - !( - option.type === "boolean" && - option.oppositeDescription && - !option.name.startsWith("no-") - ) - ); - - const groupedOptions = groupBy(options, (option) => option.category); - - const firstCategories = constant.categoryOrder.slice(0, -1); - const lastCategories = constant.categoryOrder.slice(-1); - const restCategories = Object.keys(groupedOptions).filter( - (category) => !constant.categoryOrder.includes(category) - ); - const allCategories = [ - ...firstCategories, - ...restCategories, - ...lastCategories, - ]; - - const optionsUsage = allCategories.map((category) => { - const categoryOptions = groupedOptions[category] - .map((option) => - createOptionUsage(context, option, OPTION_USAGE_THRESHOLD) - ) - .join("\n"); - return `${category} options:\n\n${indent(categoryOptions, 2)}`; - }); - - return [constant.usageSummary].concat(optionsUsage, [""]).join("\n\n"); -} - -function createOptionUsage(context, option, threshold) { - const header = createOptionUsageHeader(option); - const optionDefaultValue = getOptionDefaultValue(context, option.name); - return createOptionUsageRow( - header, - `${option.description}${ - optionDefaultValue === undefined - ? "" - : `\nDefaults to ${createDefaultValueDisplay(optionDefaultValue)}.` - }`, - threshold - ); -} - -function createDefaultValueDisplay(value) { - return Array.isArray(value) - ? `[${value.map(createDefaultValueDisplay).join(", ")}]` - : value; -} - -function createOptionUsageHeader(option) { - const name = `--${option.name}`; - const alias = option.alias ? `-${option.alias},` : null; - const type = createOptionUsageType(option); - return [alias, name, type].filter(Boolean).join(" "); -} - -function createOptionUsageRow(header, content, threshold) { - const separator = - header.length >= threshold - ? `\n${" ".repeat(threshold)}` - : " ".repeat(threshold - header.length); - - const description = content.replace(/\n/g, `\n${" ".repeat(threshold)}`); - - return `${header}${separator}${description}`; -} - -function createOptionUsageType(option) { - switch (option.type) { - case "boolean": - return null; - case "choice": - return `<${option.choices - .filter((choice) => !choice.deprecated && choice.since !== null) - .map((choice) => choice.value) - .join("|")}>`; - default: - return `<${option.type}>`; - } -} - -function createChoiceUsages(choices, margin, indentation) { - const activeChoices = choices.filter( - (choice) => !choice.deprecated && choice.since !== null - ); - const threshold = - activeChoices - .map((choice) => choice.value.length) - .reduce((current, length) => Math.max(current, length), 0) + margin; - return activeChoices.map((choice) => - indent( - createOptionUsageRow(choice.value, choice.description, threshold), - indentation - ) - ); -} - -function createDetailedUsage(context, flag) { - const option = getOptionsWithOpposites(context.detailedOptions).find( - (option) => option.name === flag || option.alias === flag - ); - - const header = createOptionUsageHeader(option); - const description = `\n\n${indent(option.description, 2)}`; - - const choices = - option.type !== "choice" - ? "" - : `\n\nValid options:\n\n${createChoiceUsages( - option.choices, - CHOICE_USAGE_MARGIN, - CHOICE_USAGE_INDENTATION - ).join("\n")}`; - - const optionDefaultValue = getOptionDefaultValue(context, option.name); - const defaults = - optionDefaultValue !== undefined - ? `\n\nDefault: ${createDefaultValueDisplay(optionDefaultValue)}` - : ""; - - const pluginDefaults = - option.pluginDefaults && Object.keys(option.pluginDefaults).length - ? `\nPlugin defaults:${Object.keys(option.pluginDefaults).map( - (key) => - `\n* ${key}: ${createDefaultValueDisplay( - option.pluginDefaults[key] - )}` - )}` - : ""; - return `${header}${description}${choices}${defaults}${pluginDefaults}`; -} - -function getOptionDefaultValue(context, optionName) { - // --no-option - if (!(optionName in context.detailedOptionMap)) { - return undefined; - } - - const option = context.detailedOptionMap[optionName]; - - if (option.default !== undefined) { - return option.default; - } - - const optionCamelName = camelCase(optionName); - if (optionCamelName in context.apiDefaultOptions) { - return context.apiDefaultOptions[optionCamelName]; - } - - return undefined; -} - -function indent(str, spaces) { - return str.replace(/^/gm, " ".repeat(spaces)); -} - -function createLogger(logLevel) { - return { - warn: createLogFunc("warn", "yellow"), - error: createLogFunc("error", "red"), - debug: createLogFunc("debug", "blue"), - log: createLogFunc("log"), - }; - - function createLogFunc(loggerName, color) { - if (!shouldLog(loggerName)) { - return () => {}; - } - - const prefix = color ? `[${chalk[color](loggerName)}] ` : ""; - return function (message, opts) { - opts = { newline: true, ...opts }; - const stream = process[loggerName === "log" ? "stdout" : "stderr"]; - stream.write(message.replace(/^/gm, prefix) + (opts.newline ? "\n" : "")); - }; - } - - function shouldLog(loggerName) { - switch (logLevel) { - case "silent": - return false; - default: - return true; - case "debug": - if (loggerName === "debug") { - return true; - } - // fall through - case "log": - if (loggerName === "log") { - return true; - } - // fall through - case "warn": - if (loggerName === "warn") { - return true; - } - // fall through - case "error": - return loggerName === "error"; - } - } -} - -function normalizeDetailedOption(name, option) { - return { - category: coreOptions.CATEGORY_OTHER, - ...option, - choices: - option.choices && - option.choices.map((choice) => { - const newChoice = { - description: "", - deprecated: false, - ...(typeof choice === "object" ? choice : { value: choice }), - }; - if (newChoice.value === true) { - newChoice.value = ""; // backward compatibility for original boolean option - } - return newChoice; - }), - }; -} - -function normalizeDetailedOptionMap(detailedOptionMap) { - return fromPairs( - Object.entries(detailedOptionMap) - .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) - .map(([name, option]) => [name, normalizeDetailedOption(name, option)]) - ); -} - -function createMinimistOptions(detailedOptions) { - return { - // we use vnopts' AliasSchema to handle aliases for better error messages - alias: {}, - boolean: detailedOptions - .filter((option) => option.type === "boolean") - .map((option) => [option.name].concat(option.alias || [])) - .reduce((a, b) => a.concat(b)), - string: detailedOptions - .filter((option) => option.type !== "boolean") - .map((option) => [option.name].concat(option.alias || [])) - .reduce((a, b) => a.concat(b)), - default: detailedOptions - .filter( - (option) => - !option.deprecated && - (!option.forwardToApi || - option.name === "plugin" || - option.name === "plugin-search-dir") && - option.default !== undefined - ) - .reduce( - (current, option) => ({ [option.name]: option.default, ...current }), - {} - ), - }; -} - -function createApiDetailedOptionMap(detailedOptions) { - return fromPairs( - detailedOptions - .filter( - (option) => option.forwardToApi && option.forwardToApi !== option.name - ) - .map((option) => [option.forwardToApi, option]) - ); -} - -function createDetailedOptionMap(supportOptions) { - return fromPairs( - supportOptions.map((option) => { - const newOption = { - ...option, - name: option.cliName || dashify(option.name), - description: option.cliDescription || option.description, - category: option.cliCategory || coreOptions.CATEGORY_FORMAT, - forwardToApi: option.name, - }; - - if (option.deprecated) { - delete newOption.forwardToApi; - delete newOption.description; - delete newOption.oppositeDescription; - newOption.deprecated = true; - } - - return [newOption.name, newOption]; - }) - ); -} - -//-----------------------------context-util-start------------------------------- -/** - * @typedef {Object} Context - * @property logger - * @property {string[]} args - * @property argv - * @property {string[]} filePatterns - * @property {any[]} supportOptions - * @property detailedOptions - * @property detailedOptionMap - * @property apiDefaultOptions - * @property languages - * @property {Partial[]} stack - */ - -/** @returns {Context} */ -function createContext(args) { - const context = { args, stack: [] }; - - updateContextArgv(context); - normalizeContextArgv(context, ["loglevel", "plugin", "plugin-search-dir"]); - - context.logger = createLogger(context.argv.loglevel); - - updateContextArgv( - context, - context.argv.plugin, - context.argv["plugin-search-dir"] - ); - - return /** @type {Context} */ (context); -} - -function initContext(context) { - // split into 2 step so that we could wrap this in a `try..catch` in cli/index.js - normalizeContextArgv(context); -} - -/** - * @param {Context} context - * @param {string[]} plugins - * @param {string[]=} pluginSearchDirs - */ -function updateContextOptions(context, plugins, pluginSearchDirs) { - const { options: supportOptions, languages } = prettier.getSupportInfo({ - showDeprecated: true, - showUnreleased: true, - showInternal: true, - plugins, - pluginSearchDirs, - }); - - const detailedOptionMap = normalizeDetailedOptionMap({ - ...createDetailedOptionMap(supportOptions), - ...constant.options, - }); - - const detailedOptions = arrayify(detailedOptionMap, "name"); - - const apiDefaultOptions = { - ...optionsModule.hiddenDefaults, - ...fromPairs( - supportOptions - .filter(({ deprecated }) => !deprecated) - .map((option) => [option.name, option.default]) - ), - }; - - Object.assign(context, { - supportOptions, - detailedOptions, - detailedOptionMap, - apiDefaultOptions, - languages, - }); -} - -/** - * @param {Context} context - * @param {string[]} plugins - * @param {string[]=} pluginSearchDirs - */ -function pushContextPlugins(context, plugins, pluginSearchDirs) { - context.stack.push( - pick(context, [ - "supportOptions", - "detailedOptions", - "detailedOptionMap", - "apiDefaultOptions", - "languages", - ]) - ); - updateContextOptions(context, plugins, pluginSearchDirs); -} - -/** - * @param {Context} context - */ -function popContextPlugins(context) { - Object.assign(context, context.stack.pop()); -} - -function updateContextArgv(context, plugins, pluginSearchDirs) { - pushContextPlugins(context, plugins, pluginSearchDirs); - - const minimistOptions = createMinimistOptions(context.detailedOptions); - const argv = minimist(context.args, minimistOptions); - - context.argv = argv; - context.filePatterns = argv._; -} - -function normalizeContextArgv(context, keys) { - const detailedOptions = !keys - ? context.detailedOptions - : context.detailedOptions.filter((option) => keys.includes(option.name)); - const argv = !keys ? context.argv : pick(context.argv, keys); - - context.argv = optionsNormalizer.normalizeCliOptions(argv, detailedOptions, { - logger: context.logger, - }); -} -//------------------------------context-util-end-------------------------------- - -module.exports = { - createContext, - createDetailedOptionMap, - createDetailedUsage, - createUsage, - format, - formatFiles, - formatStdin, - initContext, - logResolvedConfigPathOrDie, - logFileInfoOrDie, - normalizeDetailedOptionMap, -}; diff --git a/src/common/ast-path.js b/src/common/ast-path.js new file mode 100644 index 0000000000..e9d5542f1b --- /dev/null +++ b/src/common/ast-path.js @@ -0,0 +1,197 @@ +"use strict"; +const getLast = require("../utils/get-last"); + +function getNodeHelper(path, count) { + const stackIndex = getNodeStackIndexHelper(path.stack, count); + return stackIndex === -1 ? null : path.stack[stackIndex]; +} + +function getNodeStackIndexHelper(stack, count) { + for (let i = stack.length - 1; i >= 0; i -= 2) { + const value = stack[i]; + if (value && !Array.isArray(value) && --count < 0) { + return i; + } + } + return -1; +} + +class AstPath { + constructor(value) { + this.stack = [value]; + } + + // The name of the current property is always the penultimate element of + // this.stack, and always a String. + getName() { + const { stack } = this; + const { length } = stack; + if (length > 1) { + return stack[length - 2]; + } + // Since the name is always a string, null is a safe sentinel value to + // return if we do not know the name of the (root) value. + /* istanbul ignore next */ + return null; + } + + // The value of the current property is always the final element of + // this.stack. + getValue() { + return getLast(this.stack); + } + + getNode(count = 0) { + return getNodeHelper(this, count); + } + + getParentNode(count = 0) { + return getNodeHelper(this, count + 1); + } + + // Temporarily push properties named by string arguments given after the + // callback function onto this.stack, then call the callback with a + // reference to this (modified) AstPath object. Note that the stack will + // be restored to its original state after the callback is finished, so it + // is probably a mistake to retain a reference to the path. + call(callback, ...names) { + const { stack } = this; + const { length } = stack; + let value = getLast(stack); + + for (const name of names) { + value = value[name]; + stack.push(name, value); + } + const result = callback(this); + stack.length = length; + return result; + } + + callParent(callback, count = 0) { + const stackIndex = getNodeStackIndexHelper(this.stack, count + 1); + const parentValues = this.stack.splice(stackIndex + 1); + const result = callback(this); + this.stack.push(...parentValues); + return result; + } + + // Similar to AstPath.prototype.call, except that the value obtained by + // accessing this.getValue()[name1][name2]... should be array. The + // callback will be called with a reference to this path object for each + // element of the array. + each(callback, ...names) { + const { stack } = this; + const { length } = stack; + let value = getLast(stack); + + for (const name of names) { + value = value[name]; + stack.push(name, value); + } + + for (let i = 0; i < value.length; ++i) { + stack.push(i, value[i]); + callback(this, i, value); + stack.length -= 2; + } + + stack.length = length; + } + + // Similar to AstPath.prototype.each, except that the results of the + // callback function invocations are stored in an array and returned at + // the end of the iteration. + map(callback, ...names) { + const result = []; + this.each((path, index, value) => { + result[index] = callback(path, index, value); + }, ...names); + return result; + } + + /** + * @param {() => void} callback + * @internal Unstable API. Don't use in plugins for now. + */ + try(callback) { + const { stack } = this; + const stackBackup = [...stack]; + try { + return callback(); + } finally { + stack.length = 0; + stack.push(...stackBackup); + } + } + + /** + * @param {...( + * | ((node: any, name: string | null, number: number | null) => boolean) + * | undefined + * )} predicates + */ + match(...predicates) { + let stackPointer = this.stack.length - 1; + + let name = null; + let node = this.stack[stackPointer--]; + + for (const predicate of predicates) { + /* istanbul ignore next */ + if (node === undefined) { + return false; + } + + // skip index/array + let number = null; + if (typeof name === "number") { + number = name; + name = this.stack[stackPointer--]; + node = this.stack[stackPointer--]; + } + + if (predicate && !predicate(node, name, number)) { + return false; + } + + name = this.stack[stackPointer--]; + node = this.stack[stackPointer--]; + } + + return true; + } + + /** + * Traverses the ancestors of the current node heading toward the tree root + * until it finds a node that matches the provided predicate function. Will + * return the first matching ancestor. If no such node exists, returns undefined. + * @param {(node: any, name: string, number: number | null) => boolean} predicate + * @internal Unstable API. Don't use in plugins for now. + */ + findAncestor(predicate) { + let stackPointer = this.stack.length - 1; + + let name = null; + let node = this.stack[stackPointer--]; + + while (node) { + // skip index/array + let number = null; + if (typeof name === "number") { + number = name; + name = this.stack[stackPointer--]; + node = this.stack[stackPointer--]; + } + + if (name !== null && predicate(node, name, number)) { + return node; + } + + name = this.stack[stackPointer--]; + node = this.stack[stackPointer--]; + } + } +} + +module.exports = AstPath; diff --git a/src/common/common-options.js b/src/common/common-options.js index 8e6686ba10..e871764114 100644 --- a/src/common/common-options.js +++ b/src/common/common-options.js @@ -2,7 +2,7 @@ const CATEGORY_COMMON = "Common"; -// format based on https://github.com/prettier/prettier/blob/master/src/main/core-options.js +// format based on https://github.com/prettier/prettier/blob/main/src/main/core-options.js module.exports = { singleQuote: { since: "0.0.0", diff --git a/src/common/create-ignorer.js b/src/common/create-ignorer.js index 1fdae09444..6d7ba77f4f 100644 --- a/src/common/create-ignorer.js +++ b/src/common/create-ignorer.js @@ -1,12 +1,12 @@ "use strict"; -const ignore = require("ignore"); const path = require("path"); +const ignore = require("ignore"); const getFileContentOrNull = require("../utils/get-file-content-or-null"); /** - * @param {undefined | string} ignorePath - * @param {undefined | boolean} withNodeModules + * @param {string?} ignorePath + * @param {boolean?} withNodeModules */ async function createIgnorer(ignorePath, withNodeModules) { const ignoreContent = ignorePath @@ -17,8 +17,8 @@ async function createIgnorer(ignorePath, withNodeModules) { } /** - * @param {undefined | string} ignorePath - * @param {undefined | boolean} withNodeModules + * @param {string?} ignorePath + * @param {boolean?} withNodeModules */ createIgnorer.sync = function (ignorePath, withNodeModules) { const ignoreContent = !ignorePath @@ -29,7 +29,7 @@ createIgnorer.sync = function (ignorePath, withNodeModules) { /** * @param {null | string} ignoreContent - * @param {undefined | boolean} withNodeModules + * @param {boolean?} withNodeModules */ function _createIgnorer(ignoreContent, withNodeModules) { const ignorer = ignore().add(ignoreContent || ""); diff --git a/src/common/end-of-line.js b/src/common/end-of-line.js index a2bd05cb42..0f4142576d 100644 --- a/src/common/end-of-line.js +++ b/src/common/end-of-line.js @@ -19,7 +19,31 @@ function convertEndOfLineToChars(value) { } } +function countEndOfLineChars(text, eol) { + let regex; + + /* istanbul ignore else */ + if (eol === "\n") { + regex = /\n/g; + } else if (eol === "\r") { + regex = /\r/g; + } else if (eol === "\r\n") { + regex = /\r\n/g; + } else { + throw new Error(`Unexpected "eol" ${JSON.stringify(eol)}.`); + } + + const endOfLines = text.match(regex); + return endOfLines ? endOfLines.length : 0; +} + +function normalizeEndOfLine(text) { + return text.replace(/\r\n?/g, "\n"); +} + module.exports = { guessEndOfLine, convertEndOfLineToChars, + countEndOfLineChars, + normalizeEndOfLine, }; diff --git a/src/common/errors.js b/src/common/errors.js index d856b47d0e..60979117bb 100644 --- a/src/common/errors.js +++ b/src/common/errors.js @@ -3,9 +3,11 @@ class ConfigError extends Error {} class DebugError extends Error {} class UndefinedParserError extends Error {} +class ArgExpansionBailout extends Error {} module.exports = { ConfigError, DebugError, UndefinedParserError, + ArgExpansionBailout, }; diff --git a/src/common/fast-path.js b/src/common/fast-path.js deleted file mode 100644 index 3a4a7b157a..0000000000 --- a/src/common/fast-path.js +++ /dev/null @@ -1,171 +0,0 @@ -"use strict"; -const getLast = require("../utils/get-last"); - -function getNodeHelper(path, count) { - const stackIndex = getNodeStackIndexHelper(path.stack, count); - return stackIndex === -1 ? null : path.stack[stackIndex]; -} - -function getNodeStackIndexHelper(stack, count) { - for (let i = stack.length - 1; i >= 0; i -= 2) { - const value = stack[i]; - if (value && !Array.isArray(value) && --count < 0) { - return i; - } - } - return -1; -} - -class FastPath { - constructor(value) { - this.stack = [value]; - } - - // The name of the current property is always the penultimate element of - // this.stack, and always a String. - getName() { - const { stack } = this; - const { length } = stack; - if (length > 1) { - return stack[length - 2]; - } - // Since the name is always a string, null is a safe sentinel value to - // return if we do not know the name of the (root) value. - /* istanbul ignore next */ - return null; - } - - // The value of the current property is always the final element of - // this.stack. - getValue() { - return getLast(this.stack); - } - - getNode(count = 0) { - return getNodeHelper(this, count); - } - - getParentNode(count = 0) { - return getNodeHelper(this, count + 1); - } - - // Temporarily push properties named by string arguments given after the - // callback function onto this.stack, then call the callback with a - // reference to this (modified) FastPath object. Note that the stack will - // be restored to its original state after the callback is finished, so it - // is probably a mistake to retain a reference to the path. - call(callback, ...names) { - const { stack } = this; - const { length } = stack; - let value = getLast(stack); - - for (const name of names) { - value = value[name]; - stack.push(name, value); - } - const result = callback(this); - stack.length = length; - return result; - } - - callParent(callback, count = 0) { - const stackIndex = getNodeStackIndexHelper(this.stack, count + 1); - const parentValues = this.stack.splice(stackIndex + 1); - const result = callback(this); - this.stack.push(...parentValues); - return result; - } - - // Similar to FastPath.prototype.call, except that the value obtained by - // accessing this.getValue()[name1][name2]... should be array-like. The - // callback will be called with a reference to this path object for each - // element of the array. - each(callback, ...names) { - const { stack } = this; - const { length } = stack; - let value = getLast(stack); - - for (const name of names) { - value = value[name]; - stack.push(name, value); - } - - for (let i = 0; i < value.length; ++i) { - if (i in value) { - stack.push(i, value[i]); - // If the callback needs to know the value of i, call - // path.getName(), assuming path is the parameter name. - callback(this); - stack.length -= 2; - } - } - - stack.length = length; - } - - // Similar to FastPath.prototype.each, except that the results of the - // callback function invocations are stored in an array and returned at - // the end of the iteration. - map(callback, ...names) { - const { stack } = this; - const { length } = stack; - let value = getLast(stack); - - for (const name of names) { - value = value[name]; - stack.push(name, value); - } - - const result = new Array(value.length); - - for (let i = 0; i < value.length; ++i) { - if (i in value) { - stack.push(i, value[i]); - result[i] = callback(this, i); - stack.length -= 2; - } - } - - stack.length = length; - - return result; - } - - /** - * @param {...( - * | ((node: any, name: string | null, number: number | null) => boolean) - * | undefined - * )} predicates - */ - match(...predicates) { - let stackPointer = this.stack.length - 1; - - let name = null; - let node = this.stack[stackPointer--]; - - for (const predicate of predicates) { - if (node === undefined) { - return false; - } - - // skip index/array - let number = null; - if (typeof name === "number") { - number = name; - name = this.stack[stackPointer--]; - node = this.stack[stackPointer--]; - } - - if (predicate && !predicate(node, name, number)) { - return false; - } - - name = this.stack[stackPointer--]; - node = this.stack[stackPointer--]; - } - - return true; - } -} - -module.exports = FastPath; diff --git a/src/common/get-file-info.js b/src/common/get-file-info.js index 5c5f2b20d0..f7f2776e6d 100644 --- a/src/common/get-file-info.js +++ b/src/common/get-file-info.js @@ -1,9 +1,9 @@ "use strict"; -const createIgnorer = require("./create-ignorer"); +const path = require("path"); const options = require("../main/options"); const config = require("../config/resolve-config"); -const path = require("path"); +const createIgnorer = require("./create-ignorer"); /** * @typedef {{ ignorePath?: string, withNodeModules?: boolean, plugins: object }} FileInfoOptions @@ -29,9 +29,10 @@ async function getFileInfo(filePath, opts) { const ignorer = await createIgnorer(opts.ignorePath, opts.withNodeModules); return _getFileInfo({ ignorer, - filePath: normalizeFilePath(filePath, opts.ignorePath), + filePath, plugins: opts.plugins, resolveConfig: opts.resolveConfig, + ignorePath: opts.ignorePath, sync: false, }); } @@ -51,42 +52,65 @@ getFileInfo.sync = function (filePath, opts) { const ignorer = createIgnorer.sync(opts.ignorePath, opts.withNodeModules); return _getFileInfo({ ignorer, - filePath: normalizeFilePath(filePath, opts.ignorePath), + filePath, plugins: opts.plugins, resolveConfig: opts.resolveConfig, + ignorePath: opts.ignorePath, sync: true, }); }; +function getFileParser(resolvedConfig, filePath, plugins) { + if (resolvedConfig && resolvedConfig.parser) { + return resolvedConfig.parser; + } + + const inferredParser = options.inferParser(filePath, plugins); + + if (inferredParser) { + return inferredParser; + } + + return null; +} + function _getFileInfo({ ignorer, filePath, plugins, resolveConfig = false, + ignorePath, sync = false, }) { + const normalizedFilePath = normalizeFilePath(filePath, ignorePath); + const fileInfo = { - ignored: ignorer.ignores(filePath), - inferredParser: options.inferParser(filePath, plugins) || null, + ignored: ignorer.ignores(normalizedFilePath), + inferredParser: null, }; - if (!fileInfo.inferredParser && resolveConfig) { - if (!sync) { - return config.resolveConfig(filePath).then((resolvedConfig) => { - if (resolvedConfig && resolvedConfig.parser) { - fileInfo.inferredParser = resolvedConfig.parser; - } + if (fileInfo.ignored) { + return fileInfo; + } + let resolvedConfig; + + if (resolveConfig) { + if (sync) { + resolvedConfig = config.resolveConfig.sync(filePath); + } else { + return config.resolveConfig(filePath).then((resolvedConfig) => { + fileInfo.inferredParser = getFileParser( + resolvedConfig, + filePath, + plugins + ); return fileInfo; }); } - - const resolvedConfig = config.resolveConfig.sync(filePath); - if (resolvedConfig && resolvedConfig.parser) { - fileInfo.inferredParser = resolvedConfig.parser; - } } + fileInfo.inferredParser = getFileParser(resolvedConfig, filePath, plugins); return fileInfo; } diff --git a/src/common/internal-plugins.js b/src/common/internal-plugins.js deleted file mode 100644 index be59adc22a..0000000000 --- a/src/common/internal-plugins.js +++ /dev/null @@ -1,171 +0,0 @@ -"use strict"; - -// We need to use `eval("require")()` to prevent rollup from hoisting the requires. A babel -// plugin will look for `eval("require")()` and transform to `require()` in the bundle, -// and rewrite the paths to require from the top-level. - -// We need to list the parsers and getters so we can load them only when necessary. -module.exports = [ - // JS - require("../language-js"), - { - parsers: { - // JS - Babel - get babel() { - return eval("require")("../language-js/parser-babel").parsers.babel; - }, - get "babel-flow"() { - return eval("require")("../language-js/parser-babel").parsers[ - "babel-flow" - ]; - }, - get "babel-ts"() { - return eval("require")("../language-js/parser-babel").parsers[ - "babel-ts" - ]; - }, - get json() { - return eval("require")("../language-js/parser-babel").parsers.json; - }, - get json5() { - return eval("require")("../language-js/parser-babel").parsers.json5; - }, - get "json-stringify"() { - return eval("require")("../language-js/parser-babel").parsers[ - "json-stringify" - ]; - }, - get __js_expression() { - return eval("require")("../language-js/parser-babel").parsers - .__js_expression; - }, - get __vue_expression() { - return eval("require")("../language-js/parser-babel").parsers - .__vue_expression; - }, - get __vue_event_binding() { - return eval("require")("../language-js/parser-babel").parsers - .__vue_event_binding; - }, - // JS - Flow - get flow() { - return eval("require")("../language-js/parser-flow").parsers.flow; - }, - // JS - TypeScript - get typescript() { - return eval("require")("../language-js/parser-typescript").parsers - .typescript; - }, - // JS - Angular Action - get __ng_action() { - return eval("require")("../language-js/parser-angular").parsers - .__ng_action; - }, - // JS - Angular Binding - get __ng_binding() { - return eval("require")("../language-js/parser-angular").parsers - .__ng_binding; - }, - // JS - Angular Interpolation - get __ng_interpolation() { - return eval("require")("../language-js/parser-angular").parsers - .__ng_interpolation; - }, - // JS - Angular Directive - get __ng_directive() { - return eval("require")("../language-js/parser-angular").parsers - .__ng_directive; - }, - }, - }, - - // CSS - require("../language-css"), - { - parsers: { - // TODO: switch these to just `postcss` and use `language` instead. - get css() { - return eval("require")("../language-css/parser-postcss").parsers.css; - }, - get less() { - return eval("require")("../language-css/parser-postcss").parsers.less; - }, - get scss() { - return eval("require")("../language-css/parser-postcss").parsers.scss; - }, - }, - }, - - // Handlebars - require("../language-handlebars"), - { - parsers: { - get glimmer() { - return eval("require")("../language-handlebars/parser-glimmer").parsers - .glimmer; - }, - }, - }, - - // GraphQL - require("../language-graphql"), - { - parsers: { - get graphql() { - return eval("require")("../language-graphql/parser-graphql").parsers - .graphql; - }, - }, - }, - - // Markdown - require("../language-markdown"), - { - parsers: { - get remark() { - return eval("require")("../language-markdown/parser-markdown").parsers - .remark; - }, - get markdown() { - return eval("require")("../language-markdown/parser-markdown").parsers - .remark; - }, - get mdx() { - return eval("require")("../language-markdown/parser-markdown").parsers - .mdx; - }, - }, - }, - - require("../language-html"), - { - parsers: { - // HTML - get html() { - return eval("require")("../language-html/parser-html").parsers.html; - }, - // Vue - get vue() { - return eval("require")("../language-html/parser-html").parsers.vue; - }, - // Angular - get angular() { - return eval("require")("../language-html/parser-html").parsers.angular; - }, - // Lightning Web Components - get lwc() { - return eval("require")("../language-html/parser-html").parsers.lwc; - }, - }, - }, - - // YAML - require("../language-yaml"), - { - parsers: { - get yaml() { - return eval("require")("../language-yaml/parser-yaml").parsers.yaml; - }, - }, - }, -]; diff --git a/src/common/load-plugins.js b/src/common/load-plugins.js index 7cc20403c4..1e00466aa5 100644 --- a/src/common/load-plugins.js +++ b/src/common/load-plugins.js @@ -1,13 +1,13 @@ "use strict"; +const fs = require("fs"); +const path = require("path"); const uniqBy = require("lodash/uniqBy"); const partition = require("lodash/partition"); -const fs = require("fs"); const globby = require("globby"); -const path = require("path"); -const thirdParty = require("./third-party"); -const internalPlugins = require("./internal-plugins"); const mem = require("mem"); +const internalPlugins = require("../languages"); +const thirdParty = require("./third-party"); const resolve = require("./resolve"); const memoizedLoad = mem(load, { cacheKey: JSON.stringify }); @@ -26,7 +26,7 @@ function load(plugins, pluginSearchDirs) { pluginSearchDirs = []; } // unless pluginSearchDirs are provided, auto-load plugins from node_modules that are parent to Prettier - if (!pluginSearchDirs.length) { + if (pluginSearchDirs.length === 0) { const autoLoadDir = thirdParty.findParentDir(__dirname, "node_modules"); if (autoLoadDir) { pluginSearchDirs = [autoLoadDir]; @@ -44,7 +44,7 @@ function load(plugins, pluginSearchDirs) { try { // try local files requirePath = resolve(path.resolve(process.cwd(), pluginName)); - } catch (_) { + } catch { // try node modules requirePath = resolve(pluginName, { paths: [process.cwd()] }); } @@ -56,8 +56,8 @@ function load(plugins, pluginSearchDirs) { } ); - const externalAutoLoadPluginInfos = pluginSearchDirs - .map((pluginSearchDir) => { + const externalAutoLoadPluginInfos = pluginSearchDirs.flatMap( + (pluginSearchDir) => { const resolvedPluginSearchDir = path.resolve( process.cwd(), pluginSearchDir @@ -84,20 +84,21 @@ function load(plugins, pluginSearchDirs) { name: pluginName, requirePath: resolve(pluginName, { paths: [resolvedPluginSearchDir] }), })); - }) - .reduce((a, b) => a.concat(b), []); - - const externalPlugins = uniqBy( - externalManualLoadPluginInfos.concat(externalAutoLoadPluginInfos), - "requirePath" - ) - .map((externalPluginInfo) => ({ + } + ); + + const externalPlugins = [ + ...uniqBy( + [...externalManualLoadPluginInfos, ...externalAutoLoadPluginInfos], + "requirePath" + ).map((externalPluginInfo) => ({ name: externalPluginInfo.name, - ...eval("require")(externalPluginInfo.requirePath), - })) - .concat(externalPluginInstances); + ...require(externalPluginInfo.requirePath), + })), + ...externalPluginInstances, + ]; - return internalPlugins.concat(externalPlugins); + return [...internalPlugins, ...externalPlugins]; } function findPluginsInNodeModules(nodeModulesDir) { @@ -118,7 +119,7 @@ function findPluginsInNodeModules(nodeModulesDir) { function isDirectory(dir) { try { return fs.statSync(dir).isDirectory(); - } catch (e) { + } catch { return false; } } diff --git a/src/common/parser-create-error.js b/src/common/parser-create-error.js index d96df606af..a3f68defd9 100644 --- a/src/common/parser-create-error.js +++ b/src/common/parser-create-error.js @@ -5,6 +5,7 @@ function createError(message, loc) { const error = new SyntaxError( message + " (" + loc.start.line + ":" + loc.start.column + ")" ); + // @ts-ignore - TBD (...) error.loc = loc; return error; } diff --git a/src/common/parser-include-shebang.js b/src/common/parser-include-shebang.js deleted file mode 100644 index b1423113ac..0000000000 --- a/src/common/parser-include-shebang.js +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; - -function includeShebang(text, ast) { - if (!text.startsWith("#!")) { - return; - } - - const index = text.indexOf("\n"); - const shebang = text.slice(2, index); - const comment = { - type: "Line", - value: shebang, - range: [0, index], - loc: { - start: { - line: 1, - column: 0, - }, - end: { - line: 1, - column: index, - }, - }, - }; - - ast.comments = [comment].concat(ast.comments); -} - -module.exports = includeShebang; diff --git a/src/common/resolve.js b/src/common/resolve.js index f413d1cae3..a22f5c1a83 100644 --- a/src/common/resolve.js +++ b/src/common/resolve.js @@ -1,11 +1,10 @@ "use strict"; -// `/scripts/build/babel-plugins/transform-custom-require.js` doesn't support destructuring. -// eslint-disable-next-line prefer-destructuring -let resolve = eval("require").resolve; +let { resolve } = require; // In the VS Code and Atom extensions `require` is overridden and `require.resolve` doesn't support the 2nd argument. if (resolve.length === 1 || process.env.PRETTIER_FALLBACK_RESOLVE) { + // @ts-ignore resolve = (id, options) => { let basedir; if (options && options.paths && options.paths.length === 1) { diff --git a/src/common/third-party.js b/src/common/third-party.js index 0f139cc722..a70e30d3b6 100644 --- a/src/common/third-party.js +++ b/src/common/third-party.js @@ -4,6 +4,6 @@ module.exports = { cosmiconfig: require("cosmiconfig").cosmiconfig, cosmiconfigSync: require("cosmiconfig").cosmiconfigSync, findParentDir: require("find-parent-dir").sync, - getStream: require("get-stream"), + getStdin: require("get-stdin"), isCI: () => require("ci-info").isCI, }; diff --git a/src/common/util.js b/src/common/util.js index 51ab3a1dce..94b8591c77 100644 --- a/src/common/util.js +++ b/src/common/util.js @@ -3,16 +3,11 @@ const stringWidth = require("string-width"); const escapeStringRegexp = require("escape-string-regexp"); const getLast = require("../utils/get-last"); +const { getSupportInfo } = require("../main/support"); -// eslint-disable-next-line no-control-regex const notAsciiRegex = /[^\x20-\x7F]/; -function getPenultimate(arr) { - if (arr.length > 1) { - return arr[arr.length - 2]; - } - return null; -} +const getPenultimate = (arr) => arr[arr.length - 2]; /** * @typedef {{backwards?: boolean}} SkipOptions @@ -28,6 +23,7 @@ function skip(chars) { // Allow `skip` functions to be threaded together without having // to check for failures (did someone say monads?). + /* istanbul ignore next */ if (index === false) { return false; } @@ -73,7 +69,7 @@ const skipToLineEnd = skip(",; \t"); /** * @type {(text: string, index: number | false, opts?: SkipOptions) => number | false} */ -const skipEverythingButNewLine = skip(/[^\r\n]/); +const skipEverythingButNewLine = skip(/[^\n\r]/); /** * @param {string} text @@ -81,6 +77,7 @@ const skipEverythingButNewLine = skip(/[^\r\n]/); * @returns {number | false} */ function skipInlineComment(text, index) { + /* istanbul ignore next */ if (index === false) { return false; } @@ -101,6 +98,7 @@ function skipInlineComment(text, index) { * @returns {number | false} */ function skipTrailingComment(text, index) { + /* istanbul ignore next */ if (index === false) { return false; } @@ -128,6 +126,8 @@ function skipNewline(text, index, opts) { const atIndex = text.charAt(index); if (backwards) { + // We already replace `\r\n` with `\n` before parsing + /* istanbul ignore next */ if (text.charAt(index - 1) === "\r" && atIndex === "\n") { return index - 2; } @@ -140,6 +140,8 @@ function skipNewline(text, index, opts) { return index - 1; } } else { + // We already replace `\r\n` with `\n` before parsing + /* istanbul ignore next */ if (atIndex === "\r" && text.charAt(index + 1) === "\n") { return index + 2; } @@ -162,8 +164,7 @@ function skipNewline(text, index, opts) { * @param {SkipOptions=} opts * @returns {boolean} */ -function hasNewline(text, index, opts) { - opts = opts || {}; +function hasNewline(text, index, opts = {}) { const idx = skipSpaces(text, opts.backwards ? index - 1 : index, opts); const idx2 = skipNewline(text, idx, opts); return idx !== idx2; @@ -282,215 +283,26 @@ function getNextNonSpaceNonCommentCharacter(text, node, locEnd) { ); } +// Not using, but it's public utils +/* istanbul ignore next */ /** * @param {string} text * @param {number} index * @param {SkipOptions=} opts * @returns {boolean} */ -function hasSpaces(text, index, opts) { - opts = opts || {}; +function hasSpaces(text, index, opts = {}) { const idx = skipSpaces(text, opts.backwards ? index - 1 : index, opts); return idx !== index; } -/** - * @param {{range?: [number, number], start?: number}} node - * @param {number} index - */ -function setLocStart(node, index) { - if (node.range) { - node.range[0] = index; - } else { - node.start = index; - } -} - -/** - * @param {{range?: [number, number], end?: number}} node - * @param {number} index - */ -function setLocEnd(node, index) { - if (node.range) { - node.range[1] = index; - } else { - node.end = index; - } -} - -const PRECEDENCE = {}; -[ - ["|>"], - ["??"], - ["||"], - ["&&"], - ["|"], - ["^"], - ["&"], - ["==", "===", "!=", "!=="], - ["<", ">", "<=", ">=", "in", "instanceof"], - [">>", "<<", ">>>"], - ["+", "-"], - ["*", "/", "%"], - ["**"], -].forEach((tier, i) => { - tier.forEach((op) => { - PRECEDENCE[op] = i; - }); -}); - -function getPrecedence(op) { - return PRECEDENCE[op]; -} - -const equalityOperators = { - "==": true, - "!=": true, - "===": true, - "!==": true, -}; -const multiplicativeOperators = { - "*": true, - "/": true, - "%": true, -}; -const bitshiftOperators = { - ">>": true, - ">>>": true, - "<<": true, -}; - -function shouldFlatten(parentOp, nodeOp) { - if (getPrecedence(nodeOp) !== getPrecedence(parentOp)) { - return false; - } - - // ** is right-associative - // x ** y ** z --> x ** (y ** z) - if (parentOp === "**") { - return false; - } - - // x == y == z --> (x == y) == z - if (equalityOperators[parentOp] && equalityOperators[nodeOp]) { - return false; - } - - // x * y % z --> (x * y) % z - if ( - (nodeOp === "%" && multiplicativeOperators[parentOp]) || - (parentOp === "%" && multiplicativeOperators[nodeOp]) - ) { - return false; - } - - // x * y / z --> (x * y) / z - // x / y * z --> (x / y) * z - if ( - nodeOp !== parentOp && - multiplicativeOperators[nodeOp] && - multiplicativeOperators[parentOp] - ) { - return false; - } - - // x << y << z --> (x << y) << z - if (bitshiftOperators[parentOp] && bitshiftOperators[nodeOp]) { - return false; - } - - return true; -} - -function isBitwiseOperator(operator) { - return ( - !!bitshiftOperators[operator] || - operator === "|" || - operator === "^" || - operator === "&" - ); -} - -// Tests if an expression starts with `{`, or (if forbidFunctionClassAndDoExpr -// holds) `function`, `class`, or `do {}`. Will be overzealous if there's -// already necessary grouping parentheses. -function startsWithNoLookaheadToken(node, forbidFunctionClassAndDoExpr) { - node = getLeftMost(node); - switch (node.type) { - case "FunctionExpression": - case "ClassExpression": - case "DoExpression": - return forbidFunctionClassAndDoExpr; - case "ObjectExpression": - return true; - case "MemberExpression": - case "OptionalMemberExpression": - return startsWithNoLookaheadToken( - node.object, - forbidFunctionClassAndDoExpr - ); - case "TaggedTemplateExpression": - if (node.tag.type === "FunctionExpression") { - // IIFEs are always already parenthesized - return false; - } - return startsWithNoLookaheadToken(node.tag, forbidFunctionClassAndDoExpr); - case "CallExpression": - case "OptionalCallExpression": - if (node.callee.type === "FunctionExpression") { - // IIFEs are always already parenthesized - return false; - } - return startsWithNoLookaheadToken( - node.callee, - forbidFunctionClassAndDoExpr - ); - case "ConditionalExpression": - return startsWithNoLookaheadToken( - node.test, - forbidFunctionClassAndDoExpr - ); - case "UpdateExpression": - return ( - !node.prefix && - startsWithNoLookaheadToken(node.argument, forbidFunctionClassAndDoExpr) - ); - case "BindExpression": - return ( - node.object && - startsWithNoLookaheadToken(node.object, forbidFunctionClassAndDoExpr) - ); - case "SequenceExpression": - return startsWithNoLookaheadToken( - node.expressions[0], - forbidFunctionClassAndDoExpr - ); - case "TSAsExpression": - return startsWithNoLookaheadToken( - node.expression, - forbidFunctionClassAndDoExpr - ); - default: - return false; - } -} - -function getLeftMost(node) { - if (node.left) { - return getLeftMost(node.left); - } - return node; -} - /** * @param {string} value * @param {number} tabWidth * @param {number=} startIndex * @returns {number} */ -function getAlignmentSize(value, tabWidth, startIndex) { - startIndex = startIndex || 0; - +function getAlignmentSize(value, tabWidth, startIndex = 0) { let size = 0; for (let i = startIndex; i < value.length; ++i) { if (value[i] === "\t") { @@ -520,7 +332,7 @@ function getIndentSize(value, tabWidth) { return getAlignmentSize( // All the leading whitespaces - value.slice(lastNewlineIndex + 1).match(/^[ \t]*/)[0], + value.slice(lastNewlineIndex + 1).match(/^[\t ]*/)[0], tabWidth ); } @@ -569,35 +381,22 @@ function getPreferredQuote(raw, preferredQuote) { return result; } -function printString(raw, options, isDirectiveLiteral) { +function printString(raw, options) { // `rawContent` is the string exactly like it appeared in the input source // code, without its enclosing quotes. const rawContent = raw.slice(1, -1); - // Check for the alternate quote, to determine if we're allowed to swap - // the quotes on a DirectiveLiteral. - const canChangeDirectiveQuotes = - !rawContent.includes('"') && !rawContent.includes("'"); - /** @type {Quote} */ const enclosingQuote = - options.parser === "json" + options.parser === "json" || + (options.parser === "json5" && + options.quoteProps === "preserve" && + !options.singleQuote) ? '"' : options.__isInHtmlAttribute ? "'" : getPreferredQuote(raw, options.singleQuote ? "'" : '"'); - // Directives are exact code unit sequences, which means that you can't - // change the escape sequences they use. - // See https://github.com/prettier/prettier/issues/1555 - // and https://tc39.github.io/ecma262/#directive-prologue - if (isDirectiveLiteral) { - if (canChangeDirectiveQuotes) { - return enclosingQuote + rawContent + enclosingQuote; - } - return raw; - } - // It might sound unnecessary to use `makeString` even if the string already // is enclosed with `enclosingQuote`, but it isn't. The string could contain // unnecessary escapes (such as in `"\'"`). Always using `makeString` makes @@ -609,7 +408,7 @@ function printString(raw, options, isDirectiveLiteral) { options.parser === "css" || options.parser === "less" || options.parser === "scss" || - options.embeddedInHtml + options.__embeddedInHtml ) ); } @@ -624,7 +423,7 @@ function makeString(rawContent, enclosingQuote, unescapeUnnecessaryEscapes) { const otherQuote = enclosingQuote === '"' ? "'" : '"'; // Matches _any_ escape and unescaped quotes (both single and double). - const regex = /\\([\s\S])|(['"])/g; + const regex = /\\(.)|(["'])/gs; // Escape and unescape single and double quotes as needed to be able to // enclose `rawContent` with `enclosingQuote`. @@ -650,7 +449,7 @@ function makeString(rawContent, enclosingQuote, unescapeUnnecessaryEscapes) { // Unescape any unnecessarily escaped character. // Adapted from https://github.com/eslint/eslint/blob/de0b4ad7bd820ade41b1f606008bea68683dc11a/lib/rules/no-useless-escape.js#L27 return unescapeUnnecessaryEscapes && - /^[^\\nrvtbfux\r\n\u2028\u2029"'0-7]$/.test(escaped) + /^[^\n\r"'0-7\\bfnrt-vx\u2028\u2029]$/.test(escaped) ? escaped : "\\" + escaped; }); @@ -741,38 +540,11 @@ function getStringWidth(text) { return stringWidth(text); } -function hasIgnoreComment(path) { - const node = path.getValue(); - return hasNodeIgnoreComment(node); -} - -function hasNodeIgnoreComment(node) { - return ( - node && - ((node.comments && - node.comments.length > 0 && - node.comments.some( - (comment) => isNodeIgnoreComment(comment) && !comment.unignore - )) || - node.prettierIgnore) - ); -} - -function isNodeIgnoreComment(comment) { - return comment.value.trim() === "prettier-ignore"; -} - function addCommentHelper(node, comment) { const comments = node.comments || (node.comments = []); comments.push(comment); comment.printed = false; - - // For some reason, TypeScript parses `// x` inside of JSXText as a comment - // We already "print" it via the raw text, we don't need to re-print it as a - // comment - if (node.type === "JSXText") { - comment.printed = true; - } + comment.nodeDescription = describeNodeForDebugging(node); } function addLeadingComment(node, comment) { @@ -781,9 +553,12 @@ function addLeadingComment(node, comment) { addCommentHelper(node, comment); } -function addDanglingComment(node, comment) { +function addDanglingComment(node, comment, marker) { comment.leading = false; comment.trailing = false; + if (marker) { + comment.marker = marker; + } addCommentHelper(node, comment); } @@ -793,41 +568,79 @@ function addTrailingComment(node, comment) { addCommentHelper(node, comment); } -function isWithinParentArrayProperty(path, propertyName) { - const node = path.getValue(); - const parent = path.getParentNode(); +function inferParserByLanguage(language, options) { + const { languages } = getSupportInfo({ plugins: options.plugins }); + const matched = + languages.find(({ name }) => name.toLowerCase() === language) || + languages.find( + ({ aliases }) => Array.isArray(aliases) && aliases.includes(language) + ) || + languages.find( + ({ extensions }) => + Array.isArray(extensions) && extensions.includes(`.${language}`) + ); + return matched && matched.parsers[0]; +} - if (parent == null) { - return false; - } +function isFrontMatterNode(node) { + return node && node.type === "front-matter"; +} - if (!Array.isArray(parent[propertyName])) { - return false; +function getShebang(text) { + if (!text.startsWith("#!")) { + return ""; + } + const index = text.indexOf("\n"); + if (index === -1) { + return text; } + return text.slice(0, index); +} - const key = path.getName(); - return parent[propertyName][key] === node; +/** + * @param {any} object + * @returns {object is Array} + */ +function isNonEmptyArray(object) { + return Array.isArray(object) && object.length > 0; } -function replaceEndOfLineWith(text, replacement) { - const parts = []; - for (const part of text.split("\n")) { - if (parts.length !== 0) { - parts.push(replacement); +/** + * @param {string} description + * @returns {(node: any) => symbol} + */ +function createGroupIdMapper(description) { + const groupIds = new WeakMap(); + return function (node) { + if (!groupIds.has(node)) { + groupIds.set(node, Symbol(description)); } - parts.push(part); + return groupIds.get(node); + }; +} + +function describeNodeForDebugging(node) { + const nodeType = node.type || node.kind || "(unknown type)"; + let nodeName = String( + node.name || + (node.id && (typeof node.id === "object" ? node.id.name : node.id)) || + (node.key && (typeof node.key === "object" ? node.key.name : node.key)) || + (node.value && + (typeof node.value === "object" ? "" : String(node.value))) || + node.operator || + "" + ); + if (nodeName.length > 20) { + nodeName = nodeName.slice(0, 19) + "…"; } - return parts; + return nodeType + (nodeName ? " " + nodeName : ""); } module.exports = { - replaceEndOfLineWith, + inferParserByLanguage, getStringWidth, getMaxContinuousCount, getMinNotPresentContinuousCount, - getPrecedence, - shouldFlatten, - isBitwiseOperator, getPenultimate, getLast, getNextNonSpaceNonCommentCharacterIndexWithStartIndex, @@ -847,20 +660,17 @@ module.exports = { hasNewline, hasNewlineInRange, hasSpaces, - setLocStart, - setLocEnd, - startsWithNoLookaheadToken, getAlignmentSize, getIndentSize, getPreferredQuote, printString, printNumber, - hasIgnoreComment, - hasNodeIgnoreComment, - isNodeIgnoreComment, makeString, addLeadingComment, addDanglingComment, addTrailingComment, - isWithinParentArrayProperty, + isFrontMatterNode, + getShebang, + isNonEmptyArray, + createGroupIdMapper, }; diff --git a/src/config/find-project-root.js b/src/config/find-project-root.js new file mode 100644 index 0000000000..10ca7faeb9 --- /dev/null +++ b/src/config/find-project-root.js @@ -0,0 +1,26 @@ +"use strict"; + +// Simple version of `find-project-root` +// https://github.com/kirstein/find-project-root/blob/master/index.js + +const fs = require("fs"); +const path = require("path"); + +const MARKERS = [".git", ".hg"]; + +const markerExists = (directory) => + MARKERS.some((mark) => fs.existsSync(path.join(directory, mark))); + +function findProjectRoot(directory) { + while (!markerExists(directory)) { + const parentDirectory = path.resolve(directory, ".."); + if (parentDirectory === directory) { + break; + } + directory = parentDirectory; + } + + return directory; +} + +module.exports = findProjectRoot; diff --git a/src/config/resolve-config-editorconfig.js b/src/config/resolve-config-editorconfig.js index c026efd11e..ebf9971235 100644 --- a/src/config/resolve-config-editorconfig.js +++ b/src/config/resolve-config-editorconfig.js @@ -1,38 +1,26 @@ "use strict"; -const fs = require("fs"); const path = require("path"); const editorconfig = require("editorconfig"); const mem = require("mem"); const editorConfigToPrettier = require("editorconfig-to-prettier"); -const findProjectRoot = require("find-project-root"); +const findProjectRoot = require("./find-project-root"); const jsonStringifyMem = (fn) => mem(fn, { cacheKey: JSON.stringify }); -const maybeParse = (filePath, parse) => { - // findProjectRoot will throw an error if we pass a nonexistent directory to - // it, which is possible, for example, when the path is given via - // --stdin-filepath. So, first, traverse up until we find an existing - // directory. - let dirPath = path.dirname(path.resolve(filePath)); - const fsRoot = path.parse(dirPath).root; - while (dirPath !== fsRoot && !fs.existsSync(dirPath)) { - dirPath = path.dirname(dirPath); - } - const root = findProjectRoot(dirPath); - return filePath && parse(filePath, { root }); -}; +const maybeParse = (filePath, parse) => + filePath && + parse(filePath, { + root: findProjectRoot(path.dirname(path.resolve(filePath))), + }); -const editorconfigAsyncNoCache = async (filePath) => { - const editorConfig = await maybeParse(filePath, editorconfig.parse); - return editorConfigToPrettier(editorConfig); -}; +const editorconfigAsyncNoCache = async (filePath) => + editorConfigToPrettier(await maybeParse(filePath, editorconfig.parse)); const editorconfigAsyncWithCache = jsonStringifyMem(editorconfigAsyncNoCache); -const editorconfigSyncNoCache = (filePath) => { - return editorConfigToPrettier(maybeParse(filePath, editorconfig.parseSync)); -}; +const editorconfigSyncNoCache = (filePath) => + editorConfigToPrettier(maybeParse(filePath, editorconfig.parseSync)); const editorconfigSyncWithCache = jsonStringifyMem(editorconfigSyncNoCache); function getLoadFunction(opts) { diff --git a/src/config/resolve-config.js b/src/config/resolve-config.js index 3203c0156f..51cb511eff 100644 --- a/src/config/resolve-config.js +++ b/src/config/resolve-config.js @@ -1,13 +1,14 @@ "use strict"; -const thirdParty = require("../common/third-party"); -const minimatch = require("minimatch"); const path = require("path"); +const minimatch = require("minimatch"); const mem = require("mem"); +const thirdParty = require("../common/third-party"); -const resolveEditorConfig = require("./resolve-config-editorconfig"); const loadToml = require("../utils/load-toml"); +const loadJson5 = require("../utils/load-json5"); const resolve = require("../common/resolve"); +const resolveEditorConfig = require("./resolve-config-editorconfig"); const getExplorerMemoized = mem( (opts) => { @@ -18,14 +19,8 @@ const getExplorerMemoized = mem( if (result && result.config) { if (typeof result.config === "string") { const dir = path.dirname(result.filepath); - try { - const modulePath = resolve(result.config, { paths: [dir] }); - result.config = eval("require")(modulePath); - } catch (error) { - // Original message contains `__filename`, can't pass tests - error.message = `Cannot find module '${result.config}' from '${dir}'`; - throw error; - } + const modulePath = resolve(result.config, { paths: [dir] }); + result.config = require(modulePath); } if (typeof result.config !== "object") { @@ -45,12 +40,16 @@ const getExplorerMemoized = mem( ".prettierrc.json", ".prettierrc.yaml", ".prettierrc.yml", + ".prettierrc.json5", ".prettierrc.js", + ".prettierrc.cjs", "prettier.config.js", + "prettier.config.cjs", ".prettierrc.toml", ], loaders: { ".toml": loadToml, + ".json5": loadJson5, }, }); @@ -69,9 +68,9 @@ function getExplorer(opts) { function _resolveConfig(filePath, opts, sync) { opts = { useCache: true, ...opts }; const loadOpts = { - cache: !!opts.useCache, - sync: !!sync, - editorconfig: !!opts.editorconfig, + cache: Boolean(opts.useCache), + sync: Boolean(sync), + editorconfig: Boolean(opts.editorconfig), }; const { load, search } = getExplorer(loadOpts); const loadEditorConfig = resolveEditorConfig.getLoadFunction(loadOpts); @@ -86,7 +85,7 @@ function _resolveConfig(filePath, opts, sync) { ...mergeOverrides(result, filePath), }; - ["plugins", "pluginSearchDirs"].forEach((optionName) => { + for (const optionName of ["plugins", "pluginSearchDirs"]) { if (Array.isArray(merged[optionName])) { merged[optionName] = merged[optionName].map((value) => typeof value === "string" && value.startsWith(".") // relative path @@ -94,12 +93,14 @@ function _resolveConfig(filePath, opts, sync) { : value ); } - }); + } if (!result && !editorConfigured) { return null; } + // We are not using this option + delete merged.insertFinalNewline; return merged; }; @@ -153,9 +154,11 @@ function mergeOverrides(configResult, filePath) { } // Based on eslint: https://github.com/eslint/eslint/blob/master/lib/config/config-ops.js -function pathMatchesGlobs(filePath, patterns, excludedPatterns) { - const patternList = [].concat(patterns); - const excludedPatternList = [].concat(excludedPatterns || []); +function pathMatchesGlobs(filePath, patterns, excludedPatterns = []) { + const patternList = Array.isArray(patterns) ? patterns : [patterns]; + const excludedPatternList = Array.isArray(excludedPatterns) + ? excludedPatterns + : [excludedPatterns]; const opts = { matchBase: true, dot: true }; return ( diff --git a/src/document/doc-builders.js b/src/document/doc-builders.js index d9a1918f51..8e306f836e 100644 --- a/src/document/doc-builders.js +++ b/src/document/doc-builders.js @@ -8,21 +8,32 @@ * @property {boolean} [hard] * @property {boolean} [literal] * - * @typedef {string | DocObject} Doc + * @typedef {Doc[]} DocArray + * + * @typedef {string | DocObject | DocArray} Doc */ /** * @param {Doc} val */ function assertDoc(val) { - /* istanbul ignore if */ - if ( - !(typeof val === "string" || (val != null && typeof val.type === "string")) - ) { - throw new Error( - "Value " + JSON.stringify(val) + " is not a valid document" - ); + if (typeof val === "string") { + return; + } + + if (Array.isArray(val)) { + for (const doc of val) { + assertDoc(doc); + } + return; } + + if (val && typeof val.type === "string") { + return; + } + + /* istanbul ignore next */ + throw new Error("Value " + JSON.stringify(val) + " is not a valid document"); } /** @@ -31,7 +42,9 @@ function assertDoc(val) { */ function concat(parts) { if (process.env.NODE_ENV !== "production") { - parts.forEach(assertDoc); + for (const part of parts) { + assertDoc(part); + } } // We cannot do this until we change `printJSXElement` to not @@ -56,16 +69,16 @@ function indent(contents) { } /** - * @param {number} n + * @param {number | string} widthOrString * @param {Doc} contents * @returns Doc */ -function align(n, contents) { +function align(widthOrString, contents) { if (process.env.NODE_ENV !== "production") { assertDoc(contents); } - return { type: "align", contents, n }; + return { type: "align", contents, n: widthOrString }; } /** @@ -73,9 +86,7 @@ function align(n, contents) { * @param {object} [opts] - TBD ??? * @returns Doc */ -function group(contents, opts) { - opts = opts || {}; - +function group(contents, opts = {}) { if (process.env.NODE_ENV !== "production") { assertDoc(contents); } @@ -84,9 +95,9 @@ function group(contents, opts) { type: "group", id: opts.id, contents, - break: !!opts.shouldBreak, + break: Boolean(opts.shouldBreak), // [prettierx] --paren-spacing option support (...) - addedLine: !!opts.addedLine, + addedLine: Boolean(opts.addedLine), expandedStates: opts.expandedStates, }; } @@ -96,7 +107,7 @@ function group(contents, opts) { * @returns Doc */ function dedentToRoot(contents) { - return align(-Infinity, contents); + return align(Number.NEGATIVE_INFINITY, contents); } /** @@ -131,7 +142,9 @@ function conditionalGroup(states, opts) { */ function fill(parts) { if (process.env.NODE_ENV !== "production") { - parts.forEach(assertDoc); + for (const part of parts) { + assertDoc(part); + } } return { type: "fill", parts }; @@ -143,9 +156,7 @@ function fill(parts) { * @param {object} [opts] - TBD ??? * @returns Doc */ -function ifBreak(breakContents, flatContents, opts) { - opts = opts || {}; - +function ifBreak(breakContents, flatContents, opts = {}) { if (process.env.NODE_ENV !== "production") { if (breakContents) { assertDoc(breakContents); @@ -163,6 +174,21 @@ function ifBreak(breakContents, flatContents, opts) { }; } +/** + * Optimized version of `ifBreak(indent(doc), doc, { groupId: ... })` + * @param {Doc} contents + * @param {{ groupId: symbol, negate?: boolean }} opts + * @returns Doc + */ +function indentIfBreak(contents, opts) { + return { + type: "indent-if-break", + contents, + groupId: opts.groupId, + negate: opts.negate, + }; +} + /** * @param {Doc} contents * @returns Doc @@ -177,13 +203,21 @@ function lineSuffix(contents) { const lineSuffixBoundary = { type: "line-suffix-boundary" }; const breakParent = { type: "break-parent" }; const trim = { type: "trim" }; + +const hardlineWithoutBreakParent = { type: "line", hard: true }; +const literallineWithoutBreakParent = { + type: "line", + hard: true, + literal: true, +}; + const line = { type: "line" }; const softline = { type: "line", soft: true }; -const hardline = concat([{ type: "line", hard: true }, breakParent]); -const literalline = concat([ - { type: "line", hard: true, literal: true }, - breakParent, -]); +// eslint-disable-next-line prettier-internal-rules/no-doc-builder-concat +const hardline = concat([hardlineWithoutBreakParent, breakParent]); +// eslint-disable-next-line prettier-internal-rules/no-doc-builder-concat +const literalline = concat([literallineWithoutBreakParent, breakParent]); + const cursor = { type: "cursor", placeholder: Symbol("cursor") }; /** @@ -202,6 +236,7 @@ function join(sep, arr) { res.push(arr[i]); } + // eslint-disable-next-line prettier-internal-rules/no-doc-builder-concat return concat(res); } @@ -221,11 +256,15 @@ function addAlignmentToDoc(doc, size, tabWidth) { aligned = align(size % tabWidth, aligned); // size is absolute from 0 and not relative to the current // indentation, so we use -Infinity to reset the indentation to 0 - aligned = align(-Infinity, aligned); + aligned = align(Number.NEGATIVE_INFINITY, aligned); } return aligned; } +function label(label, contents) { + return { type: "label", label, contents }; +} + module.exports = { concat, join, @@ -243,9 +282,13 @@ module.exports = { ifBreak, trim, indent, + indentIfBreak, align, addAlignmentToDoc, markAsRoot, dedentToRoot, dedent, + hardlineWithoutBreakParent, + literallineWithoutBreakParent, + label, }; diff --git a/src/document/doc-debug.js b/src/document/doc-debug.js index 3ba89c0aec..6d70509294 100644 --- a/src/document/doc-debug.js +++ b/src/document/doc-debug.js @@ -1,134 +1,213 @@ "use strict"; +const { isConcat, getDocParts } = require("./doc-utils"); + function flattenDoc(doc) { - if (doc.type === "concat") { - const res = []; + if (!doc) { + return ""; + } - for (let i = 0; i < doc.parts.length; ++i) { - const doc2 = doc.parts[i]; - if (typeof doc2 !== "string" && doc2.type === "concat") { - res.push(...flattenDoc(doc2).parts); + if (isConcat(doc)) { + const res = []; + for (const part of getDocParts(doc)) { + if (isConcat(part)) { + res.push(...flattenDoc(part).parts); } else { - const flattened = flattenDoc(doc2); + const flattened = flattenDoc(part); if (flattened !== "") { res.push(flattened); } } } - return { ...doc, parts: res }; - } else if (doc.type === "if-break") { + return { type: "concat", parts: res }; + } + + if (doc.type === "if-break") { return { ...doc, - breakContents: - doc.breakContents != null ? flattenDoc(doc.breakContents) : null, - flatContents: - doc.flatContents != null ? flattenDoc(doc.flatContents) : null, + breakContents: flattenDoc(doc.breakContents), + flatContents: flattenDoc(doc.flatContents), }; - } else if (doc.type === "group") { + } + + if (doc.type === "group") { return { ...doc, contents: flattenDoc(doc.contents), - expandedStates: doc.expandedStates - ? doc.expandedStates.map(flattenDoc) - : doc.expandedStates, + expandedStates: doc.expandedStates && doc.expandedStates.map(flattenDoc), }; - } else if (doc.contents) { + } + + if (doc.type === "fill") { + return { type: "fill", parts: doc.parts.map(flattenDoc) }; + } + + if (doc.contents) { return { ...doc, contents: flattenDoc(doc.contents) }; } + return doc; } -function printDoc(doc) { - if (typeof doc === "string") { - return JSON.stringify(doc); - } +function printDocToDebug(doc) { + /** @type Record */ + const printedSymbols = Object.create(null); + /** @type Set */ + const usedKeysForSymbols = new Set(); + return printDoc(flattenDoc(doc)); - if (doc.type === "line") { - if (doc.literal) { - return "literalline"; - } - if (doc.hard) { - return "hardline"; + function printDoc(doc, index, parentParts) { + if (typeof doc === "string") { + return JSON.stringify(doc); } - if (doc.soft) { - return "softline"; - } - return "line"; - } - if (doc.type === "break-parent") { - return "breakParent"; - } + if (isConcat(doc)) { + const printed = getDocParts(doc).map(printDoc).filter(Boolean); + return printed.length === 1 ? printed[0] : `[${printed.join(", ")}]`; + } - if (doc.type === "trim") { - return "trim"; - } + if (doc.type === "line") { + const withBreakParent = + Array.isArray(parentParts) && + parentParts[index + 1] && + parentParts[index + 1].type === "break-parent"; + if (doc.literal) { + return withBreakParent + ? "literalline" + : "literallineWithoutBreakParent"; + } + if (doc.hard) { + return withBreakParent ? "hardline" : "hardlineWithoutBreakParent"; + } + if (doc.soft) { + return "softline"; + } + return "line"; + } - if (doc.type === "concat") { - return "[" + doc.parts.map(printDoc).join(", ") + "]"; - } + if (doc.type === "break-parent") { + const afterHardline = + Array.isArray(parentParts) && + parentParts[index - 1] && + parentParts[index - 1].type === "line" && + parentParts[index - 1].hard; + return afterHardline ? undefined : "breakParent"; + } - if (doc.type === "indent") { - return "indent(" + printDoc(doc.contents) + ")"; - } + if (doc.type === "trim") { + return "trim"; + } - if (doc.type === "align") { - return doc.n === -Infinity - ? "dedentToRoot(" + printDoc(doc.contents) + ")" - : doc.n < 0 - ? "dedent(" + printDoc(doc.contents) + ")" - : doc.n.type === "root" - ? "markAsRoot(" + printDoc(doc.contents) + ")" - : "align(" + JSON.stringify(doc.n) + ", " + printDoc(doc.contents) + ")"; - } + if (doc.type === "indent") { + return "indent(" + printDoc(doc.contents) + ")"; + } - if (doc.type === "if-break") { - return ( - "ifBreak(" + - printDoc(doc.breakContents) + - (doc.flatContents ? ", " + printDoc(doc.flatContents) : "") + - ")" - ); - } + if (doc.type === "align") { + return doc.n === Number.NEGATIVE_INFINITY + ? "dedentToRoot(" + printDoc(doc.contents) + ")" + : doc.n < 0 + ? "dedent(" + printDoc(doc.contents) + ")" + : doc.n.type === "root" + ? "markAsRoot(" + printDoc(doc.contents) + ")" + : "align(" + + JSON.stringify(doc.n) + + ", " + + printDoc(doc.contents) + + ")"; + } - if (doc.type === "group") { - if (doc.expandedStates) { + if (doc.type === "if-break") { return ( - "conditionalGroup(" + - "[" + - doc.expandedStates.map(printDoc).join(",") + - "])" + "ifBreak(" + + printDoc(doc.breakContents) + + (doc.flatContents ? ", " + printDoc(doc.flatContents) : "") + + (doc.groupId + ? (!doc.flatContents ? ', ""' : "") + + `, { groupId: ${printGroupId(doc.groupId)} }` + : "") + + ")" ); } - return ( - (doc.break ? "wrappedGroup" : "group") + - // [prettierx] --paren-spacing option (...) - (doc.addedLine ? "WithTrailingLine" : "") + - "(" + - printDoc(doc.contents) + - ")" - ); - } + if (doc.type === "indent-if-break") { + const optionsParts = []; - if (doc.type === "fill") { - return "fill" + "(" + doc.parts.map(printDoc).join(", ") + ")"; - } + if (doc.negate) { + optionsParts.push("negate: true"); + } - if (doc.type === "line-suffix") { - return "lineSuffix(" + printDoc(doc.contents) + ")"; - } + if (doc.groupId) { + optionsParts.push(`groupId: ${printGroupId(doc.groupId)}`); + } + + const options = + optionsParts.length > 0 ? `, { ${optionsParts.join(", ")} }` : ""; + + return `indentIfBreak(${printDoc(doc.contents)}${options})`; + } + + if (doc.type === "group") { + const optionsParts = []; + + if (doc.break && doc.break !== "propagated") { + optionsParts.push("shouldBreak: true"); + } + + if (doc.id) { + optionsParts.push(`id: ${printGroupId(doc.id)}`); + } + + const options = + optionsParts.length > 0 ? `, { ${optionsParts.join(", ")} }` : ""; + + if (doc.expandedStates) { + return `conditionalGroup([${doc.expandedStates + .map((part) => printDoc(part)) + .join(",")}]${options})`; + } - if (doc.type === "line-suffix-boundary") { - return "lineSuffixBoundary"; + return `group(${printDoc(doc.contents)}${options})`; + } + + if (doc.type === "fill") { + return `fill([${doc.parts.map((part) => printDoc(part)).join(", ")}])`; + } + + if (doc.type === "line-suffix") { + return "lineSuffix(" + printDoc(doc.contents) + ")"; + } + + if (doc.type === "line-suffix-boundary") { + return "lineSuffixBoundary"; + } + + if (doc.type === "label") { + return `label(${JSON.stringify(doc.label)}, ${printDoc(doc.contents)})`; + } + + throw new Error("Unknown doc type " + doc.type); } - throw new Error("Unknown doc type " + doc.type); + function printGroupId(id) { + if (typeof id !== "symbol") { + return JSON.stringify(String(id)); + } + + if (id in printedSymbols) { + return printedSymbols[id]; + } + + // TODO: use Symbol.prototype.description instead of slice once Node 10 is dropped + const prefix = String(id).slice(7, -1) || "symbol"; + for (let counter = 0; ; counter++) { + const key = prefix + (counter > 0 ? ` #${counter}` : ""); + if (!usedKeysForSymbols.has(key)) { + usedKeysForSymbols.add(key); + return (printedSymbols[id] = `Symbol.for(${JSON.stringify(key)})`); + } + } + } } -module.exports = { - printDocToDebug(doc) { - return printDoc(flattenDoc(doc)); - }, -}; +module.exports = { printDocToDebug }; diff --git a/src/document/doc-printer.js b/src/document/doc-printer.js index 737532fb3a..5ebf6c5795 100644 --- a/src/document/doc-printer.js +++ b/src/document/doc-printer.js @@ -1,8 +1,9 @@ "use strict"; -const { getStringWidth } = require("../common/util"); +const { getStringWidth, getLast } = require("../common/util"); const { convertEndOfLineToChars } = require("../common/end-of-line"); -const { concat, fill, cursor } = require("./doc-builders"); +const { fill, cursor, indent } = require("./doc-builders"); +const { isConcat, getDocParts } = require("./doc-utils"); /** @type {Record} */ let groupModeMap; @@ -18,25 +19,34 @@ function makeIndent(ind, options) { return generateInd(ind, { type: "indent" }, options); } -function makeAlign(ind, n, options) { - return n === -Infinity - ? ind.root || rootIndent() - : n < 0 - ? generateInd(ind, { type: "dedent" }, options) - : !n - ? ind - : n.type === "root" - ? { ...ind, root: ind } - : typeof n === "string" - ? generateInd(ind, { type: "stringAlign", n }, options) - : generateInd(ind, { type: "numberAlign", n }, options); +function makeAlign(indent, widthOrDoc, options) { + if (widthOrDoc === Number.NEGATIVE_INFINITY) { + return indent.root || rootIndent(); + } + + if (widthOrDoc < 0) { + return generateInd(indent, { type: "dedent" }, options); + } + + if (!widthOrDoc) { + return indent; + } + + if (widthOrDoc.type === "root") { + return { ...indent, root: indent }; + } + + const alignType = + typeof widthOrDoc === "string" ? "stringAlign" : "numberAlign"; + + return generateInd(indent, { type: alignType, n: widthOrDoc }, options); } function generateInd(ind, newPart, options) { const queue = newPart.type === "dedent" ? ind.queue.slice(0, -1) - : ind.queue.concat(newPart); + : [...ind.queue, newPart]; let value = ""; let length = 0; @@ -120,22 +130,22 @@ function trim(out) { // Trim whitespace at the end of line while ( out.length > 0 && - typeof out[out.length - 1] === "string" && - out[out.length - 1].match(/^[ \t]*$/) + typeof getLast(out) === "string" && + /^[\t ]*$/.test(getLast(out)) ) { trimCount += out.pop().length; } - if (out.length && typeof out[out.length - 1] === "string") { - const trimmed = out[out.length - 1].replace(/[ \t]*$/, ""); - trimCount += out[out.length - 1].length - trimmed.length; + if (out.length > 0 && typeof getLast(out) === "string") { + const trimmed = getLast(out).replace(/[\t ]*$/, ""); + trimCount += getLast(out).length - trimmed.length; out[out.length - 1] = trimmed; } return trimCount; } -function fits(next, restCommands, width, options, mustBeFlat) { +function fits(next, restCommands, width, options, hasLineSuffix, mustBeFlat) { let restIdx = restCommands.length; const cmds = [next]; // `out` is only used for width counting because `trim` requires to look @@ -159,14 +169,13 @@ function fits(next, restCommands, width, options, mustBeFlat) { out.push(doc); width -= getStringWidth(doc); + } else if (isConcat(doc)) { + const parts = getDocParts(doc); + for (let i = parts.length - 1; i >= 0; i--) { + cmds.push([ind, mode, parts[i]]); + } } else { switch (doc.type) { - case "concat": - for (let i = doc.parts.length - 1; i >= 0; i--) { - cmds.push([ind, mode, doc.parts[i]]); - } - - break; case "indent": cmds.push([makeIndent(ind, options), mode, doc.contents]); @@ -179,32 +188,54 @@ function fits(next, restCommands, width, options, mustBeFlat) { width += trim(out); break; - case "group": + case "group": { if (mustBeFlat && doc.break) { return false; } - cmds.push([ind, doc.break ? MODE_BREAK : mode, doc.contents]); + const groupMode = doc.break ? MODE_BREAK : mode; + cmds.push([ + ind, + groupMode, + // The most expanded state takes up the least space on the current line. + doc.expandedStates && groupMode === MODE_BREAK + ? getLast(doc.expandedStates) + : doc.contents, + ]); if (doc.id) { - groupModeMap[doc.id] = cmds[cmds.length - 1][1]; + groupModeMap[doc.id] = groupMode; } break; + } case "fill": for (let i = doc.parts.length - 1; i >= 0; i--) { cmds.push([ind, mode, doc.parts[i]]); } break; - case "if-break": { + case "if-break": + case "indent-if-break": { const groupMode = doc.groupId ? groupModeMap[doc.groupId] : mode; if (groupMode === MODE_BREAK) { - if (doc.breakContents) { - cmds.push([ind, mode, doc.breakContents]); + const breakContents = + doc.type === "if-break" + ? doc.breakContents + : doc.negate + ? doc.contents + : indent(doc.contents); + if (breakContents) { + cmds.push([ind, mode, breakContents]); } } if (groupMode === MODE_FLAT) { - if (doc.flatContents) { - cmds.push([ind, mode, doc.flatContents]); + const flatContents = + doc.type === "if-break" + ? doc.flatContents + : doc.negate + ? indent(doc.contents) + : doc.contents; + if (flatContents) { + cmds.push([ind, mode, flatContents]); } } @@ -229,6 +260,17 @@ function fits(next, restCommands, width, options, mustBeFlat) { return true; } break; + case "line-suffix": + hasLineSuffix = true; + break; + case "line-suffix-boundary": + if (hasLineSuffix) { + return false; + } + break; + case "label": + cmds.push([ind, mode, doc.contents]); + break; } } } @@ -249,27 +291,23 @@ function printDocToString(doc, options) { let shouldRemeasure = false; let lineSuffix = []; - while (cmds.length !== 0) { + while (cmds.length > 0) { const [ind, mode, doc] = cmds.pop(); if (typeof doc === "string") { - const formatted = - newLine !== "\n" && doc.includes("\n") - ? doc.replace(/\n/g, newLine) - : doc; + const formatted = newLine !== "\n" ? doc.replace(/\n/g, newLine) : doc; out.push(formatted); pos += getStringWidth(formatted); + } else if (isConcat(doc)) { + const parts = getDocParts(doc); + for (let i = parts.length - 1; i >= 0; i--) { + cmds.push([ind, mode, parts[i]]); + } } else { switch (doc.type) { case "cursor": out.push(cursor.placeholder); - break; - case "concat": - for (let i = doc.parts.length - 1; i >= 0; i--) { - cmds.push([ind, mode, doc.parts[i]]); - } - break; case "indent": cmds.push([makeIndent(ind, options), mode, doc.contents]); @@ -302,8 +340,9 @@ function printDocToString(doc, options) { const next = [ind, MODE_FLAT, doc.contents]; const rem = width - pos; + const hasLineSuffix = lineSuffix.length > 0; - if (!doc.break && fits(next, cmds, rem, options)) { + if (!doc.break && fits(next, cmds, rem, options, hasLineSuffix)) { cmds.push(next); } else { // Expanded states are a rare case where a document @@ -314,8 +353,7 @@ function printDocToString(doc, options) { // group has these, we need to manually go through // these states and find the first one that fits. if (doc.expandedStates) { - const mostExpanded = - doc.expandedStates[doc.expandedStates.length - 1]; + const mostExpanded = getLast(doc.expandedStates); if (doc.break) { cmds.push([ind, MODE_BREAK, mostExpanded]); @@ -331,7 +369,7 @@ function printDocToString(doc, options) { const state = doc.expandedStates[i]; const cmd = [ind, MODE_FLAT, state]; - if (fits(cmd, cmds, rem, options)) { + if (fits(cmd, cmds, rem, options, hasLineSuffix)) { cmds.push(cmd); break; @@ -349,7 +387,7 @@ function printDocToString(doc, options) { } if (doc.id) { - groupModeMap[doc.id] = cmds[cmds.length - 1][1]; + groupModeMap[doc.id] = getLast(cmds)[1]; } break; // Fills each line with as much code as possible before moving to a new @@ -383,7 +421,14 @@ function printDocToString(doc, options) { const [content, whitespace] = parts; const contentFlatCmd = [ind, MODE_FLAT, content]; const contentBreakCmd = [ind, MODE_BREAK, content]; - const contentFits = fits(contentFlatCmd, [], rem, options, true); + const contentFits = fits( + contentFlatCmd, + [], + rem, + options, + lineSuffix.length > 0, + true + ); if (parts.length === 1) { if (contentFits) { @@ -399,11 +444,9 @@ function printDocToString(doc, options) { if (parts.length === 2) { if (contentFits) { - cmds.push(whitespaceFlatCmd); - cmds.push(contentFlatCmd); + cmds.push(whitespaceFlatCmd, contentFlatCmd); } else { - cmds.push(whitespaceBreakCmd); - cmds.push(contentBreakCmd); + cmds.push(whitespaceBreakCmd, contentBreakCmd); } break; } @@ -421,41 +464,49 @@ function printDocToString(doc, options) { const firstAndSecondContentFlatCmd = [ ind, MODE_FLAT, - concat([content, whitespace, secondContent]), + [content, whitespace, secondContent], ]; const firstAndSecondContentFits = fits( firstAndSecondContentFlatCmd, [], rem, options, + lineSuffix.length > 0, true ); if (firstAndSecondContentFits) { - cmds.push(remainingCmd); - cmds.push(whitespaceFlatCmd); - cmds.push(contentFlatCmd); + cmds.push(remainingCmd, whitespaceFlatCmd, contentFlatCmd); } else if (contentFits) { - cmds.push(remainingCmd); - cmds.push(whitespaceBreakCmd); - cmds.push(contentFlatCmd); + cmds.push(remainingCmd, whitespaceBreakCmd, contentFlatCmd); } else { - cmds.push(remainingCmd); - cmds.push(whitespaceBreakCmd); - cmds.push(contentBreakCmd); + cmds.push(remainingCmd, whitespaceBreakCmd, contentBreakCmd); } break; } - case "if-break": { + case "if-break": + case "indent-if-break": { const groupMode = doc.groupId ? groupModeMap[doc.groupId] : mode; if (groupMode === MODE_BREAK) { - if (doc.breakContents) { - cmds.push([ind, mode, doc.breakContents]); + const breakContents = + doc.type === "if-break" + ? doc.breakContents + : doc.negate + ? doc.contents + : indent(doc.contents); + if (breakContents) { + cmds.push([ind, mode, breakContents]); } } if (groupMode === MODE_FLAT) { - if (doc.flatContents) { - cmds.push([ind, mode, doc.flatContents]); + const flatContents = + doc.type === "if-break" + ? doc.flatContents + : doc.negate + ? indent(doc.contents) + : doc.contents; + if (flatContents) { + cmds.push([ind, mode, flatContents]); } } @@ -492,9 +543,8 @@ function printDocToString(doc, options) { // fallthrough case MODE_BREAK: - if (lineSuffix.length) { - cmds.push([ind, mode, doc]); - cmds.push(...lineSuffix.reverse()); + if (lineSuffix.length > 0) { + cmds.push([ind, mode, doc], ...lineSuffix.reverse()); lineSuffix = []; break; } @@ -515,9 +565,19 @@ function printDocToString(doc, options) { break; } break; + case "label": + cmds.push([ind, mode, doc.contents]); + break; default: } } + + // Flush remaining line-suffix contents at the end of the document, in case + // there is no new line after the line-suffix. + if (cmds.length === 0 && lineSuffix.length > 0) { + cmds.push(...lineSuffix.reverse()); + lineSuffix = []; + } } const cursorPlaceholderIndex = out.indexOf(cursor.placeholder); diff --git a/src/document/doc-utils.js b/src/document/doc-utils.js index 27afc174bb..4faec94734 100644 --- a/src/document/doc-utils.js +++ b/src/document/doc-utils.js @@ -1,4 +1,20 @@ "use strict"; +const getLast = require("../utils/get-last"); +const { literalline, join } = require("./doc-builders"); + +const isConcat = (doc) => Array.isArray(doc) || (doc && doc.type === "concat"); +const getDocParts = (doc) => { + if (Array.isArray(doc)) { + return doc; + } + + /* istanbul ignore next */ + if (doc.type !== "concat" && doc.type !== "fill") { + throw new Error("Expect doc type to be `concat` or `fill`."); + } + + return doc.parts; +}; // Using a unique object to compare by reference. const traverseDocOnExitStackMarker = {}; @@ -6,7 +22,7 @@ const traverseDocOnExitStackMarker = {}; function traverseDoc(doc, onEnter, onExit, shouldTraverseConditionalGroups) { const docsStack = [doc]; - while (docsStack.length !== 0) { + while (docsStack.length > 0) { const doc = docsStack.pop(); if (doc === traverseDocOnExitStackMarker) { @@ -14,26 +30,23 @@ function traverseDoc(doc, onEnter, onExit, shouldTraverseConditionalGroups) { continue; } - let shouldRecurse = true; - if (onEnter) { - if (onEnter(doc) === false) { - shouldRecurse = false; - } - } - if (onExit) { - docsStack.push(doc); - docsStack.push(traverseDocOnExitStackMarker); + docsStack.push(doc, traverseDocOnExitStackMarker); } - if (shouldRecurse) { + if ( + // Should Recurse + !onEnter || + onEnter(doc) !== false + ) { // When there are multiple parts to process, // the parts need to be pushed onto the stack in reverse order, // so that they are processed in the original order // when the stack is popped. - if (doc.type === "concat" || doc.type === "fill") { - for (let ic = doc.parts.length, i = ic - 1; i >= 0; --i) { - docsStack.push(doc.parts[i]); + if (isConcat(doc) || doc.type === "fill") { + const parts = getDocParts(doc); + for (let ic = parts.length, i = ic - 1; i >= 0; --i) { + docsStack.push(parts[i]); } } else if (doc.type === "if-break") { if (doc.flatContents) { @@ -58,18 +71,53 @@ function traverseDoc(doc, onEnter, onExit, shouldTraverseConditionalGroups) { } function mapDoc(doc, cb) { - if (doc.type === "concat" || doc.type === "fill") { - const parts = doc.parts.map((part) => mapDoc(part, cb)); - return cb({ ...doc, parts }); - } else if (doc.type === "if-break") { - const breakContents = doc.breakContents && mapDoc(doc.breakContents, cb); - const flatContents = doc.flatContents && mapDoc(doc.flatContents, cb); - return cb({ ...doc, breakContents, flatContents }); - } else if (doc.contents) { - const contents = mapDoc(doc.contents, cb); - return cb({ ...doc, contents }); + // Within a doc tree, the same subtrees can be found multiple times. + // E.g., often this happens in conditional groups. + // As an optimization (those subtrees can be huge) and to maintain the + // reference structure of the tree, the mapping results are cached in + // a map and reused. + const mapped = new Map(); + + return rec(doc); + + function rec(doc) { + if (mapped.has(doc)) { + return mapped.get(doc); + } + const result = process(doc); + mapped.set(doc, result); + return result; + } + + function process(doc) { + if (Array.isArray(doc)) { + return cb(doc.map(rec)); + } + + if (doc.type === "concat" || doc.type === "fill") { + const parts = doc.parts.map(rec); + return cb({ ...doc, parts }); + } + + if (doc.type === "if-break") { + const breakContents = doc.breakContents && rec(doc.breakContents); + const flatContents = doc.flatContents && rec(doc.flatContents); + return cb({ ...doc, breakContents, flatContents }); + } + + if (doc.type === "group" && doc.expandedStates) { + const expandedStates = doc.expandedStates.map(rec); + const contents = expandedStates[0]; + return cb({ ...doc, contents, expandedStates }); + } + + if (doc.contents) { + const contents = rec(doc.contents); + return cb({ ...doc, contents }); + } + + return cb(doc); } - return cb(doc); } function findInDoc(doc, fn, defaultValue) { @@ -89,23 +137,6 @@ function findInDoc(doc, fn, defaultValue) { return result; } -function isEmpty(n) { - return typeof n === "string" && n.length === 0; -} - -function isLineNextFn(doc) { - if (typeof doc === "string") { - return false; - } - if (doc.type === "line") { - return true; - } -} - -function isLineNext(doc) { - return findInDoc(doc, isLineNextFn, false); -} - function willBreakFn(doc) { if (doc.type === "group" && doc.break) { return true; @@ -124,11 +155,13 @@ function willBreak(doc) { function breakParentGroup(groupStack) { if (groupStack.length > 0) { - const parentGroup = groupStack[groupStack.length - 1]; + const parentGroup = getLast(groupStack); // Breaks are not propagated through conditional groups because // the user is expected to manually handle what breaks. - if (!parentGroup.expandedStates) { - parentGroup.break = true; + if (!parentGroup.expandedStates && !parentGroup.break) { + // An alternative truthy value allows to distinguish propagated group breaks + // and not to print them as `group(..., { break: true })` in `--debug-print-doc`. + parentGroup.break = "propagated"; } } return null; @@ -172,9 +205,12 @@ function removeLinesFn(doc) { // of breaking existing assumptions otherwise. if (doc.type === "line" && !doc.hard) { return doc.soft ? "" : " "; - } else if (doc.type === "if-break") { + } + + if (doc.type === "if-break") { return doc.flatContents || ""; } + return doc; } @@ -182,37 +218,202 @@ function removeLines(doc) { return mapDoc(doc, removeLinesFn); } +const isHardline = (doc, nextDoc) => + doc && + doc.type === "line" && + doc.hard && + nextDoc && + nextDoc.type === "break-parent"; +function stripDocTrailingHardlineFromDoc(doc) { + if (!doc) { + return doc; + } + + if (isConcat(doc) || doc.type === "fill") { + const parts = getDocParts(doc); + + while (parts.length > 1 && isHardline(...parts.slice(-2))) { + parts.length -= 2; + } + + if (parts.length > 0) { + const lastPart = stripDocTrailingHardlineFromDoc(getLast(parts)); + parts[parts.length - 1] = lastPart; + } + return Array.isArray(doc) ? parts : { ...doc, parts }; + } + + switch (doc.type) { + case "align": + case "indent": + case "indent-if-break": + case "group": + case "line-suffix": + case "label": { + const contents = stripDocTrailingHardlineFromDoc(doc.contents); + return { ...doc, contents }; + } + case "if-break": { + const breakContents = stripDocTrailingHardlineFromDoc(doc.breakContents); + const flatContents = stripDocTrailingHardlineFromDoc(doc.flatContents); + return { ...doc, breakContents, flatContents }; + } + } + + return doc; +} + function stripTrailingHardline(doc) { // HACK remove ending hardline, original PR: #1984 - if (doc.type === "concat" && doc.parts.length !== 0) { - const lastPart = doc.parts[doc.parts.length - 1]; - if (lastPart.type === "concat") { + return stripDocTrailingHardlineFromDoc(cleanDoc(doc)); +} + +function cleanDocFn(doc) { + switch (doc.type) { + case "fill": + if (doc.parts.length === 0 || doc.parts.every((part) => part === "")) { + return ""; + } + break; + case "group": + if (!doc.contents && !doc.id && !doc.break && !doc.expandedStates) { + return ""; + } + // Remove nested only group if ( - lastPart.parts.length === 2 && - lastPart.parts[0].hard && - lastPart.parts[1].type === "break-parent" + doc.contents.type === "group" && + doc.contents.id === doc.id && + doc.contents.break === doc.break && + doc.contents.expandedStates === doc.expandedStates ) { - return { type: "concat", parts: doc.parts.slice(0, -1) }; + return doc.contents; } + break; + case "align": + case "indent": + case "indent-if-break": + case "line-suffix": + if (!doc.contents) { + return ""; + } + break; + case "if-break": + if (!doc.flatContents && !doc.breakContents) { + return ""; + } + break; + } - return { - type: "concat", - parts: doc.parts.slice(0, -1).concat(stripTrailingHardline(lastPart)), - }; + if (!isConcat(doc)) { + return doc; + } + + const parts = []; + for (const part of getDocParts(doc)) { + if (!part) { + continue; } + const [currentPart, ...restParts] = isConcat(part) + ? getDocParts(part) + : [part]; + if (typeof currentPart === "string" && typeof getLast(parts) === "string") { + parts[parts.length - 1] += currentPart; + } else { + parts.push(currentPart); + } + parts.push(...restParts); } - return doc; + if (parts.length === 0) { + return ""; + } + + if (parts.length === 1) { + return parts[0]; + } + return Array.isArray(doc) ? parts : { ...doc, parts }; +} +// A safer version of `normalizeDoc` +// - `normalizeDoc` concat strings and flat "concat" in `fill`, while `cleanDoc` don't +// - On `concat` object, `normalizeDoc` always return object with `parts`, `cleanDoc` may return strings +// - `cleanDoc` also remove nested `group`s and empty `fill`/`align`/`indent`/`line-suffix`/`if-break` if possible +function cleanDoc(doc) { + return mapDoc(doc, (currentDoc) => cleanDocFn(currentDoc)); +} + +function normalizeParts(parts) { + const newParts = []; + + const restParts = parts.filter(Boolean); + while (restParts.length > 0) { + const part = restParts.shift(); + + if (!part) { + continue; + } + + if (isConcat(part)) { + restParts.unshift(...getDocParts(part)); + continue; + } + + if ( + newParts.length > 0 && + typeof getLast(newParts) === "string" && + typeof part === "string" + ) { + newParts[newParts.length - 1] += part; + continue; + } + + newParts.push(part); + } + + return newParts; +} + +function normalizeDoc(doc) { + return mapDoc(doc, (currentDoc) => { + if (Array.isArray(currentDoc)) { + return normalizeParts(currentDoc); + } + if (!currentDoc.parts) { + return currentDoc; + } + return { + ...currentDoc, + parts: normalizeParts(currentDoc.parts), + }; + }); +} + +function replaceNewlinesWithLiterallines(doc) { + return mapDoc(doc, (currentDoc) => + typeof currentDoc === "string" && currentDoc.includes("\n") + ? join(literalline, currentDoc.split("\n")) + : currentDoc + ); +} + +// This function need return array +// TODO: remove `.parts` when we remove `docBuilders.concat()` +function replaceEndOfLineWith(text, replacement) { + return join(replacement, text.split("\n")).parts; } module.exports = { - isEmpty, + isConcat, + getDocParts, willBreak, - isLineNext, traverseDoc, findInDoc, mapDoc, propagateBreaks, removeLines, stripTrailingHardline, + normalizeParts, + normalizeDoc, + cleanDoc, + replaceEndOfLineWith, + replaceNewlinesWithLiterallines, }; diff --git a/src/document/index.js b/src/document/index.js index 4020cf063b..3629a9c212 100644 --- a/src/document/index.js +++ b/src/document/index.js @@ -1,5 +1,9 @@ "use strict"; +/** + * @typedef {import("./doc-builders").Doc} Doc + */ + module.exports = { builders: require("./doc-builders"), printer: require("./doc-printer"), diff --git a/src/index.js b/src/index.js index cf9abe856e..143b399345 100644 --- a/src/index.js +++ b/src/index.js @@ -56,16 +56,27 @@ module.exports = { plugins.clearCache(); }, - getFileInfo: /** @type {typeof getFileInfo} */ (withPlugins(getFileInfo)), - getSupportInfo: /** @type {typeof getSupportInfo} */ (withPlugins( - getSupportInfo, - 0 - )), + /** @type {typeof getFileInfo} */ + getFileInfo: withPlugins(getFileInfo), + /** @type {typeof getSupportInfo} */ + getSupportInfo: withPlugins(getSupportInfo, 0), version, util: sharedUtil, + // Internal shared + __internal: { + errors: require("./common/errors"), + coreOptions: require("./main/core-options"), + createIgnorer: require("./common/create-ignorer"), + optionsModule: require("./main/options"), + optionsNormalizer: require("./main/options-normalizer"), + utils: { + arrayify: require("./utils/arrayify"), + }, + }, + /* istanbul ignore next */ __debug: { parse: withPlugins(core.parse), diff --git a/src/language-css/clean.js b/src/language-css/clean.js index 0bc1016c41..3e103456c5 100644 --- a/src/language-css/clean.js +++ b/src/language-css/clean.js @@ -1,45 +1,57 @@ "use strict"; +const { isFrontMatterNode } = require("../common/util"); +const getLast = require("../utils/get-last"); + +const ignoredProperties = new Set([ + "raw", // front-matter + "raws", + "sourceIndex", + "source", + "before", + "after", + "trailingComma", +]); + function clean(ast, newObj, parent) { - [ - "raw", // front-matter - "raws", - "sourceIndex", - "source", - "before", - "after", - "trailingComma", - ].forEach((name) => { - delete newObj[name]; - }); - - if (ast.type === "yaml") { + if (isFrontMatterNode(ast) && ast.lang === "yaml") { delete newObj.value; } - // --insert-pragma if ( ast.type === "css-comment" && parent.type === "css-root" && - parent.nodes.length !== 0 && - // first non-front-matter comment - (parent.nodes[0] === ast || - ((parent.nodes[0].type === "yaml" || parent.nodes[0].type === "toml") && - parent.nodes[1] === ast)) + parent.nodes.length > 0 ) { - /** - * something - * - * @format - */ - delete newObj.text; + // --insert-pragma + // first non-front-matter comment + if ( + parent.nodes[0] === ast || + (isFrontMatterNode(parent.nodes[0]) && parent.nodes[1] === ast) + ) { + /** + * something + * + * @format + */ + delete newObj.text; + + // standalone pragma + if (/^\*\s*@(format|prettier)\s*$/.test(ast.text)) { + return null; + } + } - // standalone pragma - if (/^\*\s*@(format|prettier)\s*$/.test(ast.text)) { + // Last comment is not parsed, when omitting semicolon, #8675 + if (parent.type === "css-root" && getLast(parent.nodes) === ast) { return null; } } + if (ast.type === "value-root") { + delete newObj.text; + } + if ( ast.type === "media-query" || ast.type === "media-query-list" || @@ -113,7 +125,7 @@ function clean(ast, newObj, parent) { } if (newObj.value) { - newObj.value = newObj.value.trim().replace(/^['"]|['"]$/g, ""); + newObj.value = newObj.value.trim().replace(/^["']|["']$/g, ""); delete newObj.quoted; } } @@ -129,10 +141,10 @@ function clean(ast, newObj, parent) { newObj.value ) { newObj.value = newObj.value.replace( - /([\d.eE+-]+)([a-zA-Z]*)/g, + /([\d+.Ee-]+)([A-Za-z]*)/g, (match, numStr, unit) => { const num = Number(numStr); - return isNaN(num) ? match : num + unit.toLowerCase(); + return Number.isNaN(num) ? match : num + unit.toLowerCase(); } ); } @@ -156,8 +168,10 @@ function clean(ast, newObj, parent) { } } +clean.ignoredProperties = ignoredProperties; + function cleanCSSStrings(value) { - return value.replace(/'/g, '"').replace(/\\([^a-fA-F\d])/g, "$1"); + return value.replace(/'/g, '"').replace(/\\([^\dA-Fa-f])/g, "$1"); } module.exports = clean; diff --git a/src/language-css/embed.js b/src/language-css/embed.js index 626d71f523..19c7371c34 100644 --- a/src/language-css/embed.js +++ b/src/language-css/embed.js @@ -1,41 +1,15 @@ "use strict"; - const { - builders: { hardline, literalline, concat, markAsRoot }, - utils: { mapDoc }, + builders: { hardline }, } = require("../document"); +const printFrontMatter = require("../utils/front-matter/print"); function embed(path, print, textToDoc /*, options */) { const node = path.getValue(); - if (node.type === "yaml") { - return markAsRoot( - concat([ - "---", - hardline, - node.value.trim() - ? replaceNewlinesWithLiterallines( - textToDoc(node.value, { parser: "yaml" }) - ) - : "", - "---", - hardline, - ]) - ); - } - - return null; - - function replaceNewlinesWithLiterallines(doc) { - return mapDoc(doc, (currentDoc) => - typeof currentDoc === "string" && currentDoc.includes("\n") - ? concat( - currentDoc - .split(/(\n)/g) - .map((v, i) => (i % 2 === 0 ? v : literalline)) - ) - : currentDoc - ); + if (node.type === "front-matter") { + const doc = printFrontMatter(node, textToDoc); + return doc ? [doc, hardline] : ""; } } diff --git a/src/language-css/index.js b/src/language-css/index.js index 3e91afc613..c69c43461c 100644 --- a/src/language-css/index.js +++ b/src/language-css/index.js @@ -1,26 +1,32 @@ "use strict"; +const createLanguage = require("../utils/create-language"); const printer = require("./printer-postcss"); const options = require("./options"); -const createLanguage = require("../utils/create-language"); const languages = [ - createLanguage(require("linguist-languages/data/CSS"), () => ({ + createLanguage(require("linguist-languages/data/CSS.json"), (data) => ({ since: "1.4.0", parsers: ["css"], vscodeLanguageIds: ["css"], + extensions: [ + ...data.extensions, + // `WeiXin Style Sheets`(Weixin Mini Programs) + // https://developers.weixin.qq.com/miniprogram/en/dev/framework/view/wxs/ + ".wxss", + ], })), - createLanguage(require("linguist-languages/data/PostCSS"), () => ({ + createLanguage(require("linguist-languages/data/PostCSS.json"), () => ({ since: "1.4.0", parsers: ["css"], vscodeLanguageIds: ["postcss"], })), - createLanguage(require("linguist-languages/data/Less"), () => ({ + createLanguage(require("linguist-languages/data/Less.json"), () => ({ since: "1.4.0", parsers: ["less"], vscodeLanguageIds: ["less"], })), - createLanguage(require("linguist-languages/data/SCSS"), () => ({ + createLanguage(require("linguist-languages/data/SCSS.json"), () => ({ since: "1.4.0", parsers: ["scss"], vscodeLanguageIds: ["scss"], @@ -31,8 +37,22 @@ const printers = { postcss: printer, }; +const parsers = { + // TODO: switch these to just `postcss` and use `language` instead. + get css() { + return require("./parser-postcss").parsers.css; + }, + get less() { + return require("./parser-postcss").parsers.less; + }, + get scss() { + return require("./parser-postcss").parsers.scss; + }, +}; + module.exports = { languages, options, printers, + parsers, }; diff --git a/src/language-css/loc.js b/src/language-css/loc.js index b5d95784a8..3562df36b8 100644 --- a/src/language-css/loc.js +++ b/src/language-css/loc.js @@ -4,10 +4,12 @@ const lineColumnToIndex = require("../utils/line-column-to-index"); const { getLast, skipEverythingButNewLine } = require("../common/util"); function calculateLocStart(node, text) { - if (node.source) { - return lineColumnToIndex(node.source.start, text) - 1; + // value-* nodes have this + if (typeof node.sourceIndex === "number") { + return node.sourceIndex; } - return null; + + return node.source ? lineColumnToIndex(node.source.start, text) - 1 : null; } function calculateLocEnd(node, text) { @@ -25,26 +27,76 @@ function calculateLocEnd(node, text) { } function calculateLoc(node, text) { - if (node && typeof node === "object") { - if (node.source) { - node.source.startOffset = calculateLocStart(node, text); - node.source.endOffset = calculateLocEnd(node, text); + if (node.source) { + node.source.startOffset = calculateLocStart(node, text); + node.source.endOffset = calculateLocEnd(node, text); + } + + for (const key in node) { + const child = node[key]; + + if (key === "source" || !child || typeof child !== "object") { + continue; } - for (const key in node) { - calculateLoc(node[key], text); + if (child.type === "value-root" || child.type === "value-unknown") { + calculateValueNodeLoc( + child, + getValueRootOffset(node), + child.text || child.value + ); + } else { + calculateLoc(child, text); + } + } +} + +function calculateValueNodeLoc(node, rootOffset, text) { + if (node.source) { + node.source.startOffset = calculateLocStart(node, text) + rootOffset; + node.source.endOffset = calculateLocEnd(node, text) + rootOffset; + } + + for (const key in node) { + const child = node[key]; + + if (key === "source" || !child || typeof child !== "object") { + continue; } + + calculateValueNodeLoc(child, rootOffset, text); } } +function getValueRootOffset(node) { + let result = node.source.startOffset; + if (typeof node.prop === "string") { + result += node.prop.length; + } + + if (node.type === "css-atrule" && typeof node.name === "string") { + result += + 1 + node.name.length + node.raws.afterName.match(/^\s*:?\s*/)[0].length; + } + + if ( + node.type !== "css-atrule" && + node.raws && + typeof node.raws.between === "string" + ) { + result += node.raws.between.length; + } + + return result; +} + /** - * Workaround for a bug: quotes in inline comments corrupt loc data of subsequent nodes. - * This function replaces the quotes with U+FFFE and U+FFFF. Later, when the comments are printed, - * their content is extracted from the original text or restored by replacing the placeholder - * characters back with quotes. + * Workaround for a bug: quotes and asterisks in inline comments corrupt loc data of subsequent nodes. + * This function replaces the quotes and asterisks with spaces. Later, when the comments are printed, + * their content is extracted from the original text. * - https://github.com/prettier/prettier/issues/7780 * - https://github.com/shellscape/postcss-less/issues/145 - * - About noncharacters (U+FFFE and U+FFFF): http://www.unicode.org/faq/private_use.html#nonchar1 + * - https://github.com/prettier/prettier/issues/8130 * @param text {string} */ function replaceQuotesInInlineComments(text) { @@ -140,7 +192,7 @@ function replaceQuotesInInlineComments(text) { continue; case "comment-inline": - if (c === '"' || c === "'") { + if (c === '"' || c === "'" || c === "*") { inlineCommentContainsQuotes = true; } if (c === "\n" || c === "\r") { @@ -157,19 +209,24 @@ function replaceQuotesInInlineComments(text) { for (const [start, end] of inlineCommentsToReplace) { text = text.slice(0, start) + - text.slice(start, end).replace(/'/g, "\ufffe").replace(/"/g, "\uffff") + + text.slice(start, end).replace(/["'*]/g, " ") + text.slice(end); } return text; } -function restoreQuotesInInlineComments(text) { - return text.replace(/\ufffe/g, "'").replace(/\uffff/g, '"'); +function locStart(node) { + return node.source.startOffset; +} + +function locEnd(node) { + return node.source.endOffset; } module.exports = { + locStart, + locEnd, calculateLoc, replaceQuotesInInlineComments, - restoreQuotesInInlineComments, }; diff --git a/src/language-css/options.js b/src/language-css/options.js index 2522b6e5d3..47a8aa6e32 100644 --- a/src/language-css/options.js +++ b/src/language-css/options.js @@ -2,7 +2,7 @@ const commonOptions = require("../common/common-options"); -// format based on https://github.com/prettier/prettier/blob/master/src/main/core-options.js +// format based on https://github.com/prettier/prettier/blob/main/src/main/core-options.js module.exports = { singleQuote: commonOptions.singleQuote, // [prettierx] diff --git a/src/language-css/parser-postcss.js b/src/language-css/parser-postcss.js index cd3fb3c65b..213e6f91cd 100644 --- a/src/language-css/parser-postcss.js +++ b/src/language-css/parser-postcss.js @@ -1,12 +1,30 @@ "use strict"; const createError = require("../common/parser-create-error"); -const parseFrontMatter = require("../utils/front-matter"); +const getLast = require("../utils/get-last"); +const parseFrontMatter = require("../utils/front-matter/parse"); const { hasPragma } = require("./pragma"); -const { isLessParser, isSCSS, isSCSSNestedPropertyNode } = require("./utils"); +const { + hasSCSSInterpolation, + hasStringOrFunction, + isLessParser, + isSCSS, + isSCSSNestedPropertyNode, + isSCSSVariable, + stringifyNode, +} = require("./utils"); +const { locStart, locEnd } = require("./loc"); const { calculateLoc, replaceQuotesInInlineComments } = require("./loc"); -function parseValueNodes(nodes) { +const getHighestAncestor = (node) => { + while (node.parent) { + node = node.parent; + } + return node; +}; + +function parseValueNode(valueNode, options) { + const { nodes } = valueNode; let parenGroup = { open: null, close: null, @@ -23,23 +41,56 @@ function parseValueNodes(nodes) { for (let i = 0; i < nodes.length; ++i) { const node = nodes[i]; - const isUnquotedDataURLCall = - node.type === "func" && - node.value === "url" && - node.group && - node.group.groups && - node.group.groups[0] && - node.group.groups[0].groups && - node.group.groups[0].groups.length > 2 && - node.group.groups[0].groups[0].type === "word" && - node.group.groups[0].groups[0].value === "data" && - node.group.groups[0].groups[1].type === "colon" && - node.group.groups[0].groups[1].value === ":"; - - if (isUnquotedDataURLCall) { - node.group.groups = [stringifyGroup(node)]; + + if ( + isSCSS(options.parser, node.value) && + node.type === "number" && + node.unit === ".." && + getLast(node.value) === "." + ) { + // Work around postcss bug parsing `50...` as `50.` with unit `..` + // Set the unit to `...` to "accidentally" have arbitrary arguments work in the same way that cases where the node already had a unit work. + // For example, 50px... is parsed as `50` with unit `px...` already by postcss-values-parser. + node.value = node.value.slice(0, -1); + node.unit = "..."; + } + + if (node.type === "func" && node.value === "selector") { + node.group.groups = [ + parseSelector( + getHighestAncestor(valueNode).text.slice( + node.group.open.sourceIndex + 1, + node.group.close.sourceIndex + ) + ), + ]; } + if (node.type === "func" && node.value === "url") { + const groups = (node.group && node.group.groups) || []; + + // Create a view with any top-level comma groups flattened. + let groupList = []; + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (group.type === "comma_group") { + groupList = [...groupList, ...group.groups]; + } else { + groupList.push(group); + } + } + + // Stringify if the value parser can't handle the content. + if ( + hasSCSSInterpolation(groupList) || + (!hasStringOrFunction(groupList) && !isSCSSVariable(groupList[0])) + ) { + const stringifiedContent = stringifyNode({ + groups: node.group.groups, + }); + node.group.groups = [stringifiedContent.trim()]; + } + } if (node.type === "paren" && node.value === "(") { parenGroup = { open: node, @@ -55,21 +106,22 @@ function parseValueNodes(nodes) { }; commaGroupStack.push(commaGroup); } else if (node.type === "paren" && node.value === ")") { - if (commaGroup.groups.length) { + if (commaGroup.groups.length > 0) { parenGroup.groups.push(commaGroup); } parenGroup.close = node; + /* istanbul ignore next */ if (commaGroupStack.length === 1) { throw new Error("Unbalanced parenthesis"); } commaGroupStack.pop(); - commaGroup = commaGroupStack[commaGroupStack.length - 1]; + commaGroup = getLast(commaGroupStack); commaGroup.groups.push(parenGroup); parenGroupStack.pop(); - parenGroup = parenGroupStack[parenGroupStack.length - 1]; + parenGroup = getLast(parenGroupStack); } else if (node.type === "comma") { parenGroup.groups.push(commaGroup); commaGroup = { @@ -87,31 +139,6 @@ function parseValueNodes(nodes) { return rootParenGroup; } -function stringifyGroup(node) { - if (node.group) { - return stringifyGroup(node.group); - } - - if (node.groups) { - return node.groups.reduce((previousValue, currentValue, index) => { - return ( - previousValue + - stringifyGroup(currentValue) + - (currentValue.type === "comma_group" && index !== node.groups.length - 1 - ? "," - : "") - ); - }, ""); - } - - const before = node.raws && node.raws.before ? node.raws.before : ""; - const value = node.value ? node.value : ""; - const unit = node.unit ? node.unit : ""; - const after = node.raws && node.raws.after ? node.raws.after : ""; - - return before + value + unit + after; -} - function flattenGroups(node) { if ( node.type === "paren_group" && @@ -133,13 +160,16 @@ function flattenGroups(node) { return node; } -function addTypePrefix(node, prefix) { +function addTypePrefix(node, prefix, skipPrefix) { if (node && typeof node === "object") { delete node.parent; for (const key in node) { - addTypePrefix(node[key], prefix); + addTypePrefix(node[key], prefix, skipPrefix); if (key === "type" && typeof node[key] === "string") { - if (!node[key].startsWith(prefix)) { + if ( + !node[key].startsWith(prefix) && + (!skipPrefix || !skipPrefix.test(node[key])) + ) { node[key] = prefix + node[key]; } } @@ -161,37 +191,41 @@ function addMissingType(node) { return node; } -function parseNestedValue(node) { +function parseNestedValue(node, options) { if (node && typeof node === "object") { - delete node.parent; for (const key in node) { - parseNestedValue(node[key]); - if (key === "nodes") { - node.group = flattenGroups(parseValueNodes(node[key])); - delete node[key]; + if (key !== "parent") { + parseNestedValue(node[key], options); + if (key === "nodes") { + node.group = flattenGroups(parseValueNode(node, options)); + delete node[key]; + } } } + delete node.parent; } return node; } -function parseValue(value) { +function parseValue(value, options) { const valueParser = require("postcss-values-parser"); let result = null; try { result = valueParser(value, { loose: true }).parse(); - } catch (e) { + } catch { return { type: "value-unknown", value, }; } - const parsedResult = parseNestedValue(result); + result.text = value; + + const parsedResult = parseNestedValue(result, options); - return addTypePrefix(parsedResult, "value-"); + return addTypePrefix(parsedResult, "value-", /^selector-/); } function parseSelector(selector) { @@ -214,7 +248,7 @@ function parseSelector(selector) { selectorParser((result_) => { result = result_; }).process(selector); - } catch (e) { + } catch { // Fail silently. It's better to print it as is than to try and parse it // Note: A common failure is for SCSS nested properties. `background: // none { color: red; }` is parsed as a NestedDeclaration by @@ -237,8 +271,9 @@ function parseMediaQuery(params) { try { result = mediaParser(params); - } catch (e) { + } catch { // Ignore bad media queries + /* istanbul ignore next */ return { type: "selector-unknown", value: params, @@ -263,6 +298,7 @@ function parseNestedCSS(node, options) { return node; } + /* istanbul ignore next */ if (!node.raws) { node.raws = {}; } @@ -321,13 +357,17 @@ function parseNestedCSS(node, options) { // Ignore LESS mixin declaration if (selector.trim().length > 0) { + // TODO: confirm this code is dead + /* istanbul ignore next */ if (selector.startsWith("@") && selector.endsWith(":")) { return node; } + // TODO: confirm this code is dead + /* istanbul ignore next */ // Ignore LESS mixins if (node.mixin) { - node.selector = parseValue(selector); + node.selector = parseValue(selector, options); return node; } @@ -372,17 +412,24 @@ function parseNestedCSS(node, options) { }; } - node.value = parseValue(value); + node.value = parseValue(value, options); } - // extend is missing if ( isLessParser(options) && node.type === "css-decl" && - !node.extend && value.startsWith("extend(") ) { - node.extend = node.raws.between === ":"; + // extend is missing + if (!node.extend) { + node.extend = node.raws.between === ":"; + } + + // `:extend()` is parsed as value + if (node.extend && !node.selector) { + delete node.value; + node.selector = parseSelector(value.slice("extend(".length, -1)); + } } if (node.type === "css-atrule") { @@ -417,22 +464,26 @@ function parseNestedCSS(node, options) { } if (isLessParser(options)) { - // Whitespace between variable and colon + // postcss-less doesn't recognize variables in some cases. + // `@color: blue;` is recognized fine, but the cases below aren't: + + // `@color:blue;` if (node.name.includes(":") && !node.params) { node.variable = true; const parts = node.name.split(":"); node.name = parts[0]; - node.value = parseValue(parts.slice(1).join(":")); + node.value = parseValue(parts.slice(1).join(":"), options); } - // Missing whitespace between variable and colon + // `@color :blue;` if ( - !["page", "nest"].includes(node.name) && + !["page", "nest", "keyframes"].includes(node.name) && node.params && node.params[0] === ":" ) { node.variable = true; - node.value = parseValue(node.params.slice(1)); + node.value = parseValue(node.params.slice(1), options); + node.raws.afterName += ":"; } // Less variable @@ -464,8 +515,8 @@ function parseNestedCSS(node, options) { } if (name === "at-root") { - if (/^\(\s*(without|with)\s*:[\s\S]+\)$/.test(params)) { - node.params = parseValue(params); + if (/^\(\s*(without|with)\s*:.+\)$/s.test(params)) { + node.params = parseValue(params, options); } else { node.selector = parseSelector(params); delete node.params; @@ -477,7 +528,7 @@ function parseNestedCSS(node, options) { if (lowercasedName === "import") { node.import = true; delete node.filename; - node.params = parseValue(params); + node.params = parseValue(params, options); return node; } @@ -500,11 +551,11 @@ function parseNestedCSS(node, options) { ].includes(name) ) { // Remove unnecessary spaces in SCSS variable arguments - params = params.replace(/(\$\S+?)\s+?\.\.\./, "$1..."); + params = params.replace(/(\$\S+?)\s+?\.{3}/, "$1..."); // Remove unnecessary spaces before SCSS control, mixin and function directives params = params.replace(/^(?!if)(\S+)\s+\(/, "$1("); - node.value = parseValue(params); + node.value = parseValue(params, options); delete node.params; return node; @@ -542,11 +593,13 @@ function parseWithParser(parse, text, options) { try { result = parse(text); - } catch (e) { - if (typeof e.line !== "number") { - throw e; + } catch (error) { + const { name, reason, line, column } = error; + /* istanbul ignore next */ + if (typeof line !== "number") { + throw error; } - throw createError("(postcss) " + e.name + " " + e.reason, { start: e }); + throw createError(`${name}: ${reason}`, { start: { line, column } }); } result = parseNestedCSS(addTypePrefix(result, "css-"), options); @@ -554,6 +607,10 @@ function parseWithParser(parse, text, options) { calculateLoc(result, text); if (frontMatter) { + frontMatter.source = { + startOffset: 0, + endOffset: frontMatter.raw.length, + }; result.nodes.unshift(frontMatter); } @@ -601,19 +658,8 @@ function parseScss(text, parsers, options) { const postCssParser = { astFormat: "postcss", hasPragma, - locStart(node) { - if (node.source) { - return node.source.startOffset; - } - /* istanbul ignore next */ - return null; - }, - locEnd(node) { - if (node.source) { - return node.source.endOffset; - } - return null; - }, + locStart, + locEnd, }; // Export as a plugin so we can reuse the same bundle for UMD loading diff --git a/src/language-css/pragma.js b/src/language-css/pragma.js index 384c9c35f5..872f50d9c2 100644 --- a/src/language-css/pragma.js +++ b/src/language-css/pragma.js @@ -1,7 +1,7 @@ "use strict"; const jsPragma = require("../language-js/pragma"); -const parseFrontMatter = require("../utils/front-matter"); +const parseFrontMatter = require("../utils/front-matter/parse"); function hasPragma(text) { return jsPragma.hasPragma(parseFrontMatter(text).content); diff --git a/src/language-css/printer-postcss.js b/src/language-css/printer-postcss.js index 8660a9cb5f..341e9d24b7 100644 --- a/src/language-css/printer-postcss.js +++ b/src/language-css/printer-postcss.js @@ -1,20 +1,16 @@ "use strict"; -const clean = require("./clean"); -const embed = require("./embed"); -const { insertPragma } = require("./pragma"); +const getLast = require("../utils/get-last"); const { printNumber, printString, - hasIgnoreComment, hasNewline, + isFrontMatterNode, + isNextLineEmpty, + isNonEmptyArray, } = require("../common/util"); -const { isNextLineEmpty } = require("../common/util-shared"); -const { restoreQuotesInInlineComments } = require("./loc"); - const { builders: { - concat, join, line, hardline, @@ -24,9 +20,13 @@ const { indent, dedent, ifBreak, + breakParent, }, - utils: { removeLines }, + utils: { removeLines, getDocParts }, } = require("../document"); +const clean = require("./clean"); +const embed = require("./embed"); +const { insertPragma } = require("./pragma"); const { getAncestorNode, @@ -58,6 +58,7 @@ const { hasParensAroundNode, hasEmptyRawBefore, isKeyValuePairNode, + isKeyInValuePairNode, isDetachedRulesetCallNode, isTemplatePlaceholderNode, isTemplatePropNode, @@ -72,17 +73,12 @@ const { isMediaAndSupportsKeywords, isColorAdjusterFuncNode, lastLineHasInlineComment, + isAtWordPlaceholderNode, } = require("./utils"); +const { locStart, locEnd } = require("./loc"); function shouldPrintComma(options) { - switch (options.trailingComma) { - case "all": - case "es5": - return true; - case "none": - default: - return false; - } + return options.trailingComma === "es5" || options.trailingComma === "all"; } function genericPrint(path, options, print) { @@ -98,33 +94,31 @@ function genericPrint(path, options, print) { } switch (node.type) { - case "yaml": - case "toml": - return concat([node.raw, hardline]); + case "front-matter": + return [node.raw, hardline]; case "css-root": { const nodes = printNodeSequence(path, options, print); + const after = node.raws.after.trim(); - if (nodes.parts.length) { - return concat([nodes, options.__isHTMLStyleAttribute ? "" : hardline]); - } - - return nodes; + return [ + nodes, + after ? ` ${after}` : "", + getDocParts(nodes).length > 0 ? hardline : "", + ]; } case "css-comment": { const isInlineComment = node.inline || node.raws.inline; - const text = options.originalText.slice( - options.locStart(node), - options.locEnd(node) - ); + + const text = options.originalText.slice(locStart(node), locEnd(node)); return isInlineComment ? text.trimEnd() : text; } case "css-rule": { - return concat([ - path.call(print, "selector"), + return [ + print("selector"), node.important ? " !important" : "", node.nodes - ? concat([ + ? [ node.selector && node.selector.type === "selector-unknown" && lastLineHasInlineComment(node.selector.value) @@ -132,28 +126,40 @@ function genericPrint(path, options, print) { : " ", "{", node.nodes.length > 0 - ? indent( - concat([hardline, printNodeSequence(path, options, print)]) - ) + ? indent([hardline, printNodeSequence(path, options, print)]) : "", hardline, "}", isDetachedRulesetDeclarationNode(node) ? ";" : "", - ]) + ] : ";", - ]); + ]; } case "css-decl": { const parentNode = path.getParentNode(); - return concat([ + const { between: rawBetween } = node.raws; + const trimmedBetween = rawBetween.trim(); + const isColon = trimmedBetween === ":"; + + let value = hasComposesNode(node) + ? removeLines(print("value")) + : print("value"); + + if (!isColon && lastLineHasInlineComment(trimmedBetween)) { + value = indent([hardline, dedent(value)]); + } + + return [ node.raws.before.replace(/[\s;]/g, ""), insideICSSRuleNode(path) ? node.prop : maybeToLowerCase(node.prop), - node.raws.between.trim() === ":" ? ":" : node.raws.between.trim(), + trimmedBetween.startsWith("//") ? " " : "", + trimmedBetween, node.extend ? "" : " ", - hasComposesNode(node) - ? removeLines(path.call(print, "value")) - : path.call(print, "value"), + isLessParser(options) && node.extend && node.selector + ? ["extend(", print("selector"), ")"] + : "", + value, node.raws.important ? node.raws.important.replace(/\s*!\s*important/i, " !important") : node.important @@ -170,71 +176,69 @@ function genericPrint(path, options, print) { ? " !global" : "", node.nodes - ? concat([ + ? [ " {", - indent( - concat([softline, printNodeSequence(path, options, print)]) - ), + indent([softline, printNodeSequence(path, options, print)]), softline, "}", - ]) + ] : isTemplatePropNode(node) && !parentNode.raws.semicolon && - options.originalText[options.locEnd(node) - 1] !== ";" + options.originalText[locEnd(node) - 1] !== ";" ? "" + : options.__isHTMLStyleAttribute && isLastNode(path, node) + ? ifBreak(";") : ";", - ]); + ]; } case "css-atrule": { const parentNode = path.getParentNode(); const isTemplatePlaceholderNodeWithoutSemiColon = isTemplatePlaceholderNode(node) && !parentNode.raws.semicolon && - options.originalText[options.locEnd(node) - 1] !== ";"; + options.originalText[locEnd(node) - 1] !== ";"; if (isLessParser(options)) { if (node.mixin) { - return concat([ - path.call(print, "selector"), + return [ + print("selector"), node.important ? " !important" : "", isTemplatePlaceholderNodeWithoutSemiColon ? "" : ";", - ]); + ]; } if (node.function) { - return concat([ + return [ node.name, - concat([path.call(print, "params")]), + print("params"), isTemplatePlaceholderNodeWithoutSemiColon ? "" : ";", - ]); + ]; } if (node.variable) { - return concat([ + return [ "@", node.name, ": ", - node.value ? concat([path.call(print, "value")]) : "", + node.value ? print("value") : "", node.raws.between.trim() ? node.raws.between.trim() + " " : "", node.nodes - ? concat([ + ? [ "{", - indent( - concat([ - node.nodes.length > 0 ? softline : "", - printNodeSequence(path, options, print), - ]) - ), + indent([ + node.nodes.length > 0 ? softline : "", + printNodeSequence(path, options, print), + ]), softline, "}", - ]) + ] : "", isTemplatePlaceholderNodeWithoutSemiColon ? "" : ";", - ]); + ]; } } - return concat([ + return [ "@", // If a Less file ends up being parsed with the SCSS parser, Less // variable declarations will be parsed as at-rules with names ending @@ -243,7 +247,7 @@ function genericPrint(path, options, print) { ? node.name : maybeToLowerCase(node.name), node.params - ? concat([ + ? [ isDetachedRulesetCallNode(node) ? "" : isTemplatePlaceholderNode(node) @@ -252,49 +256,53 @@ function genericPrint(path, options, print) { : node.name.endsWith(":") ? " " : /^\s*\n\s*\n/.test(node.raws.afterName) - ? concat([hardline, hardline]) + ? [hardline, hardline] : /^\s*\n/.test(node.raws.afterName) ? hardline : " " : " ", - path.call(print, "params"), - ]) - : "", - node.selector - ? indent(concat([" ", path.call(print, "selector")])) + print("params"), + ] : "", + node.selector ? indent([" ", print("selector")]) : "", node.value - ? group( - concat([ - " ", - path.call(print, "value"), - isSCSSControlDirectiveNode(node) - ? hasParensAroundNode(node) - ? " " - : line - : "", - ]) - ) + ? group([ + " ", + print("value"), + isSCSSControlDirectiveNode(node) + ? hasParensAroundNode(node) + ? " " + : line + : "", + ]) : node.name === "else" ? " " : "", node.nodes - ? concat([ - isSCSSControlDirectiveNode(node) ? "" : " ", + ? [ + isSCSSControlDirectiveNode(node) + ? "" + : (node.selector && + !node.selector.nodes && + typeof node.selector.value === "string" && + lastLineHasInlineComment(node.selector.value)) || + (!node.selector && + typeof node.params === "string" && + lastLineHasInlineComment(node.params)) + ? line + : " ", "{", - indent( - concat([ - node.nodes.length > 0 ? softline : "", - printNodeSequence(path, options, print), - ]) - ), + indent([ + node.nodes.length > 0 ? softline : "", + printNodeSequence(path, options, print), + ]), softline, "}", - ]) + ] : isTemplatePlaceholderNodeWithoutSemiColon ? "" : ";", - ]); + ]; } // postcss-media-query-parser case "media-query-list": { @@ -304,16 +312,16 @@ function genericPrint(path, options, print) { if (node.type === "media-query" && node.value === "") { return; } - parts.push(childPath.call(print)); + parts.push(print()); }, "nodes"); return group(indent(join(line, parts))); } case "media-query": { - return concat([ + return [ join(" ", path.map(print, "nodes")), isLastNode(path, node) ? "" : ",", - ]); + ]; } case "media-type": { return adjustNumbers(adjustStrings(node.value, options)); @@ -325,13 +333,7 @@ function genericPrint(path, options, print) { return node.value; } // prettierx: cssParenSpacing option support (...) - return concat([ - "(", - parenSpace, - concat(path.map(print, "nodes")), - parenSpace, - ")", - ]); + return ["(", parenSpace, ...path.map(print, "nodes"), parenSpace, ")"]; } case "media-feature": { return maybeToLowerCase( @@ -339,7 +341,7 @@ function genericPrint(path, options, print) { ); } case "media-colon": { - return concat([node.value, " "]); + return [node.value, " "]; } case "media-value": { return adjustNumbers(adjustStrings(node.value, options)); @@ -349,7 +351,7 @@ function genericPrint(path, options, print) { } case "media-url": { return adjustStrings( - node.value.replace(/^url\(\s+/gi, "url(").replace(/\s+\)$/gi, ")"), + node.value.replace(/^url\(\s+/gi, "url(").replace(/\s+\)$/g, ")"), options ); } @@ -358,25 +360,23 @@ function genericPrint(path, options, print) { } // postcss-selector-parser case "selector-root": { - return group( - concat([ - insideAtRuleNode(path, "custom-selector") - ? concat([getAncestorNode(path, "css-atrule").customSelector, line]) - : "", - join( - concat([ - ",", - insideAtRuleNode(path, ["extend", "custom-selector", "nest"]) - ? line - : hardline, - ]), - path.map(print, "nodes") - ), - ]) - ); + return group([ + insideAtRuleNode(path, "custom-selector") + ? [getAncestorNode(path, "css-atrule").customSelector, line] + : "", + join( + [ + ",", + insideAtRuleNode(path, ["extend", "custom-selector", "nest"]) + ? line + : hardline, + ], + path.map(print, "nodes") + ), + ]); } case "selector-selector": { - return group(indent(concat(path.map(print, "nodes")))); + return group(indent(path.map(print, "nodes"))); } case "selector-comment": { return node.value; @@ -389,9 +389,9 @@ function genericPrint(path, options, print) { const index = parentNode && parentNode.nodes.indexOf(node); const prevNode = index && parentNode.nodes[index - 1]; - return concat([ + return [ node.namespace - ? concat([node.namespace === true ? "" : node.namespace.trim(), "|"]) + ? [node.namespace === true ? "" : node.namespace.trim(), "|"] : "", prevNode.type === "selector-nesting" ? node.value @@ -400,19 +400,19 @@ function genericPrint(path, options, print) { ? node.value.toLowerCase() : node.value ), - ]); + ]; } case "selector-id": { - return concat(["#", node.value]); + return ["#", node.value]; } case "selector-class": { - return concat([".", adjustNumbers(adjustStrings(node.value, options))]); + return [".", adjustNumbers(adjustStrings(node.value, options))]; } case "selector-attribute": { - return concat([ + return [ "[", node.namespace - ? concat([node.namespace === true ? "" : node.namespace.trim(), "|"]) + ? [node.namespace === true ? "" : node.namespace.trim(), "|"] : "", node.attribute.trim(), node.operator ? node.operator : "", @@ -424,7 +424,7 @@ function genericPrint(path, options, print) { : "", node.insensitive ? " i" : "", "]", - ]); + ]; } case "selector-combinator": { if ( @@ -440,40 +440,39 @@ function genericPrint(path, options, print) { ? "" : line; - return concat([leading, node.value, isLastNode(path, node) ? "" : " "]); + return [leading, node.value, isLastNode(path, node) ? "" : " "]; } const leading = node.value.trim().startsWith("(") ? line : ""; const value = adjustNumbers(adjustStrings(node.value.trim(), options)) || line; - return concat([leading, value]); + return [leading, value]; } case "selector-universal": { - return concat([ + return [ node.namespace - ? concat([node.namespace === true ? "" : node.namespace.trim(), "|"]) + ? [node.namespace === true ? "" : node.namespace.trim(), "|"] : "", node.value, - ]); + ]; } case "selector-pseudo": { // prettierx: cssParenSpacing option support (...) const parenSpace = options.cssParenSpacing ? " " : ""; - return concat([ + return [ maybeToLowerCase(node.value), - // [prettierx merge from prettier@2.0.5 ...] - node.nodes && node.nodes.length > 0 - ? concat([ + isNonEmptyArray(node.nodes) + ? [ // prettierx: cssParenSpacing option support (...) "(", parenSpace, join(", ", path.map(print, "nodes")), parenSpace, ")", - ]) + ] : "", - ]); + ]; } case "selector-nesting": { return node.value; @@ -491,26 +490,37 @@ function genericPrint(path, options, print) { // originalText has to be used for Less, see replaceQuotesInInlineComments in loc.js const parentNode = path.getParentNode(); if (parentNode.raws && parentNode.raws.selector) { - const start = options.locStart(parentNode); + const start = locStart(parentNode); const end = start + parentNode.raws.selector.length; return options.originalText.slice(start, end).trim(); } + // Same reason above + const grandParent = path.getParentNode(1); + if ( + parentNode.type === "value-paren_group" && + grandParent && + grandParent.type === "value-func" && + grandParent.value === "selector" + ) { + const start = locStart(parentNode.open) + 1; + const end = locEnd(parentNode.close) - 1; + const selector = options.originalText.slice(start, end).trim(); + + return lastLineHasInlineComment(selector) + ? [breakParent, selector] + : selector; + } + return node.value; } // postcss-values-parser case "value-value": case "value-root": { - return path.call(print, "group"); + return print("group"); } case "value-comment": { - return concat([ - node.inline ? "//" : "/*", - // see replaceQuotesInInlineComments in loc.js - // value-* nodes don't have correct location data, so we have to rely on placeholder characters. - restoreQuotesInInlineComments(node.value), - node.inline ? "" : "*/", - ]); + return options.originalText.slice(locStart(node), locEnd(node)); } case "value-comma_group": { const parentNode = path.getParentNode(); @@ -524,6 +534,9 @@ function genericPrint(path, options, print) { const atRuleAncestorNode = getAncestorNode(path, "css-atrule"); const isControlDirective = atRuleAncestorNode && isSCSSControlDirectiveNode(atRuleAncestorNode); + const hasInlineComment = node.groups.some((node) => + isInlineValueCommentNode(node) + ); const printed = path.map(print, "groups"); const parts = []; @@ -556,9 +569,9 @@ function genericPrint(path, options, print) { // styled.div` background: var(--${one}); ` if ( - !iPrevNode && - iNode.value === "--" && - iNextNode.type === "value-atword" + iNode.type === "value-word" && + iNode.value.endsWith("-") && + isAtWordPlaceholderNode(iNextNode) ) { continue; } @@ -661,6 +674,13 @@ function genericPrint(path, options, print) { continue; } + // absolute paths are only parsed as one token if they are part of url(/abs/path) call + // but if you have custom -fb-url(/abs/path/) then it is parsed as "division /" and rest + // of the path. We don't want to put a space after that first division in this case. + if (!iPrevNode && isDivisionNode(iNode)) { + continue; + } + // Print spaces before and after addition and subtraction math operators as is in `calc` function // due to the fact that it is not valid syntax // (i.e. `calc(1px+1px)`, `calc(1px+ 1px)`, `calc(1px +1px)`, `calc(1px + 1px)`) @@ -716,6 +736,10 @@ function genericPrint(path, options, print) { // Add `hardline` after inline comment (i.e. `// comment\n foo: bar;`) if (isInlineValueCommentNode(iNode)) { + if (parentNode.type === "value-paren_group") { + parts.push(dedent(hardline)); + continue; + } parts.push(hardline); continue; } @@ -769,17 +793,33 @@ function genericPrint(path, options, print) { continue; } + // allow function(returns-list($list)...) + if (iNextNode && iNextNode.value === "...") { + continue; + } + + if ( + isAtWordPlaceholderNode(iNode) && + isAtWordPlaceholderNode(iNextNode) && + locEnd(iNode) === locStart(iNextNode) + ) { + continue; + } // Be default all values go through `line` parts.push(line); } + if (hasInlineComment) { + parts.push(breakParent); + } + if (didBreak) { parts.unshift(hardline); } if (isControlDirective) { - return group(indent(concat(parts))); + return group(indent(parts)); } // Indent is not needed for import url when url is very long @@ -809,12 +849,12 @@ function genericPrint(path, options, print) { node.groups[0].groups[0].type === "value-word" && node.groups[0].groups[0].value.startsWith("data:"))) ) { - return concat([ + return [ // prettierx: cssParenSpacing option support (...) - node.open ? concat([path.call(print, "open"), parenSpace]) : "", + ...(node.open ? [print("open"), parenSpace] : [""]), join(",", path.map(print, "groups")), - node.close ? concat([parenSpace, path.call(print, "close")]) : "", - ]); + ...(node.close ? [parenSpace, print("close")] : [""]), + ]; } if (!node.open) { @@ -823,7 +863,7 @@ function genericPrint(path, options, print) { for (let i = 0; i < printed.length; i++) { if (i !== 0) { - res.push(concat([",", line])); + res.push([",", line]); } res.push(printed[i]); } @@ -833,52 +873,49 @@ function genericPrint(path, options, print) { // prettierx: cssParenSpacing option support (...) if (node.groups.length === 0) { - return group( - concat([ - node.open ? path.call(print, "open") : "", - node.close ? path.call(print, "close") : "", - ]) - ); + return group([ + node.open ? print("open") : "", + node.close ? print("close") : "", + ]); } const isSCSSMapItem = isSCSSMapItemNode(path); - const lastItem = node.groups[node.groups.length - 1]; + const lastItem = getLast(node.groups); const isLastItemComment = lastItem && lastItem.type === "value-comment"; - - return group( - concat([ - node.open ? path.call(print, "open") : "", - indent( - concat([ - // prettierx: cssParenSpacing option support (...) - parenLine, - join( - concat([",", line]), - path.map((childPath) => { - const node = childPath.getValue(); - const printed = print(childPath); - - // Key/Value pair in open paren already indented - if ( - isKeyValuePairNode(node) && - node.type === "value-comma_group" && - node.groups && - node.groups[2] && - node.groups[2].type === "value-paren_group" - ) { - printed.contents.contents.parts[1] = group( - printed.contents.contents.parts[1] - ); - - return group(dedent(printed)); - } - - return printed; - }, "groups") - ), - ]) - ), + const isKey = isKeyInValuePairNode(node, parentNode); + + const printed = group( + [ + node.open ? print("open") : "", + indent([ + // prettierx: cssParenSpacing option support (...) + parenLine, + join( + [",", line], + path.map((childPath) => { + const node = childPath.getValue(); + const printed = print(); + + // Key/Value pair in open paren already indented + if ( + isKeyValuePairNode(node) && + node.type === "value-comma_group" && + node.groups && + node.groups[0].type !== "value-paren_group" && + node.groups[2] && + node.groups[2].type === "value-paren_group" + ) { + const parts = getDocParts(printed.contents.contents); + parts[1] = group(parts[1]); + + return group(dedent(printed)); + } + + return printed; + }, "groups") + ), + ]), ifBreak( !isLastItemComment && isSCSS(options.parser, options.originalText) && @@ -889,27 +926,29 @@ function genericPrint(path, options, print) { ), // prettierx: cssParenSpacing option support (...) parenLine, - node.close ? path.call(print, "close") : "", - ]), + node.close ? print("close") : "", + ], { - shouldBreak: isSCSSMapItem, + shouldBreak: isSCSSMapItem && !isKey, } ); + + return isKey ? dedent(printed) : printed; } case "value-func": { - return concat([ + return [ node.value, insideAtRuleNode(path, "supports") && isMediaAndSupportsKeywords(node) ? " " : "", - path.call(print, "group"), - ]); + print("group"), + ]; } case "value-paren": { return node.value; } case "value-number": { - return concat([printCssNumber(node.value), maybeToLowerCase(node.unit)]); + return [printCssNumber(node.value), maybeToLowerCase(node.unit)]; } case "value-operator": { return node.value; @@ -922,14 +961,25 @@ function genericPrint(path, options, print) { return node.value; } case "value-colon": { - return concat([ + const parentNode = path.getParentNode(); + const index = parentNode && parentNode.groups.indexOf(node); + const prevNode = index && parentNode.groups[index - 1]; + return [ node.value, + // Don't add spaces on escaped colon `:`, e.g: grid-template-rows: [row-1-00\:00] auto; + (prevNode && + typeof prevNode.value === "string" && + getLast(prevNode.value) === "\\") || // Don't add spaces on `:` in `url` function (i.e. `url(fbglyph: cross-outline, fig-white)`) - insideValueFunctionNode(path, "url") ? "" : line, - ]); + insideValueFunctionNode(path, "url") + ? "" + : line, + ]; } + // TODO: confirm this code is dead + /* istanbul ignore next */ case "value-comma": { - return concat([node.value, " "]); + return [node.value, " "]; } case "value-string": { return printString( @@ -938,7 +988,7 @@ function genericPrint(path, options, print) { ); } case "value-atword": { - return concat(["@", node.value]); + return ["@", node.value]; } case "value-unicode-range": { return node.value; @@ -953,11 +1003,9 @@ function genericPrint(path, options, print) { } function printNodeSequence(path, options, print) { - const node = path.getValue(); const parts = []; - let i = 0; - path.map((pathChild) => { - const prevNode = node.nodes[i - 1]; + path.each((pathChild, i, nodes) => { + const prevNode = nodes[i - 1]; if ( prevNode && prevNode.type === "css-comment" && @@ -965,55 +1013,43 @@ function printNodeSequence(path, options, print) { ) { const childNode = pathChild.getValue(); parts.push( - options.originalText.slice( - options.locStart(childNode), - options.locEnd(childNode) - ) + options.originalText.slice(locStart(childNode), locEnd(childNode)) ); } else { - parts.push(pathChild.call(print)); + parts.push(print()); } - if (i !== node.nodes.length - 1) { + if (i !== nodes.length - 1) { if ( - (node.nodes[i + 1].type === "css-comment" && - !hasNewline( - options.originalText, - options.locStart(node.nodes[i + 1]), - { backwards: true } - ) && - node.nodes[i].type !== "yaml" && - node.nodes[i].type !== "toml") || - (node.nodes[i + 1].type === "css-atrule" && - node.nodes[i + 1].name === "else" && - node.nodes[i].type !== "css-comment") + (nodes[i + 1].type === "css-comment" && + !hasNewline(options.originalText, locStart(nodes[i + 1]), { + backwards: true, + }) && + !isFrontMatterNode(nodes[i])) || + (nodes[i + 1].type === "css-atrule" && + nodes[i + 1].name === "else" && + nodes[i].type !== "css-comment") ) { parts.push(" "); } else { parts.push(options.__isHTMLStyleAttribute ? line : hardline); if ( - isNextLineEmpty( - options.originalText, - pathChild.getValue(), - options.locEnd - ) && - node.nodes[i].type !== "yaml" && - node.nodes[i].type !== "toml" + isNextLineEmpty(options.originalText, pathChild.getValue(), locEnd) && + !isFrontMatterNode(nodes[i]) ) { parts.push(hardline); } } } - i++; }, "nodes"); - return concat(parts); + return parts; } -const STRING_REGEX = /(['"])(?:(?!\1)[^\\]|\\[\s\S])*\1/g; -const NUMBER_REGEX = /(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?/g; -const STANDARD_UNIT_REGEX = /[a-zA-Z]+/g; -const WORD_PART_REGEX = /[$@]?[a-zA-Z_\u0080-\uFFFF][\w\-\u0080-\uFFFF]*/g; +const STRING_REGEX = /(["'])(?:(?!\1)[^\\]|\\.)*\1/gs; +const NUMBER_REGEX = /(?:\d*\.\d+|\d+\.?)(?:[Ee][+-]?\d+)?/g; +const STANDARD_UNIT_REGEX = /[A-Za-z]+/g; +const WORD_PART_REGEX = /[$@]?[A-Z_a-z\u0080-\uFFFF][\w\u0080-\uFFFF-]*/g; const ADJUST_NUMBERS_REGEX = new RegExp( STRING_REGEX.source + "|" + @@ -1056,6 +1092,5 @@ module.exports = { print: genericPrint, embed, insertPragma, - hasPrettierIgnore: hasIgnoreComment, massageAstNode: clean, }; diff --git a/src/language-css/utils.js b/src/language-css/utils.js index 10f9bd275f..62e38a4fec 100644 --- a/src/language-css/utils.js +++ b/src/language-css/utils.js @@ -1,6 +1,7 @@ "use strict"; -const colorAdjusterFunctions = [ +const { isNonEmptyArray } = require("../common/util"); +const colorAdjusterFunctions = new Set([ "red", "green", "blue", @@ -26,10 +27,10 @@ const colorAdjusterFunctions = [ "hsla", "hwb", "hwba", -]; +]); function getAncestorCounter(path, typeOrTypes) { - const types = [].concat(typeOrTypes); + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; let counter = -1; let ancestorNode; @@ -58,14 +59,46 @@ function getPropOfDeclNode(path) { ); } +function hasSCSSInterpolation(groupList) { + if (isNonEmptyArray(groupList)) { + for (let i = groupList.length - 1; i > 0; i--) { + // If we find `#{`, return true. + if ( + groupList[i].type === "word" && + groupList[i].value === "{" && + groupList[i - 1].type === "word" && + groupList[i - 1].value.endsWith("#") + ) { + return true; + } + } + } + return false; +} + +function hasStringOrFunction(groupList) { + if (isNonEmptyArray(groupList)) { + for (let i = 0; i < groupList.length; i++) { + if (groupList[i].type === "string" || groupList[i].type === "func") { + return true; + } + } + } + return false; +} + function isSCSS(parser, text) { const hasExplicitParserChoice = parser === "less" || parser === "scss"; - const IS_POSSIBLY_SCSS = /(\w\s*:\s*[^}:]+|#){|@import[^\n]+(?:url|,)/; + const IS_POSSIBLY_SCSS = /(\w\s*:\s*[^:}]+|#){|@import[^\n]+(?:url|,)/; return hasExplicitParserChoice ? parser === "scss" : IS_POSSIBLY_SCSS.test(text); } +function isSCSSVariable(node) { + return Boolean(node && node.type === "word" && node.value.startsWith("$")); +} + function isWideKeywords(value) { return ["initial", "inherit", "unset", "revert"].includes( value.toLowerCase() @@ -116,7 +149,9 @@ function insideICSSRuleNode(path) { } function insideAtRuleNode(path, atRuleNameOrAtRuleNames) { - const atRuleNames = [].concat(atRuleNameOrAtRuleNames); + const atRuleNames = Array.isArray(atRuleNameOrAtRuleNames) + ? atRuleNameOrAtRuleNames + : [atRuleNameOrAtRuleNames]; const atRuleAncestorNode = getAncestorNode(path, "css-atrule"); return ( @@ -143,6 +178,8 @@ function isURLFunctionNode(node) { function isLastNode(path, node) { const parentNode = path.getParentNode(); + + /* istanbul ignore next */ if (!parentNode) { return false; } @@ -154,6 +191,7 @@ function isDetachedRulesetDeclarationNode(node) { // If a Less file ends up being parsed with the SCSS parser, Less // variable declarations will be parsed as atrules with names ending // with a colon, so keep the original case then. + /* istanbul ignore next */ if (!node.selector) { return false; } @@ -229,6 +267,7 @@ function isSCSSControlDirectiveNode(node) { } function isSCSSNestedPropertyNode(node) { + /* istanbul ignore next */ if (!node.selector) { return false; } @@ -364,7 +403,22 @@ function isWordNode(node) { } function isColonNode(node) { - return node.type === "value-colon"; + return node && node.type === "value-colon"; +} + +function isKeyInValuePairNode(node, parentNode) { + if (!isKeyValuePairNode(parentNode)) { + return false; + } + + const { groups } = parentNode; + const index = groups.indexOf(node); + + if (index === -1) { + return false; + } + + return isColonNode(groups[index + 1]); } function isMediaAndSupportsKeywords(node) { @@ -376,7 +430,7 @@ function isColorAdjusterFuncNode(node) { return false; } - return colorAdjusterFunctions.includes(node.value.toLowerCase()); + return colorAdjusterFunctions.has(node.value.toLowerCase()); } // TODO: only check `less` when we don't use `less` to parse `css` @@ -385,13 +439,52 @@ function isLessParser(options) { } function lastLineHasInlineComment(text) { - return /\/\//.test(text.split(/[\r\n]/).pop()); + return /\/\//.test(text.split(/[\n\r]/).pop()); +} + +function stringifyNode(node) { + if (node.groups) { + const open = node.open && node.open.value ? node.open.value : ""; + const groups = node.groups.reduce( + (previousValue, currentValue, index) => + previousValue + + stringifyNode(currentValue) + + (node.groups[0].type === "comma_group" && + index !== node.groups.length - 1 + ? "," + : ""), + "" + ); + const close = node.close && node.close.value ? node.close.value : ""; + + return open + groups + close; + } + + const before = node.raws && node.raws.before ? node.raws.before : ""; + const quote = node.raws && node.raws.quote ? node.raws.quote : ""; + const atword = node.type === "atword" ? "@" : ""; + const value = node.value ? node.value : ""; + const unit = node.unit ? node.unit : ""; + const group = node.group ? stringifyNode(node.group) : ""; + const after = node.raws && node.raws.after ? node.raws.after : ""; + + return before + quote + atword + value + quote + unit + group + after; +} + +function isAtWordPlaceholderNode(node) { + return ( + node && + node.type === "value-atword" && + node.value.startsWith("prettier-placeholder-") + ); } module.exports = { getAncestorCounter, getAncestorNode, getPropOfDeclNode, + hasSCSSInterpolation, + hasStringOrFunction, maybeToLowerCase, insideValueFunctionNode, insideICSSRuleNode, @@ -400,6 +493,7 @@ module.exports = { isKeyframeAtRuleKeywords, isWideKeywords, isSCSS, + isSCSSVariable, isLastNode, isLessParser, isSCSSControlDirectiveNode, @@ -426,6 +520,7 @@ module.exports = { isPostcssSimpleVarNode, isKeyValuePairNode, isKeyValuePairInParenGroupNode, + isKeyInValuePairNode, isSCSSMapItemNode, isInlineValueCommentNode, isHashNode, @@ -436,4 +531,6 @@ module.exports = { isMediaAndSupportsKeywords, isColorAdjusterFuncNode, lastLineHasInlineComment, + stringifyNode, + isAtWordPlaceholderNode, }; diff --git a/src/language-graphql/index.js b/src/language-graphql/index.js index be64a9e2cf..b991a850f9 100644 --- a/src/language-graphql/index.js +++ b/src/language-graphql/index.js @@ -1,11 +1,11 @@ "use strict"; +const createLanguage = require("../utils/create-language"); const printer = require("./printer-graphql"); const options = require("./options"); -const createLanguage = require("../utils/create-language"); const languages = [ - createLanguage(require("linguist-languages/data/GraphQL"), () => ({ + createLanguage(require("linguist-languages/data/GraphQL.json"), () => ({ since: "1.5.0", parsers: ["graphql"], vscodeLanguageIds: ["graphql"], @@ -16,8 +16,15 @@ const printers = { graphql: printer, }; +const parsers = { + get graphql() { + return require("./parser-graphql").parsers.graphql; + }, +}; + module.exports = { languages, options, printers, + parsers, }; diff --git a/src/language-graphql/loc.js b/src/language-graphql/loc.js new file mode 100644 index 0000000000..6d5507718b --- /dev/null +++ b/src/language-graphql/loc.js @@ -0,0 +1,17 @@ +"use strict"; + +function locStart(node) { + if (typeof node.start === "number") { + return node.start; + } + return node.loc && node.loc.start; +} + +function locEnd(node) { + if (typeof node.end === "number") { + return node.end; + } + return node.loc && node.loc.end; +} + +module.exports = { locStart, locEnd }; diff --git a/src/language-graphql/options.js b/src/language-graphql/options.js index 896412deea..3a62adaf19 100644 --- a/src/language-graphql/options.js +++ b/src/language-graphql/options.js @@ -1,6 +1,6 @@ "use strict"; -// format based on https://github.com/prettier/prettier/blob/master/src/main/core-options.js +// format based on https://github.com/prettier/prettier/blob/main/src/main/core-options.js module.exports = { graphqlCurlySpacing: { category: "Other", diff --git a/src/language-graphql/parser-graphql.js b/src/language-graphql/parser-graphql.js index d92d39b9f0..4b7ef8f5c9 100644 --- a/src/language-graphql/parser-graphql.js +++ b/src/language-graphql/parser-graphql.js @@ -1,7 +1,9 @@ "use strict"; const createError = require("../common/parser-create-error"); +const tryCombinations = require("../utils/try-combinations"); const { hasPragma } = require("./pragma"); +const { locStart, locEnd } = require("./loc"); function parseComments(ast) { const comments = []; @@ -35,40 +37,41 @@ function removeTokens(node) { return node; } -function fallbackParser(parse, source) { - const parserOptions = { - allowLegacySDLImplementsInterfaces: false, - experimentalFragmentVariables: true, - }; - try { - return parse(source, parserOptions); - } catch (_) { - parserOptions.allowLegacySDLImplementsInterfaces = true; - return parse(source, parserOptions); +const parseOptions = { + allowLegacySDLImplementsInterfaces: false, + experimentalFragmentVariables: true, +}; + +function createParseError(error) { + const { GraphQLError } = require("graphql/error/GraphQLError"); + if (error instanceof GraphQLError) { + const { + message, + locations: [start], + } = error; + return createError(message, { start }); } + + /* istanbul ignore next */ + return error; } function parse(text /*, parsers, opts*/) { // Inline the require to avoid loading all the JS if we don't use it - const parser = require("graphql/language"); - try { - const ast = fallbackParser(parser.parse, text); - ast.comments = parseComments(ast); - removeTokens(ast); - return ast; - } catch (error) { - const { GraphQLError } = require("graphql/error"); - if (error instanceof GraphQLError) { - throw createError(error.message, { - start: { - line: error.locations[0].line, - column: error.locations[0].column, - }, - }); - } else { - throw error; - } + const { parse } = require("graphql/language/parser"); + const { result: ast, error } = tryCombinations( + () => parse(text, { ...parseOptions }), + () => + parse(text, { ...parseOptions, allowLegacySDLImplementsInterfaces: true }) + ); + + if (!ast) { + throw createParseError(error); } + + ast.comments = parseComments(ast); + removeTokens(ast); + return ast; } module.exports = { @@ -77,18 +80,8 @@ module.exports = { parse, astFormat: "graphql", hasPragma, - locStart(node) { - if (typeof node.start === "number") { - return node.start; - } - return node.loc && node.loc.start; - }, - locEnd(node) { - if (typeof node.end === "number") { - return node.end; - } - return node.loc && node.loc.end; - }, + locStart, + locEnd, }, }, }; diff --git a/src/language-graphql/pragma.js b/src/language-graphql/pragma.js index d41219dc7a..af174a325f 100644 --- a/src/language-graphql/pragma.js +++ b/src/language-graphql/pragma.js @@ -1,7 +1,7 @@ "use strict"; function hasPragma(text) { - return /^\s*#[^\n\S]*@(format|prettier)\s*(\n|$)/.test(text); + return /^\s*#[^\S\n]*@(format|prettier)\s*(\n|$)/.test(text); } function insertPragma(text) { diff --git a/src/language-graphql/printer-graphql.js b/src/language-graphql/printer-graphql.js index 26a75360eb..4ca56076b9 100644 --- a/src/language-graphql/printer-graphql.js +++ b/src/language-graphql/printer-graphql.js @@ -1,648 +1,559 @@ "use strict"; const { - concat, - join, - hardline, - line, - softline, - group, - indent, - ifBreak, -} = require("../document").builders; -const { hasIgnoreComment } = require("../common/util"); -const { isNextLineEmpty } = require("../common/util-shared"); + builders: { join, hardline, line, softline, group, indent, ifBreak }, +} = require("../document"); +const { isNextLineEmpty, isNonEmptyArray } = require("../common/util"); const { insertPragma } = require("./pragma"); +const { locStart, locEnd } = require("./loc"); function genericPrint(path, options, print) { - const n = path.getValue(); - if (!n) { + const node = path.getValue(); + if (!node) { return ""; } - if (typeof n === "string") { - return n; + if (typeof node === "string") { + return node; } - switch (n.kind) { + switch (node.kind) { case "Document": { const parts = []; - path.map((pathChild, index) => { - parts.push(concat([pathChild.call(print)])); - if (index !== n.definitions.length - 1) { + path.each((pathChild, index, definitions) => { + parts.push(print()); + if (index !== definitions.length - 1) { parts.push(hardline); if ( - isNextLineEmpty( - options.originalText, - pathChild.getValue(), - options.locEnd - ) + isNextLineEmpty(options.originalText, pathChild.getValue(), locEnd) ) { parts.push(hardline); } } }, "definitions"); - return concat([concat(parts), hardline]); + return [...parts, hardline]; } case "OperationDefinition": { - const hasOperation = options.originalText[options.locStart(n)] !== "{"; - const hasName = !!n.name; - return concat([ - hasOperation ? n.operation : "", - hasOperation && hasName ? concat([" ", path.call(print, "name")]) : "", - n.variableDefinitions && n.variableDefinitions.length - ? group( - concat([ - "(", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.map(print, "variableDefinitions") - ), - ]) - ), + const hasOperation = options.originalText[locStart(node)] !== "{"; + const hasName = Boolean(node.name); + return [ + hasOperation ? node.operation : "", + hasOperation && hasName ? [" ", print("name")] : "", + hasOperation && !hasName && isNonEmptyArray(node.variableDefinitions) + ? " " + : "", + isNonEmptyArray(node.variableDefinitions) + ? group([ + "(", + indent([ softline, - ")", - ]) - ) + join( + [ifBreak("", ", "), softline], + path.map(print, "variableDefinitions") + ), + ]), + softline, + ")", + ]) : "", - printDirectives(path, print, n), - n.selectionSet ? (!hasOperation && !hasName ? "" : " ") : "", - path.call(print, "selectionSet"), - ]); + printDirectives(path, print, node), + node.selectionSet ? (!hasOperation && !hasName ? "" : " ") : "", + print("selectionSet"), + ]; } case "FragmentDefinition": { - return concat([ + return [ "fragment ", - path.call(print, "name"), - n.variableDefinitions && n.variableDefinitions.length - ? group( - concat([ - "(", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.map(print, "variableDefinitions") - ), - ]) - ), + print("name"), + isNonEmptyArray(node.variableDefinitions) + ? group([ + "(", + indent([ softline, - ")", - ]) - ) + join( + [ifBreak("", ", "), softline], + path.map(print, "variableDefinitions") + ), + ]), + softline, + ")", + ]) : "", " on ", - path.call(print, "typeCondition"), - printDirectives(path, print, n), + print("typeCondition"), + printDirectives(path, print, node), " ", - path.call(print, "selectionSet"), - ]); + print("selectionSet"), + ]; } case "SelectionSet": { - return concat([ + return [ "{", - indent( - concat([ + indent([ + hardline, + join( hardline, - join( - hardline, - path.call( - (selectionsPath) => - printSequence(selectionsPath, options, print), - "selections" - ) - ), - ]) - ), + path.call( + (selectionsPath) => printSequence(selectionsPath, options, print), + "selections" + ) + ), + ]), hardline, "}", - ]); + ]; } case "Field": { - return group( - concat([ - n.alias ? concat([path.call(print, "alias"), ": "]) : "", - path.call(print, "name"), - n.arguments.length > 0 - ? group( - concat([ - "(", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.call( - (argsPath) => printSequence(argsPath, options, print), - "arguments" - ) - ), - ]) - ), - softline, - ")", - ]) - ) - : "", - printDirectives(path, print, n), - n.selectionSet ? " " : "", - path.call(print, "selectionSet"), - ]) - ); + return group([ + node.alias ? [print("alias"), ": "] : "", + print("name"), + node.arguments.length > 0 + ? group([ + "(", + indent([ + softline, + join( + [ifBreak("", ", "), softline], + path.call( + (argsPath) => printSequence(argsPath, options, print), + "arguments" + ) + ), + ]), + softline, + ")", + ]) + : "", + printDirectives(path, print, node), + node.selectionSet ? " " : "", + print("selectionSet"), + ]); } case "Name": { - return n.value; + return node.value; } case "StringValue": { - if (n.block) { - return concat([ + if (node.block) { + return [ '"""', hardline, - join(hardline, n.value.replace(/"""/g, "\\$&").split("\n")), + join(hardline, node.value.replace(/"""/g, "\\$&").split("\n")), hardline, '"""', - ]); + ]; } - return concat([ + return [ '"', - n.value.replace(/["\\]/g, "\\$&").replace(/\n/g, "\\n"), + node.value.replace(/["\\]/g, "\\$&").replace(/\n/g, "\\n"), '"', - ]); + ]; } case "IntValue": case "FloatValue": case "EnumValue": { - return n.value; + return node.value; } case "BooleanValue": { - return n.value ? "true" : "false"; + return node.value ? "true" : "false"; } case "NullValue": { return "null"; } case "Variable": { - return concat(["$", path.call(print, "name")]); + return ["$", print("name")]; } case "ListValue": { - return group( - concat([ - "[", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.map(print, "values") - ), - ]) - ), + return group([ + "[", + indent([ softline, - "]", - ]) - ); + join([ifBreak("", ", "), softline], path.map(print, "values")), + ]), + softline, + "]", + ]); } case "ObjectValue": { - return group( - concat([ - "{", - // [prettierx] graphqlCurlySpacing option (...) - options.graphqlCurlySpacing && n.fields.length > 0 ? " " : "", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.map(print, "fields") - ), - ]) - ), + return group([ + "{", + // [prettierx]: graphqlCurlySpacing option + options.graphqlCurlySpacing && node.fields.length > 0 ? " " : "", + indent([ softline, - // [prettierx] graphqlCurlySpacing option (...) - ifBreak( - "", - options.graphqlCurlySpacing && n.fields.length > 0 ? " " : "" - ), - "}", - ]) - ); + join([ifBreak("", ", "), softline], path.map(print, "fields")), + ]), + softline, + ifBreak( + "", + // [prettierx]: graphqlCurlySpacing option + options.graphqlCurlySpacing && node.fields.length > 0 ? " " : "" + ), + "}", + ]); } case "ObjectField": case "Argument": { - return concat([ - path.call(print, "name"), - ": ", - path.call(print, "value"), - ]); + return [print("name"), ": ", print("value")]; } case "Directive": { - return concat([ + return [ "@", - path.call(print, "name"), - n.arguments.length > 0 - ? group( - concat([ - "(", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.call( - (argsPath) => printSequence(argsPath, options, print), - "arguments" - ) - ), - ]) - ), + print("name"), + node.arguments.length > 0 + ? group([ + "(", + indent([ softline, - ")", - ]) - ) + join( + [ifBreak("", ", "), softline], + path.call( + (argsPath) => printSequence(argsPath, options, print), + "arguments" + ) + ), + ]), + softline, + ")", + ]) : "", - ]); + ]; } case "NamedType": { - return path.call(print, "name"); + return print("name"); } case "VariableDefinition": { - return concat([ - path.call(print, "variable"), + return [ + print("variable"), ": ", - path.call(print, "type"), - n.defaultValue ? concat([" = ", path.call(print, "defaultValue")]) : "", - printDirectives(path, print, n), - ]); - } - - case "TypeExtensionDefinition": { - return concat(["extend ", path.call(print, "definition")]); + print("type"), + node.defaultValue ? [" = ", print("defaultValue")] : "", + printDirectives(path, print, node), + ]; } case "ObjectTypeExtension": case "ObjectTypeDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", - n.kind === "ObjectTypeExtension" ? "extend " : "", + return [ + print("description"), + node.description ? hardline : "", + node.kind === "ObjectTypeExtension" ? "extend " : "", "type ", - path.call(print, "name"), - n.interfaces.length > 0 - ? concat([ - " implements ", - concat(printInterfaces(path, options, print)), - ]) + print("name"), + node.interfaces.length > 0 + ? [" implements ", ...printInterfaces(path, options, print)] : "", - printDirectives(path, print, n), - n.fields.length > 0 - ? concat([ + printDirectives(path, print, node), + node.fields.length > 0 + ? [ " {", - indent( - concat([ + indent([ + hardline, + join( hardline, - join( - hardline, - path.call( - (fieldsPath) => printSequence(fieldsPath, options, print), - "fields" - ) - ), - ]) - ), + path.call( + (fieldsPath) => printSequence(fieldsPath, options, print), + "fields" + ) + ), + ]), hardline, "}", - ]) + ] : "", - ]); + ]; } case "FieldDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", - path.call(print, "name"), - n.arguments.length > 0 - ? group( - concat([ - "(", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.call( - (argsPath) => printSequence(argsPath, options, print), - "arguments" - ) - ), - ]) - ), + return [ + print("description"), + node.description ? hardline : "", + print("name"), + node.arguments.length > 0 + ? group([ + "(", + indent([ softline, - ")", - ]) - ) + join( + [ifBreak("", ", "), softline], + path.call( + (argsPath) => printSequence(argsPath, options, print), + "arguments" + ) + ), + ]), + softline, + ")", + ]) : "", ": ", - path.call(print, "type"), - printDirectives(path, print, n), - ]); + print("type"), + printDirectives(path, print, node), + ]; } case "DirectiveDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", + return [ + print("description"), + node.description ? hardline : "", "directive ", "@", - path.call(print, "name"), - n.arguments.length > 0 - ? group( - concat([ - "(", - indent( - concat([ - softline, - join( - concat([ifBreak("", ", "), softline]), - path.call( - (argsPath) => printSequence(argsPath, options, print), - "arguments" - ) - ), - ]) - ), + print("name"), + node.arguments.length > 0 + ? group([ + "(", + indent([ softline, - ")", - ]) - ) + join( + [ifBreak("", ", "), softline], + path.call( + (argsPath) => printSequence(argsPath, options, print), + "arguments" + ) + ), + ]), + softline, + ")", + ]) : "", - n.repeatable ? " repeatable" : "", - concat([" on ", join(" | ", path.map(print, "locations"))]), - ]); + node.repeatable ? " repeatable" : "", + " on ", + join(" | ", path.map(print, "locations")), + ]; } case "EnumTypeExtension": case "EnumTypeDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", - n.kind === "EnumTypeExtension" ? "extend " : "", + return [ + print("description"), + node.description ? hardline : "", + node.kind === "EnumTypeExtension" ? "extend " : "", "enum ", - path.call(print, "name"), - printDirectives(path, print, n), + print("name"), + printDirectives(path, print, node), - n.values.length > 0 - ? concat([ + node.values.length > 0 + ? [ " {", - indent( - concat([ + indent([ + hardline, + join( hardline, - join( - hardline, - path.call( - (valuesPath) => printSequence(valuesPath, options, print), - "values" - ) - ), - ]) - ), + path.call( + (valuesPath) => printSequence(valuesPath, options, print), + "values" + ) + ), + ]), hardline, "}", - ]) + ] : "", - ]); + ]; } case "EnumValueDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", - path.call(print, "name"), - printDirectives(path, print, n), - ]); + return [ + print("description"), + node.description ? hardline : "", + print("name"), + printDirectives(path, print, node), + ]; } case "InputValueDefinition": { - return concat([ - path.call(print, "description"), - n.description ? (n.description.block ? hardline : line) : "", - path.call(print, "name"), + return [ + print("description"), + node.description ? (node.description.block ? hardline : line) : "", + print("name"), ": ", - path.call(print, "type"), - n.defaultValue ? concat([" = ", path.call(print, "defaultValue")]) : "", - printDirectives(path, print, n), - ]); + print("type"), + node.defaultValue ? [" = ", print("defaultValue")] : "", + printDirectives(path, print, node), + ]; } case "InputObjectTypeExtension": case "InputObjectTypeDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", - n.kind === "InputObjectTypeExtension" ? "extend " : "", + return [ + print("description"), + node.description ? hardline : "", + node.kind === "InputObjectTypeExtension" ? "extend " : "", "input ", - path.call(print, "name"), - printDirectives(path, print, n), - n.fields.length > 0 - ? concat([ + print("name"), + printDirectives(path, print, node), + node.fields.length > 0 + ? [ " {", - indent( - concat([ + indent([ + hardline, + join( hardline, - join( - hardline, - path.call( - (fieldsPath) => printSequence(fieldsPath, options, print), - "fields" - ) - ), - ]) - ), + path.call( + (fieldsPath) => printSequence(fieldsPath, options, print), + "fields" + ) + ), + ]), hardline, "}", - ]) + ] : "", - ]); + ]; } case "SchemaDefinition": { - return concat([ + return [ "schema", - printDirectives(path, print, n), + printDirectives(path, print, node), " {", - n.operationTypes.length > 0 - ? indent( - concat([ + node.operationTypes.length > 0 + ? indent([ + hardline, + join( hardline, - join( - hardline, - path.call( - (opsPath) => printSequence(opsPath, options, print), - "operationTypes" - ) - ), - ]) - ) + path.call( + (opsPath) => printSequence(opsPath, options, print), + "operationTypes" + ) + ), + ]) : "", hardline, "}", - ]); + ]; } case "OperationTypeDefinition": { - return concat([ - path.call(print, "operation"), - ": ", - path.call(print, "type"), - ]); + return [print("operation"), ": ", print("type")]; } case "InterfaceTypeExtension": case "InterfaceTypeDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", - n.kind === "InterfaceTypeExtension" ? "extend " : "", + return [ + print("description"), + node.description ? hardline : "", + node.kind === "InterfaceTypeExtension" ? "extend " : "", "interface ", - path.call(print, "name"), - printDirectives(path, print, n), - - n.fields.length > 0 - ? concat([ + print("name"), + node.interfaces.length > 0 + ? [" implements ", ...printInterfaces(path, options, print)] + : "", + printDirectives(path, print, node), + node.fields.length > 0 + ? [ " {", - indent( - concat([ + indent([ + hardline, + join( hardline, - join( - hardline, - path.call( - (fieldsPath) => printSequence(fieldsPath, options, print), - "fields" - ) - ), - ]) - ), + path.call( + (fieldsPath) => printSequence(fieldsPath, options, print), + "fields" + ) + ), + ]), hardline, "}", - ]) + ] : "", - ]); + ]; } case "FragmentSpread": { - return concat([ - "...", - path.call(print, "name"), - printDirectives(path, print, n), - ]); + return ["...", print("name"), printDirectives(path, print, node)]; } case "InlineFragment": { - return concat([ + return [ "...", - n.typeCondition - ? concat([" on ", path.call(print, "typeCondition")]) - : "", - printDirectives(path, print, n), + node.typeCondition ? [" on ", print("typeCondition")] : "", + printDirectives(path, print, node), " ", - path.call(print, "selectionSet"), - ]); + print("selectionSet"), + ]; } case "UnionTypeExtension": case "UnionTypeDefinition": { - return group( - concat([ - path.call(print, "description"), - n.description ? hardline : "", - group( - concat([ - n.kind === "UnionTypeExtension" ? "extend " : "", - "union ", - path.call(print, "name"), - printDirectives(path, print, n), - n.types.length > 0 - ? concat([ - " =", - ifBreak("", " "), - indent( - concat([ - ifBreak(concat([line, " "])), - join(concat([line, "| "]), path.map(print, "types")), - ]) - ), - ]) - : "", - ]) - ), - ]) - ); + return group([ + print("description"), + node.description ? hardline : "", + group([ + node.kind === "UnionTypeExtension" ? "extend " : "", + "union ", + print("name"), + printDirectives(path, print, node), + node.types.length > 0 + ? [ + " =", + ifBreak("", " "), + indent([ + ifBreak([line, " "]), + join([line, "| "], path.map(print, "types")), + ]), + ] + : "", + ]), + ]); } case "ScalarTypeExtension": case "ScalarTypeDefinition": { - return concat([ - path.call(print, "description"), - n.description ? hardline : "", - n.kind === "ScalarTypeExtension" ? "extend " : "", + return [ + print("description"), + node.description ? hardline : "", + node.kind === "ScalarTypeExtension" ? "extend " : "", "scalar ", - path.call(print, "name"), - printDirectives(path, print, n), - ]); + print("name"), + printDirectives(path, print, node), + ]; } case "NonNullType": { - return concat([path.call(print, "type"), "!"]); + return [print("type"), "!"]; } case "ListType": { - return concat(["[", path.call(print, "type"), "]"]); + return ["[", print("type"), "]"]; } default: /* istanbul ignore next */ - throw new Error("unknown graphql type: " + JSON.stringify(n.kind)); + throw new Error("unknown graphql type: " + JSON.stringify(node.kind)); } } -function printDirectives(path, print, n) { - if (n.directives.length === 0) { +function printDirectives(path, print, node) { + if (node.directives.length === 0) { return ""; } - return concat([ - " ", - group( - indent( - concat([ - softline, - join( - concat([ifBreak("", " "), softline]), - path.map(print, "directives") - ), - ]) - ) - ), - ]); + const printed = join(line, path.map(print, "directives")); + + if ( + node.kind === "FragmentDefinition" || + node.kind === "OperationDefinition" + ) { + return group([line, printed]); + } + + return [" ", group(indent([softline, printed]))]; } function printSequence(sequencePath, options, print) { const count = sequencePath.getValue().length; return sequencePath.map((path, i) => { - const printed = print(path); + const printed = print(); if ( - isNextLineEmpty(options.originalText, path.getValue(), options.locEnd) && + isNextLineEmpty(options.originalText, path.getValue(), locEnd) && i < count - 1 ) { - return concat([printed, hardline]); + return [printed, hardline]; } return printed; @@ -659,17 +570,10 @@ function printComment(commentPath) { return "#" + comment.value.trimEnd(); } + /* istanbul ignore next */ throw new Error("Not a comment: " + JSON.stringify(comment)); } -function determineInterfaceSeparatorBetween(first, second, options) { - const textBetween = options.originalText - .slice(first.loc.end, second.loc.start) - .replace(/#.*/g, "") - .trim(); - - return textBetween === "," ? ", " : " & "; -} function printInterfaces(path, options, print) { const node = path.getNode(); const parts = []; @@ -678,30 +582,39 @@ function printInterfaces(path, options, print) { for (let index = 0; index < interfaces.length; index++) { const interfaceNode = interfaces[index]; - if (index > 0) { - parts.push( - determineInterfaceSeparatorBetween( - interfaces[index - 1], - interfaceNode, - options - ) + parts.push(printed[index]); + const nextInterfaceNode = interfaces[index + 1]; + if (nextInterfaceNode) { + const textBetween = options.originalText.slice( + interfaceNode.loc.end, + nextInterfaceNode.loc.start ); - } + const hasComment = textBetween.includes("#"); + const separator = textBetween.replace(/#.*/g, "").trim(); - parts.push(printed[index]); + parts.push(separator === "," ? "," : " &", hasComment ? line : " "); + } } + return parts; } -function clean(node, newNode /*, parent*/) { - delete newNode.loc; - delete newNode.comments; +function clean(/*node, newNode , parent*/) {} +clean.ignoredProperties = new Set(["loc", "comments"]); + +function hasPrettierIgnore(path) { + const node = path.getValue(); + return ( + node && + Array.isArray(node.comments) && + node.comments.some((comment) => comment.value.trim() === "prettier-ignore") + ); } module.exports = { print: genericPrint, massageAstNode: clean, - hasPrettierIgnore: hasIgnoreComment, + hasPrettierIgnore, insertPragma, printComment, canAttachComment, diff --git a/src/language-handlebars/clean.js b/src/language-handlebars/clean.js index 9abcac2d20..7c3e12721d 100644 --- a/src/language-handlebars/clean.js +++ b/src/language-handlebars/clean.js @@ -1,15 +1,21 @@ "use strict"; -module.exports = function (ast, newNode) { - delete newNode.loc; - delete newNode.selfClosing; - - // (Glimmer/HTML) ignore TextNode whitespace +function clean(ast, newNode /*, parent*/) { + // (Glimmer/HTML) ignore TextNode if (ast.type === "TextNode") { const trimmed = ast.chars.trim(); if (!trimmed) { return null; } - newNode.chars = trimmed; + newNode.chars = trimmed.replace(/[\t\n\f\r ]+/g, " "); + } + + // `class` is reformatted + if (ast.type === "AttrNode" && ast.name.toLowerCase() === "class") { + delete newNode.value; } -}; +} + +clean.ignoredProperties = new Set(["loc", "selfClosing"]); + +module.exports = clean; diff --git a/src/language-handlebars/index.js b/src/language-handlebars/index.js index 966ae6ba38..04ca83ed18 100644 --- a/src/language-handlebars/index.js +++ b/src/language-handlebars/index.js @@ -1,11 +1,11 @@ "use strict"; -const printer = require("./printer-glimmer"); const createLanguage = require("../utils/create-language"); +const printer = require("./printer-glimmer"); const languages = [ - createLanguage(require("linguist-languages/data/Handlebars"), () => ({ - since: null, // unreleased + createLanguage(require("linguist-languages/data/Handlebars.json"), () => ({ + since: "2.3.0", parsers: ["glimmer"], vscodeLanguageIds: ["handlebars"], })), @@ -15,7 +15,14 @@ const printers = { glimmer: printer, }; +const parsers = { + get glimmer() { + return require("./parser-glimmer").parsers.glimmer; + }, +}; + module.exports = { languages, printers, + parsers, }; diff --git a/src/language-handlebars/loc.js b/src/language-handlebars/loc.js new file mode 100644 index 0000000000..a1bd54d9da --- /dev/null +++ b/src/language-handlebars/loc.js @@ -0,0 +1,11 @@ +"use strict"; + +function locStart(node) { + return node.loc.start.offset; +} + +function locEnd(node) { + return node.loc.end.offset; +} + +module.exports = { locStart, locEnd }; diff --git a/src/language-handlebars/parser-glimmer.js b/src/language-handlebars/parser-glimmer.js index ae282d9e07..3df6859c3a 100644 --- a/src/language-handlebars/parser-glimmer.js +++ b/src/language-handlebars/parser-glimmer.js @@ -1,21 +1,78 @@ "use strict"; +const LinesAndColumns = require("lines-and-columns").default; const createError = require("../common/parser-create-error"); +const { locStart, locEnd } = require("./loc"); + +/* from the following template: `non-escaped mustache \\{{helper}}` + * glimmer parser will produce an AST missing a backslash + * so here we add it back + * */ +function addBackslash(/* options*/) { + return { + name: "addBackslash", + visitor: { + TextNode(node) { + node.chars = node.chars.replace(/\\/, "\\\\"); + }, + }, + }; +} + +// Add `loc.{start,end}.offset` +function addOffset(text) { + const lines = new LinesAndColumns(text); + const calculateOffset = ({ line, column }) => + lines.indexForLocation({ line: line - 1, column }); + return (/* options*/) => ({ + name: "addOffset", + visitor: { + All(node) { + const { start, end } = node.loc; + start.offset = calculateOffset(start); + end.offset = calculateOffset(end); + }, + }, + }); +} function parse(text) { + const { preprocess: glimmer } = require("@glimmer/syntax"); + let ast; try { - const glimmer = require("@glimmer/syntax").preprocess; - return glimmer(text, { mode: "codemod" }); - /* istanbul ignore next */ + ast = glimmer(text, { + mode: "codemod", + plugins: { ast: [addBackslash, addOffset(text)] }, + }); } catch (error) { - const matches = error.message.match(/on line (\d+)/); - if (matches) { - throw createError(error.message, { - start: { line: Number(matches[1]), column: 0 }, - }); - } else { - throw error; + const location = getErrorLocation(error); + + if (location) { + throw createError(error.message, location); } + + /* istanbul ignore next */ + throw error; + } + + return ast; +} + +function getErrorLocation(error) { + const { location, hash } = error; + if (location) { + const { start, end } = location; + if (typeof end.line !== "number") { + return { start }; + } + return location; + } + + if (hash) { + const { + loc: { last_line, last_column }, + } = hash; + return { start: { line: last_line, column: last_column + 1 } }; } } @@ -24,16 +81,8 @@ module.exports = { glimmer: { parse, astFormat: "glimmer", - // TODO: `locStart` and `locEnd` should return a number offset - // https://prettier.io/docs/en/plugins.html#parsers - // but we need access to the original text to use - // `loc.start` and `loc.end` objects to calculate the offset - locStart(node) { - return node.loc && node.loc.start; - }, - locEnd(node) { - return node.loc && node.loc.end; - }, + locStart, + locEnd, }, }, }; diff --git a/src/language-handlebars/printer-glimmer.js b/src/language-handlebars/printer-glimmer.js index ef52147ba5..66e7ae6e0b 100644 --- a/src/language-handlebars/printer-glimmer.js +++ b/src/language-handlebars/printer-glimmer.js @@ -1,284 +1,314 @@ "use strict"; -const clean = require("./clean"); - const { - concat, - join, - softline, - hardline, - line, - group, - indent, - ifBreak, -} = require("../document").builders; - + builders: { + dedent, + fill, + group, + hardline, + ifBreak, + indent, + join, + line, + softline, + literalline, + }, + utils: { getDocParts, replaceEndOfLineWith }, +} = require("../document"); +const { isNonEmptyArray } = require("../common/util"); +const { locStart, locEnd } = require("./loc"); +const clean = require("./clean"); const { getNextNode, getPreviousNode, hasPrettierIgnore, - isGlimmerComponent, + isLastNodeOfSiblings, isNextNodeOfSomeType, + isNodeOfSomeType, isParentOfSomeType, isPreviousNodeOfSomeType, + isVoid, isWhitespaceNode, } = require("./utils"); -// http://w3c.github.io/html/single-page.html#void-elements -const voidTags = [ - "area", - "base", - "br", - "col", - "embed", - "hr", - "img", - "input", - "link", - "meta", - "param", - "source", - "track", - "wbr", -]; +const NEWLINES_TO_PRESERVE_MAX = 2; // Formatter based on @glimmerjs/syntax's built-in test formatter: // https://github.com/glimmerjs/glimmer-vm/blob/master/packages/%40glimmer/syntax/lib/generation/print.ts function print(path, options, print) { - const n = path.getValue(); + const node = path.getValue(); /* istanbul ignore if*/ - if (!n) { + if (!node) { return ""; } if (hasPrettierIgnore(path)) { - const startOffset = locationToOffset( - options.originalText, - n.loc.start.line - 1, - n.loc.start.column - ); - const endOffset = locationToOffset( - options.originalText, - n.loc.end.line - 1, - n.loc.end.column - ); - - const ignoredText = options.originalText.slice(startOffset, endOffset); - return ignoredText; + return options.originalText.slice(locStart(node), locEnd(node)); } - switch (n.type) { + switch (node.type) { case "Block": case "Program": case "Template": { - return group(concat(path.map(print, "body"))); + return group(path.map(print, "body")); } + case "ElementNode": { - const hasChildren = n.children.length > 0; - - const hasNonWhitespaceChildren = n.children.some( - (n) => !isWhitespaceNode(n) - ); - - const isVoid = - (isGlimmerComponent(n) && - (!hasChildren || !hasNonWhitespaceChildren)) || - voidTags.includes(n.tag); - const closeTagForNoBreak = isVoid ? concat([" />", softline]) : ">"; - const closeTagForBreak = isVoid ? "/>" : ">"; - const printParams = (path, print) => - indent( - concat([ - n.attributes.length ? line : "", - join(line, path.map(print, "attributes")), - - n.modifiers.length ? line : "", - join(line, path.map(print, "modifiers")), - - n.comments.length ? line : "", - join(line, path.map(print, "comments")), - ]) - ); + const startingTag = group(printStartingTag(path, print)); - const nextNode = getNextNode(path); - - return concat([ - group( - concat([ - "<", - n.tag, - printParams(path, print), - n.blockParams.length ? ` as |${n.blockParams.join(" ")}|` : "", - ifBreak(softline, ""), - ifBreak(closeTagForBreak, closeTagForNoBreak), - ]) - ), - !isVoid - ? group( - concat([ - hasNonWhitespaceChildren - ? indent(printChildren(path, options, print)) - : "", - ifBreak(hasChildren ? hardline : "", ""), - concat([""]), - ]) - ) - : "", - nextNode && nextNode.type === "ElementNode" ? hardline : "", - ]); + const escapeNextElementNode = + options.htmlWhitespaceSensitivity === "ignore" && + isNextNodeOfSomeType(path, ["ElementNode"]) + ? softline + : ""; + + if (isVoid(node)) { + return [startingTag, escapeNextElementNode]; + } + + const endingTag = [""]; + + if (node.children.length === 0) { + return [startingTag, indent(endingTag), escapeNextElementNode]; + } + + if (options.htmlWhitespaceSensitivity === "ignore") { + return [ + startingTag, + indent(printChildren(path, options, print)), + hardline, + indent(endingTag), + escapeNextElementNode, + ]; + } + + return [ + startingTag, + indent(group(printChildren(path, options, print))), + indent(endingTag), + escapeNextElementNode, + ]; } + case "BlockStatement": { const pp = path.getParentNode(1); + const isElseIf = pp && pp.inverse && pp.inverse.body.length === 1 && - pp.inverse.body[0] === n && + pp.inverse.body[0] === node && pp.inverse.body[0].path.parts[0] === "if"; - const hasElseIf = - n.inverse && - n.inverse.body.length === 1 && - n.inverse.body[0].type === "BlockStatement" && - n.inverse.body[0].path.parts[0] === "if"; - const indentElse = hasElseIf ? (a) => a : indent; - const inverseElseStatement = - (n.inverseStrip.open ? "{{~" : "{{") + - "else" + - (n.inverseStrip.close ? "~}}" : "}}"); - if (n.inverse) { - return concat([ - isElseIf - ? concat([ - n.openStrip.open ? "{{~else " : "{{else ", - printPathParams(path, print), - n.openStrip.close ? "~}}" : "}}", - ]) - : printOpenBlock(path, print, n.openStrip), - indent(concat([hardline, path.call(print, "program")])), - n.inverse && !hasElseIf - ? concat([hardline, inverseElseStatement]) - : "", - n.inverse - ? indentElse(concat([hardline, path.call(print, "inverse")])) - : "", - isElseIf - ? "" - : concat([hardline, printCloseBlock(path, print, n.closeStrip)]), - ]); - } else if (isElseIf) { - return concat([ - concat([ - n.openStrip.open ? "{{~else" : "{{else ", - printPathParams(path, print), - n.openStrip.close ? "~}}" : "}}", - ]), - indent(concat([hardline, path.call(print, "program")])), - ]); + + if (isElseIf) { + return [ + printElseIfBlock(path, print), + printProgram(path, print, options), + printInverse(path, print, options), + ]; } - const hasNonWhitespaceChildren = n.program.body.some( - (n) => !isWhitespaceNode(n) - ); - - return concat([ - printOpenBlock(path, print, n.openStrip), - group( - concat([ - indent(concat([softline, path.call(print, "program")])), - hasNonWhitespaceChildren ? hardline : softline, - printCloseBlock(path, print, n.closeStrip), - ]) - ), - ]); + return [ + printOpenBlock(path, print), + group([ + printProgram(path, print, options), + printInverse(path, print, options), + printCloseBlock(path, print, options), + ]), + ]; } + case "ElementModifierStatement": { - return group( - concat(["{{", printPathParams(path, print), softline, "}}"]) - ); + return group(["{{", printPathAndParams(path, print), "}}"]); } + case "MustacheStatement": { - const isEscaped = n.escaped === false; - const { open: openStrip, close: closeStrip } = n.strip; - const opening = (isEscaped ? "{{{" : "{{") + (openStrip ? "~" : ""); - const closing = (closeStrip ? "~" : "") + (isEscaped ? "}}}" : "}}"); - - const leading = isParentOfSomeType(path, [ - "AttrNode", - "ConcatStatement", - "ElementNode", - ]) - ? [opening, indent(softline)] - : [opening]; - - return group( - concat([...leading, printPathParams(path, print), softline, closing]) - ); + return group([ + printOpeningMustache(node), + printPathAndParams(path, print), + printClosingMustache(node), + ]); } case "SubExpression": { - const params = printParams(path, print); - const printedParams = - params.length > 0 - ? indent(concat([line, group(join(line, params))])) - : ""; - return group( - concat(["(", printPath(path, print), printedParams, softline, ")"]) - ); + return group([ + "(", + printSubExpressionPathAndParams(path, print), + softline, + ")", + ]); } case "AttrNode": { - const isText = n.value.type === "TextNode"; - const isEmptyText = isText && n.value.chars === ""; + const isText = node.value.type === "TextNode"; + const isEmptyText = isText && node.value.chars === ""; - // If the text is empty and the value's loc start and end columns are the + // If the text is empty and the value's loc start and end offsets are the // same, there is no value for this AttrNode and it should be printed // without the `=""`. Example: `` -> `` - const isEmptyValue = - isEmptyText && n.value.loc.start.column === n.value.loc.end.column; - if (isEmptyValue) { - return concat([n.name]); + if (isEmptyText && locStart(node.value) === locEnd(node.value)) { + return node.name; } - const value = path.call(print, "value"); - const quotedValue = isText - ? printStringLiteral(value.parts.join(), options) - : value; - return concat([n.name, "=", quotedValue]); + + // Let's assume quotes inside the content of text nodes are already + // properly escaped with entities, otherwise the parse wouldn't have parsed them. + const quote = isText + ? chooseEnclosingQuote(options, node.value.chars).quote + : node.value.type === "ConcatStatement" + ? chooseEnclosingQuote( + options, + node.value.parts + .filter((part) => part.type === "TextNode") + .map((part) => part.chars) + .join("") + ).quote + : ""; + + const valueDoc = print("value"); + + return [ + node.name, + "=", + quote, + node.name === "class" && quote ? group(indent(valueDoc)) : valueDoc, + quote, + ]; } + case "ConcatStatement": { - return concat([ - '"', - concat( - path - .map((partPath) => print(partPath), "parts") - .filter((a) => a !== "") - ), - '"', - ]); + return path.map(print, "parts"); } + case "Hash": { - return concat([join(line, path.map(print, "pairs"))]); + return join(line, path.map(print, "pairs")); } case "HashPair": { - return concat([n.key, "=", path.call(print, "value")]); + return [node.key, "=", print("value")]; } case "TextNode": { - const maxLineBreaksToPreserve = 2; + /* if `{{my-component}}` (or any text containing "{{") + * makes it to the TextNode, it means it was escaped, + * so let's print it escaped, ie.; `\{{my-component}}` */ + let text = node.chars.replace(/{{/g, "\\{{"); + + const attrName = getCurrentAttributeName(path); + + if (attrName) { + // TODO: format style and srcset attributes + if (attrName === "class") { + const formattedClasses = text.trim().split(/\s+/).join(" "); + + let leadingSpace = false; + let trailingSpace = false; + + if (isParentOfSomeType(path, ["ConcatStatement"])) { + if ( + isPreviousNodeOfSomeType(path, ["MustacheStatement"]) && + /^\s/.test(text) + ) { + leadingSpace = true; + } + if ( + isNextNodeOfSomeType(path, ["MustacheStatement"]) && + /\s$/.test(text) && + formattedClasses !== "" + ) { + trailingSpace = true; + } + } + + return [ + leadingSpace ? line : "", + formattedClasses, + trailingSpace ? line : "", + ]; + } + + return replaceEndOfLineWith(text, literalline); + } + + const whitespacesOnlyRE = /^[\t\n\f\r ]*$/; + const isWhitespaceOnly = whitespacesOnlyRE.test(text); const isFirstElement = !getPreviousNode(path); const isLastElement = !getNextNode(path); - const isWhitespaceOnly = !/\S/.test(n.chars); - const lineBreaksCount = countNewLines(n.chars); - const hasBlockParent = path.getParentNode(0).type === "Block"; - const hasElementParent = path.getParentNode(0).type === "ElementNode"; - const hasTemplateParent = path.getParentNode(0).type === "Template"; - let leadingLineBreaksCount = countLeadingNewLines(n.chars); - let trailingLineBreaksCount = countTrailingNewLines(n.chars); + if (options.htmlWhitespaceSensitivity !== "ignore") { + // https://infra.spec.whatwg.org/#ascii-whitespace + const leadingWhitespacesRE = /^[\t\n\f\r ]*/; + const trailingWhitespacesRE = /[\t\n\f\r ]*$/; + + // let's remove the file's final newline + // https://github.com/ember-cli/ember-new-output/blob/1a04c67ddd02ccb35e0ff41bb5cbce34b31173ef/.editorconfig#L16 + const shouldTrimTrailingNewlines = + isLastElement && isParentOfSomeType(path, ["Template"]); + const shouldTrimLeadingNewlines = + isFirstElement && isParentOfSomeType(path, ["Template"]); + + if (isWhitespaceOnly) { + if (shouldTrimLeadingNewlines || shouldTrimTrailingNewlines) { + return ""; + } + + let breaks = [line]; + + const newlines = countNewLines(text); + if (newlines) { + breaks = generateHardlines(newlines); + } + + if (isLastNodeOfSiblings(path)) { + breaks = breaks.map((newline) => dedent(newline)); + } + + return breaks; + } + + const [lead] = text.match(leadingWhitespacesRE); + const [tail] = text.match(trailingWhitespacesRE); + + let leadBreaks = []; + if (lead) { + leadBreaks = [line]; + + const leadingNewlines = countNewLines(lead); + if (leadingNewlines) { + leadBreaks = generateHardlines(leadingNewlines); + } + + text = text.replace(leadingWhitespacesRE, ""); + } + + let trailBreaks = []; + if (tail) { + if (!shouldTrimTrailingNewlines) { + trailBreaks = [line]; + + const trailingNewlines = countNewLines(tail); + if (trailingNewlines) { + trailBreaks = generateHardlines(trailingNewlines); + } + + if (isLastNodeOfSiblings(path)) { + trailBreaks = trailBreaks.map((hardline) => dedent(hardline)); + } + } + + text = text.replace(trailingWhitespacesRE, ""); + } + + return [...leadBreaks, fill(getTextValueParts(text)), ...trailBreaks]; + } + + const lineBreaksCount = countNewLines(text); + + let leadingLineBreaksCount = countLeadingNewLines(text); + let trailingLineBreaksCount = countTrailingNewLines(text); if ( (isFirstElement || isLastElement) && isWhitespaceOnly && - (hasBlockParent || hasElementParent || hasTemplateParent) + isParentOfSomeType(path, ["Block", "ElementNode", "Template"]) ) { return ""; } @@ -286,7 +316,7 @@ function print(path, options, print) { if (isWhitespaceOnly && lineBreaksCount) { leadingLineBreaksCount = Math.min( lineBreaksCount, - maxLineBreaksToPreserve + NEWLINES_TO_PRESERVE_MAX ); trailingLineBreaksCount = 0; } else { @@ -294,10 +324,7 @@ function print(path, options, print) { trailingLineBreaksCount = Math.max(trailingLineBreaksCount, 1); } - if ( - isPreviousNodeOfSomeType(path, ["ElementNode"]) || - isPreviousNodeOfSomeType(path, ["BlockStatement"]) - ) { + if (isPreviousNodeOfSomeType(path, ["BlockStatement", "ElementNode"])) { leadingLineBreaksCount = Math.max(leadingLineBreaksCount, 1); } } @@ -305,87 +332,76 @@ function print(path, options, print) { let leadingSpace = ""; let trailingSpace = ""; - // preserve a space inside of an attribute node where whitespace present, - // when next to mustache statement. - const inAttrNode = path.stack.includes("attributes"); - if (inAttrNode) { - const parentNode = path.getParentNode(0); - const isConcat = parentNode.type === "ConcatStatement"; - if (isConcat) { - const { parts } = parentNode; - const partIndex = parts.indexOf(n); - if (partIndex > 0) { - const partType = parts[partIndex - 1].type; - const isMustache = partType === "MustacheStatement"; - if (isMustache) { - leadingSpace = " "; - } - } - if (partIndex < parts.length - 1) { - const partType = parts[partIndex + 1].type; - const isMustache = partType === "MustacheStatement"; - if (isMustache) { - trailingSpace = " "; - } - } - } - } else { - if ( - trailingLineBreaksCount === 0 && - isNextNodeOfSomeType(path, ["MustacheStatement"]) - ) { - trailingSpace = " "; - } + if ( + trailingLineBreaksCount === 0 && + isNextNodeOfSomeType(path, ["MustacheStatement"]) + ) { + trailingSpace = " "; + } - if ( - leadingLineBreaksCount === 0 && - isPreviousNodeOfSomeType(path, ["MustacheStatement"]) - ) { - leadingSpace = " "; - } + if ( + leadingLineBreaksCount === 0 && + isPreviousNodeOfSomeType(path, ["MustacheStatement"]) + ) { + leadingSpace = " "; + } - if (isFirstElement) { - leadingLineBreaksCount = 0; - leadingSpace = ""; - } + if (isFirstElement) { + leadingLineBreaksCount = 0; + leadingSpace = ""; + } - if (isLastElement) { - trailingLineBreaksCount = 0; - trailingSpace = ""; - } + if (isLastElement) { + trailingLineBreaksCount = 0; + trailingSpace = ""; } - return concat( - [ - ...generateHardlines(leadingLineBreaksCount, maxLineBreaksToPreserve), - n.chars - .replace(/^[\s ]+/g, leadingSpace) - .replace(/[\s ]+$/, trailingSpace), - ...generateHardlines( - trailingLineBreaksCount, - maxLineBreaksToPreserve - ), - ].filter(Boolean) - ); + text = text + .replace(/^[\t\n\f\r ]+/g, leadingSpace) + .replace(/[\t\n\f\r ]+$/, trailingSpace); + + return [ + ...generateHardlines(leadingLineBreaksCount), + fill(getTextValueParts(text)), + ...generateHardlines(trailingLineBreaksCount), + ]; } case "MustacheCommentStatement": { - const dashes = n.value.includes("}}") ? "--" : ""; - return concat(["{{!", dashes, n.value, dashes, "}}"]); + const start = locStart(node); + const end = locEnd(node); + // Starts with `{{~` + const isLeftWhiteSpaceSensitive = + options.originalText.charAt(start + 2) === "~"; + // Ends with `{{~` + const isRightWhitespaceSensitive = + options.originalText.charAt(end - 3) === "~"; + + const dashes = node.value.includes("}}") ? "--" : ""; + return [ + "{{", + isLeftWhiteSpaceSensitive ? "~" : "", + "!", + dashes, + node.value, + dashes, + isRightWhitespaceSensitive ? "~" : "", + "}}", + ]; } case "PathExpression": { - return n.original; + return node.original; } case "BooleanLiteral": { - return String(n.value); + return String(node.value); } case "CommentStatement": { - return concat([""]); + return [""]; } case "StringLiteral": { - return printStringLiteral(n.value, options); + return printStringLiteral(node.value, options); } case "NumberLiteral": { - return String(n.value); + return String(node.value); } case "UndefinedLiteral": { return "undefined"; @@ -396,30 +412,286 @@ function print(path, options, print) { /* istanbul ignore next */ default: - throw new Error("unknown glimmer type: " + JSON.stringify(n.type)); + throw new Error("unknown glimmer type: " + JSON.stringify(node.type)); + } +} + +/* ElementNode print helpers */ + +function sortByLoc(a, b) { + return locStart(a) - locStart(b); +} + +function printStartingTag(path, print) { + const node = path.getValue(); + + const types = ["attributes", "modifiers", "comments"].filter((property) => + isNonEmptyArray(node[property]) + ); + const attributes = types.flatMap((type) => node[type]).sort(sortByLoc); + + for (const attributeType of types) { + path.each((attributePath) => { + const index = attributes.indexOf(attributePath.getValue()); + attributes.splice(index, 1, [line, print()]); + }, attributeType); } + + if (isNonEmptyArray(node.blockParams)) { + attributes.push(line, printBlockParams(node)); + } + + return ["<", node.tag, indent(attributes), printStartingTagEndMarker(node)]; } function printChildren(path, options, print) { - return concat( - path.map((childPath, childIndex) => { - const childNode = path.getValue(); - const isFirstNode = childIndex === 0; - const isLastNode = - childIndex === path.getParentNode(0).children.length - 1; - const isLastNodeInMultiNodeList = isLastNode && !isFirstNode; - const isWhitespace = isWhitespaceNode(childNode); - - if (isWhitespace && isLastNodeInMultiNodeList) { - return print(childPath, options, print); - } else if (isFirstNode) { - return concat([softline, print(childPath, options, print)]); - } - return print(childPath, options, print); - }, "children") + const node = path.getValue(); + const isEmpty = node.children.every((node) => isWhitespaceNode(node)); + if (options.htmlWhitespaceSensitivity === "ignore" && isEmpty) { + return ""; + } + + return path.map((childPath, childIndex) => { + const printedChild = print(); + + if (childIndex === 0 && options.htmlWhitespaceSensitivity === "ignore") { + return [softline, printedChild]; + } + + return printedChild; + }, "children"); +} + +function printStartingTagEndMarker(node) { + if (isVoid(node)) { + return ifBreak([softline, "/>"], [" />", softline]); + } + + return ifBreak([softline, ">"], ">"); +} + +/* MustacheStatement print helpers */ + +function printOpeningMustache(node) { + const mustache = node.escaped === false ? "{{{" : "{{"; + const strip = node.strip && node.strip.open ? "~" : ""; + return [mustache, strip]; +} + +function printClosingMustache(node) { + const mustache = node.escaped === false ? "}}}" : "}}"; + const strip = node.strip && node.strip.close ? "~" : ""; + return [strip, mustache]; +} + +/* BlockStatement print helpers */ + +function printOpeningBlockOpeningMustache(node) { + const opening = printOpeningMustache(node); + const strip = node.openStrip.open ? "~" : ""; + return [opening, strip, "#"]; +} + +function printOpeningBlockClosingMustache(node) { + const closing = printClosingMustache(node); + const strip = node.openStrip.close ? "~" : ""; + return [strip, closing]; +} + +function printClosingBlockOpeningMustache(node) { + const opening = printOpeningMustache(node); + const strip = node.closeStrip.open ? "~" : ""; + return [opening, strip, "/"]; +} + +function printClosingBlockClosingMustache(node) { + const closing = printClosingMustache(node); + const strip = node.closeStrip.close ? "~" : ""; + return [strip, closing]; +} + +function printInverseBlockOpeningMustache(node) { + const opening = printOpeningMustache(node); + const strip = node.inverseStrip.open ? "~" : ""; + return [opening, strip]; +} + +function printInverseBlockClosingMustache(node) { + const closing = printClosingMustache(node); + const strip = node.inverseStrip.close ? "~" : ""; + return [strip, closing]; +} + +function printOpenBlock(path, print) { + const node = path.getValue(); + + const openingMustache = printOpeningBlockOpeningMustache(node); + const closingMustache = printOpeningBlockClosingMustache(node); + + const attributes = [printPath(path, print)]; + + const params = printParams(path, print); + if (params) { + attributes.push(line, params); + } + + if (isNonEmptyArray(node.program.blockParams)) { + const block = printBlockParams(node.program); + attributes.push(line, block); + } + + return group([ + openingMustache, + indent(attributes), + softline, + closingMustache, + ]); +} + +function printElseBlock(node, options) { + return [ + options.htmlWhitespaceSensitivity === "ignore" ? hardline : "", + printInverseBlockOpeningMustache(node), + "else", + printInverseBlockClosingMustache(node), + ]; +} + +function printElseIfBlock(path, print) { + const parentNode = path.getParentNode(1); + + return [ + printInverseBlockOpeningMustache(parentNode), + "else if ", + printParams(path, print), + printInverseBlockClosingMustache(parentNode), + ]; +} + +function printCloseBlock(path, print, options) { + const node = path.getValue(); + + if (options.htmlWhitespaceSensitivity === "ignore") { + const escape = blockStatementHasOnlyWhitespaceInProgram(node) + ? softline + : hardline; + + return [ + escape, + printClosingBlockOpeningMustache(node), + print("path"), + printClosingBlockClosingMustache(node), + ]; + } + + return [ + printClosingBlockOpeningMustache(node), + print("path"), + printClosingBlockClosingMustache(node), + ]; +} + +function blockStatementHasOnlyWhitespaceInProgram(node) { + return ( + isNodeOfSomeType(node, ["BlockStatement"]) && + node.program.body.every((node) => isWhitespaceNode(node)) + ); +} + +function blockStatementHasElseIf(node) { + return ( + blockStatementHasElse(node) && + node.inverse.body.length === 1 && + isNodeOfSomeType(node.inverse.body[0], ["BlockStatement"]) && + node.inverse.body[0].path.parts[0] === "if" ); } +function blockStatementHasElse(node) { + return isNodeOfSomeType(node, ["BlockStatement"]) && node.inverse; +} + +function printProgram(path, print, options) { + const node = path.getValue(); + + if (blockStatementHasOnlyWhitespaceInProgram(node)) { + return ""; + } + + const program = print("program"); + + if (options.htmlWhitespaceSensitivity === "ignore") { + return indent([hardline, program]); + } + + return indent(program); +} + +function printInverse(path, print, options) { + const node = path.getValue(); + + const inverse = print("inverse"); + const printed = + options.htmlWhitespaceSensitivity === "ignore" + ? [hardline, inverse] + : inverse; + + if (blockStatementHasElseIf(node)) { + return printed; + } + + if (blockStatementHasElse(node)) { + return [printElseBlock(node, options), indent(printed)]; + } + + return ""; +} + +/* TextNode print helpers */ + +function getTextValueParts(value) { + return getDocParts(join(line, splitByHtmlWhitespace(value))); +} + +function splitByHtmlWhitespace(string) { + return string.split(/[\t\n\f\r ]+/); +} + +function getCurrentAttributeName(path) { + for (let depth = 0; depth < 2; depth++) { + const parentNode = path.getParentNode(depth); + if (parentNode && parentNode.type === "AttrNode") { + return parentNode.name.toLowerCase(); + } + } +} + +function countNewLines(string) { + /* istanbul ignore next */ + string = typeof string === "string" ? string : ""; + return string.split("\n").length - 1; +} + +function countLeadingNewLines(string) { + /* istanbul ignore next */ + string = typeof string === "string" ? string : ""; + const newLines = (string.match(/^([^\S\n\r]*[\n\r])+/g) || [])[0] || ""; + return countNewLines(newLines); +} + +function countTrailingNewLines(string) { + /* istanbul ignore next */ + string = typeof string === "string" ? string : ""; + const newLines = (string.match(/([\n\r][^\S\n\r]*)+$/g) || [])[0] || ""; + return countNewLines(newLines); +} + +function generateHardlines(number = 0) { + return new Array(Math.min(number, NEWLINES_TO_PRESERVE_MAX)).fill(hardline); +} + +/* StringLiteral print helpers */ + /** * Prints a string literal with the correct surrounding quotes based on * `options.singleQuote` and the number of escaped quotes contained in @@ -430,6 +702,11 @@ function printChildren(path, options, print) { * @param {object} options - the prettier options object */ function printStringLiteral(stringLiteral, options) { + const { quote, regex } = chooseEnclosingQuote(options, stringLiteral); + return [quote, stringLiteral.replace(regex, `\\${quote}`), quote]; +} + +function chooseEnclosingQuote(options, stringLiteral) { const double = { quote: '"', regex: /"/g }; const single = { quote: "'", regex: /'/g }; @@ -453,135 +730,62 @@ function printStringLiteral(stringLiteral, options) { shouldUseAlternateQuote = numPreferredQuotes > numAlternateQuotes; } - const enclosingQuote = shouldUseAlternateQuote ? alternate : preferred; - const escapedStringLiteral = stringLiteral.replace( - enclosingQuote.regex, - `\\${enclosingQuote.quote}` - ); - - return concat([ - enclosingQuote.quote, - escapedStringLiteral, - enclosingQuote.quote, - ]); + return shouldUseAlternateQuote ? alternate : preferred; } -function printPath(path, print) { - return path.call(print, "path"); -} +/* SubExpression print helpers */ -function printParams(path, print) { - const node = path.getValue(); - let parts = []; +function printSubExpressionPathAndParams(path, print) { + const p = printPath(path, print); + const params = printParams(path, print); - if (node.params.length > 0) { - parts = parts.concat(path.map(print, "params")); + if (!params) { + return p; } - if (node.hash && node.hash.pairs.length > 0) { - parts.push(path.call(print, "hash")); - } - return parts; + return indent([p, line, group(params)]); } -function printPathParams(path, print) { - const printedPath = printPath(path, print); - const printedParams = printParams(path, print); - - const parts = [printedPath, ...printedParams]; +/* misc. print helpers */ - return indent(group(join(line, parts))); -} +function printPathAndParams(path, print) { + const p = printPath(path, print); + const params = printParams(path, print); -function printBlockParams(path) { - const block = path.getValue(); - if (!block.program || !block.program.blockParams.length) { - return ""; + if (!params) { + return p; } - return concat([" as |", block.program.blockParams.join(" "), "|"]); -} - -function printOpenBlock( - path, - print, - { open: isOpenStrip = false, close: isCloseStrip = false } = {} -) { - return group( - concat([ - isOpenStrip ? "{{~#" : "{{#", - printPathParams(path, print), - printBlockParams(path), - softline, - isCloseStrip ? "~}}" : "}}", - ]) - ); -} - -function printCloseBlock( - path, - print, - { open: isOpenStrip = false, close: isCloseStrip = false } = {} -) { - return concat([ - isOpenStrip ? "{{~/" : "{{/", - path.call(print, "path"), - isCloseStrip ? "~}}" : "}}", - ]); -} -function countNewLines(string) { - /* istanbul ignore next */ - string = typeof string === "string" ? string : ""; - return string.split("\n").length - 1; + return [indent([p, line, params]), softline]; } -function countLeadingNewLines(string) { - /* istanbul ignore next */ - string = typeof string === "string" ? string : ""; - const newLines = (string.match(/^([^\S\r\n]*[\r\n])+/g) || [])[0] || ""; - return countNewLines(newLines); +function printPath(path, print) { + return print("path"); } -function countTrailingNewLines(string) { - /* istanbul ignore next */ - string = typeof string === "string" ? string : ""; - const newLines = (string.match(/([\r\n][^\S\r\n]*)+$/g) || [])[0] || ""; - return countNewLines(newLines); -} +function printParams(path, print) { + const node = path.getValue(); + const parts = []; -function generateHardlines(number = 0, max = 0) { - return new Array(Math.min(number, max)).fill(hardline); -} + if (node.params.length > 0) { + const params = path.map(print, "params"); + parts.push(...params); + } -/* istanbul ignore next - https://github.com/glimmerjs/glimmer-vm/blob/master/packages/%40glimmer/compiler/lib/location.ts#L5-L29 -*/ -function locationToOffset(source, line, column) { - let seenLines = 0; - let seenChars = 0; + if (node.hash && node.hash.pairs.length > 0) { + const hash = print("hash"); + parts.push(hash); + } - // eslint-disable-next-line no-constant-condition - while (true) { - if (seenChars === source.length) { - return null; - } + if (parts.length === 0) { + return ""; + } - let nextLine = source.indexOf("\n", seenChars); - if (nextLine === -1) { - nextLine = source.length; - } + return join(line, parts); +} - if (seenLines === line) { - if (seenChars + column > nextLine) { - return null; - } - return seenChars + column; - } else if (nextLine === -1) { - return null; - } - seenLines += 1; - seenChars = nextLine + 1; - } +function printBlockParams(node) { + return ["as |", node.blockParams.join(" "), "|"]; } module.exports = { diff --git a/src/language-handlebars/utils.js b/src/language-handlebars/utils.js index 9cd6dfd084..807a8cc2a2 100644 --- a/src/language-handlebars/utils.js +++ b/src/language-handlebars/utils.js @@ -1,5 +1,29 @@ "use strict"; +const htmlVoidElements = require("html-void-elements"); +const getLast = require("../utils/get-last"); + +function isLastNodeOfSiblings(path) { + const node = path.getValue(); + const parentNode = path.getParentNode(0); + + if ( + isParentOfSomeType(path, ["ElementNode"]) && + getLast(parentNode.children) === node + ) { + return true; + } + + if ( + isParentOfSomeType(path, ["Block"]) && + getLast(parentNode.body) === node + ) { + return true; + } + + return false; +} + function isUppercase(string) { return string.toUpperCase() === string; } @@ -12,12 +36,21 @@ function isGlimmerComponent(node) { ); } +const voidTags = new Set(htmlVoidElements); +function isVoid(node) { + return ( + (isGlimmerComponent(node) && + node.children.every((node) => isWhitespaceNode(node))) || + voidTags.has(node.tag) + ); +} + function isWhitespaceNode(node) { return isNodeOfSomeType(node, ["TextNode"]) && !/\S/.test(node.chars); } function isNodeOfSomeType(node, types) { - return node && types.some((type) => node.type === type); + return node && types.includes(node.type); } function isParentOfSomeType(path, types) { @@ -38,7 +71,8 @@ function isNextNodeOfSomeType(path, types) { function getSiblingNode(path, offset) { const node = path.getValue(); const parentNode = path.getParentNode(0) || {}; - const children = parentNode.children || parentNode.body || []; + const children = + parentNode.children || parentNode.body || parentNode.parts || []; const index = children.indexOf(node); return index !== -1 && children[index + offset]; } @@ -71,10 +105,11 @@ module.exports = { getNextNode, getPreviousNode, hasPrettierIgnore, - isGlimmerComponent, + isLastNodeOfSiblings, isNextNodeOfSomeType, isNodeOfSomeType, isParentOfSomeType, isPreviousNodeOfSomeType, + isVoid, isWhitespaceNode, }; diff --git a/src/language-html/ast-types.d.ts b/src/language-html/ast-types.d.ts new file mode 100644 index 0000000000..4dccb8e543 --- /dev/null +++ b/src/language-html/ast-types.d.ts @@ -0,0 +1,39 @@ +import "angular-html-parser/lib/compiler/src/ml_parser/ast"; +import { HtmlTagDefinition } from "angular-html-parser/lib/compiler/src/ml_parser/html_tags"; + +declare module "angular-html-parser/lib/compiler/src/ml_parser/ast" { + interface Attribute { + startSourceSpan: never; + endSourceSpan: never; + // see restoreName in parser-html.js + namespace?: string | null; + hasExplicitNamespace?: boolean; + } + + interface CDATA { + startSourceSpan: never; + endSourceSpan: never; + } + + interface Comment { + startSourceSpan: never; + endSourceSpan: never; + } + + interface DocType { + startSourceSpan: never; + endSourceSpan: never; + } + + interface Element { + tagDefinition: HtmlTagDefinition; + // see restoreName in parser-html.js + namespace?: string | null; + hasExplicitNamespace?: boolean; + } + + interface Text { + startSourceSpan: never; + endSourceSpan: never; + } +} diff --git a/src/language-html/ast.js b/src/language-html/ast.js index 85279d5c28..ced49dcf93 100644 --- a/src/language-html/ast.js +++ b/src/language-html/ast.js @@ -1,14 +1,19 @@ "use strict"; +const { isNonEmptyArray } = require("../common/util"); +const getLast = require("../utils/get-last"); + const NODES_KEYS = { attrs: true, children: true, }; +// TODO: typechecking is problematic for this class because of this issue: +// https://github.com/microsoft/TypeScript/issues/26811 + class Node { constructor(props = {}) { - for (const key of Object.keys(props)) { - const value = props[key]; + for (const [key, value] of Object.entries(props)) { if (key in NODES_KEYS) { this._setNodes(key, value); } else { @@ -22,16 +27,16 @@ class Node { this[key] = cloneAndUpdateNodes(nodes, this); if (key === "attrs") { setNonEnumerableProperties(this, { - attrMap: this[key].reduce((reduced, attr) => { - reduced[attr.fullName] = attr.value; - return reduced; - }, Object.create(null)), + attrMap: Object.fromEntries( + this[key].map((attr) => [attr.fullName, attr.value]) + ), }); } } } map(fn) { + /** @type{any} */ let newNode = null; for (const NODES_KEY in NODES_KEYS) { @@ -53,6 +58,7 @@ class Node { newNode[key] = this[key]; } } + // @ts-ignore const { index, siblings, prev, next, parent } = this; setNonEnumerableProperties(newNode, { index, @@ -66,27 +72,30 @@ class Node { return fn(newNode || this); } + /** + * @param {Object} [overrides] + */ clone(overrides) { return new Node(overrides ? { ...this, ...overrides } : this); } get firstChild() { - return this.children && this.children.length !== 0 - ? this.children[0] - : null; + // @ts-ignore + return isNonEmptyArray(this.children) ? this.children[0] : null; } get lastChild() { - return this.children && this.children.length !== 0 - ? this.children[this.children.length - 1] - : null; + // @ts-ignore + return isNonEmptyArray(this.children) ? getLast(this.children) : null; } // for element and attribute get rawName() { + // @ts-ignore return this.hasExplicitNamespace ? this.fullName : this.name; } get fullName() { + // @ts-ignore return this.namespace ? this.namespace + ":" + this.name : this.name; } } @@ -124,10 +133,13 @@ function cloneAndUpdateNodes(nodes, parent) { } function setNonEnumerableProperties(obj, props) { - const descriptors = Object.keys(props).reduce((reduced, key) => { - reduced[key] = { value: props[key], enumerable: false }; - return reduced; - }, {}); + const descriptors = Object.fromEntries( + Object.entries(props).map(([key, value]) => [ + key, + { value, enumerable: false }, + ]) + ); + Object.defineProperties(obj, descriptors); } diff --git a/src/language-html/clean.js b/src/language-html/clean.js index 100bd9777e..4c9089cc96 100644 --- a/src/language-html/clean.js +++ b/src/language-html/clean.js @@ -1,18 +1,22 @@ "use strict"; -module.exports = function (ast, newNode) { - delete newNode.sourceSpan; - delete newNode.startSourceSpan; - delete newNode.endSourceSpan; - delete newNode.nameSpan; - delete newNode.valueSpan; +const { isFrontMatterNode } = require("../common/util"); +const ignoredProperties = new Set([ + "sourceSpan", + "startSourceSpan", + "endSourceSpan", + "nameSpan", + "valueSpan", +]); + +function clean(ast, newNode) { if (ast.type === "text" || ast.type === "comment") { return null; } // may be formatted by multiparser - if (ast.type === "yaml" || ast.type === "toml") { + if (isFrontMatterNode(ast) || ast.type === "yaml" || ast.type === "toml") { return null; } @@ -23,4 +27,8 @@ module.exports = function (ast, newNode) { if (ast.type === "docType") { delete newNode.value; } -}; +} + +clean.ignoredProperties = ignoredProperties; + +module.exports = clean; diff --git a/src/language-html/conditional-comment.js b/src/language-html/conditional-comment.js index 55199c87b9..d0f1eb5a35 100644 --- a/src/language-html/conditional-comment.js +++ b/src/language-html/conditional-comment.js @@ -1,25 +1,34 @@ "use strict"; -// https://css-tricks.com/how-to-create-an-ie-only-stylesheet +const { + ParseSourceSpan, +} = require("angular-html-parser/lib/compiler/src/parse_util"); -// -const IE_CONDITIONAL_START_END_COMMENT_REGEX = /^(\[if([^\]]*?)\]>)([\s\S]*?) -const IE_CONDITIONAL_START_COMMENT_REGEX = /^\[if([^\]]*?)\]> -const IE_CONDITIONAL_END_COMMENT_REGEX = /^ ... + regex: /^(\[if([^\]]*?)]>)(.*?) + regex: /^\[if([^\]]*?)]> + regex: /^ { try { return [true, parseHtml(data, contentStartSpan).children]; - } catch (e) { + } catch { const text = { type: "text", value: data, diff --git a/src/language-html/constants.evaluate.js b/src/language-html/constants.evaluate.js index fd52e4f08a..962adb28ed 100644 --- a/src/language-html/constants.evaluate.js +++ b/src/language-html/constants.evaluate.js @@ -1,29 +1,24 @@ "use strict"; const htmlStyles = require("html-styles"); -const fromPairs = require("lodash/fromPairs"); -const flat = require("lodash/flatten"); const getCssStyleTags = (property) => - fromPairs( - flat( - htmlStyles - .filter((htmlStyle) => htmlStyle.style[property]) - .map((htmlStyle) => - htmlStyle.selectorText - .split(",") - .map((selector) => selector.trim()) - .filter((selector) => /^[a-zA-Z0-9]+$/.test(selector)) - .map((tagName) => [tagName, htmlStyle.style[property]]) - ) - ) + Object.fromEntries( + htmlStyles + .filter((htmlStyle) => htmlStyle.style[property]) + .flatMap((htmlStyle) => + htmlStyle.selectorText + .split(",") + .map((selector) => selector.trim()) + .filter((selector) => /^[\dA-Za-z]+$/.test(selector)) + .map((tagName) => [tagName, htmlStyle.style[property]]) + ) ); const CSS_DISPLAY_TAGS = { ...getCssStyleTags("display"), // TODO: send PR to upstream - button: "inline-block", // special cases for some css display=none elements @@ -31,10 +26,23 @@ const CSS_DISPLAY_TAGS = { source: "block", track: "block", script: "block", + param: "block", + + // `noscript` is inline + // noscript: "inline", // there's no css display for these elements but they behave these ways + details: "block", + summary: "block", + dialog: "block", + meter: "inline-block", + progress: "inline-block", + object: "inline-block", video: "inline-block", audio: "inline-block", + select: "inline-block", + option: "block", + optgroup: "block", }; const CSS_DISPLAY_DEFAULT = "inline"; const CSS_WHITE_SPACE_TAGS = getCssStyleTags("white-space"); diff --git a/src/language-html/index.js b/src/language-html/index.js index 3fcfb3ea2d..1caf94c651 100644 --- a/src/language-html/index.js +++ b/src/language-html/index.js @@ -1,11 +1,11 @@ "use strict"; -const printer = require("./printer-html"); const createLanguage = require("../utils/create-language"); +const printer = require("./printer-html"); const options = require("./options"); const languages = [ - createLanguage(require("linguist-languages/data/HTML"), () => ({ + createLanguage(require("linguist-languages/data/HTML.json"), () => ({ name: "Angular", since: "1.15.0", parsers: ["angular"], @@ -13,15 +13,16 @@ const languages = [ extensions: [".component.html"], filenames: [], })), - createLanguage(require("linguist-languages/data/HTML"), (data) => ({ + createLanguage(require("linguist-languages/data/HTML.json"), (data) => ({ since: "1.15.0", parsers: ["html"], vscodeLanguageIds: ["html"], - extensions: data.extensions.concat([ + extensions: [ + ...data.extensions, ".mjml", // MJML is considered XML in Linguist but it should be formatted as HTML - ]), + ], })), - createLanguage(require("linguist-languages/data/HTML"), () => ({ + createLanguage(require("linguist-languages/data/HTML.json"), () => ({ name: "Lightning Web Components", since: "1.17.0", parsers: ["lwc"], @@ -29,7 +30,7 @@ const languages = [ extensions: [], filenames: [], })), - createLanguage(require("linguist-languages/data/Vue"), () => ({ + createLanguage(require("linguist-languages/data/Vue.json"), () => ({ since: "1.10.0", parsers: ["vue"], vscodeLanguageIds: ["vue"], @@ -40,8 +41,28 @@ const printers = { html: printer, }; +const parsers = { + // HTML + get html() { + return require("./parser-html").parsers.html; + }, + // Vue + get vue() { + return require("./parser-html").parsers.vue; + }, + // Angular + get angular() { + return require("./parser-html").parsers.angular; + }, + // Lightning Web Components + get lwc() { + return require("./parser-html").parsers.lwc; + }, +}; + module.exports = { languages, printers, options, + parsers, }; diff --git a/src/language-html/loc.js b/src/language-html/loc.js new file mode 100644 index 0000000000..79f8f0c72e --- /dev/null +++ b/src/language-html/loc.js @@ -0,0 +1,11 @@ +"use strict"; + +function locStart(node) { + return node.sourceSpan.start.offset; +} + +function locEnd(node) { + return node.sourceSpan.end.offset; +} + +module.exports = { locStart, locEnd }; diff --git a/src/language-html/options.js b/src/language-html/options.js index 0fd2af692c..4c3dd6b7d7 100644 --- a/src/language-html/options.js +++ b/src/language-html/options.js @@ -2,7 +2,7 @@ const CATEGORY_HTML = "HTML"; -// format based on https://github.com/prettier/prettier/blob/master/src/main/core-options.js +// format based on https://github.com/prettier/prettier/blob/main/src/main/core-options.js module.exports = { htmlWhitespaceSensitivity: { since: "1.15.0", diff --git a/src/language-html/parser-html.js b/src/language-html/parser-html.js index ad9207cf38..3cd533c4f1 100644 --- a/src/language-html/parser-html.js +++ b/src/language-html/parser-html.js @@ -1,16 +1,45 @@ "use strict"; -const parseFrontMatter = require("../utils/front-matter"); +const { + ParseSourceSpan, + ParseLocation, + ParseSourceFile, +} = require("angular-html-parser/lib/compiler/src/parse_util"); +const parseFrontMatter = require("../utils/front-matter/parse"); +const getLast = require("../utils/get-last"); +const createError = require("../common/parser-create-error"); +const { inferParserByLanguage } = require("../common/util"); const { HTML_ELEMENT_ATTRIBUTES, HTML_TAGS, isUnknownNamespace, } = require("./utils"); const { hasPragma } = require("./pragma"); -const createError = require("../common/parser-create-error"); const { Node } = require("./ast"); const { parseIeConditionalComment } = require("./conditional-comment"); +const { locStart, locEnd } = require("./loc"); + +/** + * @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/ast').Node} AstNode + * @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/ast').Attribute} Attribute + * @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/ast').Element} Element + * @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/parser').ParseTreeResult} ParserTreeResult + * @typedef {Omit & { + * recognizeSelfClosing?: boolean; + * normalizeTagName?: boolean; + * normalizeAttributeName?: boolean; + * }} ParserOptions + * @typedef {{ + * parser: 'html' | 'angular' | 'vue' | 'lwc', + * filepath?: string + * }} Options + */ +/** + * @param {string} input + * @param {ParserOptions} parserOptions + * @param {Options} options + */ function ngHtmlParser( input, { @@ -19,18 +48,14 @@ function ngHtmlParser( normalizeAttributeName, allowHtmComponentClosingTags, isTagNameCaseSensitive, - } + getTagContentType, + }, + options ) { const parser = require("angular-html-parser"); const { RecursiveVisitor, visitAll, - Attribute, - CDATA, - Comment, - DocType, - Element, - Text, } = require("angular-html-parser/lib/compiler/src/ml_parser/ast"); const { ParseSourceSpan, @@ -39,42 +64,115 @@ function ngHtmlParser( getHtmlTagDefinition, } = require("angular-html-parser/lib/compiler/src/ml_parser/html_tags"); - const { rootNodes, errors } = parser.parse(input, { + let { rootNodes, errors } = parser.parse(input, { canSelfClose: recognizeSelfClosing, allowHtmComponentClosingTags, isTagNameCaseSensitive, + getTagContentType, }); - if (errors.length !== 0) { - const { msg, span } = errors[0]; - const { line, col } = span.start; - throw createError(msg, { start: { line: line + 1, column: col + 1 } }); - } + if (options.parser === "vue") { + const isVueHtml = rootNodes.some( + (node) => + (node.type === "docType" && node.value === "html") || + (node.type === "element" && node.name.toLowerCase() === "html") + ); - const addType = (node) => { - if (node instanceof Attribute) { - node.type = "attribute"; - } else if (node instanceof CDATA) { - node.type = "cdata"; - } else if (node instanceof Comment) { - node.type = "comment"; - } else if (node instanceof DocType) { - node.type = "docType"; - } else if (node instanceof Element) { - node.type = "element"; - } else if (node instanceof Text) { - node.type = "text"; + if (!isVueHtml) { + const shouldParseAsHTML = (/** @type {AstNode} */ node) => { + /* istanbul ignore next */ + if (!node) { + return false; + } + if (node.type !== "element" || node.name !== "template") { + return false; + } + const langAttr = node.attrs.find((attr) => attr.name === "lang"); + const langValue = langAttr && langAttr.value; + return ( + !langValue || inferParserByLanguage(langValue, options) === "html" + ); + }; + if (rootNodes.some(shouldParseAsHTML)) { + /** @type {ParserTreeResult | undefined} */ + let secondParseResult; + const doSecondParse = () => + parser.parse(input, { + canSelfClose: recognizeSelfClosing, + allowHtmComponentClosingTags, + isTagNameCaseSensitive, + }); + const getSecondParse = () => + secondParseResult || (secondParseResult = doSecondParse()); + const getSameLocationNode = (node) => + getSecondParse().rootNodes.find( + ({ startSourceSpan }) => + startSourceSpan && + startSourceSpan.start.offset === node.startSourceSpan.start.offset + ); + for (let i = 0; i < rootNodes.length; i++) { + const node = rootNodes[i]; + const { endSourceSpan, startSourceSpan } = node; + const isUnclosedNode = endSourceSpan === null; + if (isUnclosedNode) { + const result = getSecondParse(); + errors = result.errors; + rootNodes[i] = getSameLocationNode(node) || node; + } else if (shouldParseAsHTML(node)) { + const result = getSecondParse(); + const startOffset = startSourceSpan.end.offset; + const endOffset = endSourceSpan.start.offset; + for (const error of result.errors) { + const { offset } = error.span.start; + /* istanbul ignore next */ + if (startOffset < offset && offset < endOffset) { + errors = [error]; + break; + } + } + rootNodes[i] = getSameLocationNode(node) || node; + } + } + } } else { - throw new Error(`Unexpected node ${JSON.stringify(node)}`); + // If not Vue SFC, treat as html + recognizeSelfClosing = true; + normalizeTagName = true; + normalizeAttributeName = true; + allowHtmComponentClosingTags = true; + isTagNameCaseSensitive = false; + const htmlParseResult = parser.parse(input, { + canSelfClose: recognizeSelfClosing, + allowHtmComponentClosingTags, + isTagNameCaseSensitive, + }); + + rootNodes = htmlParseResult.rootNodes; + errors = htmlParseResult.errors; } - }; + } + + if (errors.length > 0) { + const { + msg, + span: { start, end }, + } = errors[0]; + throw createError(msg, { + start: { line: start.line + 1, column: start.col + 1 }, + end: { line: end.line + 1, column: end.col + 1 }, + }); + } + /** + * @param {Attribute | Element} node + */ const restoreName = (node) => { const namespace = node.name.startsWith(":") ? node.name.slice(1).split(":")[0] : null; const rawName = node.nameSpan.toString(); - const hasExplicitNamespace = rawName.startsWith(`${namespace}:`); + const hasExplicitNamespace = + namespace !== null && rawName.startsWith(`${namespace}:`); const name = hasExplicitNamespace ? rawName.slice(namespace.length + 1) : rawName; @@ -84,25 +182,28 @@ function ngHtmlParser( node.hasExplicitNamespace = hasExplicitNamespace; }; + /** + * @param {AstNode} node + */ const restoreNameAndValue = (node) => { - if (node instanceof Element) { + if (node.type === "element") { restoreName(node); - node.attrs.forEach((attr) => { + for (const attr of node.attrs) { restoreName(attr); if (!attr.valueSpan) { attr.value = null; } else { attr.value = attr.valueSpan.toString(); - if (/['"]/.test(attr.value[0])) { + if (/["']/.test(attr.value[0])) { attr.value = attr.value.slice(1, -1); } } - }); - } else if (node instanceof Comment) { + } + } else if (node.type === "comment") { node.value = node.sourceSpan .toString() .slice("".length); - } else if (node instanceof Text) { + } else if (node.type === "text") { node.value = node.sourceSpan.toString(); } }; @@ -112,7 +213,7 @@ function ngHtmlParser( return fn(lowerCasedText) ? lowerCasedText : text; }; const normalizeName = (node) => { - if (node instanceof Element) { + if (node.type === "element") { if ( normalizeTagName && (!node.namespace || @@ -128,7 +229,7 @@ function ngHtmlParser( if (normalizeAttributeName) { const CURRENT_HTML_ELEMENT_ATTRIBUTES = HTML_ELEMENT_ATTRIBUTES[node.name] || Object.create(null); - node.attrs.forEach((attr) => { + for (const attr of node.attrs) { if (!attr.namespace) { attr.name = lowerCaseIfFn( attr.name, @@ -138,7 +239,7 @@ function ngHtmlParser( lowerCasedAttrName in CURRENT_HTML_ELEMENT_ATTRIBUTES) ); } - }); + } } } }; @@ -152,8 +253,11 @@ function ngHtmlParser( } }; + /** + * @param {AstNode} node + */ const addTagDefinition = (node) => { - if (node instanceof Element) { + if (node.type === "element") { const tagDefinition = getHtmlTagDefinition( isTagNameCaseSensitive ? node.name : node.name.toLowerCase() ); @@ -172,7 +276,6 @@ function ngHtmlParser( visitAll( new (class extends RecursiveVisitor { visit(node) { - addType(node); restoreNameAndValue(node); addTagDefinition(node); normalizeName(node); @@ -185,18 +288,31 @@ function ngHtmlParser( return rootNodes; } +/** + * @param {string} text + * @param {Options} options + * @param {ParserOptions} parserOptions + * @param {boolean} shouldParseFrontMatter + */ function _parse(text, options, parserOptions, shouldParseFrontMatter = true) { const { frontMatter, content } = shouldParseFrontMatter ? parseFrontMatter(text) : { frontMatter: null, content: text }; + const file = new ParseSourceFile(text, options.filepath); + const start = new ParseLocation(file, 0, 0, 0); + const end = start.moveBy(text.length); const rawAst = { type: "root", - sourceSpan: { start: { offset: 0 }, end: { offset: text.length } }, - children: ngHtmlParser(content, parserOptions), + sourceSpan: new ParseSourceSpan(start, end), + children: ngHtmlParser(content, parserOptions, options), }; if (frontMatter) { + const start = new ParseLocation(file, 0, 0, 0); + const end = start.moveBy(frontMatter.raw.length); + frontMatter.sourceSpan = new ParseSourceSpan(start, end); + // @ts-ignore rawAst.children.unshift(frontMatter); } @@ -204,7 +320,7 @@ function _parse(text, options, parserOptions, shouldParseFrontMatter = true) { const parseSubHtml = (subContent, startSpan) => { const { offset } = startSpan; - const fakeContent = text.slice(0, offset).replace(/[^\r\n]/g, " "); + const fakeContent = text.slice(0, offset).replace(/[^\n\r]/g, " "); const realContent = subContent; const subAst = _parse( fakeContent + realContent, @@ -212,13 +328,13 @@ function _parse(text, options, parserOptions, shouldParseFrontMatter = true) { parserOptions, false ); - const ParseSourceSpan = subAst.children[0].sourceSpan.constructor; subAst.sourceSpan = new ParseSourceSpan( startSpan, - subAst.children[subAst.children.length - 1].sourceSpan.end + getLast(subAst.children).sourceSpan.end ); const firstText = subAst.children[0]; if (firstText.length === offset) { + /* istanbul ignore next */ subAst.children.shift(); } else { firstText.sourceSpan = new ParseSourceSpan( @@ -245,20 +361,16 @@ function _parse(text, options, parserOptions, shouldParseFrontMatter = true) { }); } -function locStart(node) { - return node.sourceSpan.start.offset; -} - -function locEnd(node) { - return node.sourceSpan.end.offset; -} - +/** + * @param {ParserOptions} parserOptions + */ function createParser({ recognizeSelfClosing = false, normalizeTagName = false, normalizeAttributeName = false, allowHtmComponentClosingTags = false, isTagNameCaseSensitive = false, + getTagContentType, } = {}) { return { parse: (text, parsers, options) => @@ -268,6 +380,7 @@ function createParser({ normalizeAttributeName, allowHtmComponentClosingTags, isTagNameCaseSensitive, + getTagContentType, }), hasPragma, astFormat: "html", @@ -288,6 +401,18 @@ module.exports = { vue: createParser({ recognizeSelfClosing: true, isTagNameCaseSensitive: true, + getTagContentType: (tagName, prefix, hasParent, attrs) => { + if ( + tagName.toLowerCase() !== "html" && + !hasParent && + (tagName !== "template" || + attrs.some( + ({ name, value }) => name === "lang" && value !== "html" + )) + ) { + return require("angular-html-parser").TagContentType.RAW_TEXT; + } + }, }), lwc: createParser(), }, diff --git a/src/language-html/preprocess.js b/src/language-html/preprocess.js deleted file mode 100644 index 537ebd7517..0000000000 --- a/src/language-html/preprocess.js +++ /dev/null @@ -1,467 +0,0 @@ -"use strict"; - -const { - canHaveInterpolation, - getNodeCssStyleDisplay, - isDanglingSpaceSensitiveNode, - isIndentationSensitiveNode, - isLeadingSpaceSensitiveNode, - isTrailingSpaceSensitiveNode, - isWhitespaceSensitiveNode, -} = require("./utils"); - -const PREPROCESS_PIPELINE = [ - removeIgnorableFirstLf, - mergeIeConditonalStartEndCommentIntoElementOpeningTag, - mergeCdataIntoText, - extractInterpolation, - extractWhitespaces, - addCssDisplay, - addIsSelfClosing, - addHasHtmComponentClosingTag, - addIsSpaceSensitive, - mergeSimpleElementIntoText, -]; - -function preprocess(ast, options) { - for (const fn of PREPROCESS_PIPELINE) { - ast = fn(ast, options); - } - return ast; -} - -function removeIgnorableFirstLf(ast /*, options */) { - return ast.map((node) => { - if ( - node.type === "element" && - node.tagDefinition.ignoreFirstLf && - node.children.length !== 0 && - node.children[0].type === "text" && - node.children[0].value[0] === "\n" - ) { - const [text, ...rest] = node.children; - return node.clone({ - children: - text.value.length === 1 - ? rest - : [text.clone({ value: text.value.slice(1) }), ...rest], - }); - } - return node; - }); -} - -function mergeIeConditonalStartEndCommentIntoElementOpeningTag( - ast /*, options */ -) { - /** - * - */ - const isTarget = (node) => - node.type === "element" && - node.prev && - node.prev.type === "ieConditionalStartComment" && - node.prev.sourceSpan.end.offset === node.startSourceSpan.start.offset && - node.firstChild && - node.firstChild.type === "ieConditionalEndComment" && - node.firstChild.sourceSpan.start.offset === node.startSourceSpan.end.offset; - return ast.map((node) => { - if (node.children) { - const isTargetResults = node.children.map(isTarget); - if (isTargetResults.some(Boolean)) { - const newChildren = []; - - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - - if (isTargetResults[i + 1]) { - // ieConditionalStartComment - continue; - } - - if (isTargetResults[i]) { - const ieConditionalStartComment = child.prev; - const ieConditionalEndComment = child.firstChild; - - const ParseSourceSpan = child.sourceSpan.constructor; - const startSourceSpan = new ParseSourceSpan( - ieConditionalStartComment.sourceSpan.start, - ieConditionalEndComment.sourceSpan.end - ); - const sourceSpan = new ParseSourceSpan( - startSourceSpan.start, - child.sourceSpan.end - ); - - newChildren.push( - child.clone({ - condition: ieConditionalStartComment.condition, - sourceSpan, - startSourceSpan, - children: child.children.slice(1), - }) - ); - - continue; - } - - newChildren.push(child); - } - - return node.clone({ children: newChildren }); - } - } - return node; - }); -} - -function mergeNodeIntoText(ast, shouldMerge, getValue) { - return ast.map((node) => { - if (node.children) { - const shouldMergeResults = node.children.map(shouldMerge); - if (shouldMergeResults.some(Boolean)) { - const newChildren = []; - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - - if (child.type !== "text" && !shouldMergeResults[i]) { - newChildren.push(child); - continue; - } - - const newChild = - child.type === "text" - ? child - : child.clone({ type: "text", value: getValue(child) }); - - if ( - newChildren.length === 0 || - newChildren[newChildren.length - 1].type !== "text" - ) { - newChildren.push(newChild); - continue; - } - - const lastChild = newChildren.pop(); - const ParseSourceSpan = lastChild.sourceSpan.constructor; - newChildren.push( - lastChild.clone({ - value: lastChild.value + newChild.value, - sourceSpan: new ParseSourceSpan( - lastChild.sourceSpan.start, - newChild.sourceSpan.end - ), - }) - ); - } - return node.clone({ children: newChildren }); - } - } - - return node; - }); -} - -function mergeCdataIntoText(ast /*, options */) { - return mergeNodeIntoText( - ast, - (node) => node.type === "cdata", - (node) => `` - ); -} - -function mergeSimpleElementIntoText(ast /*, options */) { - const isSimpleElement = (node) => - node.type === "element" && - node.attrs.length === 0 && - node.children.length === 1 && - node.firstChild.type === "text" && - // \xA0: non-breaking whitespace - !/[^\S\xA0]/.test(node.children[0].value) && - !node.firstChild.hasLeadingSpaces && - !node.firstChild.hasTrailingSpaces && - node.isLeadingSpaceSensitive && - !node.hasLeadingSpaces && - node.isTrailingSpaceSensitive && - !node.hasTrailingSpaces && - node.prev && - node.prev.type === "text" && - node.next && - node.next.type === "text"; - return ast.map((node) => { - if (node.children) { - const isSimpleElementResults = node.children.map(isSimpleElement); - if (isSimpleElementResults.some(Boolean)) { - const newChildren = []; - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - if (isSimpleElementResults[i]) { - const lastChild = newChildren.pop(); - const nextChild = node.children[++i]; - const ParseSourceSpan = node.sourceSpan.constructor; - const { isTrailingSpaceSensitive, hasTrailingSpaces } = nextChild; - newChildren.push( - lastChild.clone({ - value: - lastChild.value + - `<${child.rawName}>` + - child.firstChild.value + - `` + - nextChild.value, - sourceSpan: new ParseSourceSpan( - lastChild.sourceSpan.start, - nextChild.sourceSpan.end - ), - isTrailingSpaceSensitive, - hasTrailingSpaces, - }) - ); - } else { - newChildren.push(child); - } - } - return node.clone({ children: newChildren }); - } - } - return node; - }); -} - -function extractInterpolation(ast, options) { - if (options.parser === "html") { - return ast; - } - - const interpolationRegex = /\{\{([\s\S]+?)\}\}/g; - return ast.map((node) => { - if (!canHaveInterpolation(node)) { - return node; - } - - const newChildren = []; - - for (const child of node.children) { - if (child.type !== "text") { - newChildren.push(child); - continue; - } - - const ParseSourceSpan = child.sourceSpan.constructor; - - let startSourceSpan = child.sourceSpan.start; - let endSourceSpan = null; - const components = child.value.split(interpolationRegex); - for ( - let i = 0; - i < components.length; - i++, startSourceSpan = endSourceSpan - ) { - const value = components[i]; - - if (i % 2 === 0) { - endSourceSpan = startSourceSpan.moveBy(value.length); - if (value.length !== 0) { - newChildren.push({ - type: "text", - value, - sourceSpan: new ParseSourceSpan(startSourceSpan, endSourceSpan), - }); - } - continue; - } - - endSourceSpan = startSourceSpan.moveBy(value.length + 4); // `{{` + `}}` - newChildren.push({ - type: "interpolation", - sourceSpan: new ParseSourceSpan(startSourceSpan, endSourceSpan), - children: - value.length === 0 - ? [] - : [ - { - type: "text", - value, - sourceSpan: new ParseSourceSpan( - startSourceSpan.moveBy(2), - endSourceSpan.moveBy(-2) - ), - }, - ], - }); - } - } - - return node.clone({ children: newChildren }); - }); -} - -/** - * - add `hasLeadingSpaces` field - * - add `hasTrailingSpaces` field - * - add `hasDanglingSpaces` field for parent nodes - * - add `isWhitespaceSensitive`, `isIndentationSensitive` field for text nodes - * - remove insensitive whitespaces - */ -function extractWhitespaces(ast /*, options*/) { - const TYPE_WHITESPACE = "whitespace"; - return ast.map((node) => { - if (!node.children) { - return node; - } - - if ( - node.children.length === 0 || - (node.children.length === 1 && - node.children[0].type === "text" && - node.children[0].value.trim().length === 0) - ) { - return node.clone({ - children: [], - hasDanglingSpaces: node.children.length !== 0, - }); - } - - const isWhitespaceSensitive = isWhitespaceSensitiveNode(node); - const isIndentationSensitive = isIndentationSensitiveNode(node); - - return node.clone({ - isWhitespaceSensitive, - isIndentationSensitive, - children: node.children - // extract whitespace nodes - .reduce((newChildren, child) => { - if (child.type !== "text" || isWhitespaceSensitive) { - return newChildren.concat(child); - } - - const localChildren = []; - - const [, leadingSpaces, text, trailingSpaces] = child.value.match( - /^(\s*)([\s\S]*?)(\s*)$/ - ); - - if (leadingSpaces) { - localChildren.push({ type: TYPE_WHITESPACE }); - } - - const ParseSourceSpan = child.sourceSpan.constructor; - - if (text) { - localChildren.push({ - type: "text", - value: text, - sourceSpan: new ParseSourceSpan( - child.sourceSpan.start.moveBy(leadingSpaces.length), - child.sourceSpan.end.moveBy(-trailingSpaces.length) - ), - }); - } - - if (trailingSpaces) { - localChildren.push({ type: TYPE_WHITESPACE }); - } - - return newChildren.concat(localChildren); - }, []) - // set hasLeadingSpaces/hasTrailingSpaces and filter whitespace nodes - .reduce((newChildren, child, i, children) => { - if (child.type === TYPE_WHITESPACE) { - return newChildren; - } - - const hasLeadingSpaces = - i !== 0 && children[i - 1].type === TYPE_WHITESPACE; - const hasTrailingSpaces = - i !== children.length - 1 && - children[i + 1].type === TYPE_WHITESPACE; - - return newChildren.concat({ - ...child, - hasLeadingSpaces, - hasTrailingSpaces, - }); - }, []), - }); - }); -} - -function addIsSelfClosing(ast /*, options */) { - return ast.map((node) => - Object.assign(node, { - isSelfClosing: - !node.children || - (node.type === "element" && - (node.tagDefinition.isVoid || - // self-closing - node.startSourceSpan === node.endSourceSpan)), - }) - ); -} - -function addHasHtmComponentClosingTag(ast, options) { - return ast.map((node) => - node.type !== "element" - ? node - : Object.assign(node, { - hasHtmComponentClosingTag: - node.endSourceSpan && - /^<\s*\/\s*\/\s*>$/.test( - options.originalText.slice( - node.endSourceSpan.start.offset, - node.endSourceSpan.end.offset - ) - ), - }) - ); -} - -function addCssDisplay(ast, options) { - return ast.map((node) => - Object.assign(node, { cssDisplay: getNodeCssStyleDisplay(node, options) }) - ); -} - -/** - * - add `isLeadingSpaceSensitive` field - * - add `isTrailingSpaceSensitive` field - * - add `isDanglingSpaceSensitive` field for parent nodes - */ -function addIsSpaceSensitive(ast /*, options */) { - return ast.map((node) => { - if (!node.children) { - return node; - } - - if (node.children.length === 0) { - return node.clone({ - isDanglingSpaceSensitive: isDanglingSpaceSensitiveNode(node), - }); - } - - return node.clone({ - children: node.children - .map((child) => { - return { - ...child, - isLeadingSpaceSensitive: isLeadingSpaceSensitiveNode(child), - isTrailingSpaceSensitive: isTrailingSpaceSensitiveNode(child), - }; - }) - .map((child, index, children) => ({ - ...child, - isLeadingSpaceSensitive: - index === 0 - ? child.isLeadingSpaceSensitive - : children[index - 1].isTrailingSpaceSensitive && - child.isLeadingSpaceSensitive, - isTrailingSpaceSensitive: - index === children.length - 1 - ? child.isTrailingSpaceSensitive - : children[index + 1].isLeadingSpaceSensitive && - child.isTrailingSpaceSensitive, - })), - }); - }); -} - -module.exports = preprocess; diff --git a/src/language-html/print-preprocess.js b/src/language-html/print-preprocess.js new file mode 100644 index 0000000000..06a048e10a --- /dev/null +++ b/src/language-html/print-preprocess.js @@ -0,0 +1,462 @@ +"use strict"; + +const { + ParseSourceSpan, +} = require("angular-html-parser/lib/compiler/src/parse_util"); +const getLast = require("../utils/get-last"); +const { + htmlTrim, + getLeadingAndTrailingHtmlWhitespace, + hasHtmlWhitespace, + canHaveInterpolation, + getNodeCssStyleDisplay, + isDanglingSpaceSensitiveNode, + isIndentationSensitiveNode, + isLeadingSpaceSensitiveNode, + isTrailingSpaceSensitiveNode, + isWhitespaceSensitiveNode, +} = require("./utils"); + +const PREPROCESS_PIPELINE = [ + removeIgnorableFirstLf, + mergeIeConditonalStartEndCommentIntoElementOpeningTag, + mergeCdataIntoText, + extractInterpolation, + extractWhitespaces, + addCssDisplay, + addIsSelfClosing, + addHasHtmComponentClosingTag, + addIsSpaceSensitive, + mergeSimpleElementIntoText, +]; + +function preprocess(ast, options) { + for (const fn of PREPROCESS_PIPELINE) { + ast = fn(ast, options); + } + return ast; +} + +function removeIgnorableFirstLf(ast /*, options */) { + return ast.map((node) => { + if ( + node.type === "element" && + node.tagDefinition.ignoreFirstLf && + node.children.length > 0 && + node.children[0].type === "text" && + node.children[0].value[0] === "\n" + ) { + const [text, ...rest] = node.children; + return node.clone({ + children: + text.value.length === 1 + ? rest + : [text.clone({ value: text.value.slice(1) }), ...rest], + }); + } + return node; + }); +} + +function mergeIeConditonalStartEndCommentIntoElementOpeningTag( + ast /*, options */ +) { + /** + * + */ + const isTarget = (node) => + node.type === "element" && + node.prev && + node.prev.type === "ieConditionalStartComment" && + node.prev.sourceSpan.end.offset === node.startSourceSpan.start.offset && + node.firstChild && + node.firstChild.type === "ieConditionalEndComment" && + node.firstChild.sourceSpan.start.offset === node.startSourceSpan.end.offset; + return ast.map((node) => { + if (node.children) { + const isTargetResults = node.children.map(isTarget); + if (isTargetResults.some(Boolean)) { + const newChildren = []; + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + + if (isTargetResults[i + 1]) { + // ieConditionalStartComment + continue; + } + + if (isTargetResults[i]) { + const ieConditionalStartComment = child.prev; + const ieConditionalEndComment = child.firstChild; + + const startSourceSpan = new ParseSourceSpan( + ieConditionalStartComment.sourceSpan.start, + ieConditionalEndComment.sourceSpan.end + ); + const sourceSpan = new ParseSourceSpan( + startSourceSpan.start, + child.sourceSpan.end + ); + + newChildren.push( + child.clone({ + condition: ieConditionalStartComment.condition, + sourceSpan, + startSourceSpan, + children: child.children.slice(1), + }) + ); + + continue; + } + + newChildren.push(child); + } + + return node.clone({ children: newChildren }); + } + } + return node; + }); +} + +function mergeNodeIntoText(ast, shouldMerge, getValue) { + return ast.map((node) => { + if (node.children) { + const shouldMergeResults = node.children.map(shouldMerge); + if (shouldMergeResults.some(Boolean)) { + const newChildren = []; + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + + if (child.type !== "text" && !shouldMergeResults[i]) { + newChildren.push(child); + continue; + } + + const newChild = + child.type === "text" + ? child + : child.clone({ type: "text", value: getValue(child) }); + + if ( + newChildren.length === 0 || + getLast(newChildren).type !== "text" + ) { + newChildren.push(newChild); + continue; + } + + const lastChild = newChildren.pop(); + newChildren.push( + lastChild.clone({ + value: lastChild.value + newChild.value, + sourceSpan: new ParseSourceSpan( + lastChild.sourceSpan.start, + newChild.sourceSpan.end + ), + }) + ); + } + return node.clone({ children: newChildren }); + } + } + + return node; + }); +} + +function mergeCdataIntoText(ast /*, options */) { + return mergeNodeIntoText( + ast, + (node) => node.type === "cdata", + (node) => `` + ); +} + +function mergeSimpleElementIntoText(ast /*, options */) { + const isSimpleElement = (node) => + node.type === "element" && + node.attrs.length === 0 && + node.children.length === 1 && + node.firstChild.type === "text" && + !hasHtmlWhitespace(node.children[0].value) && + !node.firstChild.hasLeadingSpaces && + !node.firstChild.hasTrailingSpaces && + node.isLeadingSpaceSensitive && + !node.hasLeadingSpaces && + node.isTrailingSpaceSensitive && + !node.hasTrailingSpaces && + node.prev && + node.prev.type === "text" && + node.next && + node.next.type === "text"; + return ast.map((node) => { + if (node.children) { + const isSimpleElementResults = node.children.map(isSimpleElement); + if (isSimpleElementResults.some(Boolean)) { + const newChildren = []; + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + if (isSimpleElementResults[i]) { + const lastChild = newChildren.pop(); + const nextChild = node.children[++i]; + const { isTrailingSpaceSensitive, hasTrailingSpaces } = nextChild; + newChildren.push( + lastChild.clone({ + value: + lastChild.value + + `<${child.rawName}>` + + child.firstChild.value + + `` + + nextChild.value, + sourceSpan: new ParseSourceSpan( + lastChild.sourceSpan.start, + nextChild.sourceSpan.end + ), + isTrailingSpaceSensitive, + hasTrailingSpaces, + }) + ); + } else { + newChildren.push(child); + } + } + return node.clone({ children: newChildren }); + } + } + return node; + }); +} + +function extractInterpolation(ast, options) { + if (options.parser === "html") { + return ast; + } + + const interpolationRegex = /{{(.+?)}}/gs; + return ast.map((node) => { + if (!canHaveInterpolation(node)) { + return node; + } + + const newChildren = []; + + for (const child of node.children) { + if (child.type !== "text") { + newChildren.push(child); + continue; + } + + let startSourceSpan = child.sourceSpan.start; + let endSourceSpan = null; + const components = child.value.split(interpolationRegex); + for ( + let i = 0; + i < components.length; + i++, startSourceSpan = endSourceSpan + ) { + const value = components[i]; + + if (i % 2 === 0) { + endSourceSpan = startSourceSpan.moveBy(value.length); + if (value.length > 0) { + newChildren.push({ + type: "text", + value, + sourceSpan: new ParseSourceSpan(startSourceSpan, endSourceSpan), + }); + } + continue; + } + + endSourceSpan = startSourceSpan.moveBy(value.length + 4); // `{{` + `}}` + newChildren.push({ + type: "interpolation", + sourceSpan: new ParseSourceSpan(startSourceSpan, endSourceSpan), + children: + value.length === 0 + ? [] + : [ + { + type: "text", + value, + sourceSpan: new ParseSourceSpan( + startSourceSpan.moveBy(2), + endSourceSpan.moveBy(-2) + ), + }, + ], + }); + } + } + + return node.clone({ children: newChildren }); + }); +} + +/** + * - add `hasLeadingSpaces` field + * - add `hasTrailingSpaces` field + * - add `hasDanglingSpaces` field for parent nodes + * - add `isWhitespaceSensitive`, `isIndentationSensitive` field for text nodes + * - remove insensitive whitespaces + */ +const WHITESPACE_NODE = { type: "whitespace" }; +function extractWhitespaces(ast /*, options*/) { + return ast.map((node) => { + if (!node.children) { + return node; + } + + if ( + node.children.length === 0 || + (node.children.length === 1 && + node.children[0].type === "text" && + htmlTrim(node.children[0].value).length === 0) + ) { + return node.clone({ + children: [], + hasDanglingSpaces: node.children.length > 0, + }); + } + + const isWhitespaceSensitive = isWhitespaceSensitiveNode(node); + const isIndentationSensitive = isIndentationSensitiveNode(node); + + return node.clone({ + isWhitespaceSensitive, + isIndentationSensitive, + children: node.children + // extract whitespace nodes + .flatMap((child) => { + if (child.type !== "text" || isWhitespaceSensitive) { + return child; + } + + const localChildren = []; + + const { leadingWhitespace, text, trailingWhitespace } = + getLeadingAndTrailingHtmlWhitespace(child.value); + + if (leadingWhitespace) { + localChildren.push(WHITESPACE_NODE); + } + + if (text) { + localChildren.push({ + type: "text", + value: text, + sourceSpan: new ParseSourceSpan( + child.sourceSpan.start.moveBy(leadingWhitespace.length), + child.sourceSpan.end.moveBy(-trailingWhitespace.length) + ), + }); + } + + if (trailingWhitespace) { + localChildren.push(WHITESPACE_NODE); + } + + return localChildren; + }) + // set hasLeadingSpaces/hasTrailingSpaces + .map((child, index, children) => { + if (child === WHITESPACE_NODE) { + return; + } + + return { + ...child, + hasLeadingSpaces: children[index - 1] === WHITESPACE_NODE, + hasTrailingSpaces: children[index + 1] === WHITESPACE_NODE, + }; + }) + // filter whitespace nodes + .filter(Boolean), + }); + }); +} + +function addIsSelfClosing(ast /*, options */) { + return ast.map((node) => + Object.assign(node, { + isSelfClosing: + !node.children || + (node.type === "element" && + (node.tagDefinition.isVoid || + // self-closing + node.startSourceSpan === node.endSourceSpan)), + }) + ); +} + +function addHasHtmComponentClosingTag(ast, options) { + return ast.map((node) => + node.type !== "element" + ? node + : Object.assign(node, { + hasHtmComponentClosingTag: + node.endSourceSpan && + /^<\s*\/\s*\/\s*>$/.test( + options.originalText.slice( + node.endSourceSpan.start.offset, + node.endSourceSpan.end.offset + ) + ), + }) + ); +} + +function addCssDisplay(ast, options) { + return ast.map((node) => + Object.assign(node, { cssDisplay: getNodeCssStyleDisplay(node, options) }) + ); +} + +/** + * - add `isLeadingSpaceSensitive` field + * - add `isTrailingSpaceSensitive` field + * - add `isDanglingSpaceSensitive` field for parent nodes + */ +function addIsSpaceSensitive(ast, options) { + return ast.map((node) => { + if (!node.children) { + return node; + } + + if (node.children.length === 0) { + return node.clone({ + isDanglingSpaceSensitive: isDanglingSpaceSensitiveNode(node), + }); + } + + return node.clone({ + children: node.children + .map((child) => ({ + ...child, + isLeadingSpaceSensitive: isLeadingSpaceSensitiveNode(child, options), + isTrailingSpaceSensitive: isTrailingSpaceSensitiveNode( + child, + options + ), + })) + .map((child, index, children) => ({ + ...child, + isLeadingSpaceSensitive: + index === 0 + ? child.isLeadingSpaceSensitive + : children[index - 1].isTrailingSpaceSensitive && + child.isLeadingSpaceSensitive, + isTrailingSpaceSensitive: + index === children.length - 1 + ? child.isTrailingSpaceSensitive + : children[index + 1].isLeadingSpaceSensitive && + child.isTrailingSpaceSensitive, + })), + }); + }); +} + +module.exports = preprocess; diff --git a/src/language-html/printer-html.js b/src/language-html/printer-html.js index b475ed9958..718e04ef0f 100644 --- a/src/language-html/printer-html.js +++ b/src/language-html/printer-html.js @@ -1,25 +1,34 @@ "use strict"; -const clean = require("./clean"); +/** + * @typedef {import("../document").Doc} Doc + */ + +const assert = require("assert"); + const { - builders, - utils: { stripTrailingHardline, mapDoc }, + builders: { + breakParent, + dedentToRoot, + fill, + group, + hardline, + ifBreak, + indentIfBreak, + indent, + join, + line, + literalline, + softline, + }, + utils: { mapDoc, cleanDoc, getDocParts, isConcat, replaceEndOfLineWith }, } = require("../document"); +const { isNonEmptyArray } = require("../common/util"); +const printFrontMatter = require("../utils/front-matter/print"); +const clean = require("./clean"); const { - breakParent, - dedentToRoot, - fill, - group, - hardline, - ifBreak, - indent, - join, - line, - literalline, - markAsRoot, - softline, -} = builders; -const { + htmlTrimPreserveIndentation, + splitByHtmlWhitespace, countChars, countParents, dedentString, @@ -30,39 +39,70 @@ const { getPrettierIgnoreAttributeCommentData, hasPrettierIgnore, inferScriptParser, + isVueCustomBlock, + isVueNonHtmlBlock, + isVueSlotAttribute, + isVueSfcBindingsAttribute, isScriptLikeTag, isTextLikeNode, - normalizeParts, preferHardlineAsLeadingSpaces, shouldNotPrintClosingTag, shouldPreserveContent, unescapeQuoteEntities, // [prettierx] support --html-void-tags option: isHtmlVoidTagNeeded, + isPreLikeNode, } = require("./utils"); -const { replaceEndOfLineWith } = require("../common/util"); -const preprocess = require("./preprocess"); -const assert = require("assert"); +const preprocess = require("./print-preprocess"); const { insertPragma } = require("./pragma"); +const { locStart, locEnd } = require("./loc"); const { printVueFor, - printVueSlotScope, + printVueBindings, isVueEventBindingExpression, } = require("./syntax-vue"); const { printImgSrcset, printClassNames } = require("./syntax-attribute"); -function concat(parts) { - const newParts = normalizeParts(parts); - return newParts.length === 0 - ? "" - : newParts.length === 1 - ? newParts[0] - : builders.concat(newParts); -} - function embed(path, print, textToDoc, options) { const node = path.getValue(); + switch (node.type) { + case "element": { + if (isScriptLikeTag(node) || node.type === "interpolation") { + // Fall through to "text" + return; + } + + if (!node.isSelfClosing && isVueNonHtmlBlock(node, options)) { + const parser = inferScriptParser(node, options); + if (!parser) { + return; + } + + const content = getNodeContent(node, options); + let isEmpty = /^\s*$/.test(content); + let doc = ""; + if (!isEmpty) { + doc = textToDoc( + htmlTrimPreserveIndentation(content), + { parser, __embeddedInHtml: true }, + { stripTrailingHardline: true } + ); + isEmpty = doc === ""; + } + + return [ + printOpeningTagPrefix(node, options), + group(printOpeningTag(path, options, print)), + isEmpty ? "" : hardline, + doc, + isEmpty ? "" : hardline, + printClosingTag(node, options), + printClosingTagSuffix(node, options), + ]; + } + break; + } case "text": { if (isScriptLikeTag(node.parent)) { const parser = inferScriptParser(node.parent); @@ -71,35 +111,54 @@ function embed(path, print, textToDoc, options) { parser === "markdown" ? dedentString(node.value.replace(/^[^\S\n]*?\n/, "")) : node.value; - return builders.concat([ - concat([ - breakParent, - printOpeningTagPrefix(node, options), - stripTrailingHardline(textToDoc(value, { parser })), - printClosingTagSuffix(node, options), - ]), - ]); + const textToDocOptions = { parser, __embeddedInHtml: true }; + if (options.parser === "html" && parser === "babel") { + let sourceType = "script"; + const { attrMap } = node.parent; + if ( + attrMap && + (attrMap.type === "module" || + (attrMap.type === "text/babel" && + attrMap["data-type"] === "module")) + ) { + sourceType = "module"; + } + textToDocOptions.__babelSourceType = sourceType; + } + return [ + breakParent, + printOpeningTagPrefix(node, options), + textToDoc(value, textToDocOptions, { + stripTrailingHardline: true, + }), + printClosingTagSuffix(node, options), + ]; } } else if (node.parent.type === "interpolation") { - return concat([ - indent( - concat([ - line, - textToDoc(node.value, { - __isInHtmlInterpolation: true, // to avoid unexpected `}}` - ...(options.parser === "angular" - ? { parser: "__ng_interpolation", trailingComma: "none" } - : options.parser === "vue" - ? { parser: "__vue_expression" } - : { parser: "__js_expression" }), - }), - ]) - ), + const textToDocOptions = { + __isInHtmlInterpolation: true, // to avoid unexpected `}}` + __embeddedInHtml: true, + }; + if (options.parser === "angular") { + textToDocOptions.parser = "__ng_interpolation"; + textToDocOptions.trailingComma = "none"; + } else if (options.parser === "vue") { + textToDocOptions.parser = "__vue_expression"; + } else { + textToDocOptions.parser = "__js_expression"; + } + return [ + indent([ + line, + textToDoc(node.value, textToDocOptions, { + stripTrailingHardline: true, + }), + ]), node.parent.next && needsToBorrowPrevClosingTagEndMarker(node.parent.next) ? " " : line, - ]); + ]; } break; } @@ -117,12 +176,12 @@ function embed(path, print, textToDoc, options) { ) ) ) { - return concat([node.rawName, "=", node.value]); + return [node.rawName, "=", node.value]; } // lwc: html`` if (options.parser === "lwc") { - const interpolationRegex = /^\{[\s\S]*\}$/; + const interpolationRegex = /^{.*}$/s; if ( interpolationRegex.test( options.originalText.slice( @@ -131,7 +190,7 @@ function embed(path, print, textToDoc, options) { ) ) ) { - return concat([node.rawName, "=", node.value]); + return [node.rawName, "=", node.value]; } } @@ -139,11 +198,15 @@ function embed(path, print, textToDoc, options) { node, (code, opts) => // strictly prefer single quote to avoid unnecessary html entity escape - textToDoc(code, { __isInHtmlAttribute: true, ...opts }), + textToDoc( + code, + { __isInHtmlAttribute: true, __embeddedInHtml: true, ...opts }, + { stripTrailingHardline: true } + ), options ); if (embeddedAttributeValueDoc) { - return concat([ + return [ node.rawName, '="', group( @@ -152,38 +215,38 @@ function embed(path, print, textToDoc, options) { ) ), '"', - ]); + ]; } break; } - case "yaml": - return markAsRoot( - concat([ - "---", - hardline, - node.value.trim().length === 0 - ? "" - : textToDoc(node.value, { parser: "yaml" }), - "---", - ]) - ); + case "front-matter": + return printFrontMatter(node, textToDoc); } } function genericPrint(path, options, print) { const node = path.getValue(); + switch (node.type) { + case "front-matter": + return replaceEndOfLineWith(node.raw, literalline); case "root": if (options.__onHtmlRoot) { options.__onHtmlRoot(node); } // use original concat to not break stripTrailingHardline - return builders.concat([ - group(printChildren(path, options, print)), - hardline, - ]); + return [group(printChildren(path, options, print)), hardline]; case "element": case "ieConditionalComment": { + if (shouldPreserveContent(node, options)) { + return [ + printOpeningTagPrefix(node, options), + group(printOpeningTag(path, options, print)), + ...replaceEndOfLineWith(getNodeContent(node, options), literalline), + ...printClosingTag(node, options), + printClosingTagSuffix(node, options), + ]; + } /** * do not break: * @@ -212,93 +275,88 @@ function genericPrint(path, options, print) { node.lastChild.isTrailingSpaceSensitive && !node.lastChild.hasTrailingSpaces; const attrGroupId = Symbol("element-attr-group-id"); - return concat([ - group( - concat([ - group(printOpeningTag(path, options, print), { id: attrGroupId }), - node.children.length === 0 - ? node.hasDanglingSpaces && node.isDanglingSpaceSensitive - ? line - : "" - : concat([ - forceBreakContent(node) ? breakParent : "", - ((childrenDoc) => - shouldHugContent - ? ifBreak(indent(childrenDoc), childrenDoc, { - groupId: attrGroupId, - }) - : isScriptLikeTag(node) && - node.parent.type === "root" && - options.parser === "vue" && - !options.vueIndentScriptAndStyle - ? childrenDoc - : indent(childrenDoc))( - concat([ - shouldHugContent - ? ifBreak(softline, "", { groupId: attrGroupId }) - : node.firstChild.hasLeadingSpaces && - node.firstChild.isLeadingSpaceSensitive - ? line - : node.firstChild.type === "text" && - node.isWhitespaceSensitive && - node.isIndentationSensitive - ? dedentToRoot(softline) - : softline, - printChildren(path, options, print), - ]) - ), - ( - node.next - ? needsToBorrowPrevClosingTagEndMarker(node.next) - : needsToBorrowLastChildClosingTagEndMarker(node.parent) - ) - ? node.lastChild.hasTrailingSpaces && - node.lastChild.isTrailingSpaceSensitive - ? " " - : "" - : shouldHugContent + return [ + group([ + group(printOpeningTag(path, options, print), { id: attrGroupId }), + node.children.length === 0 + ? node.hasDanglingSpaces && node.isDanglingSpaceSensitive + ? line + : "" + : [ + forceBreakContent(node) ? breakParent : "", + ((childrenDoc) => + shouldHugContent + ? indentIfBreak(childrenDoc, { groupId: attrGroupId }) + : (isScriptLikeTag(node) || + isVueCustomBlock(node, options)) && + node.parent.type === "root" && + options.parser === "vue" && + !options.vueIndentScriptAndStyle + ? childrenDoc + : indent(childrenDoc))([ + shouldHugContent ? ifBreak(softline, "", { groupId: attrGroupId }) - : node.lastChild.hasTrailingSpaces && - node.lastChild.isTrailingSpaceSensitive + : node.firstChild.hasLeadingSpaces && + node.firstChild.isLeadingSpaceSensitive ? line - : (node.lastChild.type === "comment" || - (node.lastChild.type === "text" && - node.isWhitespaceSensitive && - node.isIndentationSensitive)) && - new RegExp( - `\\n\\s{${ - options.tabWidth * - countParents( - path, - (n) => n.parent && n.parent.type !== "root" - ) - }}$` - ).test(node.lastChild.value) - ? /** - *
- *
-                       *         something
-                       *       
- * ~ - *
- */ - "" + : node.firstChild.type === "text" && + node.isWhitespaceSensitive && + node.isIndentationSensitive + ? dedentToRoot(softline) : softline, + printChildren(path, options, print), ]), - ]) - ), + ( + node.next + ? needsToBorrowPrevClosingTagEndMarker(node.next) + : needsToBorrowLastChildClosingTagEndMarker(node.parent) + ) + ? node.lastChild.hasTrailingSpaces && + node.lastChild.isTrailingSpaceSensitive + ? " " + : "" + : shouldHugContent + ? ifBreak(softline, "", { groupId: attrGroupId }) + : node.lastChild.hasTrailingSpaces && + node.lastChild.isTrailingSpaceSensitive + ? line + : (node.lastChild.type === "comment" || + (node.lastChild.type === "text" && + node.isWhitespaceSensitive && + node.isIndentationSensitive)) && + new RegExp( + `\\n[\\t ]{${ + options.tabWidth * + countParents( + path, + (node) => node.parent && node.parent.type !== "root" + ) + }}$` + ).test(node.lastChild.value) + ? /** + *
+ *
+                     *         something
+                     *       
+ * ~ + *
+ */ + "" + : softline, + ], + ]), printClosingTag(node, options), - ]); + ]; } case "ieConditionalStartComment": case "ieConditionalEndComment": - return concat([printOpeningTagStart(node), printClosingTagEnd(node)]); + return [printOpeningTagStart(node), printClosingTagEnd(node)]; case "interpolation": - return concat([ + return [ printOpeningTagStart(node, options), - concat(path.map(print, "children")), + ...path.map(print, "children"), printClosingTagEnd(node, options), - ]); + ]; case "text": { if (node.parent.type === "interpolation") { // replace the trailing literalline with hardline for better readability @@ -307,46 +365,42 @@ function genericPrint(path, options, print) { const value = hasTrailingNewline ? node.value.replace(trailingNewlineRegex, "") : node.value; - return concat([ - concat(replaceEndOfLineWith(value, literalline)), + return [ + ...replaceEndOfLineWith(value, literalline), hasTrailingNewline ? hardline : "", - ]); + ]; } - return fill( - normalizeParts( - [].concat( - printOpeningTagPrefix(node, options), - getTextValueParts(node), - printClosingTagSuffix(node, options) - ) - ) - ); + + const printed = cleanDoc([ + printOpeningTagPrefix(node, options), + ...getTextValueParts(node), + printClosingTagSuffix(node, options), + ]); + if (isConcat(printed) || printed.type === "fill") { + return fill(getDocParts(printed)); + } + /* istanbul ignore next */ + return printed; } case "docType": - return concat([ - group( - concat([ - printOpeningTagStart(node, options), - " ", - node.value.replace(/^html\b/i, "html").replace(/\s+/g, " "), - ]) - ), + return [ + group([ + printOpeningTagStart(node, options), + " ", + node.value.replace(/^html\b/i, "html").replace(/\s+/g, " "), + ]), printClosingTagEnd(node, options), - ]); + ]; case "comment": { - return concat([ + return [ printOpeningTagPrefix(node, options), - concat( - replaceEndOfLineWith( - options.originalText.slice( - options.locStart(node), - options.locEnd(node) - ), - literalline - ) + + ...replaceEndOfLineWith( + options.originalText.slice(locStart(node), locEnd(node)), + literalline ), printClosingTagSuffix(node, options), - ]); + ]; } case "attribute": { if (node.value === null) { @@ -356,27 +410,23 @@ function genericPrint(path, options, print) { const singleQuoteCount = countChars(value, "'"); const doubleQuoteCount = countChars(value, '"'); const quote = singleQuoteCount < doubleQuoteCount ? "'" : '"'; - return concat([ + return [ node.rawName, - concat([ - "=", - quote, - concat( - replaceEndOfLineWith( - quote === '"' - ? value.replace(/"/g, """) - : value.replace(/'/g, "'"), - literalline - ) - ), - quote, - ]), - ]); + + "=", + quote, + + ...replaceEndOfLineWith( + quote === '"' + ? value.replace(/"/g, """) + : value.replace(/'/g, "'"), + literalline + ), + quote, + ]; } - case "yaml": - case "toml": - return concat(replaceEndOfLineWith(node.raw, literalline)); default: + /* istanbul ignore next */ throw new Error(`Unexpected node type ${node.type}`); } } @@ -385,163 +435,125 @@ function printChildren(path, options, print) { const node = path.getValue(); if (forceBreakChildren(node)) { - return concat([ + return [ breakParent, - concat( - path.map((childPath) => { - const childNode = childPath.getValue(); - const prevBetweenLine = !childNode.prev + + ...path.map((childPath) => { + const childNode = childPath.getValue(); + const prevBetweenLine = !childNode.prev + ? "" + : printBetweenLine(childNode.prev, childNode); + return [ + !prevBetweenLine ? "" - : printBetweenLine(childNode.prev, childNode); - return concat([ - !prevBetweenLine - ? "" - : concat([ - prevBetweenLine, - forceNextEmptyLine(childNode.prev) ? hardline : "", - ]), - printChild(childPath), - ]); - }, "children") - ), - ]); + : [ + prevBetweenLine, + forceNextEmptyLine(childNode.prev) ? hardline : "", + ], + printChild(childPath), + ]; + }, "children"), + ]; } const groupIds = node.children.map(() => Symbol("")); - return concat( - path.map((childPath, childIndex) => { - const childNode = childPath.getValue(); - - if (isTextLikeNode(childNode)) { - if (childNode.prev && isTextLikeNode(childNode.prev)) { - const prevBetweenLine = printBetweenLine(childNode.prev, childNode); - if (prevBetweenLine) { - if (forceNextEmptyLine(childNode.prev)) { - return concat([hardline, hardline, printChild(childPath)]); - } - return concat([prevBetweenLine, printChild(childPath)]); + return path.map((childPath, childIndex) => { + const childNode = childPath.getValue(); + + if (isTextLikeNode(childNode)) { + if (childNode.prev && isTextLikeNode(childNode.prev)) { + const prevBetweenLine = printBetweenLine(childNode.prev, childNode); + if (prevBetweenLine) { + if (forceNextEmptyLine(childNode.prev)) { + return [hardline, hardline, printChild(childPath)]; } + return [prevBetweenLine, printChild(childPath)]; } - return printChild(childPath); } + return printChild(childPath); + } - const prevParts = []; - const leadingParts = []; - const trailingParts = []; - const nextParts = []; - - const prevBetweenLine = childNode.prev - ? printBetweenLine(childNode.prev, childNode) - : ""; - - const nextBetweenLine = childNode.next - ? printBetweenLine(childNode, childNode.next) - : ""; - - if (prevBetweenLine) { - if (forceNextEmptyLine(childNode.prev)) { - prevParts.push(hardline, hardline); - } else if (prevBetweenLine === hardline) { - prevParts.push(hardline); + const prevParts = []; + const leadingParts = []; + const trailingParts = []; + const nextParts = []; + + const prevBetweenLine = childNode.prev + ? printBetweenLine(childNode.prev, childNode) + : ""; + + const nextBetweenLine = childNode.next + ? printBetweenLine(childNode, childNode.next) + : ""; + + if (prevBetweenLine) { + if (forceNextEmptyLine(childNode.prev)) { + prevParts.push(hardline, hardline); + } else if (prevBetweenLine === hardline) { + prevParts.push(hardline); + } else { + if (isTextLikeNode(childNode.prev)) { + leadingParts.push(prevBetweenLine); } else { - if (isTextLikeNode(childNode.prev)) { - leadingParts.push(prevBetweenLine); - } else { - leadingParts.push( - ifBreak("", softline, { - groupId: groupIds[childIndex - 1], - }) - ); - } + leadingParts.push( + ifBreak("", softline, { + groupId: groupIds[childIndex - 1], + }) + ); } } + } - if (nextBetweenLine) { - if (forceNextEmptyLine(childNode)) { - if (isTextLikeNode(childNode.next)) { - nextParts.push(hardline, hardline); - } - } else if (nextBetweenLine === hardline) { - if (isTextLikeNode(childNode.next)) { - nextParts.push(hardline); - } - } else { - trailingParts.push(nextBetweenLine); + if (nextBetweenLine) { + if (forceNextEmptyLine(childNode)) { + if (isTextLikeNode(childNode.next)) { + nextParts.push(hardline, hardline); + } + } else if (nextBetweenLine === hardline) { + if (isTextLikeNode(childNode.next)) { + nextParts.push(hardline); } + } else { + trailingParts.push(nextBetweenLine); } + } - return concat( - [].concat( - prevParts, - group( - concat([ - concat(leadingParts), - group(concat([printChild(childPath), concat(trailingParts)]), { - id: groupIds[childIndex], - }), - ]) - ), - nextParts - ) - ); - }, "children") - ); + return [ + ...prevParts, + group([ + ...leadingParts, + group([printChild(childPath), ...trailingParts], { + id: groupIds[childIndex], + }), + ]), + ...nextParts, + ]; + }, "children"); function printChild(childPath) { const child = childPath.getValue(); if (hasPrettierIgnore(child)) { - return concat( - [].concat( - printOpeningTagPrefix(child, options), - replaceEndOfLineWith( - options.originalText.slice( - options.locStart(child) + - (child.prev && - needsToBorrowNextOpeningTagStartMarker(child.prev) - ? printOpeningTagStartMarker(child).length - : 0), - options.locEnd(child) - - (child.next && needsToBorrowPrevClosingTagEndMarker(child.next) - ? printClosingTagEndMarker(child, options).length - : 0) - ), - literalline - ), - printClosingTagSuffix(child, options) - ) - ); - } - - if (shouldPreserveContent(child, options)) { - return concat( - [].concat( - printOpeningTagPrefix(child, options), - group(printOpeningTag(childPath, options, print)), - replaceEndOfLineWith( - options.originalText.slice( - child.startSourceSpan.end.offset + - (child.firstChild && - needsToBorrowParentOpeningTagEndMarker(child.firstChild) - ? -printOpeningTagEndMarker(child).length - : 0), - child.endSourceSpan.start.offset + - (child.lastChild && - needsToBorrowParentClosingTagStartMarker(child.lastChild) - ? printClosingTagStartMarker(child, options).length - : needsToBorrowLastChildClosingTagEndMarker(child) - ? -printClosingTagEndMarker(child.lastChild, options).length - : 0) - ), - literalline + return [ + printOpeningTagPrefix(child, options), + ...replaceEndOfLineWith( + options.originalText.slice( + locStart(child) + + (child.prev && needsToBorrowNextOpeningTagStartMarker(child.prev) + ? printOpeningTagStartMarker(child).length + : 0), + locEnd(child) - + (child.next && needsToBorrowPrevClosingTagEndMarker(child.next) + ? printClosingTagEndMarker(child, options).length + : 0) ), - printClosingTag(child, options), - printClosingTagSuffix(child, options) - ) - ); + literalline + ), + printClosingTagSuffix(child, options), + ]; } - return print(childPath); + return print(); } function printBetweenLine(prevNode, nextNode) { @@ -574,7 +586,7 @@ function printChildren(path, options, print) { * ~ * attr */ - (nextNode.type === "element" && nextNode.attrs.length !== 0))) || + (nextNode.type === "element" && nextNode.attrs.length > 0))) || /** * + * ^ + */ + " " + : ""; + } + + const ignoreAttributeData = + node.prev && + node.prev.type === "comment" && + getPrettierIgnoreAttributeCommentData(node.prev.value); + + const hasPrettierIgnoreAttribute = + typeof ignoreAttributeData === "boolean" + ? () => ignoreAttributeData + : Array.isArray(ignoreAttributeData) + ? (attribute) => ignoreAttributeData.includes(attribute.rawName) + : () => false; + + const printedAttributes = path.map((attributePath) => { + const attribute = attributePath.getValue(); + return hasPrettierIgnoreAttribute(attribute) + ? replaceEndOfLineWith( + options.originalText.slice(locStart(attribute), locEnd(attribute)), + literalline + ) + : print(); + }, "attrs"); + const forceNotToBreakAttrContent = node.type === "element" && node.fullName === "script" && node.attrs.length === 1 && node.attrs[0].fullName === "src" && node.children.length === 0; - return concat([ + + /** @type {Doc[]} */ + const parts = [ + indent([ + forceNotToBreakAttrContent ? " " : line, + join(line, printedAttributes), + ]), + ]; + + if ( + /** + * 123456 + */ + (node.firstChild && + needsToBorrowParentOpeningTagEndMarker(node.firstChild)) || + /** + * 123 + */ + (node.isSelfClosing && + needsToBorrowLastChildClosingTagEndMarker(node.parent)) || + forceNotToBreakAttrContent + ) { + // [prettierx] support --html-void-tags option + parts.push( + node.isSelfClosing && !isHtmlVoidTagNeeded(node, options) ? " " : "" + ); + } else { + // [prettierx] support --html-void-tags option + parts.push( + node.isSelfClosing && !isHtmlVoidTagNeeded(node, options) + ? line + : softline + ); + } + + return parts; +} + +function printOpeningTag(path, options, print) { + const node = path.getValue(); + + return [ printOpeningTagStart(node, options), - !node.attrs || node.attrs.length === 0 - ? // [prettierx] --html-void-tags option: - node.isSelfClosing && !isHtmlVoidTagNeeded(node, options) - ? /** - *
- * ^ - */ - " " - : "" - : concat([ - indent( - concat([ - forceNotToBreakAttrContent ? " " : line, - join( - line, - ((ignoreAttributeData) => { - const hasPrettierIgnoreAttribute = - typeof ignoreAttributeData === "boolean" - ? () => ignoreAttributeData - : Array.isArray(ignoreAttributeData) - ? (attr) => ignoreAttributeData.includes(attr.rawName) - : () => false; - return path.map((attrPath) => { - const attr = attrPath.getValue(); - return hasPrettierIgnoreAttribute(attr) - ? concat( - replaceEndOfLineWith( - options.originalText.slice( - options.locStart(attr), - options.locEnd(attr) - ), - literalline - ) - ) - : print(attrPath); - }, "attrs"); - })( - node.prev && - node.prev.type === "comment" && - getPrettierIgnoreAttributeCommentData(node.prev.value) - ) - ), - ]) - ), - /** - * 123
456 - */ - (node.firstChild && - needsToBorrowParentOpeningTagEndMarker(node.firstChild)) || - /** - * 123 - */ - (node.isSelfClosing && - needsToBorrowLastChildClosingTagEndMarker(node.parent)) - ? // [prettierx] support --html-void-tags option - node.isSelfClosing && !isHtmlVoidTagNeeded(node, options) - ? " " - : "" - : // [prettierx] support --html-void-tags option - node.isSelfClosing && !isHtmlVoidTagNeeded(node, options) - ? forceNotToBreakAttrContent - ? " " - : line - : forceNotToBreakAttrContent - ? "" - : softline, - ]), + printAttributes(path, options, print), node.isSelfClosing ? "" : printOpeningTagEnd(node), - ]); + ]; } function printOpeningTagStart(node, options) { return node.prev && needsToBorrowNextOpeningTagStartMarker(node.prev) ? "" - : concat([ - printOpeningTagPrefix(node, options), - printOpeningTagStartMarker(node), - ]); + : [printOpeningTagPrefix(node, options), printOpeningTagStartMarker(node)]; } function printOpeningTagEnd(node) { @@ -711,20 +751,20 @@ function printOpeningTagEnd(node) { } function printClosingTag(node, options) { - return concat([ + return [ node.isSelfClosing ? "" : printClosingTagStart(node, options), printClosingTagEnd(node, options), - ]); + ]; } function printClosingTagStart(node, options) { return node.lastChild && needsToBorrowParentClosingTagStartMarker(node.lastChild) ? "" - : concat([ + : [ printClosingTagPrefix(node, options), printClosingTagStartMarker(node, options), - ]); + ]; } function printClosingTagEnd(node, options) { @@ -734,10 +774,10 @@ function printClosingTagEnd(node, options) { : needsToBorrowLastChildClosingTagEndMarker(node.parent) ) ? "" - : concat([ + : [ printClosingTagEndMarker(node, options), printClosingTagSuffix(node, options), - ]); + ]; } function needsToBorrowNextOpeningTagStartMarker(node) { @@ -780,6 +820,7 @@ function needsToBorrowPrevClosingTagEndMarker(node) { */ return ( node.prev && + node.prev.type !== "docType" && !isTextLikeNode(node.prev) && node.isLeadingSpaceSensitive && !node.hasLeadingSpaces @@ -798,7 +839,8 @@ function needsToBorrowLastChildClosingTagEndMarker(node) { node.lastChild && node.lastChild.isTrailingSpaceSensitive && !node.lastChild.hasTrailingSpaces && - !isTextLikeNode(getLastDescendant(node.lastChild)) + !isTextLikeNode(getLastDescendant(node.lastChild)) && + !isPreLikeNode(node) ); } @@ -882,6 +924,7 @@ function printOpeningTagEndMarker(node) { function printClosingTagStartMarker(node, options) { assert(!node.isSelfClosing); + /* istanbul ignore next */ if (shouldNotPrintClosingTag(node, options)) { return ""; } @@ -926,11 +969,10 @@ function getTextValueParts(node, value = node.value) { ? node.parent.isIndentationSensitive ? replaceEndOfLineWith(value, literalline) : replaceEndOfLineWith( - dedentString(value.replace(/^\s*?\n|\n\s*?$/g, "")), + dedentString(htmlTrimPreserveIndentation(value)), hardline ) - : // https://infra.spec.whatwg.org/#ascii-whitespace - join(line, value.split(/[\t\n\f\r ]+/)).parts; + : getDocParts(join(line, splitByHtmlWhitespace(value))); } function printEmbeddedAttributeValue(node, originalTextToDoc, options) { @@ -965,16 +1007,15 @@ function printEmbeddedAttributeValue(node, originalTextToDoc, options) { const printHug = (doc) => group(doc); const printExpand = (doc, canHaveTrailingWhitespace = true) => - group( - concat([ - indent(concat([softline, doc])), - canHaveTrailingWhitespace ? softline : "", - ]) - ); + group([indent([softline, doc]), canHaveTrailingWhitespace ? softline : ""]); const printMaybeHug = (doc) => (shouldHug ? printHug(doc) : printExpand(doc)); - const textToDoc = (code, opts) => - originalTextToDoc(code, { __onHtmlBindingRoot, ...opts }); + const attributeTextToDoc = (code, opts) => + originalTextToDoc( + code, + { __onHtmlBindingRoot, __embeddedInHtml: true, ...opts }, + { stripTrailingHardline: true } + ); if ( node.fullName === "srcset" && @@ -994,7 +1035,7 @@ function printEmbeddedAttributeValue(node, originalTextToDoc, options) { const value = getValue(); if (!value.includes("{{")) { return printExpand( - textToDoc(value, { + attributeTextToDoc(value, { parser: "css", __isHTMLStyleAttribute: true, }) @@ -1004,11 +1045,11 @@ function printEmbeddedAttributeValue(node, originalTextToDoc, options) { if (options.parser === "vue") { if (node.fullName === "v-for") { - return printVueFor(getValue(), textToDoc); + return printVueFor(getValue(), attributeTextToDoc); } - if (node.fullName === "slot-scope") { - return printVueSlotScope(getValue(), textToDoc); + if (isVueSlotAttribute(node) || isVueSfcBindingsAttribute(node, options)) { + return printVueBindings(getValue(), attributeTextToDoc); } /** @@ -1031,23 +1072,23 @@ function printEmbeddedAttributeValue(node, originalTextToDoc, options) { if (isKeyMatched(vueEventBindingPatterns)) { const value = getValue(); return printMaybeHug( - isVueEventBindingExpression(value) - ? textToDoc(value, { parser: "__js_expression" }) - : stripTrailingHardline( - textToDoc(value, { parser: "__vue_event_binding" }) - ) + attributeTextToDoc(value, { + parser: isVueEventBindingExpression(value) + ? "__js_expression" + : "__vue_event_binding", + }) ); } if (isKeyMatched(vueExpressionBindingPatterns)) { return printMaybeHug( - textToDoc(getValue(), { parser: "__vue_expression" }) + attributeTextToDoc(getValue(), { parser: "__vue_expression" }) ); } if (isKeyMatched(jsExpressionBindingPatterns)) { return printMaybeHug( - textToDoc(getValue(), { parser: "__js_expression" }) + attributeTextToDoc(getValue(), { parser: "__js_expression" }) ); } } @@ -1055,7 +1096,7 @@ function printEmbeddedAttributeValue(node, originalTextToDoc, options) { if (options.parser === "angular") { const ngTextToDoc = (code, opts) => // angular does not allow trailing comma - textToDoc(code, { ...opts, trailingComma: "none" }); + attributeTextToDoc(code, { ...opts, trailingComma: "none" }); /** * *directive="angularDirective" @@ -1106,43 +1147,35 @@ function printEmbeddedAttributeValue(node, originalTextToDoc, options) { ); } - const interpolationRegex = /\{\{([\s\S]+?)\}\}/g; + const interpolationRegex = /{{(.+?)}}/gs; const value = getValue(); if (interpolationRegex.test(value)) { const parts = []; - value.split(interpolationRegex).forEach((part, index) => { + for (const [index, part] of value.split(interpolationRegex).entries()) { if (index % 2 === 0) { - parts.push(concat(replaceEndOfLineWith(part, literalline))); + parts.push(replaceEndOfLineWith(part, literalline)); } else { try { parts.push( - group( - concat([ - "{{", - indent( - concat([ - line, - ngTextToDoc(part, { - parser: "__ng_interpolation", - __isInHtmlInterpolation: true, // to avoid unexpected `}}` - }), - ]) - ), + group([ + "{{", + indent([ line, - "}}", - ]) - ) - ); - } catch (e) { - parts.push( - "{{", - concat(replaceEndOfLineWith(part, literalline)), - "}}" + ngTextToDoc(part, { + parser: "__ng_interpolation", + __isInHtmlInterpolation: true, // to avoid unexpected `}}` + }), + ]), + line, + "}}", + ]) ); + } catch { + parts.push("{{", replaceEndOfLineWith(part, literalline), "}}"); } } - }); - return group(concat(parts)); + } + return group(parts); } } diff --git a/src/language-html/syntax-attribute.js b/src/language-html/syntax-attribute.js index c3a63d49af..c7caa0a8ac 100644 --- a/src/language-html/syntax-attribute.js +++ b/src/language-html/syntax-attribute.js @@ -1,22 +1,29 @@ "use strict"; +const parseSrcset = require("parse-srcset"); +const getLast = require("../utils/get-last"); const { - builders: { concat, ifBreak, join, line }, + builders: { group, ifBreak, indent, join, line, softline }, } = require("../document"); -const parseSrcset = require("srcset").parse; function printImgSrcset(value) { - const srcset = parseSrcset(value); + const srcset = parseSrcset(value, { + logger: { + error(message) { + throw new Error(message); + }, + }, + }); - const hasW = srcset.some((src) => src.width); - const hasH = srcset.some((src) => src.height); - const hasX = srcset.some((src) => src.density); + const hasW = srcset.some(({ w }) => w); + const hasH = srcset.some(({ h }) => h); + const hasX = srcset.some(({ d }) => d); if (hasW + hasH + hasX > 1) { throw new Error("Mixed descriptor in srcset is not supported"); } - const key = hasW ? "width" : hasH ? "height" : "density"; + const key = hasW ? "w" : hasH ? "h" : "d"; const unit = hasW ? "w" : hasH ? "h" : "x"; const getMax = (values) => Math.max(...values); @@ -34,7 +41,7 @@ function printImgSrcset(value) { const maxDescriptorLeftLength = getMax(descriptorLeftLengths); return join( - concat([",", line]), + [",", line], urls.map((url, index) => { const parts = [url]; @@ -48,13 +55,55 @@ function printImgSrcset(value) { parts.push(ifBreak(alignment, " "), descriptor + unit); } - return concat(parts); + return parts; }) ); } +const prefixDelimiters = [":", "__", "--", "_", "-"]; + +function getClassPrefix(className) { + const startIndex = className.search(/[^_-]/); + if (startIndex !== -1) { + for (const delimiter of prefixDelimiters) { + const delimiterIndex = className.indexOf(delimiter, startIndex); + if (delimiterIndex !== -1) { + return className.slice(0, delimiterIndex); + } + } + } + return className; +} + function printClassNames(value) { - return value.trim().split(/\s+/).join(" "); + const classNames = value.trim().split(/\s+/); + + // Try keeping consecutive classes with the same prefix on one line. + const groupedByPrefix = []; + let previousPrefix; + for (let i = 0; i < classNames.length; i++) { + const prefix = getClassPrefix(classNames[i]); + if ( + prefix !== previousPrefix && + // "home-link" and "home-link_blue_yes" should be considered same-prefix + prefix !== classNames[i - 1] + ) { + groupedByPrefix.push([]); + } + getLast(groupedByPrefix).push(classNames[i]); + previousPrefix = prefix; + } + + return [ + indent([ + softline, + join( + line, + groupedByPrefix.map((classNames) => group(join(line, classNames))) + ), + ]), + softline, + ]; } module.exports = { diff --git a/src/language-html/syntax-vue.js b/src/language-html/syntax-vue.js index 4d5e44e2ea..df31e0f999 100644 --- a/src/language-html/syntax-vue.js +++ b/src/language-html/syntax-vue.js @@ -1,7 +1,7 @@ "use strict"; const { - builders: { concat, group }, + builders: { group }, } = require("../document"); /** @@ -12,7 +12,7 @@ const { */ function printVueFor(value, textToDoc) { const { left, operator, right } = parseVueFor(value); - return concat([ + return [ group( textToDoc(`function _(${left}) {}`, { parser: "babel", @@ -22,14 +22,18 @@ function printVueFor(value, textToDoc) { " ", operator, " ", - textToDoc(right, { parser: "__js_expression" }), - ]); + textToDoc( + right, + { parser: "__js_expression" }, + { stripTrailingHardline: true } + ), + ]; } // modified from https://github.com/vuejs/vue/blob/v2.5.17/src/compiler/parser/index.js#L370-L387 function parseVueFor(value) { - const forAliasRE = /([^]*?)\s+(in|of)\s+([^]*)/; - const forIteratorRE = /,([^,}\]]*)(?:,([^,}\]]*))?$/; + const forAliasRE = /(.*?)\s+(in|of)\s+(.*)/s; + const forIteratorRE = /,([^,\]}]*)(?:,([^,\]}]*))?$/; const stripParensRE = /^\(|\)$/g; const inMatch = value.match(forAliasRE); @@ -59,19 +63,20 @@ function parseVueFor(value) { }; } -function printVueSlotScope(value, textToDoc) { +function printVueBindings(value, textToDoc) { return textToDoc(`function _(${value}) {}`, { parser: "babel", - __isVueSlotScope: true, + __isVueBindings: true, }); } function isVueEventBindingExpression(eventBindingValue) { // https://github.com/vuejs/vue/blob/v2.5.17/src/compiler/codegen/events.js#L3-L4 // arrow function or anonymous function - const fnExpRE = /^([\w$_]+|\([^)]*?\))\s*=>|^function\s*\(/; + const fnExpRE = /^([\w$]+|\([^)]*?\))\s*=>|^function\s*\(/; // simple member expression chain (a, a.b, a['b'], a["b"], a[0], a[b]) - const simplePathRE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/; + const simplePathRE = + /^[$A-Z_a-z][\w$]*(?:\.[$A-Z_a-z][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[$A-Z_a-z][\w$]*])*$/; // https://github.com/vuejs/vue/blob/v2.5.17/src/compiler/helpers.js#L104 const value = eventBindingValue.trim(); @@ -82,5 +87,5 @@ function isVueEventBindingExpression(eventBindingValue) { module.exports = { isVueEventBindingExpression, printVueFor, - printVueSlotScope, + printVueBindings, }; diff --git a/src/language-html/utils.js b/src/language-html/utils.js index 055ad30615..fa4df517c7 100644 --- a/src/language-html/utils.js +++ b/src/language-html/utils.js @@ -1,11 +1,8 @@ "use strict"; -const { - CSS_DISPLAY_TAGS, - CSS_DISPLAY_DEFAULT, - CSS_WHITE_SPACE_TAGS, - CSS_WHITE_SPACE_DEFAULT, -} = require("./constants.evaluate"); +/** + * @typedef {import("../common/ast-path")} AstPath + */ const htmlTagNames = require("html-tag-names"); const htmlElementAttributes = require("html-element-attributes"); @@ -13,12 +10,43 @@ const htmlElementAttributes = require("html-element-attributes"); // [prettierx] support --html-void-tags option: const htmlVoidElements = require("html-void-elements"); +const { inferParserByLanguage, isFrontMatterNode } = require("../common/util"); +const { + CSS_DISPLAY_TAGS, + CSS_DISPLAY_DEFAULT, + CSS_WHITE_SPACE_TAGS, + CSS_WHITE_SPACE_DEFAULT, +} = require("./constants.evaluate"); + const HTML_TAGS = arrayToMap(htmlTagNames); const HTML_ELEMENT_ATTRIBUTES = mapObject(htmlElementAttributes, arrayToMap); // [prettierx] support --html-void-tags option: const HTML_VOID_ELEMENT_SET = new Set(htmlVoidElements); +// https://infra.spec.whatwg.org/#ascii-whitespace +const HTML_WHITESPACE = new Set(["\t", "\n", "\f", "\r", " "]); +const htmlTrimStart = (string) => string.replace(/^[\t\n\f\r ]+/, ""); +const htmlTrimEnd = (string) => string.replace(/[\t\n\f\r ]+$/, ""); +const htmlTrim = (string) => htmlTrimStart(htmlTrimEnd(string)); +const htmlTrimLeadingBlankLines = (string) => + string.replace(/^[\t\f\r ]*?\n/g, ""); +const htmlTrimPreserveIndentation = (string) => + htmlTrimLeadingBlankLines(htmlTrimEnd(string)); +const splitByHtmlWhitespace = (string) => string.split(/[\t\n\f\r ]+/); +const getLeadingHtmlWhitespace = (string) => string.match(/^[\t\n\f\r ]*/)[0]; +const getLeadingAndTrailingHtmlWhitespace = (string) => { + const [, leadingWhitespace, text, trailingWhitespace] = string.match( + /^([\t\n\f\r ]*)(.*?)([\t\n\f\r ]*)$/s + ); + return { + leadingWhitespace, + trailingWhitespace, + text, + }; +}; +const hasHtmlWhitespace = (string) => /[\t\n\f\r ]/.test(string); + function arrayToMap(array) { const map = Object.create(null); for (const value of array) { @@ -29,26 +57,13 @@ function arrayToMap(array) { function mapObject(object, fn) { const newObject = Object.create(null); - for (const key of Object.keys(object)) { - newObject[key] = fn(object[key], key); + for (const [key, value] of Object.entries(object)) { + newObject[key] = fn(value, key); } return newObject; } function shouldPreserveContent(node, options) { - if (!node.endSourceSpan) { - return false; - } - - if ( - node.type === "element" && - node.fullName === "template" && - node.attrMap.lang && - node.attrMap.lang !== "html" - ) { - return true; - } - // unterminated node in ie conditional comment // e.g. if ( @@ -66,23 +81,6 @@ function shouldPreserveContent(node, options) { return true; } - // top-level elements (excluding