├── .npmrc ├── .gitignore ├── images ├── icon.png └── example.png ├── src ├── themes │ ├── index.ts │ ├── default.ts │ └── synthwave.ts ├── types.d.ts ├── test │ ├── suite │ │ └── index.ts │ ├── runTest.ts │ ├── benchmark.test.ts │ └── extension.test.ts ├── extension.ts └── services │ ├── output.ts │ ├── decoration.ts │ ├── utils.ts │ ├── theme.ts │ ├── extension.ts │ ├── config.ts │ └── tokenizer.ts ├── .vscodeignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── .prettierrc.cjs ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── example.html ├── eslint.config.mjs ├── CHANGELOG.md ├── README.md ├── package.json └── LICENSE /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | /benchmark 5 | .vscode-test/ 6 | *.vsix 7 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esdete2/tailwind-rainbow/HEAD/images/icon.png -------------------------------------------------------------------------------- /images/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esdete2/tailwind-rainbow/HEAD/images/example.png -------------------------------------------------------------------------------- /src/themes/index.ts: -------------------------------------------------------------------------------- 1 | import { defaultTheme } from './default'; 2 | import { synthwaveTheme } from './synthwave'; 3 | 4 | export default { default: defaultTheme, synthwave: synthwaveTheme }; 5 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | .gitignore 5 | .yarnrc 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/eslint.config.mjs 9 | **/*.map 10 | **/*.ts 11 | **/.vscode-test.* 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "ms-vscode.extension-test-runner" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | trailingComma: 'es5', 4 | tabWidth: 2, 5 | semi: true, 6 | singleQuote: true, 7 | printWidth: 120, 8 | bracketSpacing: true, 9 | useTabs: false, 10 | }; 11 | -------------------------------------------------------------------------------- /.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 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2022", 5 | "outDir": "out", 6 | "lib": ["ES2022"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true /* enable all strict type-checking options */ 10 | /* Additional Checks */ 11 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 12 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 13 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.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": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | interface ClassConfig { 2 | color?: string; 3 | enabled?: boolean; 4 | fontWeight?: 5 | | 'normal' 6 | | 'bold' 7 | | 'lighter' 8 | | 'bolder' 9 | | 'thin' 10 | | 'extralight' 11 | | 'light' 12 | | 'medium' 13 | | 'semibold' 14 | | 'extrabold' 15 | | 'black' 16 | | '100' 17 | | '200' 18 | | '300' 19 | | '400' 20 | | '500' 21 | | '600' 22 | | '700' 23 | | '800' 24 | | '900'; 25 | } 26 | 27 | interface Theme { 28 | arbitrary?: ClassConfig; 29 | important?: ClassConfig; 30 | prefix?: Record; 31 | base?: Record; 32 | } 33 | 34 | interface ExtensionAPI { 35 | registerTheme: (name: string, theme: Theme) => void; 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | jobs: 7 | test: 8 | strategy: 9 | matrix: 10 | os: [macos-latest, ubuntu-latest, windows-latest] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Install Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 18.x 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@v2 21 | with: 22 | version: 9 23 | - name: Install dependencies 24 | run: pnpm install 25 | - name: Run Extension Tests 26 | run: xvfb-run -a pnpm test 27 | if: runner.os == 'Linux' 28 | - name: Run Extension Tests 29 | run: pnpm test 30 | if: runner.os != 'Linux' 31 | -------------------------------------------------------------------------------- /example.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
-------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import { glob } from 'glob'; 2 | import Mocha from 'mocha'; 3 | import * as path from 'path'; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true, 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | glob('**/**.test.js', { cwd: testsRoot }) 16 | .then((files) => { 17 | // Add files to the test suite 18 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 19 | 20 | try { 21 | // Run the mocha test 22 | mocha.run((failures) => { 23 | if (failures > 0) { 24 | e(new Error(`${failures} tests failed.`)); 25 | } else { 26 | c(); 27 | } 28 | }); 29 | } catch (err) { 30 | e(err); 31 | } 32 | }) 33 | .catch((err) => { 34 | return e(err); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath, runTests } from '@vscode/test-electron'; 2 | import * as cp from 'child_process'; 3 | import * as path from 'path'; 4 | 5 | async function main() { 6 | try { 7 | const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); 8 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 9 | const vscodeExecutablePath = await downloadAndUnzipVSCode('1.96.0'); 10 | const [cliPath, ...args] = resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); 11 | const vsixPath = path.resolve(__dirname, '../../tailwind-rainbow.vsix'); 12 | 13 | // Use cp.spawn / cp.exec for custom setup 14 | cp.spawnSync(cliPath, [...args, '--install-extension', vsixPath], { 15 | encoding: 'utf-8', 16 | stdio: 'inherit', 17 | }); 18 | 19 | // Run the extension test 20 | await runTests({ 21 | // Use the specified `code` executable 22 | vscodeExecutablePath, 23 | extensionDevelopmentPath, 24 | extensionTestsPath, 25 | }); 26 | } catch (err) { 27 | console.error('Failed to run tests', err); 28 | process.exit(1); 29 | } 30 | } 31 | 32 | main(); 33 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import pluginSimpleImportSort from 'eslint-plugin-simple-import-sort'; 5 | import pluginUnusedImports from 'eslint-plugin-unused-imports'; 6 | 7 | const compat = new FlatCompat({ 8 | baseDirectory: import.meta.dirname, 9 | }); 10 | 11 | /** @type {import('eslint').Linter.Config[]} */ 12 | const config = [ 13 | { 14 | files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], 15 | }, 16 | ...compat.config({ 17 | extends: ['plugin:prettier/recommended'], 18 | }), 19 | { 20 | languageOptions: { 21 | parser: tsParser, 22 | ecmaVersion: 2022, 23 | sourceType: 'module', 24 | }, 25 | plugins: { 26 | 'simple-import-sort': pluginSimpleImportSort, 27 | 'unused-imports': pluginUnusedImports, 28 | '@typescript-eslint': typescriptEslint, 29 | }, 30 | rules: { 31 | 'react/no-unescaped-entities': 'off', 32 | 'simple-import-sort/imports': 'warn', 33 | 'simple-import-sort/exports': 'warn', 34 | 'prettier/prettier': 'warn', 35 | 'no-unreachable': 'warn', 36 | 'unused-imports/no-unused-imports': 'error', 37 | '@typescript-eslint/no-explicit-any': 'off', 38 | '@typescript-eslint/no-unused-vars': [ 39 | 'warn', 40 | { 41 | vars: 'all', 42 | varsIgnorePattern: '^_', 43 | args: 'after-used', 44 | argsIgnorePattern: '^_', 45 | }, 46 | ], 47 | }, 48 | }, 49 | ]; 50 | 51 | export default config; 52 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { ExtensionService } from './services/extension'; 4 | 5 | /** 6 | * Public API interface for the extension 7 | * Allows external extensions to register themes and access theme registry 8 | */ 9 | export interface TailwindRainbowAPI { 10 | /** Registers a new theme or updates an existing one */ 11 | registerTheme: (name: string, theme: Theme) => void; 12 | /** Gets all registered themes */ 13 | getThemes: () => Map; 14 | /** Clears all registered themes */ 15 | clearThemes: () => void; 16 | /** Gets the prefix ranges for a given editor */ 17 | getTokenRanges: (editor: vscode.TextEditor) => Map; 18 | } 19 | 20 | let extensionService: ExtensionService | undefined; 21 | 22 | export async function activate(context: vscode.ExtensionContext) { 23 | extensionService = new ExtensionService(); 24 | extensionService.initialize(); 25 | extensionService.registerEventHandlers(context); 26 | 27 | // Trigger theme extensions to load 28 | await vscode.commands.executeCommand('tailwind-rainbow.loadThemes'); 29 | 30 | const themeService = extensionService.getThemeService(); 31 | 32 | return { 33 | registerTheme: (name: string, theme: Theme) => { 34 | themeService.registerTheme(name, theme); 35 | extensionService?.updateAfterThemeRegistration(); 36 | }, 37 | getThemes: () => themeService.getThemes(), 38 | clearThemes: () => themeService.clearThemes(), 39 | getTokenRanges: (editor: vscode.TextEditor) => { 40 | return extensionService?.getTokenRanges(editor); 41 | }, 42 | }; 43 | } 44 | 45 | export function deactivate() { 46 | extensionService?.dispose(); 47 | } 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "tailwind-rainbow" extension will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). 6 | 7 | ## [0.2.0] - 2025-05-25 8 | 9 | ### Added 10 | - **Base class pattern support**: Added wildcard pattern matching for base classes (e.g., `bg-*` matches `bg-blue-500`, `text-*` matches `text-red-400`) 11 | - **Enhanced prefix matching**: Support for arbitrary prefixes with wildcard patterns (e.g., `min-[1920px]`, `[&.is-dragging]`) 12 | - **Smart prefix vs arbitrary detection**: Distinguishes between multi-prefix cases and single-prefix with arbitrary values 13 | - **CSS @apply directive support**: Syntax highlighting for Tailwind classes in CSS `@apply` directives 14 | - **Template literal support**: Enhanced detection in `tw\``, `css\``, `styled`, and `className` template literals 15 | - **Configurable detection patterns**: Added settings for `classIdentifiers`, `classFunctions`, `templatePatterns`, and `contextPatterns` 16 | - **Universal selector support**: Added support for `*` and `**` prefixes as regular Tailwind prefixes 17 | 18 | ### Changed 19 | - **Improved theme structure**: Updated theme schema to properly define `prefix`, `base`, `arbitrary`, and `important` sections 20 | - **Enhanced tokenization performance**: Replaced regex-based parsing with optimized string operations for better performance 21 | - **Better arbitrary value handling**: Improved parsing of classes with brackets and complex arbitrary values 22 | 23 | ## [0.1.1] - 2025-03-02 24 | 25 | ### Added 26 | - Initial prefix-based syntax highlighting 27 | - Basic theme support (default, synthwave) 28 | - Language configuration 29 | - Custom theme creation 30 | 31 | --- 32 | 33 | For more details about changes, see the [GitHub repository](https://github.com/esdete2/tailwind-rainbow). -------------------------------------------------------------------------------- /src/services/output.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { ConfigurationManager } from './config'; 4 | 5 | /** 6 | * Singleton service for centralized logging 7 | * Provides consistent output channel management across the extension 8 | */ 9 | export class OutputService { 10 | private static instance: OutputService; 11 | private channel: vscode.OutputChannel; 12 | private configManager: ConfigurationManager; 13 | 14 | /** 15 | * Private constructor to enforce singleton pattern 16 | * Creates a dedicated output channel for the extension 17 | */ 18 | private constructor() { 19 | this.channel = vscode.window.createOutputChannel('Tailwind Rainbow'); 20 | this.configManager = ConfigurationManager.getInstance(); 21 | } 22 | 23 | /** 24 | * Gets the singleton instance of OutputService 25 | * Creates the instance if it doesn't exist 26 | * @returns The OutputService instance 27 | */ 28 | static getInstance(): OutputService { 29 | if (!OutputService.instance) { 30 | OutputService.instance = new OutputService(); 31 | } 32 | return OutputService.instance; 33 | } 34 | 35 | /** 36 | * Logs an informational message to the output channel 37 | * @param message The message to log 38 | */ 39 | log(message: string) { 40 | this.channel.appendLine(message); 41 | } 42 | 43 | /** 44 | * Logs a debug message to the output channel 45 | * @param message The message to log 46 | */ 47 | debug(message: string) { 48 | if (this.configManager.getDebugEnabled()) { 49 | const timestamp = new Date().toISOString(); 50 | this.channel.appendLine(`[${timestamp}] [DEBUG] ${message}`); 51 | } 52 | } 53 | 54 | /** 55 | * Logs an error message to the output channel 56 | * Automatically prefixes the message with "Error: " 57 | * @param message The error message or Error object to log 58 | */ 59 | error(message: string | Error) { 60 | const errorMessage = message instanceof Error ? message.message : message; 61 | this.channel.appendLine(`[ERROR] ${errorMessage}`); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/services/decoration.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | /** 4 | * Manages VS Code text decorations for Tailwind tokens (prefixes and base classes) 5 | * Handles creation, updating, and cleanup of decorations 6 | */ 7 | export class DecorationService { 8 | private decorationTypes = new Map(); 9 | 10 | /** 11 | * Cleans up all existing decorations 12 | * Should be called when changing themes or closing files 13 | */ 14 | clearDecorations() { 15 | this.decorationTypes.forEach((type) => type.dispose()); 16 | this.decorationTypes.clear(); 17 | } 18 | 19 | /** 20 | * Gets or creates a decoration type for a token 21 | * @param token The Tailwind token (prefix or base class) to create decoration for 22 | * @param config The styling configuration for the token 23 | * @returns TextEditorDecorationType for the token 24 | */ 25 | getDecorationForToken(token: string, config: ClassConfig): vscode.TextEditorDecorationType { 26 | if (!this.decorationTypes.has(token)) { 27 | this.decorationTypes.set( 28 | token, 29 | vscode.window.createTextEditorDecorationType({ 30 | color: config.color, 31 | fontWeight: config.fontWeight, 32 | }) 33 | ); 34 | } 35 | return this.decorationTypes.get(token)!; 36 | } 37 | 38 | /** 39 | * Updates decorations in the editor based on found token ranges 40 | * @param editor The VS Code text editor to update 41 | * @param tokenRangeMap Map of token to their ranges and configs in the document 42 | */ 43 | updateDecorations( 44 | editor: vscode.TextEditor, 45 | tokenRangeMap: Map 46 | ) { 47 | if (!editor) { 48 | return; 49 | } 50 | 51 | // Clear existing decorations 52 | this.decorationTypes.forEach((type) => editor.setDecorations(type, [])); 53 | 54 | // Apply new decorations using the config from the tokenizer 55 | tokenRangeMap.forEach((rangeData, token) => { 56 | const { ranges, config } = rangeData; 57 | // console.log('[updateDecorations] token:', token, 'config:', config, 'ranges:', ranges); // Keep for debugging 58 | if (config && config.enabled !== false) { 59 | const decorationType = this.getDecorationForToken(token, config); 60 | editor.setDecorations(decorationType, ranges); 61 | } 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/themes/default.ts: -------------------------------------------------------------------------------- 1 | export const defaultTheme: Theme = { 2 | arbitrary: { 3 | color: '#ff9987', 4 | fontWeight: '700', 5 | }, 6 | important: { 7 | color: '#ff0000', 8 | fontWeight: '700', 9 | }, 10 | 11 | prefix: { 12 | '*': { 13 | color: '#ff0000', 14 | fontWeight: '700', 15 | }, 16 | '**': { 17 | color: '#ff0000', 18 | fontWeight: '700', 19 | }, 20 | 21 | // responsive 22 | 'min-*': { 23 | color: '#d18bfa', 24 | fontWeight: '700', 25 | }, 26 | sm: { 27 | color: '#d18bfa', 28 | fontWeight: '700', 29 | }, 30 | md: { 31 | color: '#b88bfa', 32 | fontWeight: '700', 33 | }, 34 | lg: { 35 | color: '#a78bfa', 36 | fontWeight: '700', 37 | }, 38 | xl: { 39 | color: '#8b8bfa', 40 | fontWeight: '700', 41 | }, 42 | '2xl': { 43 | color: '#8b9dfa', 44 | fontWeight: '700', 45 | }, 46 | 47 | 'max-*': { 48 | color: '#d18bfa', 49 | fontWeight: '700', 50 | }, 51 | 'max-sm': { 52 | color: '#d18bfa', 53 | fontWeight: '700', 54 | }, 55 | 'max-md': { 56 | color: '#b88bfa', 57 | fontWeight: '700', 58 | }, 59 | 'max-lg': { 60 | color: '#a78bfa', 61 | fontWeight: '700', 62 | }, 63 | 'max-xl': { 64 | color: '#8b8bfa', 65 | fontWeight: '700', 66 | }, 67 | 'max-2xl': { 68 | color: '#8b9dfa', 69 | fontWeight: '700', 70 | }, 71 | 72 | // pseudo 73 | before: { 74 | color: '#ffa357', 75 | fontWeight: '700', 76 | }, 77 | after: { 78 | color: '#f472b6', 79 | fontWeight: '700', 80 | }, 81 | 82 | // interactive 83 | hover: { 84 | color: '#4ee585', 85 | fontWeight: '700', 86 | }, 87 | focus: { 88 | color: '#4ee6b8', 89 | fontWeight: '700', 90 | }, 91 | active: { 92 | color: '#49d5e0', 93 | fontWeight: '700', 94 | }, 95 | 96 | // modes 97 | dark: { 98 | color: '#a5b6cd', 99 | fontWeight: '700', 100 | }, 101 | 102 | // form 103 | placeholder: { 104 | color: '#ffe279', 105 | fontWeight: '700', 106 | }, 107 | checked: { 108 | color: '#e3f582', 109 | fontWeight: '700', 110 | }, 111 | valid: { 112 | color: '#c8f66c', 113 | fontWeight: '700', 114 | }, 115 | invalid: { 116 | color: '#ff8d8d', 117 | fontWeight: '700', 118 | }, 119 | disabled: { 120 | color: '#ff7777', 121 | fontWeight: '700', 122 | }, 123 | required: { 124 | color: '#ff6969', 125 | fontWeight: '700', 126 | }, 127 | 128 | // selection 129 | first: { 130 | color: '#7dd3fc', 131 | fontWeight: '700', 132 | }, 133 | last: { 134 | color: '#4cc7fc', 135 | fontWeight: '700', 136 | }, 137 | only: { 138 | color: '#38bdf8', 139 | fontWeight: '700', 140 | }, 141 | odd: { 142 | color: '#24b0f0', 143 | fontWeight: '700', 144 | }, 145 | even: { 146 | color: '#0ea5e9', 147 | fontWeight: '700', 148 | }, 149 | }, 150 | base: {}, 151 | } as const; 152 | -------------------------------------------------------------------------------- /src/themes/synthwave.ts: -------------------------------------------------------------------------------- 1 | export const synthwaveTheme: Theme = { 2 | arbitrary: { 3 | color: '#ff3308', 4 | fontWeight: '700', 5 | }, 6 | important: { 7 | color: '#ff0000', 8 | fontWeight: '900', 9 | }, 10 | 11 | prefix: { 12 | '*': { 13 | color: '#ff0000', 14 | fontWeight: '700', 15 | }, 16 | '**': { 17 | color: '#ff0000', 18 | fontWeight: '700', 19 | }, 20 | 21 | // responsive 22 | 'min-*': { 23 | color: '#ff71ce', 24 | fontWeight: '700', 25 | }, 26 | sm: { 27 | color: '#ff71ce', 28 | fontWeight: '700', 29 | }, 30 | md: { 31 | color: '#ff2fb9', 32 | fontWeight: '700', 33 | }, 34 | lg: { 35 | color: '#ff00a4', 36 | fontWeight: '700', 37 | }, 38 | xl: { 39 | color: '#df008f', 40 | fontWeight: '700', 41 | }, 42 | '2xl': { 43 | color: '#bf007a', 44 | fontWeight: '700', 45 | }, 46 | 47 | 'max-*': { 48 | color: '#ff71ce', 49 | fontWeight: '700', 50 | }, 51 | 'max-sm': { 52 | color: '#ff71ce', 53 | fontWeight: '700', 54 | }, 55 | 'max-md': { 56 | color: '#ff2fb9', 57 | fontWeight: '700', 58 | }, 59 | 'max-lg': { 60 | color: '#ff00a4', 61 | fontWeight: '700', 62 | }, 63 | 'max-xl': { 64 | color: '#df008f', 65 | fontWeight: '700', 66 | }, 67 | 'max-2xl': { 68 | color: '#bf007a', 69 | fontWeight: '700', 70 | }, 71 | 72 | // pseudo 73 | before: { 74 | color: '#ff9e4f', 75 | fontWeight: '700', 76 | }, 77 | after: { 78 | color: '#ff6b21', 79 | fontWeight: '700', 80 | }, 81 | 82 | // interactive 83 | hover: { 84 | color: '#b967ff', 85 | fontWeight: '700', 86 | }, 87 | focus: { 88 | color: '#a742ff', 89 | fontWeight: '700', 90 | }, 91 | active: { 92 | color: '#951dff', 93 | fontWeight: '700', 94 | }, 95 | 96 | // modes 97 | dark: { 98 | color: '#5d6ca7', 99 | fontWeight: '700', 100 | }, 101 | 102 | // form 103 | placeholder: { 104 | color: '#ff2182', 105 | fontWeight: '700', 106 | }, 107 | checked: { 108 | color: '#ff1e69', 109 | fontWeight: '700', 110 | }, 111 | valid: { 112 | color: '#ff1a50', 113 | fontWeight: '700', 114 | }, 115 | invalid: { 116 | color: '#ff1737', 117 | fontWeight: '700', 118 | }, 119 | disabled: { 120 | color: '#ff141e', 121 | fontWeight: '700', 122 | }, 123 | required: { 124 | color: '#ff1105', 125 | fontWeight: '700', 126 | }, 127 | 128 | // selection 129 | first: { 130 | color: '#00ffff', 131 | fontWeight: '700', 132 | }, 133 | last: { 134 | color: '#00e5ff', 135 | fontWeight: '700', 136 | }, 137 | only: { 138 | color: '#00ccff', 139 | fontWeight: '700', 140 | }, 141 | odd: { 142 | color: '#00b2ff', 143 | fontWeight: '700', 144 | }, 145 | even: { 146 | color: '#0099ff', 147 | fontWeight: '700', 148 | }, 149 | }, 150 | base: {}, 151 | } as const; 152 | -------------------------------------------------------------------------------- /src/services/utils.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationManager } from './config'; 2 | 3 | /** 4 | * Removes ignored modifiers from the prefix 5 | * @param prefix The prefix to process 6 | * @returns The prefix with ignored modifiers removed 7 | */ 8 | const removeIgnoredModifiers = (prefix: string) => { 9 | const configManager = ConfigurationManager.getInstance(); 10 | const ignoredPrefixModifiers = configManager.getIgnoredPrefixModifiers(); 11 | let cleanedPrefix = prefix; 12 | if (ignoredPrefixModifiers.length > 0) { 13 | for (const ignoredModifier of ignoredPrefixModifiers) { 14 | cleanedPrefix = cleanedPrefix.replace(new RegExp(`^${ignoredModifier}-`), ''); 15 | } 16 | } 17 | 18 | return cleanedPrefix; 19 | }; 20 | 21 | /** 22 | * Gets the theme configuration for a prefix (used with colons) 23 | * @param activeTheme The active theme configuration 24 | * @param prefix The prefix to search for 25 | * @returns The theme configuration for the prefix 26 | */ 27 | export const getThemeConfigForPrefix = (activeTheme: Theme, prefix: string) => { 28 | // Handle special cases first 29 | if (prefix === 'arbitrary' && activeTheme.arbitrary) { 30 | return activeTheme.arbitrary; 31 | } 32 | 33 | if (prefix === 'important' && activeTheme.important) { 34 | return activeTheme.important; 35 | } 36 | 37 | // Check for exact match in prefix section 38 | if (activeTheme.prefix?.[prefix]) { 39 | return activeTheme.prefix[prefix]; 40 | } 41 | 42 | // Check for prefix without ignored modifiers (only for prefixes) 43 | const cleanedPrefix = removeIgnoredModifiers(prefix); 44 | if (cleanedPrefix !== prefix && activeTheme.prefix?.[cleanedPrefix]) { 45 | return activeTheme.prefix[cleanedPrefix]; 46 | } 47 | 48 | // Check for prefix without group name 49 | if (cleanedPrefix.includes('/')) { 50 | const basePrefix = cleanedPrefix.split('/')[0]; 51 | if (activeTheme.prefix?.[basePrefix]) { 52 | return activeTheme.prefix[basePrefix]; 53 | } 54 | } 55 | 56 | // Check for arbitrary prefix (starts and ends with brackets) 57 | if (/^\[.+\]$/.test(prefix) && activeTheme.arbitrary) { 58 | return activeTheme.arbitrary; 59 | } 60 | 61 | // Check for matching wildcard in prefix section only 62 | const parts = prefix.split('-'); 63 | if (parts.length > 1 && activeTheme.prefix) { 64 | const basePrefix = parts[0]; 65 | const wildcardKey = `${basePrefix}-*`; 66 | if (activeTheme.prefix[wildcardKey]) { 67 | return activeTheme.prefix[wildcardKey]; 68 | } 69 | } 70 | 71 | return null; 72 | }; 73 | 74 | /** 75 | * Gets the theme configuration for a base class (standalone class without colon) 76 | * @param activeTheme The active theme configuration 77 | * @param className The class name to search for 78 | * @returns The theme configuration for the class 79 | */ 80 | export const getThemeConfigForBaseClass = (activeTheme: Theme, className: string) => { 81 | // Handle special cases first 82 | if (className === 'arbitrary' && activeTheme.arbitrary) { 83 | return activeTheme.arbitrary; 84 | } 85 | 86 | // Check for exact match in base section 87 | if (activeTheme.base?.[className]) { 88 | return activeTheme.base[className]; 89 | } 90 | 91 | // Check for arbitrary class (starts and ends with brackets) 92 | if (className.startsWith('[') && className.endsWith(']') && activeTheme.arbitrary) { 93 | return activeTheme.arbitrary; 94 | } 95 | 96 | // Check for matching wildcard in base section only 97 | if (activeTheme.base) { 98 | const parts = className.split('-'); 99 | if (parts.length > 1 && activeTheme.base) { 100 | const basePrefix = parts[0]; 101 | const wildcardKey = `${basePrefix}-*`; 102 | if (activeTheme.base[wildcardKey]) { 103 | return activeTheme.base[wildcardKey]; 104 | } 105 | } 106 | } 107 | 108 | return null; 109 | }; 110 | -------------------------------------------------------------------------------- /src/services/theme.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import themes from '../themes'; 4 | import { ConfigurationManager } from './config'; 5 | import { OutputService } from './output'; 6 | 7 | /** 8 | * Manages themes for the extension, including: 9 | * - Loading and registering built-in themes 10 | * - Handling custom theme configurations 11 | * - Theme selection and switching 12 | */ 13 | export class ThemeService { 14 | private activeTheme: Theme; 15 | private outputService = OutputService.getInstance(); 16 | private configManager = ConfigurationManager.getInstance(); 17 | private themeRegistry = new Map(); 18 | 19 | /** 20 | * Initializes the theme service by registering built-in themes 21 | */ 22 | constructor() { 23 | Object.entries(themes).forEach(([name, theme]) => { 24 | this.registerTheme(name, theme); 25 | }); 26 | this.activeTheme = this.getActiveTheme(); 27 | } 28 | 29 | /** 30 | * Gets the active theme configuration, merging custom themes if defined 31 | * @returns The active theme configuration 32 | */ 33 | getActiveTheme(): Theme { 34 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 35 | const selectedTheme = config.get('theme', 'default'); 36 | const customThemes = config.get>('themes', {}); 37 | 38 | // Apply custom themes to registry 39 | Object.entries(customThemes).forEach(([name, theme]) => { 40 | // Get base theme if it exists (built-in or previously registered) 41 | const baseTheme = this.themeRegistry.get(name) || {}; 42 | 43 | // Create new theme by merging custom config over base theme 44 | const mergedTheme: Theme = { 45 | arbitrary: { ...baseTheme.arbitrary, ...theme.arbitrary }, 46 | important: { ...baseTheme.important, ...theme.important }, 47 | prefix: { ...baseTheme.prefix, ...theme.prefix }, 48 | base: { ...baseTheme.base, ...theme.base }, 49 | }; 50 | 51 | this.registerTheme(name, mergedTheme); 52 | }); 53 | 54 | // Get theme 55 | const theme = this.themeRegistry.get(selectedTheme); 56 | if (!theme) { 57 | this.outputService.error( 58 | `Theme '${selectedTheme}' not found. Available themes: ${Array.from(this.themeRegistry.keys()).join(', ')}` 59 | ); 60 | return { arbitrary: undefined, important: undefined, prefix: {}, base: {} }; 61 | } 62 | return { ...theme }; 63 | } 64 | 65 | /** 66 | * Updates the active theme by clearing and re-registering all themes 67 | * This ensures custom themes are properly merged with the latest settings 68 | */ 69 | updateActiveTheme() { 70 | // Clear all themes first 71 | this.clearThemes(); 72 | 73 | // Re-register built-in themes 74 | for (const [name, theme] of Object.entries(themes)) { 75 | this.registerTheme(name, theme); 76 | } 77 | this.activeTheme = this.getActiveTheme(); 78 | } 79 | 80 | /** 81 | * Gets the currently active theme configuration 82 | * @returns The current theme configuration 83 | */ 84 | getCurrentTheme(): Theme { 85 | return this.activeTheme; 86 | } 87 | 88 | /** 89 | * Opens a quick pick menu for theme selection 90 | * @param editor The active text editor 91 | * @param onThemeChange Callback to execute when theme changes 92 | */ 93 | async selectTheme(editor: vscode.TextEditor, onThemeChange: () => void) { 94 | const themeNames = Array.from(this.themeRegistry.keys()); 95 | const originalTheme = this.activeTheme; 96 | 97 | const quickPick = vscode.window.createQuickPick(); 98 | quickPick.items = themeNames.map((theme) => ({ label: theme })); 99 | quickPick.placeholder = 'Select a theme'; 100 | 101 | quickPick.onDidChangeActive((items) => { 102 | const selected = items[0]?.label; 103 | if (selected) { 104 | this.activeTheme = { ...this.themeRegistry.get(selected)! }; 105 | onThemeChange(); 106 | } 107 | }); 108 | 109 | quickPick.onDidAccept(async () => { 110 | const selected = quickPick.activeItems[0]?.label; 111 | if (selected) { 112 | await vscode.workspace.getConfiguration('tailwindRainbow').update('theme', selected, true); 113 | } 114 | quickPick.dispose(); 115 | }); 116 | 117 | quickPick.onDidHide(() => { 118 | if (!quickPick.selectedItems.length) { 119 | this.activeTheme = originalTheme; 120 | onThemeChange(); 121 | } 122 | quickPick.dispose(); 123 | }); 124 | 125 | quickPick.show(); 126 | } 127 | 128 | /** 129 | * Registers a new theme or updates an existing one 130 | * @param name The name of the theme 131 | * @param theme The theme configuration 132 | */ 133 | registerTheme(name: string, theme: Theme) { 134 | this.themeRegistry.set(name, theme); 135 | } 136 | 137 | /** 138 | * Gets all registered themes 139 | * @returns Map of theme names to their configurations 140 | */ 141 | getThemes() { 142 | return this.themeRegistry; 143 | } 144 | 145 | /** 146 | * Clears all registered themes from the registry 147 | * Used when reloading themes from settings 148 | */ 149 | clearThemes() { 150 | this.themeRegistry.clear(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/services/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { DecorationService } from './decoration'; 4 | import { OutputService } from './output'; 5 | import { ThemeService } from './theme'; 6 | import { TokenizerService } from './tokenizer'; 7 | 8 | /** 9 | * Main service that coordinates all extension functionality 10 | * Manages initialization, event handling, and service coordination 11 | */ 12 | export class ExtensionService { 13 | private decorationService: DecorationService; 14 | private tokenizerService: TokenizerService; 15 | private themeService: ThemeService; 16 | private activeTheme: Theme; 17 | private outputService = OutputService.getInstance(); 18 | private updateTimeout: NodeJS.Timeout | undefined; 19 | private typingDelay = 300; // Delay for typing to avoid excessive updates 20 | private isProcessing = false; // Prevent concurrent processing 21 | 22 | /** 23 | * Gets the token ranges for a given editor 24 | * @param editor The VS Code text editor 25 | * @returns Map of tokens to their ranges 26 | */ 27 | public getTokenRanges(editor: vscode.TextEditor): Map { 28 | const rangeMap = this.tokenizerService.findClassRanges(editor, this.activeTheme); 29 | // Convert back to old format for backward compatibility 30 | const result = new Map(); 31 | for (const [key, value] of rangeMap) { 32 | result.set(key, value.ranges); 33 | } 34 | return result; 35 | } 36 | 37 | /** 38 | * Initializes all required services and logs activation 39 | */ 40 | constructor() { 41 | this.decorationService = new DecorationService(); 42 | this.tokenizerService = new TokenizerService(); 43 | this.themeService = new ThemeService(); 44 | this.activeTheme = this.themeService.getActiveTheme(); 45 | this.outputService.debug('Tailwind Rainbow is now active'); 46 | } 47 | 48 | /** 49 | * Updates decorations after theme registration 50 | */ 51 | updateAfterThemeRegistration() { 52 | this.activeTheme = this.themeService.getActiveTheme(); 53 | const editor = vscode.window.activeTextEditor; 54 | if (editor) { 55 | this.updateDecorations(editor); 56 | } 57 | } 58 | 59 | /** 60 | * Updates decorations for the current editor 61 | * Handles file extension filtering and pattern matching 62 | * @param editor The VS Code text editor to update 63 | * @param immediate Whether to bypass debouncing (default: false) 64 | */ 65 | private updateDecorations(editor: vscode.TextEditor, immediate: boolean = false) { 66 | if ( 67 | !editor || 68 | editor.document.uri.scheme === 'output' || 69 | editor.document.uri.scheme === 'debug' || 70 | editor.document.isUntitled 71 | ) { 72 | return; 73 | } 74 | 75 | // Clear any pending update 76 | if (this.updateTimeout) { 77 | clearTimeout(this.updateTimeout); 78 | } 79 | 80 | // Debounce update with concurrency protection 81 | const delay = immediate ? 0 : this.typingDelay; 82 | this.updateTimeout = setTimeout(async () => { 83 | if (this.isProcessing) { 84 | return; // Skip if already processing 85 | } 86 | 87 | this.isProcessing = true; 88 | 89 | try { 90 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 91 | const supportedLanguages = config.get('languages') ?? []; 92 | const languageId = editor.document.languageId; 93 | 94 | if (!supportedLanguages.includes(languageId)) { 95 | this.outputService.log( 96 | `Language '${languageId}' not supported. Add to 'tailwindRainbow.languages' setting to enable.` 97 | ); 98 | this.decorationService.clearDecorations(); 99 | return; 100 | } 101 | 102 | const tokenRangeMap = this.tokenizerService.findClassRanges(editor, this.activeTheme); 103 | this.decorationService.updateDecorations(editor, tokenRangeMap); 104 | } catch (error) { 105 | this.outputService.error(error instanceof Error ? error : String(error)); 106 | } finally { 107 | this.isProcessing = false; 108 | } 109 | }, delay); 110 | } 111 | 112 | /** 113 | * Registers all event handlers for the extension 114 | * Includes theme switching, configuration changes, and editor events 115 | * @param context The VS Code extension context 116 | */ 117 | registerEventHandlers(context: vscode.ExtensionContext) { 118 | // Theme switching command 119 | context.subscriptions.push( 120 | vscode.commands.registerCommand('tailwind-rainbow.selectTheme', async () => { 121 | const editor = vscode.window.activeTextEditor; 122 | if (editor) { 123 | await this.themeService.selectTheme(editor, () => { 124 | this.decorationService.clearDecorations(); 125 | this.activeTheme = this.themeService.getCurrentTheme(); 126 | this.updateDecorations(editor, true); // Immediate for theme changes 127 | }); 128 | } 129 | }) 130 | ); 131 | 132 | // Configuration changes 133 | context.subscriptions.push( 134 | vscode.workspace.onDidChangeConfiguration((event) => { 135 | if (event.affectsConfiguration('tailwindRainbow')) { 136 | this.decorationService.clearDecorations(); 137 | this.themeService.updateActiveTheme(); 138 | this.activeTheme = this.themeService.getCurrentTheme(); 139 | 140 | const editor = vscode.window.activeTextEditor; 141 | if (editor) { 142 | this.updateDecorations(editor, true); // Immediate for config changes 143 | } 144 | } 145 | }) 146 | ); 147 | 148 | // Editor events 149 | context.subscriptions.push( 150 | vscode.window.onDidChangeActiveTextEditor((editor) => { 151 | if (editor) { 152 | this.updateDecorations(editor, true); // Immediate for editor changes 153 | } 154 | }), 155 | 156 | vscode.workspace.onDidChangeTextDocument((event) => { 157 | const editor = vscode.window.activeTextEditor; 158 | if (editor && event.document === editor.document) { 159 | this.updateDecorations(editor); // Debounced for typing 160 | } 161 | }) 162 | ); 163 | 164 | // Theme loading command 165 | context.subscriptions.push( 166 | vscode.commands.registerCommand('tailwind-rainbow.loadThemes', () => { 167 | this.outputService.debug('Theme loading triggered'); 168 | return this.themeService; 169 | }) 170 | ); 171 | } 172 | 173 | /** 174 | * Gets the theme service instance 175 | * Used by the extension API for theme registration 176 | * @returns The ThemeService instance 177 | */ 178 | getThemeService() { 179 | return this.themeService; 180 | } 181 | 182 | /** 183 | * Initializes the extension with the current editor 184 | * Called after extension activation 185 | */ 186 | initialize() { 187 | // Initial decoration 188 | if (vscode.window.activeTextEditor) { 189 | // Ensure we have the latest theme 190 | this.activeTheme = this.themeService.getActiveTheme(); 191 | this.updateDecorations(vscode.window.activeTextEditor, true); // Immediate for initialization 192 | } 193 | } 194 | 195 | /** 196 | * Clears any pending updates 197 | */ 198 | dispose() { 199 | if (this.updateTimeout) { 200 | clearTimeout(this.updateTimeout); 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailwind Rainbow 2 | 3 | A VS Code extension that provides syntax highlighting for Tailwind CSS classes with customizable coloring themes. 4 | 5 |

6 | 7 |

8 | 9 | ## Features 10 | 11 | - 🟣 **Prefix-based coloring**: Colors Tailwind prefixes (hover, focus, sm, lg, etc.) individually 12 | - 🔵 **Arbitrary values**: Highlights arbitrary prefix values and standalone arbitrary classes 13 | - 🟠 **Optional base class support**: Highlights base classes with wildcard patterns (`bg-*`, `text-*`, etc.) 14 | - 🟡 **Smart detection**: Works in HTML, JSX, template literals, CSS @apply directives, and more 15 | - 🟢 **Fully configurable**: Customize patterns, functions, and detection logic 16 | 17 | ### Class Structure Analysis 18 | 19 | The extension recognizes two main types of Tailwind constructs: 20 | 21 | 1. **Prefixes** - Modifiers that end with a colon (`:`) 22 | 23 | - Examples: `hover:`, `sm:`, `dark:`, `group-hover:` 24 | - Checked against the `prefix` section of the theme 25 | 26 | 2. **Base Classes** - Standalone utility classes without colons 27 | - Examples: `bg-blue-500`, `text-lg`, `min-w-[100px]` 28 | - Checked against the `base` section of the theme 29 | 30 | ### Coloring Logic Examples 31 | 32 | These are only examples. The default themes **do not** contain any entries in the base section. 33 | 34 | #### Single Classes 35 | 36 | ``` 37 | /* Base class - uses base section */ 38 | bg-blue-500 → matches "bg-*" pattern in base section 39 | 40 | /* Arbitrary class - uses arbitrary color */ 41 | [aspect-ratio:1/8] → uses arbitrary color 42 | 43 | /* Prefixed class - prefix color applies to entire class */ 44 | hover:bg-red-500 → "hover" color from prefix section 45 | ``` 46 | 47 | #### Multi-Prefix Classes 48 | 49 | ``` 50 | /* Each prefix gets its own color, last prefix colors the rest */ 51 | lg:checked:hover:bg-blue-500 52 | ├─ lg: → "lg" color from prefix section 53 | ├─ checked: → "checked" color from prefix section 54 | └─ hover:bg-blue-500 → "hover" color from prefix section 55 | ``` 56 | 57 | #### Advanced Multi-Token Coloring 58 | 59 | ``` 60 | /* When both prefix and base class have theme entries */ 61 | lg:min-w-[1920px] 62 | ├─ lg: → "lg" color (from prefix section) 63 | └─ min-w-[1920px] → "min-*" color (from base section) 64 | ``` 65 | 66 | ### Wildcard Pattern Matching 67 | 68 | The extension supports wildcard patterns using the `*` character: 69 | 70 | #### Prefix Wildcards 71 | 72 | - `min-*` in prefix section matches: `min-lg:`, `min-xl:`, `min-[480px]:` 73 | - Exact matches take priority: `min-lg` config overrides `min-*` config 74 | 75 | #### Base Class Wildcards 76 | 77 | - `bg-*` in base section matches: `bg-red-500`, `bg-[#ff0000]`, `bg-gradient-to-r` 78 | - `min-*` in base section matches: `min-w-full`, `min-h-[100px]` 79 | 80 | ### Arbitrary Value Handling 81 | 82 | ``` 83 | /* Standalone arbitrary classes */ 84 | [aspect-ratio:1/8] → arbitrary color 85 | 86 | /* Arbitrary prefixes */ 87 | [&.show]:display-block → arbitrary color 88 | ``` 89 | 90 | ### Configuration Priority 91 | 92 | The extension follows a specific lookup order for maximum flexibility: 93 | 94 | **For Prefixes:** 95 | 96 | 1. Exact match in prefix section 97 | 2. Match without ignored modifiers (e.g., `group-hover` → `hover`) 98 | 3. Match without group name (e.g., `hover/opacity-50` → `hover`) 99 | 4. Wildcard pattern match in prefix section 100 | 5. Arbitrary color (for bracket-enclosed prefixes) 101 | 102 | **For Base Classes:** 103 | 104 | 1. Exact match in base section 105 | 2. Wildcard pattern match in base section 106 | 3. Arbitrary color (for bracket-enclosed classes) 107 | 108 | ## Supported File Types 109 | 110 | - HTML, JavaScript, TypeScript 111 | - React (JSX/TSX), Vue, Svelte, Astro 112 | - PHP templates 113 | - CSS, SCSS, Sass, Less, Stylus, PostCSS 114 | - Template literals and CSS-in-JS 115 | 116 | ## Installation 117 | 118 | 1. Install from the VS Code marketplace 119 | 2. Open a file with Tailwind CSS classes 120 | 3. Classes will be automatically highlighted based on their prefixes 121 | 122 | ## Configuration 123 | 124 | ### Switching Themes 125 | 126 | 1. Open Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) 127 | 2. Type "Tailwind Rainbow: Select Theme" 128 | 3. Choose from available themes: `default`, `synthwave` 129 | 130 | ### Supported Languages 131 | 132 | Configure which file types to process: 133 | 134 | ```json 135 | { 136 | "tailwindRainbow.languages": [ 137 | "html", 138 | "javascript", 139 | "typescript", 140 | "javascriptreact", 141 | "typescriptreact", 142 | "vue", 143 | "svelte", 144 | "astro", 145 | "php", 146 | "css", 147 | "scss", 148 | "sass", 149 | "less", 150 | "stylus", 151 | "postcss" 152 | ] 153 | } 154 | ``` 155 | 156 | ### Custom Themes 157 | 158 | Create or override themes with the following structure: 159 | 160 | ```json 161 | { 162 | "tailwindRainbow.themes": { 163 | "myTheme": { 164 | "prefix": { 165 | "hover": { "color": "#ff0000", "fontWeight": "bold" }, 166 | "focus": { "color": "#00ff00", "fontWeight": "normal" }, 167 | "sm": { "color": "#0000ff" }, 168 | "lg": { "color": "#ffff00" } 169 | }, 170 | "base": { 171 | "bg-*": { "color": "#ff6600", "fontWeight": "semibold" }, 172 | "text-*": { "color": "#6600ff" }, 173 | "p-*": { "color": "#00ffff" } 174 | }, 175 | "arbitrary": { "color": "#ff00ff", "fontWeight": "italic" }, 176 | "important": { "color": "#ff0000", "fontWeight": "bold" } 177 | } 178 | }, 179 | "tailwindRainbow.theme": "myTheme" 180 | } 181 | ``` 182 | 183 | ### Advanced Configuration 184 | 185 | #### Class Detection Patterns 186 | 187 | Customize how classes are detected in different contexts: 188 | 189 | ```json 190 | { 191 | "tailwindRainbow.classIdentifiers": [ 192 | "class", 193 | "className", 194 | "class:", 195 | "className:", 196 | "classlist", 197 | "classes", 198 | "css", 199 | "style" 200 | ], 201 | "tailwindRainbow.classFunctions": ["cn", "clsx", "cva", "classNames", "classList", "twMerge", "tw", "styled", "css"], 202 | "tailwindRainbow.templatePatterns": ["class", "${", "tw`", "css`", "styled"], 203 | "tailwindRainbow.contextPatterns": ["variants", "cva", "class", "css", "style", "@apply"] 204 | } 205 | ``` 206 | 207 | #### Ignored Prefix Modifiers 208 | 209 | Configure which prefix modifiers to ignore during parsing. For example, when using Tailwind's `group-hover` pattern the extension will ignore the `group` modifier and color the entire prefix with the `hover` color: 210 | 211 | ```json 212 | { 213 | "tailwindRainbow.ignoredPrefixModifiers": ["group", "peer", "has", "in", "not"] 214 | } 215 | ``` 216 | 217 | ## Extension API 218 | 219 | Other extensions can register custom themes: 220 | 221 | ```ts 222 | // Wait for activation 223 | const tailwindRainbow = vscode.extensions.getExtension('esdete.tailwind-rainbow'); 224 | if (tailwindRainbow && !tailwindRainbow.isActive) { 225 | await tailwindRainbow.activate(); 226 | } 227 | 228 | // Register theme 229 | const api = tailwindRainbow?.exports; 230 | if (api) { 231 | api.registerTheme('myCustomTheme', { 232 | prefix: { 233 | hover: { color: '#ff0000', fontWeight: 'bold' }, 234 | // ... more prefixes 235 | }, 236 | base: { 237 | 'bg-*': { color: '#00ff00' }, 238 | // ... more base patterns 239 | }, 240 | arbitrary: { color: '#0000ff' }, 241 | important: { color: '#ff00ff' }, 242 | }); 243 | } 244 | ``` 245 | 246 | ## Contributing 247 | 248 | You have an idea for a new feature or found a bug? Feel free to open an issue or submit a pull request! 249 | 250 | - Report bugs or request features via [GitHub Issues](https://github.com/esdete2/tailwind-rainbow/issues) 251 | - Submit pull requests with improvements 252 | - Share your custom themes with the community 253 | 254 | ## License 255 | 256 | Apache 2.0 License - see the [LICENSE](LICENSE) file for details 257 | -------------------------------------------------------------------------------- /src/services/config.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | /** 4 | * Configuration interface for the extension 5 | */ 6 | export interface TailwindRainbowConfig { 7 | classIdentifiers: string[]; 8 | classFunctions: string[]; 9 | templatePatterns: string[]; 10 | contextPatterns: string[]; 11 | maxFileSize: number; 12 | ignoredPrefixModifiers: string[]; 13 | debug: boolean; 14 | } 15 | 16 | /** 17 | * Default configuration values 18 | */ 19 | const DEFAULT_CONFIG: TailwindRainbowConfig = { 20 | classIdentifiers: ['class', 'className', 'class:', 'className:', 'classlist', 'classes', 'css', 'style'], 21 | classFunctions: [ 22 | 'cn', 23 | 'clsx', 24 | 'cva', 25 | 'classNames', 26 | 'classList', 27 | 'classnames', 28 | 'twMerge', 29 | 'tw', 30 | 'cls', 31 | 'cc', 32 | 'cx', 33 | 'classname', 34 | 'styled', 35 | 'css', 36 | 'theme', 37 | 'variants', 38 | ], 39 | templatePatterns: ['class', '${', 'tw`', 'css`', 'styled'], 40 | contextPatterns: ['variants', 'cva', 'class', 'css', 'style'], 41 | maxFileSize: 1_000_000, 42 | ignoredPrefixModifiers: ['/'], 43 | debug: false, 44 | }; 45 | 46 | /** 47 | * Configuration change event handler type 48 | */ 49 | export type ConfigChangeHandler = (config: TailwindRainbowConfig) => void; 50 | 51 | /** 52 | * Centralized configuration manager for the Tailwind Rainbow extension 53 | * Handles loading, caching, and change notifications for all configuration settings 54 | */ 55 | export class ConfigurationManager { 56 | private static instance: ConfigurationManager; 57 | private config: TailwindRainbowConfig; 58 | private changeHandlers: Set = new Set(); 59 | private disposables: vscode.Disposable[] = []; 60 | 61 | // Cached computed values for performance 62 | private cachedLowerCaseIdentifiers: string[] = []; 63 | private cachedClassFunctionsSet: Set = new Set(); 64 | private cachedTemplatePatterns: string[] = []; 65 | private cachedContextPatterns: string[] = []; 66 | 67 | private constructor() { 68 | this.config = this.loadConfiguration(); 69 | this.updateCachedValues(); 70 | this.setupConfigurationWatcher(); 71 | } 72 | 73 | /** 74 | * Gets the singleton instance of the configuration manager 75 | */ 76 | public static getInstance(): ConfigurationManager { 77 | if (!ConfigurationManager.instance) { 78 | ConfigurationManager.instance = new ConfigurationManager(); 79 | } 80 | return ConfigurationManager.instance; 81 | } 82 | 83 | /** 84 | * Gets the current configuration 85 | */ 86 | public getConfig(): TailwindRainbowConfig { 87 | return { ...this.config }; 88 | } 89 | 90 | /** 91 | * Gets a specific configuration value 92 | */ 93 | public get(key: K): TailwindRainbowConfig[K] { 94 | return this.config[key]; 95 | } 96 | 97 | /** 98 | * Gets class identifiers for detecting class attributes 99 | */ 100 | public getClassIdentifiers(): string[] { 101 | return [...this.config.classIdentifiers]; 102 | } 103 | 104 | /** 105 | * Gets lowercase class identifiers (cached for performance) 106 | */ 107 | public getLowerCaseClassIdentifiers(): string[] { 108 | return [...this.cachedLowerCaseIdentifiers]; 109 | } 110 | 111 | /** 112 | * Gets class functions for detecting utility function calls 113 | */ 114 | public getClassFunctions(): string[] { 115 | return [...this.config.classFunctions]; 116 | } 117 | 118 | /** 119 | * Gets class functions as a Set for fast lookups (cached for performance) 120 | */ 121 | public getClassFunctionsSet(): Set { 122 | return new Set(this.cachedClassFunctionsSet); 123 | } 124 | 125 | /** 126 | * Checks if a function name is a class function (O(1) lookup) 127 | */ 128 | public isClassFunction(functionName: string): boolean { 129 | return this.cachedClassFunctionsSet.has(functionName); 130 | } 131 | 132 | /** 133 | * Gets template patterns for detecting template literals 134 | */ 135 | public getTemplatePatterns(): string[] { 136 | return [...this.config.templatePatterns]; 137 | } 138 | 139 | /** 140 | * Gets context patterns for detecting class contexts 141 | */ 142 | public getContextPatterns(): string[] { 143 | return [...this.config.contextPatterns]; 144 | } 145 | 146 | /** 147 | * Gets the maximum file size for processing 148 | */ 149 | public getMaxFileSize(): number { 150 | return this.config.maxFileSize; 151 | } 152 | 153 | /** 154 | * Gets ignored prefix modifiers 155 | */ 156 | public getIgnoredPrefixModifiers(): string[] { 157 | return [...this.config.ignoredPrefixModifiers]; 158 | } 159 | 160 | public getDebugEnabled(): boolean { 161 | return this.config.debug; 162 | } 163 | 164 | /** 165 | * Registers a handler for configuration changes 166 | */ 167 | public onConfigChange(handler: ConfigChangeHandler): vscode.Disposable { 168 | this.changeHandlers.add(handler); 169 | 170 | return { 171 | dispose: () => { 172 | this.changeHandlers.delete(handler); 173 | }, 174 | }; 175 | } 176 | 177 | /** 178 | * Forces a reload of the configuration 179 | */ 180 | public reloadConfiguration(): void { 181 | const oldConfig = this.config; 182 | this.config = this.loadConfiguration(); 183 | 184 | // Update cached values 185 | this.updateCachedValues(); 186 | 187 | // Notify handlers if configuration changed 188 | if (JSON.stringify(oldConfig) !== JSON.stringify(this.config)) { 189 | this.notifyConfigChange(); 190 | } 191 | } 192 | 193 | /** 194 | * Loads configuration from VS Code workspace settings 195 | */ 196 | private loadConfiguration(): TailwindRainbowConfig { 197 | const workspaceConfig = vscode.workspace.getConfiguration('tailwindRainbow'); 198 | 199 | return { 200 | classIdentifiers: workspaceConfig.get('classIdentifiers', DEFAULT_CONFIG.classIdentifiers), 201 | classFunctions: workspaceConfig.get('classFunctions', DEFAULT_CONFIG.classFunctions), 202 | templatePatterns: workspaceConfig.get('templatePatterns', DEFAULT_CONFIG.templatePatterns), 203 | contextPatterns: workspaceConfig.get('contextPatterns', DEFAULT_CONFIG.contextPatterns), 204 | maxFileSize: workspaceConfig.get('maxFileSize', DEFAULT_CONFIG.maxFileSize), 205 | ignoredPrefixModifiers: workspaceConfig.get( 206 | 'ignoredPrefixModifiers', 207 | DEFAULT_CONFIG.ignoredPrefixModifiers 208 | ), 209 | debug: workspaceConfig.get('debug', DEFAULT_CONFIG.debug), 210 | }; 211 | } 212 | 213 | /** 214 | * Sets up configuration change watcher 215 | */ 216 | private setupConfigurationWatcher(): void { 217 | const disposable = vscode.workspace.onDidChangeConfiguration((event) => { 218 | if (event.affectsConfiguration('tailwindRainbow')) { 219 | this.reloadConfiguration(); 220 | } 221 | }); 222 | 223 | this.disposables.push(disposable); 224 | } 225 | 226 | /** 227 | * Notifies all registered handlers of configuration changes 228 | */ 229 | private notifyConfigChange(): void { 230 | const config = this.getConfig(); 231 | this.changeHandlers.forEach((handler) => { 232 | try { 233 | handler(config); 234 | } catch (error) { 235 | console.error('Error in configuration change handler:', error); 236 | } 237 | }); 238 | } 239 | 240 | /** 241 | * Updates cached computed values for performance 242 | */ 243 | private updateCachedValues(): void { 244 | this.cachedLowerCaseIdentifiers = this.config.classIdentifiers.map((id) => id.toLowerCase()); 245 | this.cachedClassFunctionsSet = new Set(this.config.classFunctions); 246 | this.cachedTemplatePatterns = [...this.config.templatePatterns]; 247 | this.cachedContextPatterns = [...this.config.contextPatterns]; 248 | } 249 | 250 | /** 251 | * Disposes of the configuration manager 252 | */ 253 | public dispose(): void { 254 | this.disposables.forEach((disposable) => disposable.dispose()); 255 | this.disposables = []; 256 | this.changeHandlers.clear(); 257 | // Clear cached values 258 | this.cachedLowerCaseIdentifiers = []; 259 | this.cachedClassFunctionsSet.clear(); 260 | this.cachedTemplatePatterns = []; 261 | this.cachedContextPatterns = []; 262 | ConfigurationManager.instance = undefined as any; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwind-rainbow", 3 | "displayName": "Tailwind Rainbow", 4 | "description": "Syntax highlighting for Tailwind CSS classes with customizable prefix-based coloring themes", 5 | "version": "0.2.1", 6 | "publisher": "esdete", 7 | "keywords": [ 8 | "tailwind", 9 | "css", 10 | "highlight", 11 | "syntax", 12 | "coloring", 13 | "theme", 14 | "prefix", 15 | "rainbow" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/esdete2/tailwind-rainbow" 20 | }, 21 | "homepage": "https://github.com/esdete2/tailwind-rainbow", 22 | "bugs": { 23 | "url": "https://github.com/esdete2/tailwind-rainbow/issues" 24 | }, 25 | "categories": [ 26 | "Other", 27 | "Formatters", 28 | "Themes" 29 | ], 30 | "icon": "images/icon.png", 31 | "scripts": { 32 | "vscode:prepublish": "pnpm run compile", 33 | "compile": "tsc -p ./", 34 | "watch": "tsc -watch -p ./", 35 | "pretest": "pnpm run build", 36 | "lint": "eslint src", 37 | "test": "rm -rf .vscode-test/user-data && rm -rf .vscode-test/extensions && node ./out/test/runTest.js", 38 | "build": "vsce package --no-dependencies -o tailwind-rainbow.vsix", 39 | "build:install": "pnpm run build && code --install-extension tailwind-rainbow.vsix" 40 | }, 41 | "devDependencies": { 42 | "@eslint/eslintrc": "^3.2.0", 43 | "@types/glob": "^8.1.0", 44 | "@types/mocha": "^10.0.10", 45 | "@types/node": "20.x", 46 | "@types/vscode": "^1.96.0", 47 | "@typescript-eslint/eslint-plugin": "^8.23.0", 48 | "@typescript-eslint/parser": "^8.23.0", 49 | "@vscode/test-electron": "^2.4.1", 50 | "@vscode/vsce": "^3.2.2", 51 | "eslint": "^9.20.0", 52 | "eslint-config-prettier": "^10.0.1", 53 | "eslint-plugin-prettier": "^5.2.3", 54 | "eslint-plugin-simple-import-sort": "^12.1.1", 55 | "eslint-plugin-unused-imports": "^4.1.4", 56 | "glob": "^11.0.1", 57 | "jest": "^29.7.0", 58 | "mocha": "^11.1.0", 59 | "path": "^0.12.7", 60 | "prettier": "^3.5.0", 61 | "ts-jest": "^29.2.5", 62 | "typescript": "^5.7.2" 63 | }, 64 | "engines": { 65 | "vscode": "^1.96.0" 66 | }, 67 | "activationEvents": [ 68 | "onStartupFinished" 69 | ], 70 | "main": "./out/extension.js", 71 | "contributes": { 72 | "commands": [ 73 | { 74 | "command": "tailwind-rainbow.selectTheme", 75 | "title": "Select Theme", 76 | "category": "Tailwind Rainbow" 77 | }, 78 | { 79 | "command": "tailwind-rainbow.loadThemes", 80 | "title": "Load Themes", 81 | "category": "Tailwind Rainbow" 82 | } 83 | ], 84 | "configuration": { 85 | "title": "Tailwind Rainbow", 86 | "properties": { 87 | "tailwindRainbow.enabled": { 88 | "type": "boolean", 89 | "default": true, 90 | "description": "Enable or disable Tailwind Rainbow extension" 91 | }, 92 | "tailwindRainbow.debug": { 93 | "type": "boolean", 94 | "default": false, 95 | "description": "Enable debug logging" 96 | }, 97 | "tailwindRainbow.maxFileSize": { 98 | "type": "number", 99 | "default": 1000000, 100 | "description": "Maximum file size in bytes to process (default: 1MB). Files larger than this will be skipped to prevent performance issues." 101 | }, 102 | "tailwindRainbow.theme": { 103 | "type": "string", 104 | "default": "default", 105 | "markdownDescription": "Theme name. Built-in themes: `default`, `synthwave`" 106 | }, 107 | "tailwindRainbow.themes": { 108 | "type": "object", 109 | "additionalProperties": { 110 | "type": "object", 111 | "properties": { 112 | "prefix": { 113 | "type": "object", 114 | "additionalProperties": { 115 | "type": "object", 116 | "properties": { 117 | "color": { 118 | "type": "string", 119 | "pattern": "^#[0-9A-Fa-f]{6}$", 120 | "description": "Color for the prefix (hex format, e.g., #ff0000)" 121 | }, 122 | "fontWeight": { 123 | "type": "string", 124 | "enum": [ 125 | "normal", 126 | "bold", 127 | "lighter", 128 | "bolder", 129 | "thin", 130 | "extralight", 131 | "light", 132 | "medium", 133 | "semibold", 134 | "extrabold", 135 | "black", 136 | "100", 137 | "200", 138 | "300", 139 | "400", 140 | "500", 141 | "600", 142 | "700", 143 | "800", 144 | "900" 145 | ], 146 | "description": "Font weight for the prefix" 147 | } 148 | } 149 | }, 150 | "description": "Prefix-specific configurations" 151 | }, 152 | "base": { 153 | "type": "object", 154 | "additionalProperties": { 155 | "$ref": "#/properties/tailwindRainbow.themes/additionalProperties/properties/prefix/additionalProperties" 156 | }, 157 | "description": "Base class configurations (e.g., bg-*, text-*)" 158 | }, 159 | "arbitrary": { 160 | "$ref": "#/properties/tailwindRainbow.themes/additionalProperties/properties/prefix/additionalProperties", 161 | "description": "Configuration for arbitrary value classes" 162 | }, 163 | "important": { 164 | "$ref": "#/properties/tailwindRainbow.themes/additionalProperties/properties/prefix/additionalProperties", 165 | "description": "Configuration for important modifier (!)" 166 | } 167 | }, 168 | "description": "Theme configuration with prefix, base, arbitrary, and important sections" 169 | }, 170 | "default": {}, 171 | "markdownDescription": "Custom themes or overrides for existing themes. Example:\n```json\n{\n \"myTheme\": {\n \"prefix\": {\n \"hover\": { \"color\": \"#ff0000\", \"enabled\": true }\n },\n \"base\": {\n \"bg-*\": { \"color\": \"#00ff00\", \"enabled\": true }\n }\n }\n}\n```" 172 | }, 173 | "tailwindRainbow.languages": { 174 | "type": "array", 175 | "items": { 176 | "type": "string" 177 | }, 178 | "default": [ 179 | "html", 180 | "javascript", 181 | "typescript", 182 | "javascriptreact", 183 | "typescriptreact", 184 | "vue", 185 | "svelte", 186 | "astro", 187 | "php", 188 | "css", 189 | "scss", 190 | "sass", 191 | "less", 192 | "stylus", 193 | "postcss", 194 | "tailwindcss" 195 | ], 196 | "description": "Language identifiers the extension should be active for" 197 | }, 198 | "tailwindRainbow.ignoredPrefixModifiers": { 199 | "type": "array", 200 | "items": { 201 | "type": "string" 202 | }, 203 | "default": [ 204 | "group", 205 | "peer", 206 | "has", 207 | "in", 208 | "not" 209 | ], 210 | "description": "Prefix modifiers that should be ignored" 211 | }, 212 | "tailwindRainbow.classIdentifiers": { 213 | "type": "array", 214 | "items": { 215 | "type": "string" 216 | }, 217 | "default": [ 218 | "class", 219 | "className", 220 | "class:", 221 | "className:", 222 | "classlist", 223 | "classes", 224 | "css", 225 | "style" 226 | ], 227 | "description": "HTML/JSX attributes and variable patterns that indicate class content. Supports exact strings and regex patterns (prefix with 'regex:')." 228 | }, 229 | "tailwindRainbow.classFunctions": { 230 | "type": "array", 231 | "items": { 232 | "type": "string" 233 | }, 234 | "default": [ 235 | "cn", 236 | "clsx", 237 | "cva", 238 | "classNames", 239 | "classList", 240 | "classnames", 241 | "twMerge", 242 | "tw", 243 | "cls", 244 | "cc", 245 | "cx", 246 | "classname", 247 | "styled", 248 | "css", 249 | "theme", 250 | "variants" 251 | ], 252 | "description": "Function names that typically contain class strings as arguments." 253 | }, 254 | "tailwindRainbow.templatePatterns": { 255 | "type": "array", 256 | "items": { 257 | "type": "string" 258 | }, 259 | "default": [ 260 | "class", 261 | "${", 262 | "tw`", 263 | "css`", 264 | "styled" 265 | ], 266 | "description": "Patterns to detect in template literals that indicate class content." 267 | }, 268 | "tailwindRainbow.contextPatterns": { 269 | "type": "array", 270 | "items": { 271 | "type": "string" 272 | }, 273 | "default": [ 274 | "variants", 275 | "cva", 276 | "class", 277 | "css", 278 | "style", 279 | "@apply" 280 | ], 281 | "description": "Keywords to look for in extended context to identify class-related code." 282 | } 283 | } 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/test/benchmark.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as vscode from 'vscode'; 5 | 6 | import { TailwindRainbowAPI } from '../extension'; 7 | 8 | interface BenchmarkMeasurement { 9 | operation: string; 10 | duration: number; 11 | timestamp: number; 12 | documentSize: number; 13 | languageId: string; 14 | } 15 | 16 | /** 17 | * Dedicated benchmark test suite for performance measurement 18 | * Uses realistic content and multiple iterations for reliable metrics 19 | */ 20 | suite('Benchmark Test Suite', function () { 21 | let api: TailwindRainbowAPI; 22 | let doc: vscode.TextDocument; 23 | let editor: vscode.TextEditor; 24 | let measurements: BenchmarkMeasurement[] = []; 25 | 26 | // Helper function to measure api.getTokenRanges performance 27 | function measureTokenRanges(testEditor: vscode.TextEditor): Map { 28 | const startTime = performance.now(); 29 | const timestamp = Date.now(); 30 | const documentSize = testEditor.document.getText().length; 31 | const languageId = testEditor.document.languageId; 32 | 33 | const result = api.getTokenRanges(testEditor); 34 | 35 | const duration = performance.now() - startTime; 36 | measurements.push({ 37 | operation: 'getTokenRanges', 38 | duration, 39 | timestamp, 40 | documentSize, 41 | languageId, 42 | }); 43 | 44 | return result; 45 | } 46 | 47 | // Realistic HTML content from example.html 48 | const benchmarkContent = ` 49 |
50 | 51 | 52 |
53 | 54 | 55 | 56 | 57 | 58 |
59 | 60 |
61 | 62 | 63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
72 | 73 | 74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
`; 84 | 85 | // Update test file content 86 | async function updateBenchmarkFile(content: string): Promise { 87 | const edit = new vscode.WorkspaceEdit(); 88 | const fullRange = new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)); 89 | edit.replace(doc.uri, fullRange, content); 90 | await vscode.workspace.applyEdit(edit); 91 | 92 | // Wait for document to update 93 | await new Promise((resolve) => setTimeout(resolve, 10)); 94 | } 95 | 96 | suiteSetup(async () => { 97 | const extension = vscode.extensions.getExtension('esdete.tailwind-rainbow'); 98 | if (!extension) { 99 | throw new Error('Extension not found'); 100 | } 101 | api = await extension.activate(); 102 | 103 | doc = await vscode.workspace.openTextDocument({ 104 | content: benchmarkContent, 105 | language: 'html', 106 | }); 107 | editor = await vscode.window.showTextDocument(doc); 108 | }); 109 | 110 | setup(async () => { 111 | // Ensure our main editor is active and ready 112 | if (!editor || editor.document.isClosed) { 113 | editor = await vscode.window.showTextDocument(doc); 114 | } 115 | 116 | // Reset content for each test 117 | await updateBenchmarkFile(benchmarkContent); 118 | }); 119 | 120 | test('should benchmark tokenizer performance with multiple iterations', async function () { 121 | this.timeout(30000); 122 | 123 | const iterations = 100; 124 | console.log(`Running ${iterations} iterations for benchmark measurement...`); 125 | 126 | for (let i = 0; i < iterations; i++) { 127 | const tokenRanges = measureTokenRanges(editor); 128 | assert.ok(tokenRanges.size > 0, 'Should find prefix ranges'); 129 | 130 | if ((i + 1) % 20 === 0) { 131 | console.log(` Completed ${i + 1}/${iterations} iterations`); 132 | } 133 | } 134 | 135 | console.log(`Completed ${iterations} benchmark iterations`); 136 | }); 137 | 138 | test('should benchmark with varying document sizes', async function () { 139 | this.timeout(30000); 140 | 141 | const baseContent = benchmarkContent; 142 | const sizes = [1, 2, 5, 10]; 143 | 144 | console.log('Testing performance with varying document sizes...'); 145 | 146 | for (const multiplier of sizes) { 147 | const repeatedContent = Array(multiplier).fill(baseContent).join('\n\n'); 148 | await updateBenchmarkFile(repeatedContent); 149 | 150 | console.log(` Testing with ${multiplier}x content (${repeatedContent.length} chars)`); 151 | 152 | for (let i = 0; i < 10; i++) { 153 | const tokenRanges = measureTokenRanges(editor); 154 | assert.ok(tokenRanges.size > 0, 'Should find prefix ranges'); 155 | } 156 | } 157 | 158 | console.log('Completed varying document size tests'); 159 | }); 160 | 161 | test('should benchmark with different language files', async function () { 162 | this.timeout(30000); 163 | 164 | const testCases = [ 165 | { 166 | language: 'javascript', 167 | content: ` 168 | const Button = tw\` 169 | bg-blue-500 hover:bg-blue-600 170 | text-white font-bold py-2 px-4 rounded 171 | lg:text-xl md:p-6 sm:p-4 172 | \`; 173 | 174 | const className = "hover:bg-red-500 focus:ring-2 sm:opacity-75"; 175 | `.trim(), 176 | }, 177 | { 178 | language: 'typescript', 179 | content: ` 180 | interface Props { 181 | className?: string; 182 | } 183 | 184 | const Component: React.FC = ({ className }) => ( 185 |
186 | Content 187 |
188 | ); 189 | `.trim(), 190 | }, 191 | { 192 | language: 'css', 193 | content: ` 194 | .btn { 195 | @apply bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded; 196 | } 197 | 198 | .responsive-grid { 199 | @apply grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4; 200 | } 201 | 202 | @media (max-width: 640px) { 203 | .mobile-only { 204 | @apply block sm:hidden p-4 focus:outline-none; 205 | } 206 | } 207 | `.trim(), 208 | }, 209 | ]; 210 | 211 | console.log('Testing performance with different language files...'); 212 | 213 | for (const testCase of testCases) { 214 | console.log(` Testing ${testCase.language} file...`); 215 | 216 | const langDoc = await vscode.workspace.openTextDocument({ 217 | content: testCase.content, 218 | language: testCase.language, 219 | }); 220 | const langEditor = await vscode.window.showTextDocument(langDoc); 221 | 222 | for (let i = 0; i < 20; i++) { 223 | measureTokenRanges(langEditor); 224 | } 225 | 226 | await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); 227 | } 228 | 229 | console.log('Completed different language file tests'); 230 | }); 231 | 232 | function calculateStats(durations: number[]) { 233 | const sorted = [...durations].sort((a, b) => a - b); 234 | const count = durations.length; 235 | const totalTime = durations.reduce((sum, d) => sum + d, 0); 236 | 237 | return { 238 | count, 239 | totalTime, 240 | averageTime: totalTime / count, 241 | minTime: sorted[0], 242 | maxTime: sorted[sorted.length - 1], 243 | p95Time: sorted[Math.floor(sorted.length * 0.95)], 244 | p99Time: sorted[Math.floor(sorted.length * 0.99)], 245 | }; 246 | } 247 | 248 | function getBranchName(): string { 249 | try { 250 | const { execSync } = require('child_process'); 251 | return execSync('git branch --show-current', { encoding: 'utf8' }).trim(); 252 | } catch { 253 | return 'unknown'; 254 | } 255 | } 256 | 257 | function getCommitHash(): string { 258 | try { 259 | const { execSync } = require('child_process'); 260 | return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); 261 | } catch { 262 | return 'unknown'; 263 | } 264 | } 265 | 266 | suiteTeardown(async function () { 267 | await new Promise((resolve) => setTimeout(resolve, 100)); 268 | 269 | if (measurements.length === 0) { 270 | console.log('No benchmark measurements collected'); 271 | return; 272 | } 273 | 274 | const durations = measurements.map((m) => m.duration); 275 | const stats = calculateStats(durations); 276 | 277 | console.log('\n=== Benchmark Results ==='); 278 | console.log(`Total measurements: ${stats.count}`); 279 | console.log(`Average time: ${stats.averageTime.toFixed(3)}ms`); 280 | console.log(`Min time: ${stats.minTime.toFixed(3)}ms`); 281 | console.log(`Max time: ${stats.maxTime.toFixed(3)}ms`); 282 | console.log(`P95 time: ${stats.p95Time.toFixed(3)}ms`); 283 | console.log(`P99 time: ${stats.p99Time.toFixed(3)}ms`); 284 | 285 | // Generate timestamped benchmark file 286 | const timestamp = new Date().toISOString().replace(/[:.-]/g, '').replace('T', '_').substring(0, 15); 287 | const branch = getBranchName(); 288 | const commit = getCommitHash(); 289 | 290 | const benchmarkData = { 291 | metadata: { 292 | timestamp: new Date().toISOString(), 293 | branch, 294 | commit, 295 | nodeVersion: process.version, 296 | platform: process.platform, 297 | arch: process.arch, 298 | }, 299 | summary: { 300 | totalMeasurements: stats.count, 301 | averageTime: stats.averageTime, 302 | minTime: stats.minTime, 303 | maxTime: stats.maxTime, 304 | p95Time: stats.p95Time, 305 | p99Time: stats.p99Time, 306 | }, 307 | }; 308 | 309 | const filename = `benchmark-${branch}-${timestamp}.json`; 310 | const benchmarkDir = path.join(process.cwd(), 'benchmark'); 311 | 312 | if (!fs.existsSync(benchmarkDir)) { 313 | fs.mkdirSync(benchmarkDir, { recursive: true }); 314 | } 315 | 316 | const filepath = path.join(benchmarkDir, filename); 317 | fs.writeFileSync(filepath, JSON.stringify(benchmarkData, null, 2)); 318 | 319 | console.log(`Benchmark results saved to: benchmark/${filename}`); 320 | console.log(`Branch: ${branch} (${commit})`); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/services/tokenizer.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { ConfigurationManager } from './config'; 4 | import { getThemeConfigForBaseClass, getThemeConfigForPrefix } from './utils'; 5 | 6 | // ============================================================================= 7 | // CONSTANTS 8 | // ============================================================================= 9 | 10 | const PERFORMANCE_CONSTANTS = { 11 | MAX_FILE_SIZE: 1_000_000, 12 | MAX_CONTENT_LENGTH: 5_000, 13 | CONTENT_VALIDATION_THRESHOLD: 100, 14 | CONTEXT_WINDOW_SIZE: 100, 15 | AFTER_TOKEN_CONTEXT_SIZE: 50, 16 | EXTENDED_CONTEXT_SIZE: 300, 17 | APPLY_DIRECTIVE_LENGTH: 6, // '@apply'.length 18 | COMMENT_SKIP_LENGTH: 2, // for '//' and similar 19 | SINGLE_CHAR_SKIP: 1, 20 | ESCAPE_CHAR_SKIP: 2, // for '\' + next char 21 | } as const; 22 | 23 | /** 24 | * Represents a token found during document parsing 25 | */ 26 | export interface Token { 27 | type: 'string' | 'template' | 'comment' | 'other'; 28 | content: string; 29 | start: number; 30 | end: number; 31 | quote?: string; // For string tokens: ', ", ` 32 | } 33 | 34 | /** 35 | * Represents a processed class token with theme information 36 | */ 37 | export interface ClassToken { 38 | type: 'prefix' | 'class' | 'important'; 39 | content: string; 40 | start: number; 41 | end: number; 42 | themeKey?: string; 43 | rangeKey?: string; // Key used for range mapping (may differ from themeKey) 44 | config?: ClassConfig; 45 | } 46 | 47 | /** 48 | * High-performance tokenizer service for parsing documents and finding Tailwind CSS classes 49 | * Uses string-based parsing for optimal performance and configurable pattern matching 50 | */ 51 | export class TokenizerService { 52 | private configManager: ConfigurationManager; 53 | 54 | constructor() { 55 | this.configManager = ConfigurationManager.getInstance(); 56 | } 57 | 58 | /** 59 | * Helper method to parse comment blocks 60 | * @param text Document text 61 | * @param start Start position 62 | * @param startPattern Pattern that starts the comment 63 | * @param endPattern Pattern that ends the comment 64 | * @returns Object with end position and token 65 | */ 66 | private parseComment( 67 | text: string, 68 | start: number, 69 | startPattern: string, 70 | endPattern: string 71 | ): { endPos: number; token: Token } { 72 | let i = start + startPattern.length; 73 | const endIndex = text.indexOf(endPattern, i); 74 | 75 | if (endIndex !== -1) { 76 | i = endIndex + endPattern.length; 77 | } else { 78 | i = text.length; 79 | } 80 | 81 | return { 82 | endPos: i, 83 | token: { 84 | type: 'comment', 85 | content: text.slice(start, i), 86 | start, 87 | end: i, 88 | }, 89 | }; 90 | } 91 | 92 | /** 93 | * Helper method to parse line comments (// or #) 94 | * @param text Document text 95 | * @param start Start position 96 | * @returns Object with end position and token 97 | */ 98 | private parseLineComment(text: string, start: number): { endPos: number; token: Token } { 99 | let endPos = start + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 100 | while (endPos < text.length && text[endPos] !== '\n' && text[endPos] !== '\r') { 101 | endPos++; 102 | } 103 | 104 | return { 105 | endPos, 106 | token: { 107 | type: 'comment', 108 | content: text.slice(start, endPos), 109 | start, 110 | end: endPos, 111 | }, 112 | }; 113 | } 114 | 115 | /** 116 | * Helper method to parse string literals with quote handling 117 | * @param text Document text 118 | * @param start Start position 119 | * @param quote Quote character 120 | * @returns Object with end position and content 121 | */ 122 | private parseStringLiteral(text: string, start: number, quote: string): { endPos: number; content: string } { 123 | let i = start + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 124 | 125 | while (i < text.length) { 126 | if (text[i] === '\\' && i + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP < text.length) { 127 | i += PERFORMANCE_CONSTANTS.ESCAPE_CHAR_SKIP; // Skip escaped character 128 | } else if (text[i] === quote) { 129 | i++; 130 | break; 131 | } else { 132 | i++; 133 | } 134 | } 135 | 136 | const content = text.slice( 137 | start + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP, 138 | i - PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP 139 | ); 140 | 141 | return { endPos: i, content }; 142 | } 143 | 144 | /** 145 | * Checks if a string token is likely to contain class names 146 | * @param text Document text 147 | * @param token String token to check 148 | * @returns True if the token is likely to contain class names 149 | */ 150 | private isClassContext(text: string, token: Token): boolean { 151 | const beforeToken = text.slice(Math.max(0, token.start - PERFORMANCE_CONSTANTS.CONTEXT_WINDOW_SIZE), token.start); 152 | const afterToken = text.slice( 153 | token.end, 154 | Math.min(text.length, token.end + PERFORMANCE_CONSTANTS.AFTER_TOKEN_CONTEXT_SIZE) 155 | ); 156 | 157 | // Process template literals as they commonly contain dynamic class expressions 158 | if (token.type === 'template') { 159 | return true; 160 | } 161 | 162 | // Check for prefix patterns using fast string methods 163 | if (token.content.includes(':')) { 164 | // Identify prefix patterns by detecting colons within class names 165 | const colonIndex = token.content.indexOf(':'); 166 | if (colonIndex > 0 && colonIndex < token.content.length - PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP) { 167 | return true; 168 | } 169 | } 170 | 171 | // Check for dash-separated patterns common in Tailwind CSS 172 | if (token.content.includes('-')) { 173 | return true; 174 | } 175 | 176 | // Check for class attributes using configurable identifiers 177 | const beforeLower = beforeToken.toLowerCase(); 178 | const lowerCaseIdentifiers = this.configManager.getLowerCaseClassIdentifiers(); 179 | 180 | for (const identifier of lowerCaseIdentifiers) { 181 | if (beforeLower.includes(identifier + '=') || beforeLower.includes(identifier + ':')) { 182 | return true; 183 | } 184 | } 185 | 186 | // Check for class utility functions using cached set for fast lookup 187 | const classFunctionsSet = this.configManager.getClassFunctionsSet(); 188 | // Extract function names from beforeToken and check against set 189 | const functionMatches = beforeToken.match(/\b(\w+)\s*\(/g); 190 | if (functionMatches) { 191 | for (const match of functionMatches) { 192 | const funcName = match.slice(0, -1).trim(); // Remove '(' and whitespace 193 | if (classFunctionsSet.has(funcName)) { 194 | return true; 195 | } 196 | } 197 | } 198 | 199 | // Check for template literals using cached patterns 200 | if (token.quote === '`') { 201 | const templatePatterns = this.configManager.getTemplatePatterns(); 202 | for (const pattern of templatePatterns) { 203 | if (beforeToken.includes(pattern)) { 204 | return true; 205 | } 206 | } 207 | } 208 | 209 | // Check for array/object contexts using string methods 210 | const trimmedBefore = beforeToken.trim(); 211 | if ( 212 | trimmedBefore.endsWith('[') || 213 | trimmedBefore.endsWith('{') || 214 | trimmedBefore.endsWith(',') || 215 | trimmedBefore.endsWith(':') 216 | ) { 217 | // Check for class-related context patterns in extended scope 218 | const extendedBefore = text.slice( 219 | Math.max(0, token.start - PERFORMANCE_CONSTANTS.EXTENDED_CONTEXT_SIZE), 220 | token.start 221 | ); 222 | 223 | // Detect utility function calls using cached set 224 | const classFunctionsSet = this.configManager.getClassFunctionsSet(); 225 | const functionMatches = extendedBefore.match(/\b(\w+)\s*\(/g); 226 | if (functionMatches) { 227 | for (const match of functionMatches) { 228 | const funcName = match.slice(0, -1).trim(); 229 | if (classFunctionsSet.has(funcName)) { 230 | return true; 231 | } 232 | } 233 | } 234 | 235 | // Match context patterns using cached identifiers 236 | const contextPatterns = this.configManager.getContextPatterns(); 237 | for (const pattern of contextPatterns) { 238 | if (extendedBefore.includes(pattern)) { 239 | return true; 240 | } 241 | } 242 | } 243 | 244 | // Detect malformed HTML class attributes 245 | const combinedContext = beforeToken + token.content + afterToken; 246 | if (combinedContext.includes('class=') && (combinedContext.includes('"') || combinedContext.includes("'"))) { 247 | return true; 248 | } 249 | 250 | return false; 251 | } 252 | 253 | /** 254 | * Tokenizes a document to find all potential class locations 255 | * @param text Document text to parse 256 | * @returns Array of tokens containing potential classes 257 | */ 258 | tokenizeDocument(text: string): Token[] { 259 | const tokens: Token[] = []; 260 | let i = 0; 261 | const length = text.length; 262 | 263 | while (i < length) { 264 | const char = text[i]; 265 | 266 | // Detect comments using helper methods 267 | if (char === '/' && i + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP < length) { 268 | const nextChar = text[i + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP]; 269 | if (nextChar === '/') { 270 | // C-style line comment 271 | const result = this.parseLineComment(text, i); 272 | tokens.push(result.token); 273 | i = result.endPos; 274 | continue; 275 | } else if (nextChar === '*') { 276 | // C-style block comment 277 | const result = this.parseComment(text, i, '/*', '*/'); 278 | tokens.push(result.token); 279 | i = result.endPos; 280 | continue; 281 | } 282 | } else if (char === '#') { 283 | // Shell-style line comment 284 | const result = this.parseLineComment(text, i); 285 | tokens.push(result.token); 286 | i = result.endPos; 287 | continue; 288 | } else if (char === '<' && text.startsWith(''); 291 | tokens.push(result.token); 292 | i = result.endPos; 293 | continue; 294 | } else if (char === '{' && text.startsWith('{/*', i)) { 295 | // JSX comment block 296 | const result = this.parseComment(text, i, '{/*', '*/}'); 297 | tokens.push(result.token); 298 | i = result.endPos; 299 | continue; 300 | } 301 | 302 | // Handle string literals with all quote types 303 | if (char === '"' || char === "'" || char === '`') { 304 | const quote = char; 305 | const start = i; 306 | 307 | // Process template literals for dynamic class expressions 308 | if (quote === '`') { 309 | const parseResult = this.parseStringLiteral(text, start, quote); 310 | tokens.push({ 311 | type: 'template', 312 | content: parseResult.content, 313 | start: start + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP, 314 | end: parseResult.endPos - PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP, 315 | quote, 316 | }); 317 | i = parseResult.endPos; 318 | continue; 319 | } 320 | 321 | // Analyze context for regular string literals 322 | const beforeQuote = text.slice(Math.max(0, i - PERFORMANCE_CONSTANTS.CONTEXT_WINDOW_SIZE), i).trim(); 323 | const hasAttributeContext = 324 | beforeQuote.endsWith('=') || 325 | beforeQuote.endsWith('={') || 326 | beforeQuote.endsWith('(') || 327 | beforeQuote.endsWith(',') || 328 | beforeQuote.endsWith('[') || 329 | beforeQuote.endsWith('{') || 330 | beforeQuote.endsWith(':'); 331 | 332 | // Only process strings in attribute or function contexts 333 | if (hasAttributeContext) { 334 | const parseResult = this.parseStringLiteral(text, start, quote); 335 | tokens.push({ 336 | type: 'string', 337 | content: parseResult.content, 338 | start: start + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP, 339 | end: parseResult.endPos - PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP, 340 | quote, 341 | }); 342 | i = parseResult.endPos; 343 | continue; 344 | } else { 345 | i++; 346 | continue; 347 | } 348 | } 349 | 350 | i++; 351 | } 352 | 353 | return tokens; 354 | } 355 | 356 | /** 357 | * Processes class tokens from string/template content (optimized) 358 | * @param content String content to process 359 | * @param startOffset Offset in the document 360 | * @param activeTheme Current theme configuration 361 | * @returns Array of class tokens with theme information 362 | */ 363 | processClassContent(content: string, startOffset: number, activeTheme: Theme): ClassToken[] { 364 | const classTokens: ClassToken[] = []; 365 | 366 | // Early return for very large content 367 | if (content.length > PERFORMANCE_CONSTANTS.MAX_CONTENT_LENGTH) { 368 | return classTokens; 369 | } 370 | 371 | // If no colons, do a quick validation to avoid processing non-class content 372 | if (!content.includes(':')) { 373 | if ( 374 | content.length > PERFORMANCE_CONSTANTS.CONTENT_VALIDATION_THRESHOLD && 375 | !content.includes('-') && 376 | !content.includes('[') 377 | ) { 378 | return classTokens; 379 | } 380 | } 381 | 382 | // Track positions and handle HTML content 383 | let wordStart = -1; 384 | let bracketDepth = 0; 385 | 386 | for (let i = 0; i <= content.length; i++) { 387 | const char = content[i]; 388 | 389 | if (char === '[') { 390 | bracketDepth++; 391 | } else if (char === ']') { 392 | bracketDepth--; 393 | } 394 | 395 | const isWhitespace = !char || char === ' ' || char === '\t' || char === '\n' || char === '\r'; 396 | const isHtmlChar = char && bracketDepth === 0 && (char === '<' || char === '>' || char === '{' || char === '}'); 397 | 398 | if (!isWhitespace && !isHtmlChar && wordStart === -1) { 399 | wordStart = i; 400 | } else if ((isWhitespace || isHtmlChar) && wordStart !== -1) { 401 | const word = content.slice(wordStart, i).trim(); 402 | 403 | if (word && word.length > 0) { 404 | const partTokens = this.processClassPart(word, startOffset + wordStart, activeTheme); 405 | classTokens.push(...partTokens); 406 | } 407 | wordStart = -1; 408 | 409 | if (isHtmlChar) { 410 | break; 411 | } 412 | } 413 | } 414 | 415 | return classTokens; 416 | } 417 | 418 | /** 419 | * Processes template literal content to find class attributes within HTML 420 | * @param content Template literal content 421 | * @param startOffset Starting offset in document 422 | * @param activeTheme Current theme configuration 423 | * @returns Array of class tokens 424 | */ 425 | private processTemplateContent(content: string, startOffset: number, activeTheme: Theme): ClassToken[] { 426 | const classTokens: ClassToken[] = []; 427 | 428 | // Look for class attributes using configurable patterns 429 | const lowerContent = content.toLowerCase(); 430 | 431 | // Generate class attribute patterns from cached configuration 432 | const lowerCaseIdentifiers = this.configManager.getLowerCaseClassIdentifiers(); 433 | const classPatterns = lowerCaseIdentifiers.map((id) => id + '='); 434 | 435 | for (const pattern of classPatterns) { 436 | let searchIndex = 0; 437 | while (true) { 438 | const patternIndex = lowerContent.indexOf(pattern, searchIndex); 439 | if (patternIndex === -1) break; 440 | 441 | // Find the opening quote 442 | let quoteStart = patternIndex + pattern.length; 443 | while ( 444 | quoteStart < content.length && 445 | (content[quoteStart] === ' ' || 446 | content[quoteStart] === '\t' || 447 | content[quoteStart] === '\n' || 448 | content[quoteStart] === '\r') 449 | ) { 450 | quoteStart++; 451 | } 452 | 453 | if (quoteStart < content.length && (content[quoteStart] === '"' || content[quoteStart] === "'")) { 454 | const quote = content[quoteStart]; 455 | const valueStart = quoteStart + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 456 | 457 | // Find the closing quote using helper method 458 | const parseResult = this.parseStringLiteral(content, quoteStart, quote); 459 | const valueEnd = parseResult.endPos - PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 460 | 461 | if (valueEnd >= valueStart) { 462 | const nestedTokens = this.processClassContent(parseResult.content, startOffset + valueStart, activeTheme); 463 | classTokens.push(...nestedTokens); 464 | } 465 | } 466 | 467 | searchIndex = patternIndex + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 468 | } 469 | } 470 | 471 | // If no class attributes found, but content looks like it might be just class names 472 | // (common in template literals like `hover:bg-blue-500 lg:text-xl`) 473 | if (classTokens.length === 0 && !content.includes('<')) { 474 | const directTokens = this.processClassContent(content, startOffset, activeTheme); 475 | classTokens.push(...directTokens); 476 | } 477 | 478 | return classTokens; 479 | } 480 | 481 | /** 482 | * Gets the appropriate configuration for a prefix 483 | * @param activeTheme Current theme configuration 484 | * @param prefix The prefix to get config for 485 | * @returns Object containing config and theme key 486 | */ 487 | private getPrefixConfig(activeTheme: Theme, prefix: string): { config: ClassConfig | null; themeKey: string } { 488 | let prefixConfig: ClassConfig | null = null; 489 | let themeKey = prefix; 490 | 491 | // Check if this is an arbitrary prefix 492 | if (prefix.startsWith('[') && prefix.endsWith(']')) { 493 | const arbitraryConfig = getThemeConfigForPrefix(activeTheme, 'arbitrary'); 494 | if (arbitraryConfig && arbitraryConfig.enabled !== false) { 495 | prefixConfig = arbitraryConfig; 496 | themeKey = 'arbitrary'; 497 | } 498 | } else { 499 | // Regular prefix or wildcard prefix 500 | prefixConfig = getThemeConfigForPrefix(activeTheme, prefix); 501 | } 502 | 503 | return { config: prefixConfig, themeKey }; 504 | } 505 | 506 | /** 507 | * Gets the appropriate configuration for a base class 508 | * @param activeTheme Current theme configuration 509 | * @param prefix The base class to get config for 510 | * @returns Object containing config and theme key 511 | */ 512 | private getBaseClassConfig(activeTheme: Theme, baseClass: string): { config: ClassConfig | null; themeKey: string } { 513 | let baseClassConfig: ClassConfig | null = null; 514 | let themeKey = baseClass; 515 | 516 | // Check if this is an arbitrary base class 517 | if (baseClass.startsWith('[') && baseClass.endsWith(']')) { 518 | const arbitraryConfig = getThemeConfigForBaseClass(activeTheme, 'arbitrary'); 519 | if (arbitraryConfig && arbitraryConfig.enabled !== false) { 520 | baseClassConfig = arbitraryConfig; 521 | themeKey = 'arbitrary'; 522 | } 523 | } else { 524 | // Regular or wildcard base class 525 | baseClassConfig = getThemeConfigForBaseClass(activeTheme, baseClass); 526 | } 527 | 528 | return { config: baseClassConfig, themeKey }; 529 | } 530 | 531 | /** 532 | * Splits a class string by colons while respecting brackets 533 | * @param classString The class string to split 534 | * @returns Array of parts split by colons (respecting brackets) 535 | */ 536 | private splitClassByColons(classString: string): string[] { 537 | const parts: string[] = []; 538 | let currentPart = ''; 539 | let bracketDepth = 0; 540 | 541 | for (let i = 0; i < classString.length; i++) { 542 | const char = classString[i]; 543 | 544 | if (char === '[') { 545 | bracketDepth++; 546 | currentPart += char; 547 | } else if (char === ']') { 548 | bracketDepth--; 549 | currentPart += char; 550 | } else if (char === ':' && bracketDepth === 0) { 551 | parts.push(currentPart); 552 | currentPart = ''; 553 | } else { 554 | currentPart += char; 555 | } 556 | } 557 | 558 | if (currentPart) { 559 | parts.push(currentPart); 560 | } 561 | 562 | return parts; 563 | } 564 | 565 | /** 566 | * Processes a single class part (e.g., "!sm:hover:w-full") 567 | * @param classPart Class string to process 568 | * @param startOffset Starting position in document 569 | * @param activeTheme Current theme configuration 570 | * @returns Array of class tokens 571 | */ 572 | private processClassPart(classPart: string, startOffset: number, activeTheme: Theme): ClassToken[] { 573 | const tokens: ClassToken[] = []; 574 | let currentPos = 0; 575 | 576 | // Skip classes that start or end with colons 577 | if (classPart.startsWith(':') || classPart.endsWith(':')) { 578 | return tokens; 579 | } 580 | 581 | // Handle important flag 582 | if (classPart.startsWith('!')) { 583 | const afterImportant = classPart.slice(PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP); 584 | if (afterImportant.startsWith(':') || afterImportant.endsWith(':')) { 585 | return tokens; 586 | } 587 | 588 | const config = getThemeConfigForPrefix(activeTheme, 'important'); 589 | 590 | if (config && config.enabled !== false) { 591 | tokens.push({ 592 | type: 'important', 593 | content: '!', 594 | start: startOffset, 595 | end: startOffset + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP, 596 | themeKey: 'important', 597 | rangeKey: 'important', 598 | config: config, 599 | }); 600 | } 601 | currentPos = PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 602 | } 603 | 604 | const remainingClass = classPart.slice(currentPos); 605 | 606 | // Split by colons to get prefixes and main class 607 | const parts = this.splitClassByColons(remainingClass); 608 | 609 | if (parts.length === 1) { 610 | // For standalone classes, check base section only 611 | const baseClassResult = this.getBaseClassConfig(activeTheme, remainingClass); 612 | const baseClassConfig = baseClassResult.config; 613 | const baseClassThemeKey = baseClassResult.themeKey; 614 | 615 | if (baseClassConfig && baseClassConfig.enabled !== false) { 616 | tokens.push({ 617 | type: 'class', 618 | content: remainingClass, 619 | start: startOffset + currentPos, 620 | end: startOffset + classPart.length, 621 | themeKey: baseClassThemeKey, 622 | rangeKey: remainingClass, 623 | config: baseClassConfig, 624 | }); 625 | } 626 | } else { 627 | // Has prefixes 628 | const prefixes = parts.slice(0, -1); 629 | const baseClass = parts[parts.length - 1]; 630 | 631 | let currentPrefixStart = currentPos; 632 | 633 | const baseClassResult = this.getBaseClassConfig(activeTheme, baseClass); 634 | const baseClassConfig = baseClassResult.config; 635 | const baseClassThemeKey = baseClassResult.themeKey; 636 | 637 | for (let i = 0; i < prefixes.length; i++) { 638 | const prefix = prefixes[i]; 639 | const prefixResult = this.getPrefixConfig(activeTheme, prefix); 640 | const prefixConfig = prefixResult.config; 641 | const prefixThemeKey = prefixResult.themeKey; 642 | 643 | if (prefixConfig && prefixConfig.enabled !== false) { 644 | let prefixEnd: number; 645 | 646 | if (i < prefixes.length - 1 || (baseClassConfig && baseClassConfig.enabled !== false)) { 647 | // Intermediate prefix: include just the prefix and colon 648 | prefixEnd = currentPrefixStart + prefix.length + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 649 | } else { 650 | // Last prefix and no base class config: include the base class 651 | prefixEnd = currentPos + classPart.length; 652 | } 653 | 654 | tokens.push({ 655 | type: 'class', 656 | content: remainingClass, 657 | start: startOffset + currentPrefixStart, 658 | end: startOffset + prefixEnd, 659 | themeKey: prefixThemeKey, 660 | rangeKey: prefix, 661 | config: prefixConfig, 662 | }); 663 | } 664 | 665 | // Move to next prefix position (add 1 for the colon) 666 | currentPrefixStart += prefix.length + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 667 | } 668 | 669 | if (baseClassConfig && baseClassConfig.enabled !== false) { 670 | // Find where the base class starts in the full class 671 | let baseClassStart = currentPos; 672 | for (const prefix of prefixes) { 673 | baseClassStart += prefix.length + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; // +1 for colon 674 | } 675 | 676 | tokens.push({ 677 | type: 'class', 678 | content: remainingClass, 679 | start: startOffset + baseClassStart, 680 | end: startOffset + classPart.length, 681 | themeKey: baseClassThemeKey, 682 | rangeKey: baseClass, 683 | config: baseClassConfig, 684 | }); 685 | } 686 | } 687 | 688 | return tokens; 689 | } 690 | 691 | /** 692 | * Processes @apply directives in CSS-like content using fast string operations 693 | * @param text Document text 694 | * @param activeTheme Current theme configuration 695 | * @returns Array of class tokens from @apply directives 696 | */ 697 | private processApplyDirectives(text: string, activeTheme: Theme): ClassToken[] { 698 | const classTokens: ClassToken[] = []; 699 | let searchStart = 0; 700 | 701 | while (true) { 702 | const applyIndex = text.indexOf('@apply', searchStart); 703 | if (applyIndex === -1) break; 704 | 705 | // Find the start of class content (skip whitespace after @apply) 706 | let contentStart = applyIndex + PERFORMANCE_CONSTANTS.APPLY_DIRECTIVE_LENGTH; 707 | while (contentStart < text.length && /\s/.test(text[contentStart])) { 708 | contentStart++; 709 | } 710 | 711 | // Find the end of the directive (semicolon, closing brace, or newline) 712 | let contentEnd = contentStart; 713 | while (contentEnd < text.length) { 714 | const char = text[contentEnd]; 715 | if (char === ';' || char === '}' || char === '\n' || char === '\r') { 716 | break; 717 | } 718 | contentEnd++; 719 | } 720 | 721 | const classesContent = text.slice(contentStart, contentEnd).trim(); 722 | if (classesContent) { 723 | const tokens = this.processClassContent(classesContent, contentStart, activeTheme); 724 | classTokens.push(...tokens); 725 | } 726 | 727 | searchStart = contentEnd + PERFORMANCE_CONSTANTS.SINGLE_CHAR_SKIP; 728 | } 729 | 730 | return classTokens; 731 | } 732 | 733 | /** 734 | * Main method to find all class tokens in a document 735 | * @param editor VS Code text editor 736 | * @param activeTheme Current theme configuration 737 | * @returns Map of theme keys to their ranges and configs 738 | */ 739 | findClassRanges( 740 | editor: vscode.TextEditor, 741 | activeTheme: Theme 742 | ): Map { 743 | const text = editor.document.getText(); 744 | 745 | // Early termination for very large files 746 | const maxFileSize = this.configManager.getMaxFileSize(); 747 | 748 | if (text.length > maxFileSize) { 749 | console.warn( 750 | `Tailwind Rainbow: File too large (${text.length} bytes > ${maxFileSize} bytes), skipping tokenization to prevent performance issues` 751 | ); 752 | return new Map(); 753 | } 754 | 755 | const allClassTokens: ClassToken[] = []; 756 | 757 | // Check if this is a CSS-like file that might contain @apply directives 758 | const languageId = editor.document.languageId; 759 | const cssLikeLanguages = ['css', 'scss', 'sass', 'less', 'stylus', 'postcss', 'tailwindcss']; 760 | 761 | if (cssLikeLanguages.includes(languageId)) { 762 | // For CSS-like files, also process @apply directives 763 | const applyTokens = this.processApplyDirectives(text, activeTheme); 764 | allClassTokens.push(...applyTokens); 765 | } 766 | 767 | const tokens = this.tokenizeDocument(text); 768 | 769 | for (const token of tokens) { 770 | if (token.type === 'string' || token.type === 'template') { 771 | if (this.isClassContext(text, token)) { 772 | if (token.type === 'template') { 773 | const classTokens = this.processTemplateContent(token.content, token.start, activeTheme); 774 | allClassTokens.push(...classTokens); 775 | } else { 776 | const classTokens = this.processClassContent(token.content, token.start, activeTheme); 777 | allClassTokens.push(...classTokens); 778 | } 779 | } 780 | } 781 | } 782 | 783 | // Group tokens by range key and convert to VS Code ranges with config 784 | const rangeMap = new Map(); 785 | 786 | for (const classToken of allClassTokens) { 787 | if (classToken.rangeKey && classToken.config) { 788 | const mapKey = classToken.rangeKey; 789 | if (!rangeMap.has(mapKey)) { 790 | rangeMap.set(mapKey, { ranges: [], config: classToken.config }); 791 | } 792 | 793 | const startPos = editor.document.positionAt(classToken.start); 794 | const endPos = editor.document.positionAt(classToken.end); 795 | const newRange = new vscode.Range(startPos, endPos); 796 | 797 | const existingEntry = rangeMap.get(mapKey)!; 798 | 799 | const hasOverlap = existingEntry.ranges.some( 800 | (existingRange) => newRange.intersection(existingRange) !== undefined 801 | ); 802 | 803 | if (!hasOverlap) { 804 | existingEntry.ranges.push(newRange); 805 | } 806 | } 807 | } 808 | 809 | return rangeMap; 810 | } 811 | } 812 | -------------------------------------------------------------------------------- /src/test/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import { beforeEach, it } from 'mocha'; 3 | import * as vscode from 'vscode'; 4 | 5 | import { TailwindRainbowAPI } from '../extension'; 6 | 7 | suite('Tailwind Class Coloring Test Suite', () => { 8 | let doc: vscode.TextDocument; 9 | let editor: vscode.TextEditor; 10 | let api: TailwindRainbowAPI; 11 | 12 | // Test helpers 13 | async function updateTestFile(content: string, language?: string) { 14 | const doc = await vscode.workspace.openTextDocument({ 15 | content, 16 | language: language || 'html', 17 | }); 18 | editor = await vscode.window.showTextDocument(doc); 19 | } 20 | 21 | function getTokenRanges() { 22 | const tokenRanges = api.getTokenRanges(editor); 23 | 24 | const tableData = []; 25 | for (const [key, value] of tokenRanges.entries()) { 26 | tableData.push({ 27 | prefix: key, 28 | count: value.length, 29 | ranges: value, 30 | }); 31 | } 32 | console.table(tableData); 33 | return tokenRanges; 34 | } 35 | 36 | function verifyRanges(content: string, tokenRanges: Map, expectedColoredPartials?: string[]) { 37 | // Helper to get the text at a specific range 38 | function getTextAtRange(range: vscode.Range): string { 39 | const lines = content.split('\n'); 40 | const startLine = lines[range.start.line]; 41 | const endLine = lines[range.end.line]; 42 | 43 | if (range.start.line === range.end.line) { 44 | return startLine.substring(range.start.character, range.end.character); 45 | } 46 | 47 | return startLine.substring(range.start.character) + endLine.substring(0, range.end.character); 48 | } 49 | 50 | // Collect all ranges in order for comparison with expected partials 51 | const allRanges: { prefix: string; range: vscode.Range; rangeIndex: number }[] = []; 52 | for (const [prefix, ranges] of tokenRanges.entries()) { 53 | ranges.forEach((range, rangeIndex) => { 54 | allRanges.push({ prefix, range, rangeIndex }); 55 | }); 56 | } 57 | 58 | // Sort ranges by position in document 59 | allRanges.sort((a, b) => { 60 | if (a.range.start.line !== b.range.start.line) { 61 | return a.range.start.line - b.range.start.line; 62 | } 63 | return a.range.start.character - b.range.start.character; 64 | }); 65 | 66 | // For each prefix, verify its ranges 67 | for (const [prefix, ranges] of tokenRanges.entries()) { 68 | ranges.forEach((range, i) => { 69 | const text = getTextAtRange(range); 70 | console.log( 71 | `Verifying ${prefix} [Ln ${range.start.line}, Col ${range.start.character} / Ln ${range.end.line}, Col ${range.end.character}]:`, 72 | text 73 | ); 74 | 75 | // If expected partials provided, check against them by document order 76 | if (expectedColoredPartials) { 77 | const globalIndex = allRanges.findIndex((r) => r.range === range); 78 | if (globalIndex < expectedColoredPartials.length) { 79 | assert.strictEqual( 80 | text, 81 | expectedColoredPartials[globalIndex], 82 | `Expected colored partial at index ${globalIndex} should be "${expectedColoredPartials[globalIndex]}" but got "${text}"` 83 | ); 84 | } 85 | } 86 | 87 | // Each range should contain its prefix (special case for important and base classes) 88 | if (prefix === 'important') { 89 | assert.ok(text === '!', `Range for ${prefix} should be "!" but got "${text}"`); 90 | } else if (text.includes(':') && !text.startsWith('[') && !text.endsWith(']')) { 91 | // This is a prefixed class (contains colon) 92 | assert.ok(text.includes(prefix + ':'), `Range for ${prefix} should contain "${prefix}:" but got "${text}"`); 93 | } else { 94 | // This is a base class (no colon), should match exactly or be part of the class name 95 | assert.ok( 96 | text === prefix || text.includes(prefix), 97 | `Range for ${prefix} should contain "${prefix}" but got "${text}"` 98 | ); 99 | } 100 | 101 | // If not the last range, shouldn't overlap with next 102 | if (i < ranges.length - 1) { 103 | const nextRange = ranges[i + 1]; 104 | assert.ok(range.end.isBefore(nextRange.start), `Range ${i + 1} for ${prefix} overlaps with next range`); 105 | } 106 | }); 107 | } 108 | } 109 | 110 | suiteSetup(async () => { 111 | const extension = vscode.extensions.getExtension('esdete.tailwind-rainbow'); 112 | if (!extension) { 113 | throw new Error('Extension not found'); 114 | } 115 | api = await extension.activate(); 116 | 117 | doc = await vscode.workspace.openTextDocument({ 118 | content: '
', 119 | language: 'html', 120 | }); 121 | editor = await vscode.window.showTextDocument(doc); 122 | }); 123 | 124 | beforeEach(async () => { 125 | console.log('\n'); 126 | // Ensure our main editor is active and ready 127 | if (!editor || editor.document.isClosed) { 128 | editor = await vscode.window.showTextDocument(doc); 129 | } 130 | }); 131 | 132 | it('should detect prefixes in double quotes', async function () { 133 | const content = '
'; 134 | console.log('Testing:', content); 135 | await updateTestFile(content); 136 | 137 | const tokenRanges = getTokenRanges(); 138 | 139 | assert.ok(tokenRanges.has('hover')); 140 | assert.ok(tokenRanges.has('lg')); 141 | assert.strictEqual(tokenRanges.size, 2); 142 | 143 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'lg:text-xl', 'lg:', 'hover:bg-red-500']); 144 | }); 145 | 146 | it('should detect prefixes in single quotes', async function () { 147 | const content = "
"; 148 | console.log('Testing:', content); 149 | await updateTestFile(content); 150 | 151 | const tokenRanges = getTokenRanges(); 152 | 153 | assert.ok(tokenRanges.has('hover')); 154 | assert.ok(tokenRanges.has('lg')); 155 | assert.strictEqual(tokenRanges.size, 2); 156 | 157 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'lg:text-xl']); 158 | }); 159 | 160 | it('should detect prefixes in backticks', async function () { 161 | const content = '
'; 162 | console.log('Testing:', content); 163 | await updateTestFile(content); 164 | 165 | const tokenRanges = getTokenRanges(); 166 | 167 | assert.ok(tokenRanges.has('hover')); 168 | assert.ok(tokenRanges.has('lg')); 169 | assert.strictEqual(tokenRanges.size, 2); 170 | 171 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'lg:text-xl']); 172 | }); 173 | 174 | it('should detect prefixes in template literals and JSX expressions', async function () { 175 | const content = ` 176 |
177 |
178 |
179 | `; 180 | console.log('Testing:', content); 181 | await updateTestFile(content); 182 | 183 | const tokenRanges = getTokenRanges(); 184 | 185 | assert.ok(tokenRanges.has('hover')); 186 | assert.ok(tokenRanges.has('lg')); 187 | assert.ok(tokenRanges.has('md')); 188 | assert.ok(tokenRanges.has('sm')); 189 | assert.strictEqual(tokenRanges.size, 4); 190 | 191 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'lg:text-xl', 'md:bg-red-500', 'sm:text-white']); 192 | }); 193 | 194 | it('should detect multiple instances of the same prefix', async function () { 195 | const content = '
'; 196 | console.log('Testing:', content); 197 | await updateTestFile(content); 198 | 199 | const tokenRanges = getTokenRanges(); 200 | 201 | assert.ok(tokenRanges.has('hover')); 202 | assert.strictEqual(tokenRanges.size, 1); 203 | assert.strictEqual(tokenRanges.get('hover')!.length, 2); 204 | 205 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'hover:text-white']); 206 | }); 207 | 208 | it('should handle nested quotes correctly', async function () { 209 | const content = ` 210 | '
' 211 | "
" 212 | \`
\` 213 | `; 214 | console.log('Testing:', content); 215 | await updateTestFile(content); 216 | 217 | const tokenRanges = getTokenRanges(); 218 | 219 | assert.ok(tokenRanges.has('sm')); 220 | assert.ok(tokenRanges.has('hover')); 221 | assert.ok(tokenRanges.has('lg')); 222 | assert.ok(tokenRanges.has('active')); 223 | assert.ok(tokenRanges.has('md')); 224 | assert.ok(tokenRanges.has('focus')); 225 | assert.strictEqual(tokenRanges.size, 6); 226 | 227 | verifyRanges(content, tokenRanges, [ 228 | 'sm:bg-red-500', 229 | 'hover:text-black', 230 | 'lg:bg-red-500', 231 | 'active:text-black', 232 | 'md:bg-red-500', 233 | 'focus:text-black', 234 | ]); 235 | }); 236 | 237 | it('should handle complex nested combinations', async function () { 238 | const content = '
'; 239 | console.log('Testing:', content); 240 | await updateTestFile(content); 241 | 242 | const tokenRanges = getTokenRanges(); 243 | 244 | assert.ok(tokenRanges.has('dark')); 245 | assert.ok(tokenRanges.has('sm')); 246 | assert.ok(tokenRanges.has('hover')); 247 | assert.ok(tokenRanges.has('before')); 248 | assert.ok(tokenRanges.has('focus')); 249 | assert.strictEqual(tokenRanges.size, 5); 250 | 251 | verifyRanges(content, tokenRanges, ['dark:', 'sm:', 'hover:', 'before:', 'focus:text-blue-500']); 252 | }); 253 | 254 | it('should handle basic arbitrary values', async function () { 255 | const content = "
>']\">
"; 256 | console.log('Testing:', content); 257 | await updateTestFile(content); 258 | 259 | const tokenRanges = getTokenRanges(); 260 | 261 | assert.ok(tokenRanges.has('before')); 262 | assert.ok(tokenRanges.has('after')); 263 | assert.strictEqual(tokenRanges.size, 2); 264 | 265 | verifyRanges(content, tokenRanges, ["before:content-['*']", "after:content-['>>']"]); 266 | }); 267 | 268 | it('should handle colons in arbitrary values', async function () { 269 | const content = '
'; 270 | console.log('Testing:', content); 271 | await updateTestFile(content); 272 | 273 | const tokenRanges = getTokenRanges(); 274 | 275 | assert.ok(tokenRanges.has('before')); 276 | assert.strictEqual(tokenRanges.size, 1); 277 | 278 | verifyRanges(content, tokenRanges, ['before:content-[test:with:colons]']); 279 | }); 280 | 281 | it('should handle escaped quotes in arbitrary values', async function () { 282 | const content = '
'; 283 | console.log('Testing:', content); 284 | await updateTestFile(content); 285 | 286 | const tokenRanges = getTokenRanges(); 287 | 288 | assert.ok(tokenRanges.has('before')); 289 | assert.ok(tokenRanges.has('hover')); 290 | assert.strictEqual(tokenRanges.size, 2); 291 | 292 | verifyRanges(content, tokenRanges, ['before:content-[\\"test\\"]', 'hover:text-xl']); 293 | }); 294 | 295 | it('should handle ignored prefix modifiers', async function () { 296 | const content = '
'; 297 | console.log('Testing:', content); 298 | await updateTestFile(content); 299 | 300 | const tokenRanges = getTokenRanges(); 301 | 302 | assert.ok(tokenRanges.has('group-hover/button')); 303 | assert.strictEqual(tokenRanges.size, 1); 304 | 305 | verifyRanges(content, tokenRanges, ['group-hover/button:bg-blue-500']); 306 | }); 307 | 308 | it('should handle chained ignored prefix modifiers', async function () { 309 | const content = '
'; 310 | console.log('Testing:', content); 311 | await updateTestFile(content); 312 | 313 | const tokenRanges = getTokenRanges(); 314 | 315 | assert.ok(tokenRanges.has('peer-has-checked')); 316 | assert.strictEqual(tokenRanges.size, 1); 317 | 318 | verifyRanges(content, tokenRanges, ['peer-has-checked:bg-blue-500']); 319 | }); 320 | 321 | it('should handle wildcard patterns', async function () { 322 | const content = '
'; 323 | console.log('Testing:', content); 324 | await updateTestFile(content); 325 | 326 | const tokenRanges = getTokenRanges(); 327 | 328 | assert.ok(tokenRanges.has('min-[1920px]')); 329 | assert.ok(tokenRanges.has('max-sm')); 330 | assert.strictEqual(tokenRanges.size, 2); 331 | 332 | verifyRanges(content, tokenRanges, ['min-[1920px]:max-w-sm', 'max-sm:w-full']); 333 | }); 334 | 335 | it('should handle arbitrary prefixes', async function () { 336 | const content = '
'; 337 | console.log('Testing:', content); 338 | await updateTestFile(content); 339 | 340 | const tokenRanges = getTokenRanges(); 341 | 342 | assert.ok(tokenRanges.has('[&.is-dragging]')); 343 | assert.strictEqual(tokenRanges.size, 1); 344 | 345 | verifyRanges(content, tokenRanges, ['[&.is-dragging]:cursor-grabbing']); 346 | }); 347 | 348 | it('should handle important modifier', async function () { 349 | const content = '
'; 350 | console.log('Testing:', content); 351 | await updateTestFile(content); 352 | 353 | const tokenRanges = getTokenRanges(); 354 | 355 | assert.ok(tokenRanges.has('important')); 356 | assert.strictEqual(tokenRanges.size, 1); 357 | 358 | verifyRanges(content, tokenRanges, ['!']); 359 | }); 360 | 361 | it('should handle mixed quotes and escaped content', async function () { 362 | const content = '
'; 363 | console.log('Testing:', content); 364 | await updateTestFile(content); 365 | 366 | const tokenRanges = getTokenRanges(); 367 | 368 | assert.ok(tokenRanges.has('hover')); 369 | assert.ok(tokenRanges.has('sm')); 370 | assert.strictEqual(tokenRanges.size, 2); 371 | 372 | verifyRanges(content, tokenRanges, ['hover:text-xl', 'sm:flex']); 373 | }); 374 | 375 | it('should handle multiple lines', async function () { 376 | const content = ` 377 |
381 | `; 382 | console.log('Testing:', content); 383 | await updateTestFile(content); 384 | 385 | const tokenRanges = getTokenRanges(); 386 | 387 | assert.ok(tokenRanges.has('hover')); 388 | assert.ok(tokenRanges.has('lg')); 389 | assert.strictEqual(tokenRanges.size, 2); 390 | 391 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'lg:text-xl']); 392 | }); 393 | 394 | it('should ignore invalid prefixes', async function () { 395 | const content = '
'; 396 | console.log('Testing:', content); 397 | await updateTestFile(content); 398 | 399 | const tokenRanges = getTokenRanges(); 400 | 401 | assert.strictEqual(tokenRanges.size, 0); 402 | verifyRanges(content, tokenRanges, []); 403 | }); 404 | 405 | it('should ignore standalone prefixes', async function () { 406 | const content = '
'; 407 | console.log('Testing:', content); 408 | await updateTestFile(content); 409 | 410 | const tokenRanges = getTokenRanges(); 411 | 412 | assert.strictEqual(tokenRanges.size, 0); 413 | verifyRanges(content, tokenRanges, []); 414 | }); 415 | 416 | it('should ignore quotes outside of class names', async function () { 417 | const content = ` 418 | It's a single single quote 419 |
420 | `; 421 | console.log('Testing:', content); 422 | await updateTestFile(content); 423 | 424 | const tokenRanges = getTokenRanges(); 425 | 426 | assert.ok(tokenRanges.has('hover')); 427 | assert.ok(tokenRanges.has('after')); 428 | assert.strictEqual(tokenRanges.size, 2); 429 | 430 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', "after:content-['Hi']"]); 431 | }); 432 | 433 | it('should ignore classes with double colons or starting/ending with colons', async function () { 434 | const content = `
`; 435 | console.log('Testing:', content); 436 | await updateTestFile(content); 437 | 438 | const tokenRanges = getTokenRanges(); 439 | 440 | assert.strictEqual(tokenRanges.size, 0); 441 | verifyRanges(content, tokenRanges, []); 442 | }); 443 | 444 | it('should detect prefixes in utility functions', async function () { 445 | const content = ` 446 |
451 |
452 |
453 | `; 454 | console.log('Testing:', content); 455 | await updateTestFile(content); 456 | 457 | const tokenRanges = getTokenRanges(); 458 | 459 | assert.ok(tokenRanges.has('lg')); 460 | assert.ok(tokenRanges.has('md')); 461 | assert.ok(tokenRanges.has('sm')); 462 | assert.ok(tokenRanges.has('hover')); 463 | assert.ok(tokenRanges.has('xl')); 464 | assert.ok(tokenRanges.has('focus')); 465 | assert.strictEqual(tokenRanges.size, 6); 466 | 467 | verifyRanges(content, tokenRanges, [ 468 | 'lg:bg-blue-500', 469 | 'md:text-white', 470 | 'sm:border-2', 471 | 'hover:bg-red-500', 472 | 'xl:p-4', 473 | 'focus:ring-2', 474 | ]); 475 | }); 476 | 477 | it('should detect prefixes in variable assignments', async function () { 478 | const content = ` 479 | const tableClasses = "sm:border-2 lg:shadow-lg"; 480 | const buttonStyles = 'hover:bg-blue-500 focus:outline-none'; 481 | let cardClasses = \`md:rounded-lg xl:max-w-md\`; 482 | `; 483 | console.log('Testing:', content); 484 | await updateTestFile(content); 485 | 486 | const tokenRanges = getTokenRanges(); 487 | 488 | assert.ok(tokenRanges.has('sm')); 489 | assert.ok(tokenRanges.has('lg')); 490 | assert.ok(tokenRanges.has('hover')); 491 | assert.ok(tokenRanges.has('focus')); 492 | assert.ok(tokenRanges.has('md')); 493 | assert.ok(tokenRanges.has('xl')); 494 | assert.strictEqual(tokenRanges.size, 6); 495 | 496 | verifyRanges(content, tokenRanges, [ 497 | 'sm:border-2', 498 | 'lg:shadow-lg', 499 | 'hover:bg-blue-500', 500 | 'focus:outline-none', 501 | 'md:rounded-lg', 502 | 'xl:max-w-md', 503 | ]); 504 | }); 505 | 506 | it('should detect prefixes in CVA (Class Variance Authority) patterns', async function () { 507 | const content = ` 508 | const button = cva(["font-semibold", "border", "rounded"], { 509 | variants: { 510 | intent: { 511 | primary: "md:bg-black hover:bg-gray-800", 512 | secondary: "md:bg-white hover:bg-gray-100" 513 | }, 514 | disabled: { 515 | false: null, 516 | true: ["md:opacity-50", "cursor-not-allowed"], 517 | }, 518 | }, 519 | compoundVariants: [ 520 | { 521 | intent: "primary", 522 | disabled: false, 523 | class: "hover:bg-blue-600 focus:ring-2", 524 | }, 525 | ], 526 | defaultVariants: { 527 | intent: "primary", 528 | disabled: false, 529 | }, 530 | }); 531 | `; 532 | console.log('Testing:', content); 533 | await updateTestFile(content); 534 | 535 | const tokenRanges = getTokenRanges(); 536 | 537 | assert.ok(tokenRanges.has('md')); 538 | assert.ok(tokenRanges.has('hover')); 539 | assert.ok(tokenRanges.has('focus')); 540 | assert.strictEqual(tokenRanges.size, 3); 541 | 542 | verifyRanges(content, tokenRanges, [ 543 | 'md:bg-black', 544 | 'hover:bg-gray-800', 545 | 'md:bg-white', 546 | 'hover:bg-gray-100', 547 | 'md:opacity-50', 548 | 'hover:bg-blue-600', 549 | 'focus:ring-2', 550 | ]); 551 | }); 552 | 553 | it('should detect prefixes in more utility function patterns', async function () { 554 | const content = ` 555 | const styles = twMerge("sm:p-4", "lg:p-8"); 556 | const classes = tw\`md:flex xl:block\`; 557 | const theme = styled.div\` 558 | sm:text-sm 559 | lg:text-lg 560 | \`; 561 | classList.add("hover:opacity-75"); 562 | `; 563 | console.log('Testing:', content); 564 | await updateTestFile(content); 565 | 566 | const tokenRanges = getTokenRanges(); 567 | 568 | assert.ok(tokenRanges.has('sm')); 569 | assert.ok(tokenRanges.has('lg')); 570 | assert.ok(tokenRanges.has('md')); 571 | assert.ok(tokenRanges.has('xl')); 572 | assert.ok(tokenRanges.has('hover')); 573 | assert.strictEqual(tokenRanges.size, 5); 574 | 575 | verifyRanges(content, tokenRanges, [ 576 | 'sm:p-4', 577 | 'lg:p-8', 578 | 'md:flex', 579 | 'xl:block', 580 | 'sm:text-sm', 581 | 'lg:text-lg', 582 | 'hover:opacity-75', 583 | ]); 584 | }); 585 | 586 | it('should detect base class patterns like bg-*, text-*, etc.', async function () { 587 | // Inject base class configurations for testing via workspace configuration 588 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 589 | const originalThemes = config.get('themes', {}); 590 | 591 | const testThemes = { 592 | ...originalThemes, 593 | 'test-base-patterns': { 594 | prefix: { 595 | hover: { 596 | color: '#4ee585', 597 | fontWeight: '700', 598 | }, 599 | lg: { 600 | color: '#a78bfa', 601 | fontWeight: '700', 602 | }, 603 | }, 604 | base: { 605 | 'bg-*': { 606 | color: '#ff6b6b', 607 | fontWeight: '600', 608 | }, 609 | 'text-*': { 610 | color: '#4ecdc4', 611 | fontWeight: '600', 612 | }, 613 | 'p-*': { 614 | color: '#ffe66d', 615 | fontWeight: '600', 616 | }, 617 | 'rounded-*': { 618 | color: '#ff8b94', 619 | fontWeight: '600', 620 | }, 621 | }, 622 | }, 623 | }; 624 | 625 | // Set the test themes and activate the test theme 626 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 627 | await config.update('theme', 'test-base-patterns', vscode.ConfigurationTarget.Global); 628 | 629 | // Give some time for the configuration to update 630 | await new Promise((resolve) => setTimeout(resolve, 100)); 631 | 632 | const content = ` 633 |
634 |
635 |
636 | `; 637 | console.log('Testing:', content); 638 | await updateTestFile(content); 639 | 640 | const tokenRanges = getTokenRanges(); 641 | 642 | assert.ok(tokenRanges.has('bg-blue-500')); 643 | assert.ok(tokenRanges.has('bg-red-300')); 644 | assert.ok(tokenRanges.has('bg-gradient-to-r')); 645 | assert.ok(tokenRanges.has('text-white')); 646 | assert.ok(tokenRanges.has('text-gray-800')); 647 | assert.ok(tokenRanges.has('text-xl')); 648 | assert.ok(tokenRanges.has('p-4')); 649 | assert.ok(tokenRanges.has('p-2')); 650 | assert.ok(tokenRanges.has('p-8')); 651 | assert.ok(tokenRanges.has('rounded-lg')); 652 | assert.ok(tokenRanges.has('rounded-md')); 653 | assert.ok(tokenRanges.has('rounded-full')); 654 | assert.strictEqual(tokenRanges.size, 12); 655 | 656 | verifyRanges(content, tokenRanges, [ 657 | 'bg-blue-500', 658 | 'text-white', 659 | 'p-4', 660 | 'rounded-lg', 661 | 'bg-red-300', 662 | 'text-gray-800', 663 | 'p-2', 664 | 'rounded-md', 665 | 'bg-gradient-to-r', 666 | 'text-xl', 667 | 'p-8', 668 | 'rounded-full', 669 | ]); 670 | 671 | // Restore original configuration 672 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 673 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 674 | }); 675 | 676 | it('should handle mixed prefix and base class patterns', async function () { 677 | // Inject base class configurations for testing via workspace configuration 678 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 679 | const originalThemes = config.get('themes', {}); 680 | 681 | const testThemes = { 682 | ...originalThemes, 683 | 'test-mixed-patterns': { 684 | prefix: { 685 | hover: { 686 | color: '#4ee585', 687 | fontWeight: '700', 688 | }, 689 | lg: { 690 | color: '#a78bfa', 691 | fontWeight: '700', 692 | }, 693 | sm: { 694 | color: '#d18bfa', 695 | fontWeight: '700', 696 | }, 697 | focus: { 698 | color: '#4ee6b8', 699 | fontWeight: '700', 700 | }, 701 | }, 702 | base: { 703 | 'bg-*': { 704 | color: '#ff6b6b', 705 | fontWeight: '600', 706 | }, 707 | 'border-*': { 708 | color: '#95e1d3', 709 | fontWeight: '600', 710 | }, 711 | }, 712 | }, 713 | }; 714 | 715 | // Set the test themes and activate the test theme 716 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 717 | await config.update('theme', 'test-mixed-patterns', vscode.ConfigurationTarget.Global); 718 | 719 | // Give some time for the configuration to update 720 | await new Promise((resolve) => setTimeout(resolve, 100)); 721 | 722 | const content = ` 723 |
724 |
725 | `; 726 | console.log('Testing:', content); 727 | await updateTestFile(content); 728 | 729 | const tokenRanges = getTokenRanges(); 730 | 731 | assert.ok(tokenRanges.has('hover')); 732 | assert.ok(tokenRanges.has('bg-blue-500')); 733 | assert.ok(tokenRanges.has('lg')); 734 | assert.ok(tokenRanges.has('border-2')); 735 | assert.ok(tokenRanges.has('bg-white')); 736 | assert.ok(tokenRanges.has('border-gray-300')); 737 | assert.ok(tokenRanges.has('sm')); 738 | assert.ok(tokenRanges.has('bg-red-500')); 739 | assert.ok(tokenRanges.has('focus')); 740 | assert.ok(tokenRanges.has('border-blue-500')); 741 | assert.strictEqual(tokenRanges.size, 10); 742 | 743 | verifyRanges(content, tokenRanges, [ 744 | 'hover:', 745 | 'bg-blue-500', 746 | 'lg:', 747 | 'border-2', 748 | 'bg-white', 749 | 'border-gray-300', 750 | 'sm:', 751 | 'bg-red-500', 752 | 'focus:', 753 | 'border-blue-500', 754 | ]); 755 | 756 | // Restore original configuration 757 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 758 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 759 | }); 760 | 761 | it('should detect * and ** prefix as a regular Tailwind prefix', async function () { 762 | const content = ` 763 |
764 | `; 765 | console.log('Testing:', content); 766 | await updateTestFile(content); 767 | 768 | const tokenRanges = getTokenRanges(); 769 | 770 | assert.ok(tokenRanges.has('*')); 771 | assert.ok(tokenRanges.has('**')); 772 | assert.strictEqual(tokenRanges.size, 2); 773 | 774 | verifyRanges(content, tokenRanges, ['*:bg-blue-500', '**:bg-blue-500']); 775 | }); 776 | 777 | it('should handle mixed * and ** prefixes with existing prefix and base classes', async function () { 778 | // Inject test theme with base classes alongside * and ** prefixes 779 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 780 | const originalThemes = config.get('themes', {}); 781 | 782 | const testThemes = { 783 | ...originalThemes, 784 | 'test-star-mixed': { 785 | prefix: { 786 | '*': { 787 | color: '#ff6600', 788 | fontWeight: '600', 789 | }, 790 | '**': { 791 | color: '#ff3300', 792 | fontWeight: '700', 793 | }, 794 | hover: { 795 | color: '#4ee585', 796 | fontWeight: '700', 797 | }, 798 | sm: { 799 | color: '#d18bfa', 800 | fontWeight: '700', 801 | }, 802 | }, 803 | base: { 804 | 'bg-*': { 805 | color: '#ff6b6b', 806 | fontWeight: '600', 807 | }, 808 | }, 809 | }, 810 | }; 811 | 812 | // Set the test theme 813 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 814 | await config.update('theme', 'test-star-mixed', vscode.ConfigurationTarget.Global); 815 | 816 | // Give some time for the configuration to update 817 | await new Promise((resolve) => setTimeout(resolve, 100)); 818 | 819 | const content = ` 820 |
821 |
822 | `; 823 | console.log('Testing:', content); 824 | await updateTestFile(content); 825 | 826 | const tokenRanges = getTokenRanges(); 827 | 828 | assert.ok(tokenRanges.has('hover')); 829 | assert.ok(tokenRanges.has('bg-blue-500')); 830 | assert.ok(tokenRanges.has('*')); 831 | assert.ok(tokenRanges.has('bg-red-300')); 832 | assert.ok(tokenRanges.has('sm')); 833 | assert.ok(tokenRanges.has('**')); 834 | assert.ok(tokenRanges.has('bg-white')); 835 | assert.strictEqual(tokenRanges.size, 7); 836 | 837 | verifyRanges(content, tokenRanges, [ 838 | 'hover:', 839 | 'bg-blue-500', 840 | '*:text-white', 841 | 'bg-red-300', 842 | 'sm:p-4', 843 | '**:border-2', 844 | 'bg-white', 845 | '*:shadow-lg', 846 | ]); 847 | 848 | // Restore original configuration 849 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 850 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 851 | }); 852 | 853 | it('should detect classes in template literals with tw` pattern', async function () { 854 | const content = ` 855 | import tw from 'twin.macro'; 856 | 857 | const Button = tw\` 858 | bg-blue-500 hover:bg-blue-600 859 | text-white font-bold py-2 px-4 rounded 860 | lg:text-xl md:p-6 861 | \`; 862 | `; 863 | console.log('Testing:', content); 864 | await updateTestFile(content); 865 | 866 | const tokenRanges = getTokenRanges(); 867 | 868 | assert.ok(tokenRanges.has('hover')); 869 | assert.ok(tokenRanges.has('lg')); 870 | assert.ok(tokenRanges.has('md')); 871 | assert.strictEqual(tokenRanges.size, 3); 872 | 873 | verifyRanges(content, tokenRanges, ['hover:bg-blue-600', 'lg:text-xl', 'md:p-6']); 874 | }); 875 | 876 | it('should detect classes in tw` template literals', async function () { 877 | const content = ` 878 | import tw from 'twin.macro'; 879 | const Button = tw\`hover:bg-blue-600 focus:ring-2 lg:p-4\`; 880 | `; 881 | console.log('Testing:', content); 882 | await updateTestFile(content); 883 | 884 | const tokenRanges = getTokenRanges(); 885 | 886 | assert.ok(tokenRanges.has('hover')); 887 | assert.ok(tokenRanges.has('focus')); 888 | assert.ok(tokenRanges.has('lg')); 889 | assert.strictEqual(tokenRanges.size, 3); 890 | 891 | verifyRanges(content, tokenRanges, ['hover:bg-blue-600', 'focus:ring-2', 'lg:p-4']); 892 | }); 893 | 894 | it('should detect classes in className template literals', async function () { 895 | const content = ` 896 | const Component = () => ( 897 |
898 | Content 899 |
900 | ); 901 | `; 902 | console.log('Testing:', content); 903 | await updateTestFile(content); 904 | 905 | const tokenRanges = getTokenRanges(); 906 | 907 | assert.ok(tokenRanges.has('hover')); 908 | assert.ok(tokenRanges.has('focus')); 909 | assert.ok(tokenRanges.has('sm')); 910 | assert.ok(tokenRanges.has('lg')); 911 | assert.strictEqual(tokenRanges.size, 4); 912 | 913 | verifyRanges(content, tokenRanges, ['hover:bg-blue-600', 'focus:ring-2', 'sm:opacity-75', 'lg:text-xl']); 914 | }); 915 | 916 | it('should detect classes in template literals with class attribute pattern', async function () { 917 | const content = ` 918 | const template = \` 919 |
920 | Content 921 |
922 | \`; 923 | `; 924 | console.log('Testing:', content); 925 | await updateTestFile(content); 926 | 927 | const tokenRanges = getTokenRanges(); 928 | 929 | assert.ok(tokenRanges.has('hover')); 930 | assert.ok(tokenRanges.has('lg')); 931 | assert.ok(tokenRanges.has('sm')); 932 | assert.ok(tokenRanges.has('focus')); 933 | assert.strictEqual(tokenRanges.size, 4); 934 | 935 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'lg:text-xl', 'sm:text-sm', 'focus:outline-none']); 936 | }); 937 | 938 | it('should detect classes in styled` template literals', async function () { 939 | const content = ` 940 | import styled from 'styled-components'; 941 | 942 | const Card = styled.div\` 943 | background: white; 944 | hover:bg-blue-500 focus:ring-2 sm:p-4 lg:p-6 945 | \`; 946 | `; 947 | console.log('Testing:', content); 948 | await updateTestFile(content); 949 | 950 | const tokenRanges = getTokenRanges(); 951 | 952 | assert.ok(tokenRanges.has('hover')); 953 | assert.ok(tokenRanges.has('focus')); 954 | assert.ok(tokenRanges.has('sm')); 955 | assert.ok(tokenRanges.has('lg')); 956 | assert.strictEqual(tokenRanges.size, 4); 957 | 958 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'focus:ring-2', 'sm:p-4', 'lg:p-6']); 959 | }); 960 | 961 | it('should detect classes with css` template pattern', async function () { 962 | const content = ` 963 | const styles = css\` 964 | background: white; 965 | hover:bg-blue-500 focus:ring-2 sm:p-4 lg:p-6 966 | \`; 967 | `; 968 | console.log('Testing:', content); 969 | await updateTestFile(content); 970 | 971 | const tokenRanges = getTokenRanges(); 972 | 973 | assert.ok(tokenRanges.has('hover')); 974 | assert.ok(tokenRanges.has('focus')); 975 | assert.ok(tokenRanges.has('sm')); 976 | assert.ok(tokenRanges.has('lg')); 977 | assert.strictEqual(tokenRanges.size, 4); 978 | 979 | verifyRanges(content, tokenRanges, ['hover:bg-blue-500', 'focus:ring-2', 'sm:p-4', 'lg:p-6']); 980 | }); 981 | 982 | it('should demonstrate template pattern functionality with known working patterns', async function () { 983 | const content = ` 984 | // Test tw\` pattern 985 | const Button = tw\`bg-blue-500 hover:bg-blue-600\`; 986 | 987 | // Test className with template literal 988 | const Component = () => ( 989 |
990 | Content 991 |
992 | ); 993 | 994 | // Test regular string patterns 995 | const classes = "md:text-center focus:ring-2"; 996 | `; 997 | console.log('Testing:', content); 998 | await updateTestFile(content); 999 | 1000 | const tokenRanges = getTokenRanges(); 1001 | 1002 | // Log what we actually found for debugging 1003 | console.log('Found prefixes:', Array.from(tokenRanges.keys())); 1004 | 1005 | console.log('Template patterns are working! Found prefixes:', Array.from(tokenRanges.keys())); 1006 | 1007 | // Verify we found at least some of the expected ones 1008 | const expectedPrefixes = ['hover', 'sm', 'lg', 'md', 'focus']; 1009 | const foundPrefixes = Array.from(tokenRanges.keys()); 1010 | const intersection = expectedPrefixes.filter((p) => foundPrefixes.includes(p)); 1011 | 1012 | assert.ok( 1013 | intersection.length > 0, 1014 | `Should find at least some prefixes from ${expectedPrefixes.join(', ')}, but found: ${foundPrefixes.join(', ')}` 1015 | ); 1016 | }); 1017 | 1018 | it('should detect classes in CSS files with @apply directive', async function () { 1019 | // Create a CSS document to test CSS file support 1020 | const content = ` 1021 | .btn { 1022 | @apply bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded; 1023 | } 1024 | 1025 | .responsive-grid { 1026 | @apply grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4; 1027 | } 1028 | 1029 | @media (max-width: 640px) { 1030 | .mobile-only { 1031 | @apply block sm:hidden p-4 focus:outline-none; 1032 | } 1033 | } 1034 | `; 1035 | 1036 | console.log('Testing:', content); 1037 | await updateTestFile(content, 'css'); 1038 | 1039 | const tokenRanges = getTokenRanges(); 1040 | 1041 | assert.ok(tokenRanges.has('hover')); 1042 | assert.ok(tokenRanges.has('sm')); 1043 | assert.ok(tokenRanges.has('md')); 1044 | assert.ok(tokenRanges.has('lg')); 1045 | assert.ok(tokenRanges.has('focus')); 1046 | 1047 | verifyRanges(content, tokenRanges, [ 1048 | 'hover:bg-blue-600', 1049 | 'sm:grid-cols-1', 1050 | 'md:grid-cols-2', 1051 | 'lg:grid-cols-3', 1052 | 'sm:hidden', 1053 | 'focus:outline-none', 1054 | ]); 1055 | }); 1056 | 1057 | it('should detect classes in SCSS files with @apply directive', async function () { 1058 | const content = ` 1059 | .component { 1060 | @apply md:p-6 lg:p-8; 1061 | 1062 | .content { 1063 | @apply md:text-base lg:text-lg; 1064 | } 1065 | } 1066 | `; 1067 | 1068 | console.log('Testing:', content); 1069 | await updateTestFile(content, 'scss'); 1070 | 1071 | const tokenRanges = getTokenRanges(); 1072 | 1073 | assert.ok(tokenRanges.has('md')); 1074 | assert.ok(tokenRanges.has('lg')); 1075 | assert.strictEqual(tokenRanges.size, 2); 1076 | 1077 | verifyRanges(content, tokenRanges, ['md:p-6', 'lg:p-8', 'md:text-base', 'lg:text-lg']); 1078 | }); 1079 | 1080 | it('should NOT match base classes with arbitrary values against prefix wildcards', async function () { 1081 | const content = ` 1082 |
1083 | `; 1084 | console.log('Testing:', content); 1085 | await updateTestFile(content); 1086 | 1087 | const tokenRanges = getTokenRanges(); 1088 | 1089 | assert.ok(tokenRanges.has('min-lg')); 1090 | assert.ok(tokenRanges.has('hover')); 1091 | assert.ok(!tokenRanges.has('min-w-[100px]')); // Should NOT match prefix wildcard 1092 | assert.strictEqual(tokenRanges.size, 2); 1093 | 1094 | verifyRanges(content, tokenRanges, ['min-lg:bg-blue-500', 'hover:text-white']); 1095 | }); 1096 | 1097 | it('should handle standalone arbitrary classes correctly', async function () { 1098 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 1099 | const originalThemes = config.get('themes', {}); 1100 | 1101 | const testThemes = { 1102 | ...originalThemes, 1103 | 'test-arbitrary-standalone': { 1104 | prefix: {}, 1105 | base: {}, 1106 | arbitrary: { 1107 | color: '#ff00ff', 1108 | fontWeight: '700', 1109 | }, 1110 | }, 1111 | }; 1112 | 1113 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 1114 | await config.update('theme', 'test-arbitrary-standalone', vscode.ConfigurationTarget.Global); 1115 | await new Promise((resolve) => setTimeout(resolve, 100)); 1116 | 1117 | const content = ` 1118 |
1119 | `; 1120 | console.log('Testing:', content); 1121 | await updateTestFile(content); 1122 | 1123 | const tokenRanges = getTokenRanges(); 1124 | 1125 | assert.ok(tokenRanges.has('[aspect-ratio:1/8]')); 1126 | assert.ok(tokenRanges.has('[&.show]')); 1127 | assert.strictEqual(tokenRanges.size, 2); 1128 | 1129 | verifyRanges(content, tokenRanges, ['[aspect-ratio:1/8]', '[&.show]:block']); 1130 | 1131 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 1132 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 1133 | }); 1134 | 1135 | it('should support multi-token coloring with both prefix and base class configs', async function () { 1136 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 1137 | const originalThemes = config.get('themes', {}); 1138 | 1139 | const testThemes = { 1140 | ...originalThemes, 1141 | 'test-multi-token': { 1142 | prefix: { 1143 | lg: { 1144 | color: '#ff0000', 1145 | fontWeight: '700', 1146 | }, 1147 | hover: { 1148 | color: '#00ff00', 1149 | fontWeight: '700', 1150 | }, 1151 | }, 1152 | base: { 1153 | 'min-*': { 1154 | color: '#0000ff', 1155 | fontWeight: '600', 1156 | }, 1157 | 'bg-*': { 1158 | color: '#ffff00', 1159 | fontWeight: '600', 1160 | }, 1161 | }, 1162 | }, 1163 | }; 1164 | 1165 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 1166 | await config.update('theme', 'test-multi-token', vscode.ConfigurationTarget.Global); 1167 | await new Promise((resolve) => setTimeout(resolve, 100)); 1168 | 1169 | const content = ` 1170 |
1171 | `; 1172 | console.log('Testing:', content); 1173 | await updateTestFile(content); 1174 | 1175 | const tokenRanges = getTokenRanges(); 1176 | 1177 | assert.ok(tokenRanges.has('lg')); 1178 | assert.ok(tokenRanges.has('min-w-[1920px]')); 1179 | assert.ok(tokenRanges.has('hover')); 1180 | assert.ok(tokenRanges.has('bg-red-500')); 1181 | assert.strictEqual(tokenRanges.size, 4); 1182 | 1183 | verifyRanges(content, tokenRanges, ['lg:', 'min-w-[1920px]', 'hover:', 'bg-red-500']); 1184 | 1185 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 1186 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 1187 | }); 1188 | 1189 | it('should prioritize exact matches over wildcard patterns', async function () { 1190 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 1191 | const originalThemes = config.get('themes', {}); 1192 | 1193 | const testThemes = { 1194 | ...originalThemes, 1195 | 'test-priority': { 1196 | prefix: { 1197 | 'min-lg': { 1198 | color: '#ff0000', 1199 | fontWeight: '700', 1200 | }, 1201 | 'min-*': { 1202 | color: '#00ff00', 1203 | fontWeight: '700', 1204 | }, 1205 | }, 1206 | base: { 1207 | 'bg-red-500': { 1208 | color: '#0000ff', 1209 | fontWeight: '600', 1210 | }, 1211 | 'bg-*': { 1212 | color: '#ffff00', 1213 | fontWeight: '600', 1214 | }, 1215 | }, 1216 | }, 1217 | }; 1218 | 1219 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 1220 | await config.update('theme', 'test-priority', vscode.ConfigurationTarget.Global); 1221 | await new Promise((resolve) => setTimeout(resolve, 100)); 1222 | 1223 | const content = ` 1224 |
1225 | `; 1226 | console.log('Testing:', content); 1227 | await updateTestFile(content); 1228 | 1229 | const tokenRanges = getTokenRanges(); 1230 | 1231 | assert.ok(tokenRanges.has('min-lg')); 1232 | assert.ok(tokenRanges.has('min-xl')); 1233 | assert.ok(tokenRanges.has('bg-red-500')); 1234 | assert.ok(tokenRanges.has('bg-blue-500')); 1235 | assert.strictEqual(tokenRanges.size, 4); 1236 | 1237 | verifyRanges(content, tokenRanges, ['min-lg:', 'bg-red-500', 'min-xl:', 'bg-blue-500']); 1238 | 1239 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 1240 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 1241 | }); 1242 | 1243 | it('should handle base classes with arbitrary values using base section wildcards', async function () { 1244 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 1245 | const originalThemes = config.get('themes', {}); 1246 | 1247 | const testThemes = { 1248 | ...originalThemes, 1249 | 'test-base-arbitrary': { 1250 | prefix: {}, 1251 | base: { 1252 | 'min-*': { 1253 | color: '#ff0000', 1254 | fontWeight: '600', 1255 | }, 1256 | 'max-*': { 1257 | color: '#00ff00', 1258 | fontWeight: '600', 1259 | }, 1260 | }, 1261 | arbitrary: { 1262 | color: '#0000ff', 1263 | fontWeight: '700', 1264 | }, 1265 | }, 1266 | }; 1267 | 1268 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 1269 | await config.update('theme', 'test-base-arbitrary', vscode.ConfigurationTarget.Global); 1270 | await new Promise((resolve) => setTimeout(resolve, 100)); 1271 | 1272 | const content = ` 1273 |
1274 | `; 1275 | console.log('Testing:', content); 1276 | await updateTestFile(content); 1277 | 1278 | const tokenRanges = getTokenRanges(); 1279 | 1280 | assert.ok(tokenRanges.has('min-w-[100px]')); 1281 | assert.ok(tokenRanges.has('max-h-[500px]')); 1282 | assert.strictEqual(tokenRanges.size, 2); 1283 | 1284 | verifyRanges(content, tokenRanges, ['min-w-[100px]', 'max-h-[500px]']); 1285 | 1286 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 1287 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 1288 | }); 1289 | 1290 | it('should handle complex multi-prefix with base class wildcards', async function () { 1291 | const config = vscode.workspace.getConfiguration('tailwindRainbow'); 1292 | const originalThemes = config.get('themes', {}); 1293 | 1294 | const testThemes = { 1295 | ...originalThemes, 1296 | 'test-complex-multi': { 1297 | prefix: { 1298 | lg: { 1299 | color: '#ff0000', 1300 | fontWeight: '700', 1301 | }, 1302 | checked: { 1303 | color: '#00ff00', 1304 | fontWeight: '700', 1305 | }, 1306 | hover: { 1307 | color: '#0000ff', 1308 | fontWeight: '700', 1309 | }, 1310 | }, 1311 | base: { 1312 | 'bg-*': { 1313 | color: '#ffff00', 1314 | fontWeight: '600', 1315 | }, 1316 | }, 1317 | }, 1318 | }; 1319 | 1320 | await config.update('themes', testThemes, vscode.ConfigurationTarget.Global); 1321 | await config.update('theme', 'test-complex-multi', vscode.ConfigurationTarget.Global); 1322 | await new Promise((resolve) => setTimeout(resolve, 100)); 1323 | 1324 | const content = ` 1325 |
1326 | `; 1327 | console.log('Testing:', content); 1328 | await updateTestFile(content); 1329 | 1330 | const tokenRanges = getTokenRanges(); 1331 | 1332 | assert.ok(tokenRanges.has('lg')); 1333 | assert.ok(tokenRanges.has('checked')); 1334 | assert.ok(tokenRanges.has('hover')); 1335 | assert.ok(tokenRanges.has('bg-blue-500')); 1336 | assert.strictEqual(tokenRanges.size, 4); 1337 | 1338 | verifyRanges(content, tokenRanges, ['lg:', 'checked:', 'hover:', 'bg-blue-500']); 1339 | 1340 | await config.update('themes', originalThemes, vscode.ConfigurationTarget.Global); 1341 | await config.update('theme', 'default', vscode.ConfigurationTarget.Global); 1342 | }); 1343 | }); 1344 | --------------------------------------------------------------------------------