├── .circleci └── config.yml ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── icon.png ├── img ├── explainer.gif └── settings.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── extension.ts └── utils.ts ├── tests └── utils.spec.ts └── tsconfig.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:10 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | steps: 18 | - checkout 19 | 20 | # Download and cache dependencies 21 | - restore_cache: 22 | keys: 23 | - v1-dependencies-{{ checksum "package.json" }} 24 | # fallback to using the latest cache if no exact match is found 25 | - v1-dependencies- 26 | 27 | - run: npm install 28 | 29 | - save_cache: 30 | paths: 31 | - node_modules 32 | key: v1-dependencies-{{ checksum "package.json" }} 33 | 34 | # run tests! 35 | - run: npm run test 36 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Headwind is open-source and contributions are always welcome. If you're interested in submitting a pull request, please take a moment to review this document. 4 | 5 | ## Pull requests 6 | 7 | Thinking of submitting a pull request? Please check the [issues page](https://github.com/heybourn/headwind/issues) to ensure your idea is not already being implemented or discussed. 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | open_collective: headwind 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | 6 | name: Build vsix 7 | 8 | jobs: 9 | build: 10 | name: Build vsix 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '14' 17 | - name: Install 18 | run: | 19 | npm install 20 | npm install -g vsce 21 | - name: Build 22 | run: | 23 | vsce package 24 | - name: Release 25 | uses: softprops/action-gh-release@v1 26 | with: 27 | prerelease: true 28 | fail_on_unmatched_files: true 29 | files: | 30 | *.vsix 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /out 2 | /node_modules 3 | .DS_Store 4 | *.vsix 5 | .history -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "singleQuote": true, 4 | "semi": true, 5 | "tabWidth": 2, 6 | "useTabs": true 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/out/**/*.js" 18 | ], 19 | "preLaunchTask": "npm: watch" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": false 3 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ### Changed 11 | 12 | * Overhauled class detecting regex for javascript/javascriptreact/typescript/typescriptreact, thanks [@petertriho](https://github.com/petertriho) in [#109](https://github.com/heybourn/headwind/pull/109) 13 | * Support multiple class name regexes per language and optional separator and replacement options, thanks [@han-tyumi](https://github.com/han-tyumi) in [#112](https://github.com/heybourn/headwind/pull/112) 14 | * Fix `cmd+shift+t` overriding default vscode keymap for reopening previously closed tabs, thanks [@tylerjlawson](https://github.com/tylerjlawson) in [#163](https://github.com/heybourn/headwind/pull/163) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ryan Heybourn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Headwind 2 | 3 | [![CircleCI](https://circleci.com/gh/heybourn/headwind.svg?style=svg)](https://circleci.com/gh/heybourn/headwind) 4 | 5 | Headwind is an opinionated Tailwind CSS class sorter for Visual Studio Code. It enforces consistent ordering of classes by parsing your code and reprinting class tags to follow a given order. 6 | 7 | > Headwind runs on save, will remove duplicate classes and can even sort entire workspaces. 8 | 9 | --- 10 | 11 | **[Get it from the VS Code Marketplace →](https://marketplace.visualstudio.com/items?itemName=heybourn.headwind)** 12 | 13 | **[Use PHPStorm? Get @WalrusSoup's Headwind port →](https://plugins.jetbrains.com/plugin/13376-tailwind-formatter/)** 14 | 15 | Explainer 16 | 17 | ## Usage 18 | 19 | You can install Headwind via the VS Code Marketplace, or package it yourself using [vsce](https://code.visualstudio.com/api/working-with-extensions/publishing-extension). Headwind works globally once installed and will run on save if a `tailwind.config.js` file is present within your working directory. 20 | 21 | You can also trigger Headwind by: 22 | 23 | * Pressing ALT + Shift + T on Mac 24 | * Pressing CTRL + ALT + T on Windows 25 | * Pressing CTRL + ALT + T on Linux 26 | 27 | 28 | Headwind can sort individual files by running 'Sort Tailwind CSS Classes' via the Command Palette. Workspaces can also be sorted by running 'Sort Tailwind CSS Classes on Entire Workspace'. 29 | 30 | Any breakpoints or unknown classes will be moved to the end of the class list, whilst duplicate classes will be removed. 31 | 32 | ## Customisation 33 | 34 | Headwind ships with a default class order (located in [package.json](package.json)). You can edit this (and other settings) to your liking on the extension settings page. 35 | 36 | ### `headwind.classRegex`: 37 | 38 | An object with language IDs as keys and their values determining the regex to search for Tailwind CSS classes. 39 | The default is located in [package.json](package.json) but this can be customized to suit your needs. 40 | 41 | There can be multiple capturing groups, that should only contain a string with Tailwind CSS classes (without any apostrophies etc.). If a new group, which doesn't contain the `class` string, is created, ensure that it is non-capturing by using `(?:)`. 42 | 43 | Example from `package.json`: 44 | 45 | ```json 46 | "headwind.classRegex": { 47 | "html": "\\bclass\\s*=\\s*[\\\"\\']([_a-zA-Z0-9\\s\\-\\:\\/]+)[\\\"\\']", 48 | "javascriptreact": "(?:\\bclassName\\s*=\\s*[\\\"\\']([_a-zA-Z0-9\\s\\-\\:\\/]+)[\\\"\\'])|(?:\\btw\\s*`([_a-zA-Z0-9\\s\\-\\:\\/]*)`)" 49 | } 50 | ``` 51 | 52 | #### Multi-step Regex 53 | 54 | A multi-step regex can be specified by using an array of regexes to be executed in order. 55 | 56 | Example from `package.json`: 57 | 58 | ```js 59 | "headwind.classRegex": { 60 | "javascript": [ 61 | "(?:\\bclass(?:Name)?\\s*=\\s*(?:{([\\w\\d\\s_\\-:/${}()[\\]\"'`,]+)})|([\"'`][\\w\\d\\s_\\-:/]+[\"'`]))|(?:\\btw\\s*(`[\\w\\d\\s_\\-:/]+`))", 62 | "(?:[\"'`]([\\w\\d\\s_\\-:/${}()[\\]\"']+)[\"'`])" 63 | ], 64 | } 65 | ``` 66 | 67 | The first regex will look for JSX `class` or `className` attributes or [twin.macro](https://github.com/ben-rogerson/twin.macro) usage. 68 | 69 | The second regex will then look for class names to be sorted within these matches. 70 | 71 | #### Configuration Object 72 | 73 | Optionally a configuration object can be passed to specify additional options for sorting class names. 74 | 75 | - `regex` - specifies the regex to be used to find class names 76 | - `separator` - regex pattern that is used to separate class names (default: `"\\s+"`) 77 | - `replacement` - string used to replace separator matches (default: `" "`) 78 | 79 | Example from `package.json`: 80 | 81 | ```js 82 | "headwind.classRegex": { 83 | "jade": [ 84 | { 85 | "regex": "\\.([\\._a-zA-Z0-9\\-]+)", 86 | "separator": "\\.", 87 | "replacement": "." 88 | }, 89 | "\\bclass\\s*=\\s*[\\\"\\']([_a-zA-Z0-9\\s\\-\\:\\/]+)[\\\"\\']" 90 | ], 91 | } 92 | ``` 93 | 94 | #### Debugging Custom Regex: 95 | 96 | To debug custom `classRegex`, you can use the code below: 97 | ```js 98 | // Your test string here 99 | const editorText = ` 100 | export const Layout = ({ children }) => ( 101 |
102 |
103 |
{children}
104 |
105 | ) 106 | ` 107 | // Your Regex here 108 | const regex = /(?:\b(?:class|className)?\s*=\s*{?[\"\']([_a-zA-Z0-9\s\-\:/]+)[\"\']}?)/ 109 | const classWrapperRegex = new RegExp(regex, 'gi') 110 | 111 | let classWrapper 112 | while ((classWrapper = classWrapperRegex.exec(editorText)) !== null) { 113 | const wrapperMatch = classWrapper[0] 114 | const valueMatchIndex = classWrapper.findIndex((match, idx) => idx !== 0 && match) 115 | const valueMatch = classWrapper[valueMatchIndex] 116 | 117 | console.log('classWrapper', classWrapper) 118 | console.log('wrapperMatch', wrapperMatch) 119 | console.log('valueMatchIndex', valueMatchIndex) 120 | console.log('valueMatch', valueMatch) 121 | } 122 | ``` 123 | 124 | The result of `valueMatch` should be the class text _exactly_, with no other characters. 125 | 126 | Good example value: `valueMatch w-64 h-full bg-blue-400 relative` 127 | 128 | **Note**: Changes made to Headwind's JSON configuration options may not take effect immediately. When experimenting with custom `classRegex`, after each change you should open the control pallete (Ctrl/Cmd + Shift + P) and run `Developer: Reload Window` to ensure changes are applied. 129 | 130 |
131 | 132 | ### `headwind.defaultSortOrder`: 133 | 134 | An array that determines Headwind's default sort order. 135 | 136 | ### `headwind.removeDuplicates`: 137 | 138 | Headwind will remove duplicate class names by default. This can be toggled on or off. 139 | 140 | `"headwind.removeDuplicates": false` 141 | 142 | ### `headwind.prependCustomClasses`: 143 | 144 | Headwind will append custom class names by default. They can be prepended instead. 145 | 146 | `"headwind.prependCustomClasses": true` 147 | 148 | ### `headwind.runOnSave`: 149 | 150 | Headwind will run on save by default (if a `tailwind.config.js` file is present within your working directory). This can be toggled on or off. 151 | 152 | `"headwind.runOnSave": false` 153 | 154 | ## Contributing 155 | 156 | Headwind is open-source and contributions are always welcome. If you're interested in submitting a pull request, please take a moment to review [CONTRIBUTING.md](.github/CONTRIBUTING.md). 157 | 158 | ## Contributors 159 | 160 | ### Code Contributors 161 | 162 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 163 | 164 | 165 | ### Financial Contributors 166 | 167 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/headwind/contribute)] 168 | 169 | #### Individuals 170 | 171 | 172 | 173 | #### Organizations 174 | 175 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/headwind/contribute)] 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heybourn/headwind/ae5d26f7ec9b1923705abca3a85b8be4fc3904ad/icon.png -------------------------------------------------------------------------------- /img/explainer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heybourn/headwind/ae5d26f7ec9b1923705abca3a85b8be4fc3904ad/img/explainer.gif -------------------------------------------------------------------------------- /img/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heybourn/headwind/ae5d26f7ec9b1923705abca3a85b8be4fc3904ad/img/settings.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { commands, workspace, ExtensionContext, Range, window } from 'vscode'; 4 | import { sortClassString, getTextMatch, buildMatchers } from './utils'; 5 | import { spawn } from 'child_process'; 6 | import { rustyWindPath } from 'rustywind'; 7 | 8 | export type LangConfig = 9 | | string 10 | | string[] 11 | | { regex?: string | string[]; separator?: string; replacement?: string } 12 | | undefined; 13 | 14 | const config = workspace.getConfiguration(); 15 | const langConfig: { [key: string]: LangConfig | LangConfig[] } = 16 | config.get('headwind.classRegex') || {}; 17 | 18 | const sortOrder = config.get('headwind.defaultSortOrder'); 19 | 20 | const customTailwindPrefixConfig = config.get('headwind.customTailwindPrefix'); 21 | const customTailwindPrefix = 22 | typeof customTailwindPrefixConfig === 'string' 23 | ? customTailwindPrefixConfig 24 | : ''; 25 | 26 | const shouldRemoveDuplicatesConfig = config.get('headwind.removeDuplicates'); 27 | const shouldRemoveDuplicates = 28 | typeof shouldRemoveDuplicatesConfig === 'boolean' 29 | ? shouldRemoveDuplicatesConfig 30 | : true; 31 | 32 | const shouldPrependCustomClassesConfig = config.get( 33 | 'headwind.prependCustomClasses' 34 | ); 35 | const shouldPrependCustomClasses = 36 | typeof shouldPrependCustomClassesConfig === 'boolean' 37 | ? shouldPrependCustomClassesConfig 38 | : false; 39 | 40 | export function activate(context: ExtensionContext) { 41 | let disposable = commands.registerTextEditorCommand( 42 | 'headwind.sortTailwindClasses', 43 | function (editor, edit) { 44 | const editorText = editor.document.getText(); 45 | const editorLangId = editor.document.languageId; 46 | 47 | const matchers = buildMatchers( 48 | langConfig[editorLangId] || langConfig['html'] 49 | ); 50 | 51 | for (const matcher of matchers) { 52 | getTextMatch(matcher.regex, editorText, (text, startPosition) => { 53 | const endPosition = startPosition + text.length; 54 | const range = new Range( 55 | editor.document.positionAt(startPosition), 56 | editor.document.positionAt(endPosition) 57 | ); 58 | 59 | const options = { 60 | shouldRemoveDuplicates, 61 | shouldPrependCustomClasses, 62 | customTailwindPrefix, 63 | separator: matcher.separator, 64 | replacement: matcher.replacement, 65 | }; 66 | 67 | edit.replace( 68 | range, 69 | sortClassString( 70 | text, 71 | Array.isArray(sortOrder) ? sortOrder : [], 72 | options 73 | ) 74 | ); 75 | }); 76 | } 77 | } 78 | ); 79 | 80 | let runOnProject = commands.registerCommand( 81 | 'headwind.sortTailwindClassesOnWorkspace', 82 | () => { 83 | let workspaceFolder = workspace.workspaceFolders || []; 84 | if (workspaceFolder[0]) { 85 | window.showInformationMessage( 86 | `Running Headwind on: ${workspaceFolder[0].uri.fsPath}` 87 | ); 88 | 89 | let rustyWindArgs = [ 90 | workspaceFolder[0].uri.fsPath, 91 | '--write', 92 | shouldRemoveDuplicates ? '' : '--allow-duplicates', 93 | ].filter((arg) => arg !== ''); 94 | 95 | let rustyWindProc = spawn(rustyWindPath, rustyWindArgs); 96 | 97 | rustyWindProc.stdout.on( 98 | 'data', 99 | (data) => 100 | data && 101 | data.toString() !== '' && 102 | console.log('rustywind stdout:\n', data.toString()) 103 | ); 104 | 105 | rustyWindProc.stderr.on('data', (data) => { 106 | if (data && data.toString() !== '') { 107 | console.log('rustywind stderr:\n', data.toString()); 108 | window.showErrorMessage(`Headwind error: ${data.toString()}`); 109 | } 110 | }); 111 | } 112 | } 113 | ); 114 | 115 | context.subscriptions.push(runOnProject); 116 | context.subscriptions.push(disposable); 117 | 118 | // if runOnSave is enabled organize tailwind classes before saving 119 | if (config.get('headwind.runOnSave')) { 120 | context.subscriptions.push( 121 | workspace.onWillSaveTextDocument((_e) => { 122 | commands.executeCommand('headwind.sortTailwindClasses'); 123 | }) 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { LangConfig } from './extension'; 2 | 3 | export interface Options { 4 | shouldRemoveDuplicates: boolean; 5 | shouldPrependCustomClasses: boolean; 6 | customTailwindPrefix: string; 7 | separator?: RegExp; 8 | replacement?: string; 9 | } 10 | 11 | /** 12 | * Sorts a string of CSS classes according to a predefined order. 13 | * @param classString The string to sort 14 | * @param sortOrder The default order to sort the array at 15 | * 16 | * @returns The sorted string 17 | */ 18 | export const sortClassString = ( 19 | classString: string, 20 | sortOrder: string[], 21 | options: Options 22 | ): string => { 23 | let classArray = classString.split(options.separator || /\s+/g); 24 | 25 | if (options.shouldRemoveDuplicates) { 26 | classArray = removeDuplicates(classArray); 27 | } 28 | 29 | // prepend custom tailwind prefix to all tailwind sortOrder-classes 30 | const sortOrderClone = [...sortOrder]; 31 | if (options.customTailwindPrefix.length > 0) { 32 | for (var i = 0; i < sortOrderClone.length; i++) { 33 | sortOrderClone[i] = options.customTailwindPrefix + sortOrderClone[i]; 34 | } 35 | } 36 | 37 | classArray = sortClassArray( 38 | classArray, 39 | sortOrderClone, 40 | options.shouldPrependCustomClasses 41 | ); 42 | 43 | return classArray.join(options.replacement || ' ').trim(); 44 | }; 45 | 46 | const sortClassArray = ( 47 | classArray: string[], 48 | sortOrder: string[], 49 | shouldPrependCustomClasses: boolean 50 | ): string[] => [ 51 | ...classArray.filter( 52 | (el) => shouldPrependCustomClasses && sortOrder.indexOf(el) === -1 53 | ), // append the classes that were not in the sort order if configured this way 54 | ...classArray 55 | .filter((el) => sortOrder.indexOf(el) !== -1) // take the classes that are in the sort order 56 | .sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)), // and sort them 57 | ...classArray.filter( 58 | (el) => !shouldPrependCustomClasses && sortOrder.indexOf(el) === -1 59 | ), // prepend the classes that were not in the sort order if configured this way 60 | ]; 61 | 62 | const removeDuplicates = (classArray: string[]): string[] => [ 63 | ...new Set(classArray), 64 | ]; 65 | 66 | function isArrayOfStrings(value: unknown): value is string[] { 67 | return ( 68 | Array.isArray(value) && value.every((item) => typeof item === 'string') 69 | ); 70 | } 71 | 72 | export type Matcher = { 73 | regex: RegExp[]; 74 | separator?: RegExp; 75 | replacement?: string; 76 | }; 77 | 78 | function buildMatcher(value: LangConfig): Matcher { 79 | if (typeof value === 'string') { 80 | return { 81 | regex: [new RegExp(value, 'gi')], 82 | }; 83 | } else if (isArrayOfStrings(value)) { 84 | return { 85 | regex: value.map((v) => new RegExp(v, 'gi')), 86 | }; 87 | } else if (value == undefined) { 88 | return { 89 | regex: [], 90 | }; 91 | } else { 92 | return { 93 | regex: 94 | typeof value.regex === 'string' 95 | ? [new RegExp(value.regex, 'gi')] 96 | : isArrayOfStrings(value.regex) 97 | ? value.regex.map((v) => new RegExp(v, 'gi')) 98 | : [], 99 | separator: 100 | typeof value.separator === 'string' 101 | ? new RegExp(value.separator, 'g') 102 | : undefined, 103 | replacement: value.replacement || value.separator, 104 | }; 105 | } 106 | } 107 | 108 | export function buildMatchers(value: LangConfig | LangConfig[]): Matcher[] { 109 | if (value == undefined) { 110 | return []; 111 | } else if (Array.isArray(value)) { 112 | if (!value.length) { 113 | return []; 114 | } else if (!isArrayOfStrings(value)) { 115 | return value.map((v) => buildMatcher(v)); 116 | } 117 | } 118 | return [buildMatcher(value)]; 119 | } 120 | 121 | export function getTextMatch( 122 | regexes: RegExp[], 123 | text: string, 124 | callback: (text: string, startPosition: number) => void, 125 | startPosition: number = 0 126 | ): void { 127 | if (regexes.length >= 1) { 128 | let wrapper: RegExpExecArray | null; 129 | while ((wrapper = regexes[0].exec(text)) !== null) { 130 | const wrapperMatch = wrapper[0]; 131 | const valueMatchIndex = wrapper.findIndex( 132 | (match, idx) => idx !== 0 && match 133 | ); 134 | const valueMatch = wrapper[valueMatchIndex]; 135 | 136 | const newStartPosition = 137 | startPosition + wrapper.index + wrapperMatch.lastIndexOf(valueMatch); 138 | 139 | if (regexes.length === 1) { 140 | callback(valueMatch, newStartPosition); 141 | } else { 142 | getTextMatch(regexes.slice(1), valueMatch, callback, newStartPosition); 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sortClassString, 3 | getTextMatch, 4 | buildMatchers, 5 | Matcher, 6 | } from '../src/utils'; 7 | import { LangConfig } from '../src/extension'; 8 | import 'jest'; 9 | import * as _ from 'lodash'; 10 | 11 | const pjson = require('../package.json'); 12 | 13 | const sortOrder: string[] = 14 | pjson.contributes.configuration[0].properties['headwind.defaultSortOrder'] 15 | .default; 16 | const customClass: string = 'yoda'; 17 | 18 | const randomizedClassString = _.shuffle(sortOrder).join(' '); 19 | const randomizedClassStringWithCustom = _.shuffle([ 20 | ...sortOrder, 21 | customClass, 22 | ]).join(' '); 23 | 24 | describe('sortClassString', () => { 25 | it('sorts half classes properly', () => { 26 | const result = sortClassString( 27 | 'mt-4 mb-0.5 flex inline-block inline px-0.5 pt-10 random-class justify-items absolute relative another-random-class', 28 | sortOrder, 29 | { 30 | shouldRemoveDuplicates: true, 31 | shouldPrependCustomClasses: false, 32 | customTailwindPrefix: '', 33 | } 34 | ); 35 | expect(result).toBe( 36 | 'inline-block inline flex absolute relative px-0.5 pt-10 mt-4 mb-0.5 random-class justify-items another-random-class' 37 | ); 38 | }); 39 | 40 | it('should return a sorted class list string', () => { 41 | const result = sortClassString(randomizedClassString, sortOrder, { 42 | shouldRemoveDuplicates: true, 43 | shouldPrependCustomClasses: false, 44 | customTailwindPrefix: '', 45 | }); 46 | expect(result).toBe(sortOrder.join(' ')); 47 | }); 48 | 49 | it('should return a sorted class list string with appended custom classes', () => { 50 | const result = sortClassString(randomizedClassStringWithCustom, sortOrder, { 51 | shouldRemoveDuplicates: true, 52 | shouldPrependCustomClasses: false, 53 | customTailwindPrefix: '', 54 | }); 55 | expect(result).toBe([...sortOrder, customClass].join(' ')); 56 | }); 57 | 58 | it('should return a sorted class list string with prepended custom classes', () => { 59 | const result = sortClassString(randomizedClassStringWithCustom, sortOrder, { 60 | shouldRemoveDuplicates: true, 61 | shouldPrependCustomClasses: true, 62 | customTailwindPrefix: '', 63 | }); 64 | expect(result).toBe([customClass, ...sortOrder].join(' ')); 65 | }); 66 | 67 | it.each<[RegExp | undefined, string | undefined, string]>([ 68 | [undefined, undefined, ' '], 69 | [/\+\+/g, undefined, '++'], 70 | [undefined, ',', ' '], 71 | [/\./g, '.', '.'], 72 | ])( 73 | 'should handle a `%s` class name separator with a `%s` class name separator replacement', 74 | (separator, replacement, join) => { 75 | const validClasses = sortOrder.filter((c) => !c.includes(join)); 76 | const randomizedClassString = _.shuffle(validClasses).join(join); 77 | 78 | const result = sortClassString(randomizedClassString, sortOrder, { 79 | shouldRemoveDuplicates: true, 80 | shouldPrependCustomClasses: false, 81 | customTailwindPrefix: '', 82 | separator, 83 | replacement, 84 | }); 85 | 86 | expect(result).toBe(validClasses.join(replacement || ' ')); 87 | } 88 | ); 89 | }); 90 | 91 | describe('removeDuplicates', () => { 92 | it('should remove duplicate classes', () => { 93 | const randomizedAndDuplicatedClassString = 94 | randomizedClassString + ' ' + _.shuffle(sortOrder).join(' '); 95 | 96 | const result = sortClassString( 97 | randomizedAndDuplicatedClassString, 98 | sortOrder, 99 | { 100 | shouldRemoveDuplicates: true, 101 | shouldPrependCustomClasses: false, 102 | customTailwindPrefix: '', 103 | } 104 | ); 105 | expect(result).toBe(sortOrder.join(' ')); 106 | }); 107 | 108 | it('should remove not delete duplicate classes when flag is set', () => { 109 | const randomizedAndDuplicatedClassString = 110 | 'container random random' + ' ' + _.shuffle(sortOrder).join(' '); 111 | 112 | const result = sortClassString( 113 | randomizedAndDuplicatedClassString, 114 | sortOrder, 115 | { 116 | shouldRemoveDuplicates: false, 117 | shouldPrependCustomClasses: false, 118 | customTailwindPrefix: '', 119 | } 120 | ); 121 | expect(result).toBe( 122 | ['container', ...sortOrder, 'random', 'random'].join(' ') 123 | ); 124 | }); 125 | }); 126 | 127 | describe('extract className (jsx) string with single regex', () => { 128 | const classString = 'w-64 h-full bg-blue-400 relative'; 129 | 130 | const beforeText = `export const Layout = ({ children }) => { 131 | const doNotSort = "hello flex"; 132 | 133 | return (
134 |
136 |
{children}
137 |
) 138 | }`; 139 | 140 | const generateEditorText = (classNameString: string) => 141 | `${beforeText}${classNameString}${afterText}`; 142 | 143 | const startPosition = beforeText.length; 144 | 145 | const multiLineClassString = ` 146 | w-64 147 | h-full 148 | bg-blue-400 149 | relative 150 | `; 151 | 152 | it.each([ 153 | [ 154 | 'simple single quotes', 155 | generateEditorText(`'${classString}'`), 156 | classString, 157 | startPosition + 1, 158 | ], 159 | [ 160 | 'simple double quotes', 161 | generateEditorText(`"${classString}"`), 162 | classString, 163 | startPosition + 1, 164 | ], 165 | [ 166 | 'curly braces around single quotes', 167 | generateEditorText(`{ '${classString}' }`), 168 | classString, 169 | startPosition + "{ '".length, 170 | ], 171 | [ 172 | 'curly braces around double quotes', 173 | generateEditorText(`{ "${classString}" }`), 174 | classString, 175 | startPosition + '{ "'.length, 176 | ], 177 | [ 178 | 'simple clsx single quotes', 179 | generateEditorText(`{ clsx('${classString}') }`), 180 | classString, 181 | startPosition + "{ clsx('".length, 182 | ], 183 | [ 184 | 'simple clsx double quotes', 185 | generateEditorText(`{ clsx("${classString}") }`), 186 | classString, 187 | startPosition + '{ clsx("'.length, 188 | ], 189 | [ 190 | 'simple classname single quotes', 191 | generateEditorText(`{ classname('${classString}') }`), 192 | classString, 193 | startPosition + "{ classname('".length, 194 | ], 195 | [ 196 | 'simple classname double quotes', 197 | generateEditorText(`{ classname("${classString}") }`), 198 | classString, 199 | startPosition + '{ classname("'.length, 200 | ], 201 | [ 202 | 'simple foo func single quotes', 203 | generateEditorText(`{ foo('${classString}') }`), 204 | classString, 205 | startPosition + "{ foo('".length, 206 | ], 207 | [ 208 | 'simple foo func double quotes', 209 | generateEditorText(`{ foo("${classString}") }`), 210 | classString, 211 | startPosition + '{ foo("'.length, 212 | ], 213 | [ 214 | 'foo func multi str single quotes (only extracts first string)', 215 | generateEditorText(`{ foo('${classString}', 'class1 class2') }`), 216 | classString, 217 | startPosition + "{ foo('".length, 218 | ], 219 | [ 220 | 'foo func multi str double quotes (only extracts first string)', 221 | generateEditorText(`{ foo("${classString}", "class1, class2") }`), 222 | classString, 223 | startPosition + '{ foo("'.length, 224 | ], 225 | [ 226 | 'foo func multi var single quotes', 227 | generateEditorText(`{ clsx(foo, bar, '${classString}', foo, bar) }`), 228 | classString, 229 | startPosition + "{ clsx(foo, bar, '".length, 230 | ], 231 | [ 232 | 'foo func multi var double quotes', 233 | generateEditorText(`{ clsx(foo, bar, "${classString}", foo, bar) }`), 234 | classString, 235 | startPosition + '{ clsx(foo, bar, "'.length, 236 | ], 237 | [ 238 | 'foo func multi var multi str single quotes', 239 | generateEditorText( 240 | `{ clsx(foo, bar, '${classString}', foo, 'class1 class2', bar) }` 241 | ), 242 | classString, 243 | startPosition + "{ clsx(foo, bar, '".length, 244 | ], 245 | [ 246 | 'foo func multi var multi str double quotes', 247 | generateEditorText( 248 | `{ clsx(foo, bar, "${classString}", foo, "class1 class2", bar) }` 249 | ), 250 | classString, 251 | startPosition + '{ clsx(foo, bar, "'.length, 252 | ], 253 | [ 254 | 'complex foo func single quotes multi lines', 255 | generateEditorText(`{ clsx( 256 | foo, 257 | bar, 258 | '${classString}', 259 | foo, 260 | 'class1 class2', 261 | bar) 262 | }`), 263 | classString, 264 | startPosition + 265 | `{ clsx( 266 | foo, 267 | bar, 268 | '`.length, 269 | ], 270 | [ 271 | 'simple multi line double quotes', 272 | generateEditorText(`\"${multiLineClassString}\"`), 273 | multiLineClassString, 274 | startPosition + 1, 275 | ], 276 | [ 277 | 'complex foo func double quotes multi lines', 278 | generateEditorText(`{ clsx( 279 | foo, 280 | bar, 281 | "${classString}", 282 | foo, 283 | "class1 class2", 284 | bar 285 | }`), 286 | classString, 287 | startPosition + 288 | `{ clsx( 289 | foo, 290 | bar, 291 | "`.length, 292 | ], 293 | ['class attribute', `class="${classString}"`, classString, 7], 294 | [ 295 | 'string literal', 296 | `export function FormGroup({className = '', ...props}) { 297 | return
298 | }`, 299 | `${classString} \$\{className\}`, 300 | `export function FormGroup({className = '', ...props}) { 301 | return
{ 304 | const stringRegex = 305 | '(?:\\bclass(?:Name)?\\s*=[\\w\\d\\s_,{}()[\\]]*["\'`]([\\w\\d\\s_\\-:/${}]+)["\'`][\\w\\d\\s_,{}()[\\]]*)|(?:\\btw\\s*`([\\w\\d\\s_\\-:/]*)`)'; 306 | const callback = jest.fn(); 307 | 308 | for (const matcher of buildMatchers(stringRegex)) { 309 | getTextMatch(matcher.regex, editorText.toString(), callback); 310 | } 311 | 312 | expect(callback).toHaveBeenCalledWith( 313 | expectedTextMatch, 314 | expectedStartPosition 315 | ); 316 | }); 317 | }); 318 | 319 | describe('extract className (jsx) string(s) with multiple regexes', () => { 320 | const configRegex: { [key: string]: string } = 321 | pjson.contributes.configuration[0].properties['headwind.classRegex'] 322 | .default; 323 | const jsxLanguages = [ 324 | 'javascript', 325 | 'javascriptreact', 326 | 'typescript', 327 | 'typescriptreact', 328 | ]; 329 | 330 | const classString = 'w-64 h-full bg-blue-400 relative'; 331 | 332 | const beforeText = ` 333 | export const Layout = ({ children }) => { 334 | const doNotSort = "hello flex"; 335 | 336 | return (
337 |
340 |
{children}
341 |
342 | )}`; 343 | 344 | const generateEditorText = (classNameString: string) => 345 | `${beforeText}${classNameString}${afterText}`; 346 | 347 | const startPosition = beforeText.length; 348 | 349 | const multiLineClassString = ` 350 | w-64 351 | h-full 352 | bg-blue-400 353 | relative 354 | `; 355 | 356 | it.each([ 357 | [ 358 | 'simple single quotes', 359 | generateEditorText(`'${classString}'`), 360 | classString, 361 | startPosition + 1, 362 | ], 363 | [ 364 | 'simple double quotes', 365 | generateEditorText(`"${classString}"`), 366 | classString, 367 | startPosition + 1, 368 | ], 369 | [ 370 | 'curly braces around single quotes', 371 | generateEditorText(`{ '${classString}' }`), 372 | classString, 373 | startPosition + "{ '".length, 374 | ], 375 | [ 376 | 'curly braces around double quotes', 377 | generateEditorText(`{ "${classString}" }`), 378 | classString, 379 | startPosition + '{ "'.length, 380 | ], 381 | [ 382 | 'simple clsx single quotes', 383 | generateEditorText(`{ clsx('${classString}') }`), 384 | classString, 385 | startPosition + "{ clsx('".length, 386 | ], 387 | [ 388 | 'simple clsx double quotes', 389 | generateEditorText(`{ clsx("${classString}") }`), 390 | classString, 391 | startPosition + '{ clsx("'.length, 392 | ], 393 | [ 394 | 'simple classname single quotes', 395 | generateEditorText(`{ classname('${classString}') }`), 396 | classString, 397 | startPosition + "{ className('".length, 398 | ], 399 | [ 400 | 'simple classname double quotes', 401 | generateEditorText(`{ classname("${classString}") }`), 402 | classString, 403 | startPosition + '{ className("'.length, 404 | ], 405 | [ 406 | 'simple foo func single quotes', 407 | generateEditorText(`{ foo('${classString}') }`), 408 | classString, 409 | startPosition + "{ foo('".length, 410 | ], 411 | [ 412 | 'simple foo func double quotes', 413 | generateEditorText(`{ foo("${classString}") }`), 414 | classString, 415 | startPosition + '{ foo("'.length, 416 | ], 417 | [ 418 | 'foo func multi var single quotes', 419 | generateEditorText(`{ clsx(foo, bar, '${classString}', foo, bar) }`), 420 | classString, 421 | startPosition + "{ clsx(foo, bar, '".length, 422 | ], 423 | [ 424 | 'foo func multi var double quotes', 425 | generateEditorText(`{ clsx(foo, bar, "${classString}", foo, bar) }`), 426 | classString, 427 | startPosition + '{ clsx(foo, bar, "'.length, 428 | ], 429 | [ 430 | 'simple multi line double quotes', 431 | generateEditorText(`\"${multiLineClassString}\"`), 432 | multiLineClassString, 433 | startPosition + 1, 434 | ], 435 | ['class attribute', `class="${classString}"`, classString, 7], 436 | [ 437 | 'string literal', 438 | `export function FormGroup({className = '', ...props}) { 439 | return
440 | }`, 441 | `${classString} \$\{className\}`, 442 | `export function FormGroup({className = '', ...props}) { 443 | return
{ 446 | for (const jsxLanguage of jsxLanguages) { 447 | const callback = jest.fn(); 448 | 449 | for (const matcher of buildMatchers(configRegex[jsxLanguage])) { 450 | getTextMatch(matcher.regex, editorText.toString(), callback); 451 | } 452 | 453 | expect(callback).toHaveBeenCalledWith( 454 | expectedTextMatch, 455 | expectedStartPosition 456 | ); 457 | } 458 | }); 459 | 460 | it('should do nothing if no regexes (empty array) are provided', () => { 461 | const callback = jest.fn(); 462 | getTextMatch([], 'test', callback); 463 | expect(callback).toHaveBeenCalledTimes(0); 464 | }); 465 | 466 | it.each([ 467 | [ 468 | 'simple multi string', 469 | `className={clsx("hello", "world")}`, 470 | [ 471 | { match: 'hello', startPosition: 'className={clsx("'.length }, 472 | { match: 'world', startPosition: 'className={clsx("hello", "'.length }, 473 | ], 474 | ], 475 | [ 476 | 'foo func multi str single quotes', 477 | generateEditorText(`{ foo('${classString}', 'class1 class2') }`), 478 | [ 479 | { match: classString, startPosition: startPosition + "{ foo('".length }, 480 | { 481 | match: 'class1 class2', 482 | startPosition: 483 | startPosition + 484 | "{ foo('".length + 485 | classString.length + 486 | "', '".length, 487 | }, 488 | ], 489 | ], 490 | [ 491 | 'foo func multi str double quotes', 492 | generateEditorText(`{ foo("${classString}", "class1 class2") }`), 493 | [ 494 | { match: classString, startPosition: startPosition + '{ foo("'.length }, 495 | { 496 | match: 'class1 class2', 497 | startPosition: 498 | startPosition + 499 | '{ foo("'.length + 500 | classString.length + 501 | '", "'.length, 502 | }, 503 | ], 504 | ], 505 | [ 506 | 'foo func multi var multi str single quotes', 507 | generateEditorText( 508 | `{ clsx(foo, bar, '${classString}', foo, 'class1 class2', bar) }` 509 | ), 510 | [ 511 | { 512 | match: classString, 513 | startPosition: startPosition + "{ clsx(foo, bar, '".length, 514 | }, 515 | { 516 | match: 'class1 class2', 517 | startPosition: 518 | startPosition + `{ clsx(foo, bar, '${classString}', foo, '`.length, 519 | }, 520 | ], 521 | ], 522 | [ 523 | 'foo func multi var multi str double quotes', 524 | generateEditorText( 525 | `{ clsx(foo, bar, "${classString}", foo, "class1 class2", bar) }` 526 | ), 527 | [ 528 | { 529 | match: classString, 530 | startPosition: startPosition + '{ clsx(foo, bar, "'.length, 531 | }, 532 | { 533 | match: 'class1 class2', 534 | startPosition: 535 | startPosition + `{ clsx(foo, bar, "${classString}", foo, "`.length, 536 | }, 537 | ], 538 | ], 539 | [ 540 | 'complex foo func single quotes multi lines', 541 | generateEditorText(`{ clsx( 542 | foo, 543 | bar, 544 | '${classString}', 545 | foo, 546 | 'class1 class2', 547 | bar) 548 | }`), 549 | [ 550 | { 551 | match: classString, 552 | startPosition: 553 | startPosition + 554 | `{ clsx( 555 | foo, 556 | bar, 557 | '`.length, 558 | }, 559 | { 560 | match: 'class1 class2', 561 | startPosition: 562 | startPosition + 563 | `{ clsx( 564 | foo, 565 | bar, 566 | '${classString}', 567 | foo, 568 | '`.length, 569 | }, 570 | ], 571 | ], 572 | [ 573 | 'complex foo func double quotes multi lines', 574 | generateEditorText(`{ clsx( 575 | foo, 576 | bar, 577 | "${classString}", 578 | foo, 579 | "class1 class2", 580 | bar 581 | }`), 582 | [ 583 | { 584 | match: classString, 585 | startPosition: 586 | startPosition + 587 | `{ clsx( 588 | foo, 589 | bar, 590 | "`.length, 591 | }, 592 | { 593 | match: 'class1 class2', 594 | startPosition: 595 | startPosition + 596 | `{ clsx( 597 | foo, 598 | bar, 599 | "${classString}", 600 | foo, 601 | "`.length, 602 | }, 603 | ], 604 | ], 605 | ])('%s', (testName, editorText, expectedResults) => { 606 | for (const jsxLanguage of jsxLanguages) { 607 | const callback = jest.fn(); 608 | 609 | for (const matcher of buildMatchers(configRegex[jsxLanguage])) { 610 | getTextMatch(matcher.regex, editorText.toString(), callback); 611 | } 612 | 613 | expect(callback).toHaveBeenCalledTimes(expectedResults.length); 614 | expect(typeof expectedResults !== 'string').toBeTruthy(); 615 | 616 | if (typeof expectedResults !== 'string') { 617 | expectedResults.forEach((expectedResult, idx) => { 618 | expect(callback).toHaveBeenNthCalledWith( 619 | idx + 1, 620 | expectedResult.match, 621 | expectedResult.startPosition 622 | ); 623 | }); 624 | } 625 | } 626 | }); 627 | }); 628 | 629 | describe('twin macro - extract tw prop (jsx) string(s) with multiple regexes', () => { 630 | const configRegex: { [key: string]: string } = 631 | pjson.contributes.configuration[0].properties['headwind.classRegex'] 632 | .default; 633 | const jsxLanguages = [ 634 | 'javascript', 635 | 'javascriptreact', 636 | 'typescript', 637 | 'typescriptreact', 638 | ]; 639 | 640 | it.each([ 641 | [ 642 | 'simple twin macro example', 643 | `import 'twin.macro' 644 | 645 | const Input = () => 646 | `, 647 | [ 648 | { 649 | match: 'border hover:border-black', 650 | startPosition: `import 'twin.macro' 651 | 652 | const Input = () => 661 | `, 662 | [ 663 | { 664 | match: 'border hover:border-black', 665 | startPosition: `import 'twin.macro' 666 | 667 | const Input = () => { 678 | for (const jsxLanguage of jsxLanguages) { 679 | const callback = jest.fn(); 680 | 681 | for (const matcher of buildMatchers(configRegex[jsxLanguage])) { 682 | getTextMatch(matcher.regex, editorText.toString(), callback); 683 | } 684 | 685 | expect(callback).toHaveBeenCalledTimes(expectedResults.length); 686 | expect(typeof expectedResults !== 'string').toBeTruthy(); 687 | 688 | if (typeof expectedResults !== 'string') { 689 | expectedResults.forEach((expectedResult, idx) => { 690 | expect(callback).toHaveBeenNthCalledWith( 691 | idx + 1, 692 | expectedResult.match, 693 | expectedResult.startPosition 694 | ); 695 | }); 696 | } 697 | } 698 | }); 699 | }); 700 | 701 | describe('buildMatchers', () => { 702 | it.each<[string, LangConfig | LangConfig[], Matcher[]]>([ 703 | ['undefined', undefined, []], 704 | ['empty', [], []], 705 | [ 706 | 'layered regexes', 707 | [ 708 | '(?:\\bclass(?:Name)?\\s*=\\s*(?:{([\\w\\d\\s_\\-:/${}()[\\]"\'`,]+)})|(["\'`][\\w\\d\\s_\\-:/]+["\'`]))|(?:\\btw\\s*(`[\\w\\d\\s_\\-:/]+`))', 709 | '(?:["\'`]([\\w\\d\\s_\\-:/${}()[\\]"\']+)["\'`])', 710 | ], 711 | [ 712 | { 713 | regex: [ 714 | /(?:\bclass(?:Name)?\s*=\s*(?:{([\w\d\s_\-:/${}()[\]"'`,]+)})|(["'`][\w\d\s_\-:/]+["'`]))|(?:\btw\s*(`[\w\d\s_\-:/]+`))/gi, 715 | /(?:["'`]([\w\d\s_\-:/${}()[\]"']+)["'`])/gi, 716 | ], 717 | }, 718 | ], 719 | ], 720 | [ 721 | 'multiple layered regexes', 722 | [ 723 | [ 724 | '(?:\\bclass(?:Name)?\\s*=\\s*(?:{([\\w\\d\\s_\\-:/${}()[\\]"\'`,]+)})|(["\'`][\\w\\d\\s_\\-:/]+["\'`]))|(?:\\btw\\s*(`[\\w\\d\\s_\\-:/]+`))', 725 | '(?:["\'`]([\\w\\d\\s_\\-:/${}()[\\]"\']+)["\'`])', 726 | ], 727 | [ 728 | '(?:\\bclass(?:Name)?\\s*=\\s*(?:{([\\w\\d\\s_\\-:/${}()[\\]"\'`,]+)})|(["\'`][\\w\\d\\s_\\-:/]+["\'`]))|(?:\\btw\\s*(`[\\w\\d\\s_\\-:/]+`))', 729 | '(?:["\'`]([\\w\\d\\s_\\-:/${}()[\\]"\']+)["\'`])', 730 | ], 731 | ], 732 | [ 733 | { 734 | regex: [ 735 | /(?:\bclass(?:Name)?\s*=\s*(?:{([\w\d\s_\-:/${}()[\]"'`,]+)})|(["'`][\w\d\s_\-:/]+["'`]))|(?:\btw\s*(`[\w\d\s_\-:/]+`))/gi, 736 | /(?:["'`]([\w\d\s_\-:/${}()[\]"']+)["'`])/gi, 737 | ], 738 | }, 739 | { 740 | regex: [ 741 | /(?:\bclass(?:Name)?\s*=\s*(?:{([\w\d\s_\-:/${}()[\]"'`,]+)})|(["'`][\w\d\s_\-:/]+["'`]))|(?:\btw\s*(`[\w\d\s_\-:/]+`))/gi, 742 | /(?:["'`]([\w\d\s_\-:/${}()[\]"']+)["'`])/gi, 743 | ], 744 | }, 745 | ], 746 | ], 747 | [ 748 | 'matcher', 749 | { 750 | regex: [ 751 | '(?:\\bclass(?:Name)?\\s*=\\s*(?:{([\\w\\d\\s_\\-:/${}()[\\]"\'`,]+)})|(["\'`][\\w\\d\\s_\\-:/]+["\'`]))|(?:\\btw\\s*(`[\\w\\d\\s_\\-:/]+`))', 752 | '(?:["\'`]([\\w\\d\\s_\\-:/${}()[\\]"\']+)["\'`])', 753 | ], 754 | separator: '\\+\\+', 755 | replacement: '++', 756 | }, 757 | [ 758 | { 759 | regex: [ 760 | /(?:\bclass(?:Name)?\s*=\s*(?:{([\w\d\s_\-:/${}()[\]"'`,]+)})|(["'`][\w\d\s_\-:/]+["'`]))|(?:\btw\s*(`[\w\d\s_\-:/]+`))/gi, 761 | /(?:["'`]([\w\d\s_\-:/${}()[\]"']+)["'`])/gi, 762 | ], 763 | separator: /\+\+/g, 764 | replacement: '++', 765 | }, 766 | ], 767 | ], 768 | [ 769 | 'empty matcher', 770 | {}, 771 | [ 772 | { 773 | regex: [], 774 | separator: undefined, 775 | replacement: undefined, 776 | }, 777 | ], 778 | ], 779 | [ 780 | 'various', 781 | [ 782 | [ 783 | '(?:\\bclass(?:Name)?\\s*=\\s*(?:{([\\w\\d\\s_\\-:/${}()[\\]"\'`,]+)})|(["\'`][\\w\\d\\s_\\-:/]+["\'`]))|(?:\\btw\\s*(`[\\w\\d\\s_\\-:/]+`))', 784 | ], 785 | '(?:["\'`]([\\w\\d\\s_\\-:/${}()[\\]"\']+)["\'`])', 786 | { 787 | regex: [ 788 | '(?:\\bclass(?:Name)?\\s*=\\s*(?:{([\\w\\d\\s_\\-:/${}()[\\]"\'`,]+)})|(["\'`][\\w\\d\\s_\\-:/]+["\'`]))|(?:\\btw\\s*(`[\\w\\d\\s_\\-:/]+`))', 789 | '(?:["\'`]([\\w\\d\\s_\\-:/${}()[\\]"\']+)["\'`])', 790 | ], 791 | replacement: ' ', 792 | }, 793 | { 794 | regex: '(?:["\'`]([\\w\\d\\s_\\-:/${}()[\\]"\']+)["\'`])', 795 | separator: '\\.', 796 | replacement: '.', 797 | }, 798 | ], 799 | [ 800 | { 801 | regex: [ 802 | /(?:\bclass(?:Name)?\s*=\s*(?:{([\w\d\s_\-:/${}()[\]"'`,]+)})|(["'`][\w\d\s_\-:/]+["'`]))|(?:\btw\s*(`[\w\d\s_\-:/]+`))/gi, 803 | ], 804 | }, 805 | { 806 | regex: [/(?:["'`]([\w\d\s_\-:/${}()[\]"']+)["'`])/gi], 807 | }, 808 | { 809 | regex: [ 810 | /(?:\bclass(?:Name)?\s*=\s*(?:{([\w\d\s_\-:/${}()[\]"'`,]+)})|(["'`][\w\d\s_\-:/]+["'`]))|(?:\btw\s*(`[\w\d\s_\-:/]+`))/gi, 811 | /(?:["'`]([\w\d\s_\-:/${}()[\]"']+)["'`])/gi, 812 | ], 813 | separator: undefined, 814 | replacement: ' ', 815 | }, 816 | { 817 | regex: [/(?:["'`]([\w\d\s_\-:/${}()[\]"']+)["'`])/gi], 818 | separator: /\./g, 819 | replacement: '.', 820 | }, 821 | ], 822 | ], 823 | ])('should handle %s configs', (_name, langConfig, matchers) => { 824 | expect(buildMatchers(langConfig)).toStrictEqual(matchers); 825 | }); 826 | }); 827 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "sourceMap": true, 7 | "strict": true, 8 | "rootDir": "src" 9 | }, 10 | "exclude": ["node_modules", ".vscode-test", "**/*.spec.ts"] 11 | } 12 | --------------------------------------------------------------------------------