├── .github └── workflows │ └── nodejs.yaml ├── .gitignore ├── LICENSE ├── README.md ├── biome.json ├── package-lock.json ├── package.json ├── src ├── CompletionProvider.ts ├── DefinitionProvider.ts ├── cli.ts ├── connection.ts ├── spec │ ├── __snapshots__ │ │ └── utils.spec.ts.snap │ ├── resolveAliasedFilepath.spec.ts │ ├── styles │ │ ├── nested.css │ │ ├── nested.less │ │ ├── nested.sass │ │ ├── nested.scss │ │ ├── regular.css │ │ ├── second-nested-selector.css │ │ ├── something.styl │ │ └── tsconfig.json │ └── utils.spec.ts ├── textDocuments.ts ├── utils.ts └── utils │ ├── resolveAliasedImport.ts │ └── resolveJson5File.ts └── tsconfig.json /.github/workflows/nodejs.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: 9 | branches: 10 | - '**' 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [22.x] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - name: install dependencies 26 | run: npm ci 27 | - name: types 28 | run: npm run build 29 | 30 | lint: 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | node-version: [22.x] 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | - name: install dependencies 42 | run: npm ci 43 | - name: check codestyle 44 | run: npm run lint 45 | 46 | tests: 47 | runs-on: ubuntu-latest 48 | strategy: 49 | matrix: 50 | node-version: [18.x, 20.x, 22.x] 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Use Node.js ${{ matrix.node-version }} 54 | uses: actions/setup-node@v4 55 | with: 56 | node-version: ${{ matrix.node-version }} 57 | - name: install dependencies 58 | run: npm ci 59 | - name: tests 60 | run: npm run test 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # yarn v2 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .yarn/install-state.gz 118 | .pnp.* 119 | 120 | .DS_Store 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present Anton Kastritskiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cssmodules-language-server 2 | 3 | Language server for `autocompletion` and `go-to-definition` functionality for css modules. 4 | 5 |

6 | 7 | Features: 8 | 9 | - **definition** jumps to class name under cursor. 10 | - **implementation** (works the same as definition). 11 | - **hover** provides comments before the class name with direct declarations within the class name. 12 | 13 | The supported languages are `css`(postcss), `sass` and `scss`. `styl` files are parsed as regular `css`. 14 | 15 | ## Installation 16 | 17 | ```sh 18 | npm install --global cssmodules-language-server 19 | ``` 20 | 21 | ## Configuration 22 | 23 | See if your editor supports language servers or if there is a plugin to add support for language servers 24 | 25 | ### Neovim 26 | 27 | Example uses [`nvim-lspconfig`](https://github.com/neovim/nvim-lspconfig) 28 | 29 | ```lua 30 | require'lspconfig'.cssmodules_ls.setup { 31 | -- provide your on_attach to bind keymappings 32 | on_attach = custom_on_attach, 33 | -- optionally 34 | init_options = { 35 | camelCase = 'dashes', 36 | }, 37 | } 38 | ``` 39 | 40 | **Known issue**: if you have multiple LSP that provide hover and go-to-definition support, there can be races(example typescript and cssmodules-language-server work simultaneously). As a workaround you can disable **definition** in favor of **implementation** to avoid conflicting with typescript's go-to-definition. 41 | 42 | ```lua 43 | require'lspconfig'.cssmodules_ls.setup { 44 | on_attach = function (client) 45 | -- avoid accepting `definitionProvider` responses from this LSP 46 | client.server_capabilities.definitionProvider = false 47 | custom_on_attach(client) 48 | end, 49 | } 50 | ``` 51 | 52 | ### [coc.nvim](https://github.com/neoclide/coc.nvim) 53 | 54 | ```vim 55 | let cssmodules_config = { 56 | \ "command": "cssmodules-language-server", 57 | \ "initializationOptions": {"camelCase": "dashes"}, 58 | \ "filetypes": ["javascript", "javascriptreact", "typescript", "typescriptreact"], 59 | \ "requireRootPattern": 0, 60 | \ "settings": {} 61 | \ } 62 | coc#config('languageserver.cssmodules', cssmodules_config) 63 | ``` 64 | 65 | ### [AstroNvim](https://github.com/AstroNvim/AstroNvim) 66 | 67 | As per [`AstroNvim's documentation`](https://astronvim.github.io/#%EF%B8%8F-installation), you can install cssmodules_ls with: 68 | 69 | ```vim 70 | :TSInstall cssmodules_ls 71 | ``` 72 | 73 | **Known issue**: since AstroNvim uses `nvim-lspconfig`, it suffers from the same issue as above. Here's a workaround to be inserted into init.nvim: 74 | ```lua 75 | -- previous config 76 | lsp = { 77 | -- previous configuration 78 | ["server-settings"] = { 79 | cssmodules_ls = { 80 | capabilities = { 81 | definitionProvider = false, 82 | }, 83 | }, 84 | }, 85 | } 86 | ``` 87 | From then, you can use `gI` which is the default shortcut for (go to implementation) as opposed to the usual `gd`. 88 | 89 | For more information on how to config LSP for AstroNvim, please refer to the [`Advanced LSP`](https://astronvim.github.io/Recipes/advanced_lsp) part of the documentation. 90 | 91 | ## Initialization options 92 | 93 | ### `camelCase` 94 | 95 | If you write kebab-case classes in css files, but want to get camelCase complete items, set following to true. 96 | 97 | ```json 98 | { 99 | "camelCase": true 100 | } 101 | ``` 102 | 103 | You can set the `cssmodules.camelCase` option to `true`, `"dashes"` or `false`(default). 104 | 105 | | Classname in css file | `true`(default | `dashes` | `false` | 106 | | --------------------- | ----------------- | --------------- | ----------------- | 107 | | `.button` | `.button` | `.button` | `.button` | 108 | | `.btn__icon--mod` | `.btnIconMod` | `.btn__iconMod` | `.btn__icon--mod` | 109 | 110 | 111 | ## Acknowledgments 112 | 113 | This plugin was extracted from [`coc-cssmodules`](https://github.com/antonk52/coc-cssmodules) as a standalone language server. 114 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "complexity": { 11 | "noForEach": "off" 12 | }, 13 | "correctness": { 14 | "noConstantCondition": "off" 15 | }, 16 | "style": { 17 | "useNodejsImportProtocol": "off" 18 | } 19 | } 20 | }, 21 | "css": { 22 | "formatter": { 23 | "indentStyle": "space", 24 | "indentWidth": 4, 25 | "quoteStyle": "double" 26 | } 27 | }, 28 | "javascript": { 29 | "formatter": { 30 | "indentStyle": "space", 31 | "indentWidth": 4, 32 | "quoteStyle": "single", 33 | "arrowParentheses": "asNeeded", 34 | "bracketSpacing": false 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cssmodules-language-server", 3 | "version": "1.5.1", 4 | "description": "language server for cssmodules", 5 | "bin": { 6 | "cssmodules-language-server": "./lib/cli.js" 7 | }, 8 | "scripts": { 9 | "clean": "rimraf lib *.tsbuildinfo", 10 | "build": "tsc", 11 | "watch": "tsc --watch", 12 | "lint": "biome check ./src biome.json", 13 | "format": "biome format --write ./src biome.json", 14 | "test": "vitest --run", 15 | "preversion": "npm run clean && npm run build && npm run lint && npm run test", 16 | "postversion": "npm publish && git push --follow-tags" 17 | }, 18 | "keywords": [ 19 | "language-server", 20 | "css-modules", 21 | "cssmodules" 22 | ], 23 | "author": "antonk52", 24 | "license": "MIT", 25 | "main": "lib/connection.js", 26 | "files": [ 27 | "lib/*.{js,d.ts}", 28 | "lib/!(spec)/**/*.{js,d.ts}" 29 | ], 30 | "devDependencies": { 31 | "@biomejs/biome": "^1.9.4", 32 | "@types/lodash.camelcase": "^4.3.9", 33 | "@types/node": "^18.19.26", 34 | "rimraf": "^6.0.1", 35 | "typescript": "^5.7.3", 36 | "vitest": "^3.0.4" 37 | }, 38 | "dependencies": { 39 | "json5": "^2.2.3", 40 | "lilconfig": "^3.1.3", 41 | "lodash.camelcase": "^4.3.0", 42 | "postcss": "^8.1.10", 43 | "postcss-less": "^6.0.0", 44 | "postcss-sass": "^0.5.0", 45 | "postcss-scss": "^4.0.9", 46 | "vscode-languageserver": "^9.0.1", 47 | "vscode-languageserver-protocol": "^3.17.5", 48 | "vscode-languageserver-textdocument": "^1.0.12", 49 | "vscode-uri": "^3.0.8" 50 | }, 51 | "funding": "https://github.com/sponsors/antonk52", 52 | "engines": { 53 | "node": ">=18" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/CompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import {CompletionItem, type Position} from 'vscode-languageserver-protocol'; 2 | import type {TextDocument} from 'vscode-languageserver-textdocument'; 3 | import * as lsp from 'vscode-languageserver/node'; 4 | import {textDocuments} from './textDocuments'; 5 | import { 6 | findImportPath, 7 | getAllClassNames, 8 | getCurrentDirFromUri, 9 | getEOL, 10 | getTransformer, 11 | } from './utils'; 12 | import type {CamelCaseValues} from './utils'; 13 | 14 | export const COMPLETION_TRIGGERS = ['.', '[', '"', "'"]; 15 | 16 | type FieldOptions = { 17 | wrappingBracket: boolean; 18 | startsWithQuote: boolean; 19 | endsWithQuote: boolean; 20 | }; 21 | 22 | /** 23 | * check if current character or last character is any of the completion triggers (i.e. `.`, `[`) and return it 24 | * 25 | * @see COMPLETION_TRIGGERS 26 | */ 27 | function findTrigger(line: string, position: Position): string | undefined { 28 | const i = position.character - 1; 29 | 30 | for (const trigger of COMPLETION_TRIGGERS) { 31 | if (line[i] === trigger) { 32 | return trigger; 33 | } 34 | if (i > 1 && line[i - 1] === trigger) { 35 | return trigger; 36 | } 37 | } 38 | 39 | return undefined; 40 | } 41 | 42 | /** 43 | * Given the line, position and trigger, returns the identifier referencing the styles spreadsheet and the (partial) field selected with options to help construct the completion item later. 44 | * 45 | */ 46 | function getWords( 47 | line: string, 48 | position: Position, 49 | trigger: string, 50 | ): [string, string, FieldOptions?] | undefined { 51 | const text = line.slice(0, position.character); 52 | const index = text.search(/[a-z0-9\._\[\]'"\-]*$/i); 53 | if (index === -1) { 54 | return undefined; 55 | } 56 | 57 | const words = text.slice(index); 58 | 59 | if (words === '' || words.indexOf(trigger) === -1) { 60 | return undefined; 61 | } 62 | 63 | switch (trigger) { 64 | // process `.` trigger 65 | case '.': 66 | return words.split('.') as [string, string]; 67 | // process `[` trigger 68 | case '[': { 69 | const [obj, field] = words.split('['); 70 | 71 | let lineAhead = line.slice(position.character); 72 | const endsWithQuote = lineAhead.search(/^["']/) !== -1; 73 | 74 | lineAhead = endsWithQuote ? lineAhead.slice(1) : lineAhead; 75 | const wrappingBracket = lineAhead.search(/^\s*\]/) !== -1; 76 | 77 | const startsWithQuote = 78 | field.length > 0 && (field[0] === '"' || field[0] === "'"); 79 | 80 | return [ 81 | obj, 82 | field.slice(startsWithQuote ? 1 : 0), 83 | {wrappingBracket, startsWithQuote, endsWithQuote}, 84 | ]; 85 | } 86 | default: { 87 | throw new Error(`Unsupported trigger character ${trigger}`); 88 | } 89 | } 90 | } 91 | 92 | function createCompletionItem( 93 | trigger: string, 94 | name: string, 95 | position: Position, 96 | fieldOptions: FieldOptions | undefined, 97 | ): CompletionItem { 98 | const nameIncludesDashes = name.includes('-'); 99 | const completionField = 100 | trigger === '[' || nameIncludesDashes ? `['${name}']` : name; 101 | 102 | let completionItem: CompletionItem; 103 | // in case of items with dashes, we need to replace the `.` and suggest the field using the subscript expression `[` 104 | if (trigger === '.') { 105 | if (nameIncludesDashes) { 106 | const range = lsp.Range.create( 107 | lsp.Position.create(position.line, position.character - 1), // replace the `.` character 108 | position, 109 | ); 110 | 111 | completionItem = CompletionItem.create(completionField); 112 | completionItem.textEdit = lsp.InsertReplaceEdit.create( 113 | completionField, 114 | range, 115 | range, 116 | ); 117 | } else { 118 | completionItem = CompletionItem.create(completionField); 119 | } 120 | } else { 121 | // trigger === '[' 122 | const startPositionCharacter = 123 | position.character - 124 | 1 - // replace the `[` character 125 | (fieldOptions?.startsWithQuote ? 1 : 0); // replace the starting quote if present 126 | 127 | const endPositionCharacter = 128 | position.character + 129 | (fieldOptions?.endsWithQuote ? 1 : 0) + // replace the ending quote if present 130 | (fieldOptions?.wrappingBracket ? 1 : 0); // replace the wrapping bracket if present 131 | 132 | const range = lsp.Range.create( 133 | lsp.Position.create(position.line, startPositionCharacter), 134 | lsp.Position.create(position.line, endPositionCharacter), 135 | ); 136 | 137 | completionItem = CompletionItem.create(completionField); 138 | completionItem.textEdit = lsp.InsertReplaceEdit.create( 139 | completionField, 140 | range, 141 | range, 142 | ); 143 | } 144 | 145 | return completionItem; 146 | } 147 | 148 | export class CSSModulesCompletionProvider { 149 | _classTransformer: (x: string) => string; 150 | 151 | constructor(camelCaseConfig: CamelCaseValues) { 152 | this._classTransformer = getTransformer(camelCaseConfig); 153 | } 154 | 155 | updateSettings(camelCaseConfig: CamelCaseValues): void { 156 | this._classTransformer = getTransformer(camelCaseConfig); 157 | } 158 | 159 | completion = async (params: lsp.CompletionParams) => { 160 | const textdocument = textDocuments.get(params.textDocument.uri); 161 | if (textdocument === undefined) { 162 | return []; 163 | } 164 | 165 | return this.provideCompletionItems(textdocument, params.position); 166 | }; 167 | 168 | async provideCompletionItems( 169 | textdocument: TextDocument, 170 | position: Position, 171 | ): Promise { 172 | const fileContent = textdocument.getText(); 173 | const lines = fileContent.split(getEOL(fileContent)); 174 | const currentLine = lines[position.line]; 175 | if (typeof currentLine !== 'string') return null; 176 | const currentDir = getCurrentDirFromUri(textdocument.uri); 177 | 178 | const trigger = findTrigger(currentLine, position); 179 | if (!trigger) { 180 | return []; 181 | } 182 | 183 | const foundFields = getWords(currentLine, position, trigger); 184 | if (!foundFields) { 185 | return []; 186 | } 187 | 188 | const [obj, field, fieldOptions] = foundFields; 189 | 190 | const importPath = findImportPath(fileContent, obj, currentDir); 191 | if (importPath === '') { 192 | return []; 193 | } 194 | 195 | const classNames: string[] = await getAllClassNames( 196 | importPath, 197 | field, 198 | this._classTransformer, 199 | ).catch(() => []); 200 | 201 | const res = classNames.map(_class => { 202 | const name = this._classTransformer(_class); 203 | 204 | const completionItem = createCompletionItem( 205 | trigger, 206 | name, 207 | position, 208 | fieldOptions, 209 | ); 210 | 211 | return completionItem; 212 | }); 213 | 214 | return res.map((x, i) => ({ 215 | ...x, 216 | kind: lsp.CompletionItemKind.Field, 217 | data: i + 1, 218 | })); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/DefinitionProvider.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { 3 | type Hover, 4 | Location, 5 | Position, 6 | Range, 7 | } from 'vscode-languageserver-protocol'; 8 | import type {TextDocument} from 'vscode-languageserver-textdocument'; 9 | import type * as lsp from 'vscode-languageserver/node'; 10 | import {textDocuments} from './textDocuments'; 11 | import { 12 | type CamelCaseValues, 13 | type Classname, 14 | filePathToClassnameDict, 15 | findImportPath, 16 | genImportRegExp, 17 | getCurrentDirFromUri, 18 | getEOL, 19 | getPosition, 20 | getTransformer, 21 | getWords, 22 | isImportLineMatch, 23 | stringifyClassname, 24 | } from './utils'; 25 | 26 | export class CSSModulesDefinitionProvider { 27 | _camelCaseConfig: CamelCaseValues; 28 | 29 | constructor(camelCaseConfig: CamelCaseValues) { 30 | this._camelCaseConfig = camelCaseConfig; 31 | } 32 | 33 | updateSettings(camelCaseConfig: CamelCaseValues): void { 34 | this._camelCaseConfig = camelCaseConfig; 35 | } 36 | 37 | definition = async (params: lsp.DefinitionParams) => { 38 | const textdocument = textDocuments.get(params.textDocument.uri); 39 | if (textdocument === undefined) { 40 | return []; 41 | } 42 | 43 | return this.provideDefinition(textdocument, params.position); 44 | }; 45 | 46 | hover = async (params: lsp.HoverParams) => { 47 | const textdocument = textDocuments.get(params.textDocument.uri); 48 | if (textdocument === undefined) { 49 | return null; 50 | } 51 | 52 | return this.provideHover(textdocument, params.position); 53 | }; 54 | 55 | async provideHover( 56 | textdocument: TextDocument, 57 | position: Position, 58 | ): Promise { 59 | const fileContent = textdocument.getText(); 60 | const EOL = getEOL(fileContent); 61 | const lines = fileContent.split(EOL); 62 | const currentLine = lines[position.line]; 63 | 64 | if (typeof currentLine !== 'string') { 65 | return null; 66 | } 67 | const currentDir = getCurrentDirFromUri(textdocument.uri); 68 | 69 | const words = getWords(currentLine, position); 70 | if (words === null) { 71 | return null; 72 | } 73 | 74 | const [obj, field] = words; 75 | 76 | const importPath = findImportPath(fileContent, obj, currentDir); 77 | if (importPath === '') { 78 | return null; 79 | } 80 | 81 | const dict = await filePathToClassnameDict( 82 | importPath, 83 | getTransformer(this._camelCaseConfig), 84 | ); 85 | 86 | const node: undefined | Classname = dict[`.${field}`]; 87 | 88 | if (!node) return null; 89 | 90 | return { 91 | contents: { 92 | language: 'css', 93 | value: stringifyClassname( 94 | field, 95 | node.declarations, 96 | node.comments, 97 | EOL, 98 | ), 99 | }, 100 | }; 101 | } 102 | 103 | async provideDefinition( 104 | textdocument: TextDocument, 105 | position: Position, 106 | ): Promise { 107 | const fileContent = textdocument.getText(); 108 | const lines = fileContent.split(getEOL(fileContent)); 109 | const currentLine = lines[position.line]; 110 | 111 | if (typeof currentLine !== 'string') { 112 | return null; 113 | } 114 | const currentDir = getCurrentDirFromUri(textdocument.uri); 115 | 116 | const matches = genImportRegExp('(\\S+)').exec(currentLine); 117 | if ( 118 | matches && 119 | isImportLineMatch(currentLine, matches, position.character) 120 | ) { 121 | const filePath: string = path.resolve(currentDir, matches[2]); 122 | const targetRange: Range = Range.create( 123 | Position.create(0, 0), 124 | Position.create(0, 0), 125 | ); 126 | return Location.create(filePath, targetRange); 127 | } 128 | 129 | const words = getWords(currentLine, position); 130 | if (words === null) { 131 | return null; 132 | } 133 | 134 | const [obj, field] = words; 135 | 136 | const importPath = findImportPath(fileContent, obj, currentDir); 137 | if (importPath === '') { 138 | return null; 139 | } 140 | 141 | const targetPosition = await getPosition( 142 | importPath, 143 | field, 144 | this._camelCaseConfig, 145 | ); 146 | 147 | if (targetPosition === null) { 148 | return null; 149 | } 150 | const targetRange: Range = { 151 | start: targetPosition, 152 | end: targetPosition, 153 | }; 154 | return Location.create(`file://${importPath}`, targetRange); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {createConnection} from './connection'; 4 | 5 | const args = process.argv; 6 | 7 | if (args.includes('--version') || args.includes('-v')) { 8 | process.stdout.write(`${require('../package.json').version}`); 9 | process.exit(0); 10 | } 11 | 12 | if (args.includes('rage')) { 13 | const environment = { 14 | Platform: process.platform, 15 | Arch: process.arch, 16 | NodeVersion: process.version, 17 | NodePath: process.execPath, 18 | CssModulesLanguageServerVersion: require('../package.json').version, 19 | }; 20 | 21 | Object.entries(environment).forEach(([key, value]) => { 22 | process.stdout.write(`${key}: ${value}\n`); 23 | }); 24 | 25 | process.exit(0); 26 | } 27 | 28 | createConnection().listen(); 29 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import * as lsp from 'vscode-languageserver/node'; 2 | 3 | import { 4 | COMPLETION_TRIGGERS, 5 | CSSModulesCompletionProvider, 6 | } from './CompletionProvider'; 7 | import {CSSModulesDefinitionProvider} from './DefinitionProvider'; 8 | import {textDocuments} from './textDocuments'; 9 | 10 | export function createConnection(): lsp.Connection { 11 | const connection = lsp.createConnection(process.stdin, process.stdout); 12 | 13 | textDocuments.listen(connection); 14 | 15 | const defaultSettings = { 16 | camelCase: true, 17 | } as const; 18 | 19 | const completionProvider = new CSSModulesCompletionProvider( 20 | defaultSettings.camelCase, 21 | ); 22 | const definitionProvider = new CSSModulesDefinitionProvider( 23 | defaultSettings.camelCase, 24 | ); 25 | 26 | connection.onInitialize(({capabilities, initializationOptions}) => { 27 | if (initializationOptions) { 28 | if ('camelCase' in initializationOptions) { 29 | completionProvider.updateSettings( 30 | initializationOptions.camelCase, 31 | ); 32 | definitionProvider.updateSettings( 33 | initializationOptions.camelCase, 34 | ); 35 | } 36 | } 37 | const hasWorkspaceFolderCapability = !!( 38 | capabilities.workspace && !!capabilities.workspace.workspaceFolders 39 | ); 40 | const result: lsp.InitializeResult = { 41 | capabilities: { 42 | textDocumentSync: lsp.TextDocumentSyncKind.Incremental, 43 | hoverProvider: true, 44 | definitionProvider: true, 45 | implementationProvider: true, 46 | completionProvider: { 47 | /** 48 | * only invoke completion once `.` or `[` are pressed 49 | */ 50 | triggerCharacters: COMPLETION_TRIGGERS, 51 | resolveProvider: true, 52 | }, 53 | }, 54 | }; 55 | if (hasWorkspaceFolderCapability) { 56 | result.capabilities.workspace = { 57 | workspaceFolders: { 58 | supported: true, 59 | }, 60 | }; 61 | } 62 | 63 | return result; 64 | }); 65 | 66 | connection.onCompletion(completionProvider.completion); 67 | 68 | connection.onDefinition(definitionProvider.definition); 69 | connection.onImplementation(definitionProvider.definition); 70 | 71 | connection.onHover(definitionProvider.hover); 72 | 73 | return connection; 74 | } 75 | -------------------------------------------------------------------------------- /src/spec/__snapshots__/utils.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`filePathToClassnameDict > CSS > gets a dictionary of classnames and their location 1`] = ` 4 | { 5 | ".block--element__mod": { 6 | "comments": [], 7 | "declarations": [ 8 | "color: green;", 9 | ], 10 | "position": { 11 | "column": 1, 12 | "line": 9, 13 | }, 14 | }, 15 | ".inMedia": { 16 | "comments": [], 17 | "declarations": [ 18 | "color: hotpink;", 19 | ], 20 | "position": { 21 | "column": 5, 22 | "line": 30, 23 | }, 24 | }, 25 | ".m-9": { 26 | "comments": [], 27 | "declarations": [ 28 | "color: blue;", 29 | ], 30 | "position": { 31 | "column": 1, 32 | "line": 13, 33 | }, 34 | }, 35 | ".one": { 36 | "comments": [], 37 | "declarations": [ 38 | "color: green;", 39 | ], 40 | "position": { 41 | "column": 1, 42 | "line": 5, 43 | }, 44 | }, 45 | ".single": { 46 | "comments": [], 47 | "declarations": [ 48 | "color: red;", 49 | ], 50 | "position": { 51 | "column": 1, 52 | "line": 1, 53 | }, 54 | }, 55 | ".two": { 56 | "comments": [], 57 | "declarations": [ 58 | "color: green;", 59 | ], 60 | "position": { 61 | "column": 5, 62 | "line": 5, 63 | }, 64 | }, 65 | ".💩": { 66 | "comments": [], 67 | "declarations": [ 68 | "color: brown;", 69 | ], 70 | "position": { 71 | "column": 1, 72 | "line": 17, 73 | }, 74 | }, 75 | ".🔥🚒": { 76 | "comments": [], 77 | "declarations": [ 78 | "color: yellow;", 79 | ], 80 | "position": { 81 | "column": 1, 82 | "line": 21, 83 | }, 84 | }, 85 | ".🤢-_-😷": { 86 | "comments": [], 87 | "declarations": [ 88 | "color: lime;", 89 | ], 90 | "position": { 91 | "column": 1, 92 | "line": 25, 93 | }, 94 | }, 95 | } 96 | `; 97 | 98 | exports[`filePathToClassnameDict > CSS > gets a dictionary of nested classnames 1`] = ` 99 | { 100 | ".child": { 101 | "comments": [], 102 | "declarations": [ 103 | "color: red;", 104 | ], 105 | "position": { 106 | "column": 5, 107 | "line": 7, 108 | }, 109 | }, 110 | ".inMedia": { 111 | "comments": [], 112 | "declarations": [ 113 | "color: hotpink;", 114 | ], 115 | "position": { 116 | "column": 5, 117 | "line": 33, 118 | }, 119 | }, 120 | ".inMedia__mod": { 121 | "comments": [], 122 | "declarations": [ 123 | "color: yellow;", 124 | ], 125 | "position": { 126 | "column": 9, 127 | "line": 36, 128 | }, 129 | }, 130 | ".parent": { 131 | "comments": [ 132 | "* foo bar", 133 | ], 134 | "declarations": [], 135 | "position": { 136 | "column": 1, 137 | "line": 6, 138 | }, 139 | }, 140 | ".parent--aa": { 141 | "comments": [], 142 | "declarations": [ 143 | "color: rebeccapurple;", 144 | ], 145 | "position": { 146 | "column": 5, 147 | "line": 26, 148 | }, 149 | }, 150 | ".parent--bb": { 151 | "comments": [], 152 | "declarations": [ 153 | "color: rebeccapurple;", 154 | ], 155 | "position": { 156 | "column": 5, 157 | "line": 26, 158 | }, 159 | }, 160 | ".parent--mod": { 161 | "comments": [], 162 | "declarations": [ 163 | "color: green;", 164 | ], 165 | "position": { 166 | "column": 5, 167 | "line": 11, 168 | }, 169 | }, 170 | ".parent--mod--addon": { 171 | "comments": [], 172 | "declarations": [ 173 | "color: lightgreen;", 174 | ], 175 | "position": { 176 | "column": 9, 177 | "line": 14, 178 | }, 179 | }, 180 | ".single": { 181 | "comments": [], 182 | "declarations": [ 183 | "color: red;", 184 | ], 185 | "position": { 186 | "column": 1, 187 | "line": 1, 188 | }, 189 | }, 190 | } 191 | `; 192 | 193 | exports[`filePathToClassnameDict > LESS > gets a dictionary of nested classnames from less files 1`] = ` 194 | { 195 | ".button": { 196 | "comments": [], 197 | "declarations": [], 198 | "position": { 199 | "column": 1, 200 | "line": 14, 201 | }, 202 | }, 203 | ".button-cancel": { 204 | "comments": [], 205 | "declarations": [ 206 | "background-image: url("cancel.png");", 207 | ], 208 | "position": { 209 | "column": 5, 210 | "line": 18, 211 | }, 212 | }, 213 | ".button-custom": { 214 | "comments": [], 215 | "declarations": [ 216 | "background-image: url("custom.png");", 217 | ], 218 | "position": { 219 | "column": 5, 220 | "line": 22, 221 | }, 222 | }, 223 | ".button-ok": { 224 | "comments": [], 225 | "declarations": [ 226 | "background-image: url("ok.png");", 227 | ], 228 | "position": { 229 | "column": 5, 230 | "line": 15, 231 | }, 232 | }, 233 | ".class": { 234 | "comments": [], 235 | "declarations": [ 236 | "property: 1px * 2px;", 237 | ], 238 | "position": { 239 | "column": 1, 240 | "line": 52, 241 | }, 242 | }, 243 | ".container": { 244 | "comments": [], 245 | "declarations": [], 246 | "position": { 247 | "column": 1, 248 | "line": 121, 249 | }, 250 | }, 251 | ".element": { 252 | "comments": [], 253 | "declarations": [ 254 | "color: @@color;", 255 | ], 256 | "position": { 257 | "column": 5, 258 | "line": 74, 259 | }, 260 | }, 261 | ".inMedia": { 262 | "comments": [], 263 | "declarations": [ 264 | "color: hotpink;", 265 | ], 266 | "position": { 267 | "column": 5, 268 | "line": 112, 269 | }, 270 | }, 271 | ".inMedia__mod": { 272 | "comments": [], 273 | "declarations": [ 274 | "color: yellow;", 275 | ], 276 | "position": { 277 | "column": 9, 278 | "line": 115, 279 | }, 280 | }, 281 | ".inner": { 282 | "comments": [], 283 | "declarations": [ 284 | "color: red;", 285 | ], 286 | "position": { 287 | "column": 5, 288 | "line": 98, 289 | }, 290 | }, 291 | ".inside-the-css-guard": { 292 | "comments": [], 293 | "declarations": [ 294 | "color: white;", 295 | ], 296 | "position": { 297 | "column": 5, 298 | "line": 105, 299 | }, 300 | }, 301 | ".link": { 302 | "comments": [], 303 | "declarations": [], 304 | "position": { 305 | "column": 1, 306 | "line": 27, 307 | }, 308 | }, 309 | ".linkish": { 310 | "comments": [], 311 | "declarations": [ 312 | "color: cyan;", 313 | ], 314 | "position": { 315 | "column": 5, 316 | "line": 40, 317 | }, 318 | }, 319 | ".math": { 320 | "comments": [], 321 | "declarations": [ 322 | "a: 1 + 1;", 323 | "b: 2px / 2;", 324 | "c: 2px ./ 2;", 325 | "d: (2px / 2);", 326 | ], 327 | "position": { 328 | "column": 1, 329 | "line": 45, 330 | }, 331 | }, 332 | ".mixin": { 333 | "comments": [ 334 | "extend", 335 | "mixins", 336 | ], 337 | "declarations": [ 338 | "box-shadow+: inset 0 0 10px #555;", 339 | ], 340 | "position": { 341 | "column": 1, 342 | "line": 88, 343 | }, 344 | }, 345 | ".my-optional-style": { 346 | "comments": [ 347 | "parent selectos without &", 348 | "CSS Guards", 349 | ], 350 | "declarations": [], 351 | "position": { 352 | "column": 1, 353 | "line": 104, 354 | }, 355 | }, 356 | ".myclass": { 357 | "comments": [], 358 | "declarations": [ 359 | "box-shadow+: 0 0 20px black;", 360 | ], 361 | "position": { 362 | "column": 1, 363 | "line": 91, 364 | }, 365 | }, 366 | ".section": { 367 | "comments": [], 368 | "declarations": [], 369 | "position": { 370 | "column": 1, 371 | "line": 71, 372 | }, 373 | }, 374 | ".single": { 375 | "comments": [], 376 | "declarations": [ 377 | "color: red;", 378 | ], 379 | "position": { 380 | "column": 1, 381 | "line": 10, 382 | }, 383 | }, 384 | ".withinMedia": { 385 | "comments": [], 386 | "declarations": [ 387 | "color: hotpink;", 388 | ], 389 | "position": { 390 | "column": 9, 391 | "line": 123, 392 | }, 393 | }, 394 | ".withinMedia__mod": { 395 | "comments": [], 396 | "declarations": [ 397 | "color: yellow;", 398 | ], 399 | "position": { 400 | "column": 13, 401 | "line": 126, 402 | }, 403 | }, 404 | } 405 | `; 406 | 407 | exports[`filePathToClassnameDict > SASS > gets a dictionary of nested classnames 1`] = ` 408 | { 409 | ".accordion": { 410 | "comments": [ 411 | "parent selectors", 412 | ], 413 | "declarations": [ 414 | "max-width: 600px;", 415 | "margin: 4rem auto;", 416 | "width: 90%;", 417 | "font-family: "Raleway", sans-serif;", 418 | "background: #f4f4f4;", 419 | ], 420 | "position": { 421 | "column": 1, 422 | "line": 55, 423 | }, 424 | }, 425 | ".accordion__copy": { 426 | "comments": [ 427 | "variables", 428 | ], 429 | "declarations": [ 430 | "display: none;", 431 | "padding: 1rem 1.5rem 2rem 1.5rem;", 432 | "color: gray;", 433 | "line-height: 1.6;", 434 | "font-size: 14px;", 435 | "font-weight: 500;", 436 | ], 437 | "position": { 438 | "column": 5, 439 | "line": 62, 440 | }, 441 | }, 442 | ".accordion__copy--open": { 443 | "comments": [], 444 | "declarations": [ 445 | "display: block;", 446 | ], 447 | "position": { 448 | "column": 9, 449 | "line": 70, 450 | }, 451 | }, 452 | ".alert": { 453 | "comments": [], 454 | "declarations": [ 455 | "border: 1px solid border-dark;", 456 | ], 457 | "position": { 458 | "column": 1, 459 | "line": 48, 460 | }, 461 | }, 462 | ".inMedia": { 463 | "comments": [], 464 | "declarations": [ 465 | "color: hotpink;", 466 | ], 467 | "position": { 468 | "column": 5, 469 | "line": 74, 470 | }, 471 | }, 472 | ".inMedia__mod": { 473 | "comments": [], 474 | "declarations": [ 475 | "color: yellow;", 476 | ], 477 | "position": { 478 | "column": 9, 479 | "line": 77, 480 | }, 481 | }, 482 | ".pulse": { 483 | "comments": [ 484 | "mixins", 485 | ], 486 | "declarations": [], 487 | "position": { 488 | "column": 1, 489 | "line": 35, 490 | }, 491 | }, 492 | } 493 | `; 494 | 495 | exports[`filePathToClassnameDict > SCSS > gets a dictionary of nested classnames for \`"dashes"\` setting 1`] = ` 496 | { 497 | ".accordion": { 498 | "comments": [ 499 | "parent selectors", 500 | ], 501 | "declarations": [ 502 | "max-width: 600px;", 503 | "margin: 4rem auto;", 504 | "width: 90%;", 505 | "font-family: "Raleway", sans-serif;", 506 | "background: #f4f4f4;", 507 | ], 508 | "position": { 509 | "column": 1, 510 | "line": 69, 511 | }, 512 | }, 513 | ".accordion__copy": { 514 | "comments": [], 515 | "declarations": [ 516 | "display: none;", 517 | "padding: 1rem 1.5rem 2rem 1.5rem;", 518 | "color: gray;", 519 | "line-height: 1.6;", 520 | "font-size: 14px;", 521 | "font-weight: 500;", 522 | ], 523 | "position": { 524 | "column": 5, 525 | "line": 76, 526 | }, 527 | }, 528 | ".accordion__copyOpen": { 529 | "comments": [], 530 | "declarations": [ 531 | "display: block;", 532 | ], 533 | "position": { 534 | "column": 9, 535 | "line": 84, 536 | }, 537 | }, 538 | ".accordion__sm": { 539 | "comments": [], 540 | "declarations": [ 541 | "width: 100%;", 542 | ], 543 | "position": { 544 | "column": 9, 545 | "line": 90, 546 | }, 547 | }, 548 | ".accordion__smShrink": { 549 | "comments": [], 550 | "declarations": [ 551 | "width: 80%;", 552 | ], 553 | "position": { 554 | "column": 13, 555 | "line": 93, 556 | }, 557 | }, 558 | ".alert": { 559 | "comments": [ 560 | "variables", 561 | ], 562 | "declarations": [ 563 | "border: 1px solid $border-dark;", 564 | ], 565 | "position": { 566 | "column": 1, 567 | "line": 58, 568 | }, 569 | }, 570 | ".inMedia": { 571 | "comments": [], 572 | "declarations": [ 573 | "color: hotpink;", 574 | ], 575 | "position": { 576 | "column": 5, 577 | "line": 101, 578 | }, 579 | }, 580 | ".inMedia__mod": { 581 | "comments": [], 582 | "declarations": [ 583 | "color: yellow;", 584 | ], 585 | "position": { 586 | "column": 9, 587 | "line": 104, 588 | }, 589 | }, 590 | ".pulse": { 591 | "comments": [], 592 | "declarations": [], 593 | "position": { 594 | "column": 1, 595 | "line": 46, 596 | }, 597 | }, 598 | } 599 | `; 600 | 601 | exports[`filePathToClassnameDict > SCSS > gets a dictionary of nested classnames for \`false\` setting 1`] = ` 602 | { 603 | ".accordion": { 604 | "comments": [ 605 | "parent selectors", 606 | ], 607 | "declarations": [ 608 | "max-width: 600px;", 609 | "margin: 4rem auto;", 610 | "width: 90%;", 611 | "font-family: "Raleway", sans-serif;", 612 | "background: #f4f4f4;", 613 | ], 614 | "position": { 615 | "column": 1, 616 | "line": 69, 617 | }, 618 | }, 619 | ".accordion__copy": { 620 | "comments": [], 621 | "declarations": [ 622 | "display: none;", 623 | "padding: 1rem 1.5rem 2rem 1.5rem;", 624 | "color: gray;", 625 | "line-height: 1.6;", 626 | "font-size: 14px;", 627 | "font-weight: 500;", 628 | ], 629 | "position": { 630 | "column": 5, 631 | "line": 76, 632 | }, 633 | }, 634 | ".accordion__copy--open": { 635 | "comments": [], 636 | "declarations": [ 637 | "display: block;", 638 | ], 639 | "position": { 640 | "column": 9, 641 | "line": 84, 642 | }, 643 | }, 644 | ".accordion__sm": { 645 | "comments": [], 646 | "declarations": [ 647 | "width: 100%;", 648 | ], 649 | "position": { 650 | "column": 9, 651 | "line": 90, 652 | }, 653 | }, 654 | ".accordion__sm--shrink": { 655 | "comments": [], 656 | "declarations": [ 657 | "width: 80%;", 658 | ], 659 | "position": { 660 | "column": 13, 661 | "line": 93, 662 | }, 663 | }, 664 | ".alert": { 665 | "comments": [ 666 | "variables", 667 | ], 668 | "declarations": [ 669 | "border: 1px solid $border-dark;", 670 | ], 671 | "position": { 672 | "column": 1, 673 | "line": 58, 674 | }, 675 | }, 676 | ".inMedia": { 677 | "comments": [], 678 | "declarations": [ 679 | "color: hotpink;", 680 | ], 681 | "position": { 682 | "column": 5, 683 | "line": 101, 684 | }, 685 | }, 686 | ".inMedia__mod": { 687 | "comments": [], 688 | "declarations": [ 689 | "color: yellow;", 690 | ], 691 | "position": { 692 | "column": 9, 693 | "line": 104, 694 | }, 695 | }, 696 | ".pulse": { 697 | "comments": [], 698 | "declarations": [], 699 | "position": { 700 | "column": 1, 701 | "line": 46, 702 | }, 703 | }, 704 | } 705 | `; 706 | 707 | exports[`filePathToClassnameDict > SCSS > gets a dictionary of nested classnames for \`true\` setting 1`] = ` 708 | { 709 | ".accordion": { 710 | "comments": [ 711 | "parent selectors", 712 | ], 713 | "declarations": [ 714 | "max-width: 600px;", 715 | "margin: 4rem auto;", 716 | "width: 90%;", 717 | "font-family: "Raleway", sans-serif;", 718 | "background: #f4f4f4;", 719 | ], 720 | "position": { 721 | "column": 1, 722 | "line": 69, 723 | }, 724 | }, 725 | ".accordionCopy": { 726 | "comments": [], 727 | "declarations": [ 728 | "display: none;", 729 | "padding: 1rem 1.5rem 2rem 1.5rem;", 730 | "color: gray;", 731 | "line-height: 1.6;", 732 | "font-size: 14px;", 733 | "font-weight: 500;", 734 | ], 735 | "position": { 736 | "column": 5, 737 | "line": 76, 738 | }, 739 | }, 740 | ".accordionCopyOpen": { 741 | "comments": [], 742 | "declarations": [ 743 | "display: block;", 744 | ], 745 | "position": { 746 | "column": 9, 747 | "line": 84, 748 | }, 749 | }, 750 | ".accordionSm": { 751 | "comments": [], 752 | "declarations": [ 753 | "width: 100%;", 754 | ], 755 | "position": { 756 | "column": 9, 757 | "line": 90, 758 | }, 759 | }, 760 | ".accordionSmShrink": { 761 | "comments": [], 762 | "declarations": [ 763 | "width: 80%;", 764 | ], 765 | "position": { 766 | "column": 13, 767 | "line": 93, 768 | }, 769 | }, 770 | ".alert": { 771 | "comments": [ 772 | "variables", 773 | ], 774 | "declarations": [ 775 | "border: 1px solid $border-dark;", 776 | ], 777 | "position": { 778 | "column": 1, 779 | "line": 58, 780 | }, 781 | }, 782 | ".inMedia": { 783 | "comments": [], 784 | "declarations": [ 785 | "color: hotpink;", 786 | ], 787 | "position": { 788 | "column": 5, 789 | "line": 101, 790 | }, 791 | }, 792 | ".inMediaMod": { 793 | "comments": [], 794 | "declarations": [ 795 | "color: yellow;", 796 | ], 797 | "position": { 798 | "column": 9, 799 | "line": 104, 800 | }, 801 | }, 802 | ".pulse": { 803 | "comments": [], 804 | "declarations": [], 805 | "position": { 806 | "column": 1, 807 | "line": 46, 808 | }, 809 | }, 810 | } 811 | `; 812 | -------------------------------------------------------------------------------- /src/spec/resolveAliasedFilepath.spec.ts: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'fs'; 2 | import {lilconfigSync} from 'lilconfig'; 3 | import {type Mock, describe, expect, it, vi} from 'vitest'; 4 | import {resolveAliasedImport} from '../utils/resolveAliasedImport'; 5 | import {resolveJson5File} from '../utils/resolveJson5File'; 6 | 7 | vi.mock('lilconfig', async () => { 8 | const actual: typeof import('lilconfig') = 9 | await vi.importActual('lilconfig'); 10 | return { 11 | ...actual, 12 | lilconfigSync: vi.fn(), 13 | }; 14 | }); 15 | 16 | vi.mock('../utils/resolveJson5File', async () => { 17 | return { 18 | resolveJson5File: vi.fn(), 19 | }; 20 | }); 21 | 22 | vi.mock('fs', async () => { 23 | const actual: typeof import('fs') = await vi.importActual('fs'); 24 | const existsSync = vi.fn(); 25 | return { 26 | ...actual, 27 | existsSync, 28 | default: { 29 | // @ts-ignore 30 | ...actual.default, 31 | existsSync, 32 | }, 33 | }; 34 | }); 35 | 36 | describe('utils: resolveAliasedFilepath', () => { 37 | it('returns null if config does not exist', () => { 38 | (lilconfigSync as Mock).mockReturnValueOnce({ 39 | search: () => null, 40 | }); 41 | const result = resolveAliasedImport({ 42 | location: '', 43 | importFilepath: '', 44 | }); 45 | const expected = null; 46 | 47 | expect(result).toEqual(expected); 48 | }); 49 | 50 | it('returns null when baseUrl is not set in the config', () => { 51 | (lilconfigSync as Mock).mockReturnValueOnce({ 52 | search: () => ({ 53 | config: { 54 | compilerOptions: { 55 | // missing "baseUrl" 56 | paths: {}, 57 | }, 58 | }, 59 | filepath: '/path/to/config', 60 | }), 61 | }); 62 | const result = resolveAliasedImport({ 63 | location: '', 64 | importFilepath: '', 65 | }); 66 | const expected = null; 67 | 68 | expect(result).toEqual(expected); 69 | }); 70 | 71 | it('returns null when "paths" is not set in the config and path does not match', () => { 72 | (lilconfigSync as Mock).mockReturnValueOnce({ 73 | search: () => ({ 74 | config: { 75 | compilerOptions: { 76 | baseUrl: './', 77 | // missing "paths" 78 | }, 79 | }, 80 | filepath: '/path/to/config', 81 | }), 82 | }); 83 | (existsSync as Mock).mockReturnValue(false); 84 | const result = resolveAliasedImport({ 85 | location: '', 86 | importFilepath: '', 87 | }); 88 | const expected = null; 89 | 90 | expect(result).toEqual(expected); 91 | }); 92 | 93 | it('returns resolved filepath when "paths" is not set in the config, and file exists', () => { 94 | (lilconfigSync as Mock).mockReturnValueOnce({ 95 | search: () => ({ 96 | config: { 97 | compilerOptions: { 98 | baseUrl: './', 99 | // missing "paths" 100 | }, 101 | }, 102 | filepath: '/path/to/tsconfig.json', 103 | }), 104 | }); 105 | (existsSync as Mock).mockReturnValue(true); 106 | const result = resolveAliasedImport({ 107 | location: '', 108 | importFilepath: 'src/styles/file.css', 109 | }); 110 | const expected = '/path/to/src/styles/file.css'; 111 | 112 | expect(result).toEqual(expected); 113 | }); 114 | 115 | it('returns baseUrl-mapped path when no alias matched import path', () => { 116 | (lilconfigSync as Mock).mockReturnValueOnce({ 117 | search: () => ({ 118 | config: { 119 | compilerOptions: { 120 | baseUrl: './', 121 | paths: { 122 | '@bar/*': ['./bar/*'], 123 | }, 124 | }, 125 | }, 126 | filepath: '/path/to/config', 127 | }), 128 | }); 129 | const result = resolveAliasedImport({ 130 | location: '', 131 | importFilepath: '@foo', 132 | }); 133 | const expected = '/path/to/@foo'; 134 | 135 | expect(result).toEqual(expected); 136 | }); 137 | 138 | it('returns null when no files matching alias were found', () => { 139 | (lilconfigSync as Mock).mockReturnValueOnce({ 140 | search: () => ({ 141 | config: { 142 | compilerOptions: { 143 | baseUrl: './', 144 | paths: { 145 | '@bar/*': ['./bar/*'], 146 | }, 147 | }, 148 | }, 149 | filepath: '/path/to/config', 150 | }), 151 | }); 152 | (existsSync as Mock).mockReturnValue(false); 153 | const result = resolveAliasedImport({ 154 | location: '', 155 | importFilepath: '@bar/file.css', 156 | }); 157 | const expected = null; 158 | 159 | expect(result).toEqual(expected); 160 | }); 161 | 162 | it('returns resolved filepath when matched alias file is found', () => { 163 | (lilconfigSync as Mock).mockReturnValueOnce({ 164 | search: () => ({ 165 | config: { 166 | compilerOptions: { 167 | baseUrl: './', 168 | paths: { 169 | '@bar/*': ['./bar/*'], 170 | }, 171 | }, 172 | }, 173 | filepath: '/path/to/tsconfig.json', 174 | }), 175 | }); 176 | (existsSync as Mock).mockReturnValue(true); 177 | const result = resolveAliasedImport({ 178 | location: '', 179 | importFilepath: '@bar/file.css', 180 | }); 181 | const expected = '/path/to/bar/file.css'; 182 | 183 | expect(result).toEqual(expected); 184 | }); 185 | 186 | it('searches for paths in parent configs when extends is set', () => { 187 | (lilconfigSync as Mock).mockReturnValueOnce({ 188 | search: () => ({ 189 | config: { 190 | compilerOptions: {}, 191 | extends: '../tsconfig.base.json', 192 | }, 193 | filepath: '/root/module/tsconfig.json', 194 | }), 195 | }); 196 | (existsSync as Mock).mockReturnValue(true); 197 | (resolveJson5File as Mock).mockReturnValueOnce({ 198 | config: { 199 | compilerOptions: { 200 | baseUrl: './', 201 | paths: { 202 | '@other/*': ['./other/*'], 203 | }, 204 | }, 205 | }, 206 | filepath: '/root/tsconfig.base.json', 207 | }); 208 | const result = resolveAliasedImport({ 209 | location: '', 210 | importFilepath: '@other/file.css', 211 | }); 212 | const expected = '/root/other/file.css'; 213 | 214 | expect(result).toEqual(expected); 215 | }); 216 | 217 | it('handles infinite extends loops', () => { 218 | (lilconfigSync as Mock).mockReturnValueOnce({ 219 | search: () => ({ 220 | config: { 221 | compilerOptions: {}, 222 | extends: '../tsconfig.base.json', 223 | }, 224 | filepath: '/root/module/tsconfig.json', 225 | }), 226 | }); 227 | (existsSync as Mock).mockReturnValue(true); 228 | (resolveJson5File as Mock).mockReturnValue({ 229 | config: { 230 | compilerOptions: {}, 231 | extends: './tsconfig.base.json', 232 | }, 233 | filepath: '/root/tsconfig.base.json', 234 | }); 235 | const result = resolveAliasedImport({ 236 | location: '', 237 | importFilepath: '@bar/file.css', 238 | }); 239 | const expected = null; 240 | 241 | expect(result).toEqual(expected); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /src/spec/styles/nested.css: -------------------------------------------------------------------------------- 1 | .single { 2 | color: red; 3 | } 4 | 5 | /** foo bar */ 6 | .parent { 7 | & .child { 8 | color: red; 9 | } 10 | 11 | &--mod { 12 | color: green; 13 | 14 | &--addon { 15 | color: lightgreen; 16 | } 17 | } 18 | 19 | &[disabled] { 20 | color: pink; 21 | } 22 | 23 | &:active { 24 | color: yellow; 25 | } 26 | &--aa:active, 27 | &--bb:active { 28 | color: rebeccapurple; 29 | } 30 | } 31 | 32 | @media (min-width: 320px) { 33 | .inMedia { 34 | color: hotpink; 35 | 36 | &__mod { 37 | color: yellow; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/spec/styles/nested.less: -------------------------------------------------------------------------------- 1 | // imports 2 | @import "foo"; // foo.less is imported 3 | @import "foo.less"; // foo.less is imported 4 | @import "foo.php"; // foo.php imported as a Less file 5 | @import "foo.css"; // statement left in place, as-is 6 | 7 | // plugins 8 | @plugin "my-plugin"; 9 | 10 | .single { 11 | color: red; 12 | } 13 | 14 | .button { 15 | &-ok { 16 | background-image: url("ok.png"); 17 | } 18 | &-cancel { 19 | background-image: url("cancel.png"); 20 | } 21 | 22 | &-custom { 23 | background-image: url("custom.png"); 24 | } 25 | } 26 | 27 | .link { 28 | & + & { 29 | color: red; 30 | } 31 | 32 | & & { 33 | color: green; 34 | } 35 | 36 | && { 37 | color: blue; 38 | } 39 | 40 | &, &ish { 41 | color: cyan; 42 | } 43 | } 44 | 45 | .math { 46 | a: 1 + 1; 47 | b: 2px / 2; 48 | c: 2px ./ 2; 49 | d: (2px / 2); 50 | } 51 | 52 | .class { 53 | property: 1px * 2px; 54 | } 55 | 56 | /* NOT SUPPORTED FOR GO TO DEFINITION */ 57 | 58 | // Variables 59 | @my-selector: banner; 60 | 61 | // Usage 62 | .@{my-selector} { 63 | font-weight: bold; 64 | line-height: 40px; 65 | margin: 0 auto; 66 | } 67 | 68 | @primary: green; 69 | @secondary: blue; 70 | 71 | .section { 72 | @color: primary; 73 | 74 | .element { 75 | color: @@color; 76 | } 77 | } 78 | 79 | // extend 80 | 81 | nav ul { 82 | &:extend(.inline); 83 | background: blue; 84 | } 85 | 86 | // mixins 87 | 88 | .mixin() { 89 | box-shadow+: inset 0 0 10px #555; 90 | } 91 | .myclass { 92 | .mixin(); 93 | box-shadow+: 0 0 20px black; 94 | } 95 | 96 | // parent selectos without & 97 | #outer() { 98 | .inner { 99 | color: red; 100 | } 101 | } 102 | 103 | // CSS Guards 104 | .my-optional-style() when (@my-option = true) { 105 | .inside-the-css-guard { 106 | color: white; 107 | } 108 | } 109 | .my-optional-style(); 110 | 111 | @media (min-width: 320px) { 112 | .inMedia { 113 | color: hotpink; 114 | 115 | &__mod { 116 | color: yellow; 117 | } 118 | } 119 | } 120 | 121 | .container { 122 | @media (min-width: 320px) { 123 | .withinMedia { 124 | color: hotpink; 125 | 126 | &__mod { 127 | color: yellow; 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/spec/styles/nested.sass: -------------------------------------------------------------------------------- 1 | // mixins 2 | @mixin button-base() 3 | @include typography(button) 4 | @include ripple-surface 5 | @include ripple-radius-bounded 6 | 7 | display: inline-flex 8 | position: relative 9 | height: $button-height 10 | border: none 11 | vertical-align: middle 12 | 13 | &:hover 14 | cursor: pointer 15 | 16 | &:disabled 17 | color: $mdc-button-disabled-ink-color 18 | cursor: default 19 | pointer-events: none 20 | 21 | @include corner-icon("mail", top, left) 22 | 23 | 24 | @mixin inline-animation($duration) 25 | $name: inline-#{unique-id()} 26 | 27 | @keyframes #{$name} 28 | @content 29 | 30 | animation-name: $name 31 | animation-duration: $duration 32 | animation-iteration-count: infinite 33 | 34 | 35 | .pulse 36 | @include inline-animation(2s) 37 | from 38 | background-color: yellow 39 | to 40 | background-color: red 41 | 42 | 43 | // variables 44 | 45 | $base-color: #c6538c 46 | $border-dark: rgba($base-color, 0.88) 47 | 48 | .alert 49 | border: 1px solid $border-dark 50 | 51 | @use 'library' with ($black: #222, $border-radius: 0.1rem) 52 | 53 | // parent selectors 54 | 55 | .accordion 56 | max-width: 600px 57 | margin: 4rem auto 58 | width: 90% 59 | font-family: "Raleway", sans-serif 60 | background: #f4f4f4 61 | 62 | &__copy 63 | display: none 64 | padding: 1rem 1.5rem 2rem 1.5rem 65 | color: gray 66 | line-height: 1.6 67 | font-size: 14px 68 | font-weight: 500 69 | 70 | &--open 71 | display: block 72 | 73 | @media (min-width: 320px) 74 | .inMedia 75 | color: hotpink; 76 | 77 | &__mod 78 | color: yellow; 79 | -------------------------------------------------------------------------------- /src/spec/styles/nested.scss: -------------------------------------------------------------------------------- 1 | // mixins 2 | @mixin button-base() { 3 | @include typography(button); 4 | @include ripple-surface; 5 | @include ripple-radius-bounded; 6 | 7 | display: inline-flex; 8 | position: relative; 9 | height: $button-height; 10 | border: none; 11 | vertical-align: middle; 12 | 13 | &:hover { cursor: pointer; } 14 | 15 | &:disabled { 16 | color: $mdc-button-disabled-ink-color; 17 | cursor: default; 18 | pointer-events: none; 19 | } 20 | } 21 | 22 | @mixin corner-icon($name, $top-or-bottom, $left-or-right) { 23 | .icon-#{$name} { 24 | background-image: url("/icons/#{$name}.svg"); 25 | position: absolute; 26 | #{$top-or-bottom}: 0; 27 | #{$left-or-right}: 0; 28 | } 29 | } 30 | 31 | @include corner-icon("mail", top, left); 32 | 33 | 34 | @mixin inline-animation($duration) { 35 | $name: inline-#{unique-id()}; 36 | 37 | @keyframes #{$name} { 38 | @content; 39 | } 40 | 41 | animation-name: $name; 42 | animation-duration: $duration; 43 | animation-iteration-count: infinite; 44 | } 45 | 46 | .pulse { 47 | @include inline-animation(2s) { 48 | from { background-color: yellow } 49 | to { background-color: red } 50 | } 51 | } 52 | 53 | // variables 54 | 55 | $base-color: #c6538c; 56 | $border-dark: rgba($base-color, 0.88); 57 | 58 | .alert { 59 | border: 1px solid $border-dark; 60 | } 61 | 62 | @use 'library' with ( 63 | $black: #222, 64 | $border-radius: 0.1rem 65 | ); 66 | 67 | // parent selectors 68 | 69 | .accordion { 70 | max-width: 600px; 71 | margin: 4rem auto; 72 | width: 90%; 73 | font-family: "Raleway", sans-serif; 74 | background: #f4f4f4; 75 | 76 | &__copy { 77 | display: none; 78 | padding: 1rem 1.5rem 2rem 1.5rem; 79 | color: gray; 80 | line-height: 1.6; 81 | font-size: 14px; 82 | font-weight: 500; 83 | 84 | &--open { 85 | display: block; 86 | } 87 | } 88 | 89 | @media (min-width: 320px) { 90 | &__sm { 91 | width: 100%; 92 | 93 | &--shrink { 94 | width: 80%; 95 | } 96 | } 97 | } 98 | } 99 | 100 | @media (min-width: 320px) { 101 | .inMedia { 102 | color: hotpink; 103 | 104 | &__mod { 105 | color: yellow; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/spec/styles/regular.css: -------------------------------------------------------------------------------- 1 | .single { 2 | color: red; 3 | } 4 | 5 | .one.two { 6 | color: green; 7 | } 8 | 9 | .block--element__mod { 10 | color: green; 11 | } 12 | 13 | .m-9 { 14 | color: blue; 15 | } 16 | 17 | .💩 { 18 | color: brown; 19 | } 20 | 21 | .🔥🚒 { 22 | color: yellow; 23 | } 24 | 25 | .🤢-_-😷 { 26 | color: lime; 27 | } 28 | 29 | @media (min-width: 320px) { 30 | .inMedia { 31 | color: hotpink; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/spec/styles/second-nested-selector.css: -------------------------------------------------------------------------------- 1 | /** foo bar */ 2 | .parent { 3 | & .child { 4 | color: red; 5 | } 6 | 7 | &--mod, 8 | &--alt { 9 | color: yellow; 10 | } 11 | 12 | /* hover */ 13 | &--mod:hover { 14 | color: green; 15 | } 16 | 17 | /* foo bar */ 18 | /** 19 | * things on the first line 20 | * things on the second line 21 | * things on the third line 22 | * things on the fourth line 23 | */ 24 | &--mod { 25 | &--addon { 26 | color: lightgreen; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/spec/styles/something.styl: -------------------------------------------------------------------------------- 1 | .foo 2 | & .bar 3 | width: 10px 4 | 5 | ^[0]:hover ^[1..-1] 6 | width: 20px 7 | 8 | .a 9 | .b 10 | &__c 11 | content: selectors() 12 | 13 | /* Disambiguation */ 14 | 15 | pad(n) 16 | margin (- n) 17 | 18 | body 19 | pad(5px) 20 | 21 | /* Variables */ 22 | 23 | font-size = 14px 24 | font = font-size "Lucida Grande", Arial 25 | 26 | body 27 | font font, sans-serif 28 | -------------------------------------------------------------------------------- /src/spec/styles/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | // @spec-styles points to this directory: 6 | "@spec-styles/*": ["*"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/spec/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import {describe, expect, it} from 'vitest'; 3 | import {Position} from 'vscode-languageserver-protocol'; 4 | import { 5 | filePathToClassnameDict, 6 | findImportPath, 7 | getTransformer, 8 | getWords, 9 | } from '../utils'; 10 | 11 | describe('filePathToClassnameDict', () => { 12 | describe('CSS', () => { 13 | it('gets a dictionary of classnames and their location', async () => { 14 | const filepath = path.join(__dirname, 'styles', 'regular.css'); 15 | const result = await filePathToClassnameDict( 16 | filepath, 17 | getTransformer(false), 18 | ); 19 | 20 | expect(result).toMatchSnapshot(); 21 | }); 22 | 23 | it('gets a dictionary of nested classnames', async () => { 24 | const filepath = path.join(__dirname, 'styles', 'nested.css'); 25 | const result = await filePathToClassnameDict( 26 | filepath, 27 | getTransformer(false), 28 | ); 29 | 30 | expect(result).toMatchSnapshot(); 31 | }); 32 | 33 | // TODO 34 | it.skip('multiple nested classnames in a single selector', async () => { 35 | const filepath = path.join( 36 | __dirname, 37 | 'styles', 38 | 'second-nested-selector.css', 39 | ); 40 | const result = await filePathToClassnameDict( 41 | filepath, 42 | getTransformer(false), 43 | ); 44 | 45 | expect(result).toMatchSnapshot(); 46 | }); 47 | }); 48 | 49 | describe('LESS', () => { 50 | it('gets a dictionary of nested classnames from less files', async () => { 51 | const filepath = path.join(__dirname, 'styles', 'nested.less'); 52 | const result = await filePathToClassnameDict( 53 | filepath, 54 | getTransformer(false), 55 | ); 56 | 57 | expect(result).toMatchSnapshot(); 58 | }); 59 | }); 60 | 61 | describe('SCSS', () => { 62 | it('gets a dictionary of nested classnames for `false` setting', async () => { 63 | const filepath = path.join(__dirname, 'styles', 'nested.scss'); 64 | const result = await filePathToClassnameDict( 65 | filepath, 66 | getTransformer(false), 67 | ); 68 | expect(result).toMatchSnapshot(); 69 | }); 70 | 71 | it('gets a dictionary of nested classnames for `true` setting', async () => { 72 | const filepath = path.join(__dirname, 'styles', 'nested.scss'); 73 | const result = await filePathToClassnameDict( 74 | filepath, 75 | getTransformer(true), 76 | ); 77 | 78 | expect(result).toMatchSnapshot(); 79 | }); 80 | 81 | it('gets a dictionary of nested classnames for `"dashes"` setting', async () => { 82 | const filepath = path.join(__dirname, 'styles', 'nested.scss'); 83 | const result = await filePathToClassnameDict( 84 | filepath, 85 | getTransformer('dashes'), 86 | ); 87 | 88 | expect(result).toMatchSnapshot(); 89 | }); 90 | }); 91 | 92 | describe('SASS', () => { 93 | it('gets a dictionary of nested classnames', async () => { 94 | const filepath = path.join(__dirname, 'styles', 'nested.sass'); 95 | const result = await filePathToClassnameDict( 96 | filepath, 97 | getTransformer(false), 98 | ); 99 | 100 | expect(result).toMatchSnapshot(); 101 | }); 102 | }); 103 | }); 104 | 105 | const fileContent = ` 106 | import React from 'react' 107 | 108 | import css from './style.css' 109 | import cssm from './style.module.css' 110 | import style from './style.css' 111 | import styles from './styles.css' 112 | import lCss from './styles.less' 113 | import sCss from './styles.scss' 114 | import sass from './styles.sass' 115 | import styl from './styles.styl' 116 | 117 | import aliasedRegularCss from '@spec-styles/regular.css' 118 | import aliasedNestedSass from '@spec-styles/nested.sass' 119 | 120 | const rCss = require('./style.css') 121 | const rStyle = require('./style.css') 122 | const rStyles = require('./styles.css') 123 | const rLCss = require('./styles.less') 124 | const rSCss = require('./styles.scss') 125 | const rSass = require('./styles.sass') 126 | const rStyl = require('./styles.styl') 127 | `.trim(); 128 | 129 | describe('findImportPath', () => { 130 | const dirPath = '/User/me/project/Component'; 131 | 132 | [ 133 | ['css', path.join(dirPath, 'style.css')], 134 | ['cssm', path.join(dirPath, 'style.module.css')], 135 | ['style', path.join(dirPath, 'style.css')], 136 | ['styles', path.join(dirPath, 'styles.css')], 137 | ['lCss', path.join(dirPath, 'styles.less')], 138 | ['sCss', path.join(dirPath, 'styles.scss')], 139 | ['sass', path.join(dirPath, 'styles.sass')], 140 | ['styl', path.join(dirPath, 'styles.styl')], 141 | 142 | ['rCss', path.join(dirPath, './style.css')], 143 | ['rStyle', path.join(dirPath, './style.css')], 144 | ['rStyles', path.join(dirPath, './styles.css')], 145 | ['rLCss', path.join(dirPath, './styles.less')], 146 | ['rSCss', path.join(dirPath, './styles.scss')], 147 | ['rSass', path.join(dirPath, './styles.sass')], 148 | ['rStyl', path.join(dirPath, './styles.styl')], 149 | ].forEach(([importName, expected]) => 150 | it(`finds the correct import path for ${importName}`, () => { 151 | const result = findImportPath(fileContent, importName, dirPath); 152 | expect(result).toBe(expected); 153 | }), 154 | ); 155 | 156 | const realDirPath = path.join(__dirname, 'styles'); 157 | 158 | [ 159 | ['aliasedRegularCss', path.join(realDirPath, 'regular.css')], 160 | ['aliasedNestedSass', path.join(realDirPath, 'nested.sass')], 161 | ].forEach(([importName, expected]) => { 162 | it(`resolves aliased import path for ${importName}`, () => { 163 | const result = findImportPath(fileContent, importName, realDirPath); 164 | expect(result).toBe(expected); 165 | }); 166 | }); 167 | 168 | it('returns an empty string when there is no import', () => { 169 | const simpleComponentFile = [ 170 | "import React from 'react'", 171 | 'export () =>

hello world

', 172 | ].join('\n'); 173 | 174 | const result = findImportPath(simpleComponentFile, 'css', dirPath); 175 | const expected = ''; 176 | 177 | expect(result).toEqual(expected); 178 | }); 179 | }); 180 | 181 | describe('getTransformer', () => { 182 | describe('for `true` setting', () => { 183 | const transformer = getTransformer(true); 184 | it('classic BEM classnames get camelified', () => { 185 | const input = '.el__block--mod'; 186 | const result = transformer(input); 187 | const expected = '.elBlockMod'; 188 | 189 | expect(result).toEqual(expected); 190 | }); 191 | it('emojis stay the same', () => { 192 | const input = '.✌'; 193 | const result = transformer(input); 194 | const expected = '.✌'; 195 | 196 | expect(result).toEqual(expected); 197 | }); 198 | }); 199 | describe('for `dashes` setting', () => { 200 | const transformer = getTransformer('dashes'); 201 | it('only dashes in BEM classnames get camelified', () => { 202 | const input = '.el__block--mod'; 203 | const result = transformer(input); 204 | const expected = '.el__blockMod'; 205 | 206 | expect(result).toEqual(expected); 207 | }); 208 | it('emojis stay the same', () => { 209 | const input = '.✌'; 210 | const result = transformer(input); 211 | const expected = '.✌'; 212 | 213 | expect(result).toEqual(expected); 214 | }); 215 | }); 216 | describe('for `false` setting', () => { 217 | const transformer = getTransformer(false); 218 | 219 | it('classic BEM classnames get camelified', () => { 220 | const input = '.el__block--mod'; 221 | const result = transformer(input); 222 | 223 | expect(result).toEqual(input); 224 | }); 225 | it('emojis stay the same', () => { 226 | const input = '.✌'; 227 | const result = transformer(input); 228 | 229 | expect(result).toEqual(input); 230 | }); 231 | }); 232 | }); 233 | describe('getWords', () => { 234 | it('returns null for a line with no .', () => { 235 | const line = 'nostyles'; 236 | const position = Position.create(0, 1); 237 | const result = getWords(line, position); 238 | 239 | expect(result).toEqual(null); 240 | }); 241 | it('returns pair of obj and field for line with property accessor expression', () => { 242 | const line = 'styles.myclass'; 243 | const position = Position.create(0, 'styles.'.length); 244 | const result = getWords(line, position); 245 | 246 | expect(result).toEqual(['styles', 'myclass']); 247 | }); 248 | it('returns pair of obj and field for line with subscript accessor expression (single quoted)', () => { 249 | const line = "styles['myclass']"; 250 | const position = Position.create(0, "styles['".length); 251 | const result = getWords(line, position); 252 | 253 | expect(result).toEqual(['styles', 'myclass']); 254 | }); 255 | it('returns pair of obj and field for line with subscript accessor expression (double quoted)', () => { 256 | const line = 'styles["myclass"]'; 257 | const position = Position.create(0, 'styles["'.length); 258 | const result = getWords(line, position); 259 | 260 | expect(result).toEqual(['styles', 'myclass']); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /src/textDocuments.ts: -------------------------------------------------------------------------------- 1 | import {TextDocument} from 'vscode-languageserver-textdocument'; 2 | import {TextDocuments} from 'vscode-languageserver/node'; 3 | 4 | export const textDocuments: TextDocuments = new TextDocuments( 5 | TextDocument, 6 | ); 7 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import _camelCase from 'lodash.camelcase'; 5 | import {Position} from 'vscode-languageserver-protocol'; 6 | import type {DocumentUri} from 'vscode-languageserver-textdocument'; 7 | 8 | import postcss from 'postcss'; 9 | import type {Comment, Node, Parser, ProcessOptions} from 'postcss'; 10 | 11 | import {resolveAliasedImport} from './utils/resolveAliasedImport'; 12 | 13 | export function getCurrentDirFromUri(uri: DocumentUri) { 14 | const filePath = url.fileURLToPath(uri); 15 | return path.dirname(filePath); 16 | } 17 | 18 | export type CamelCaseValues = false | true | 'dashes'; 19 | 20 | export function genImportRegExp(importName: string): RegExp { 21 | const file = '(.+\\.(styl|sass|scss|less|css))'; 22 | const fromOrRequire = '(?:from\\s+|=\\s+require(?:)?\\()'; 23 | const requireEndOptional = '\\)?'; 24 | const pattern = `\\b${importName}\\s+${fromOrRequire}["']${file}["']${requireEndOptional}`; 25 | 26 | return new RegExp(pattern); 27 | } 28 | 29 | function isRelativeFilePath(str: string): boolean { 30 | return str.startsWith('../') || str.startsWith('./'); 31 | } 32 | 33 | /** 34 | * Returns absolute file path to a file where css modules is from or an empty string 35 | * 36 | * @example "/users/foo/path/to/project/styles/foo.css" 37 | */ 38 | export function findImportPath( 39 | fileContent: string, 40 | importName: string, 41 | directoryPath: string, 42 | ): string { 43 | const re = genImportRegExp(importName); 44 | const results = re.exec(fileContent); 45 | 46 | if (results == null) { 47 | return ''; 48 | } 49 | 50 | const rawImportedFrom = results[1]; 51 | 52 | // "./style.modules.css" or "../../style.modules.css" 53 | if (isRelativeFilePath(rawImportedFrom)) { 54 | return path.resolve(directoryPath, results[1]); 55 | } 56 | 57 | return ( 58 | resolveAliasedImport({ 59 | importFilepath: rawImportedFrom, 60 | location: directoryPath, 61 | }) ?? '' 62 | ); 63 | } 64 | 65 | export type StringTransformer = (str: string) => string; 66 | export function getTransformer( 67 | camelCaseConfig: CamelCaseValues, 68 | ): StringTransformer { 69 | switch (camelCaseConfig) { 70 | case true: 71 | /** 72 | * _camelCase will remove the dots in the string though if the 73 | * classname starts with a dot we want to preserve it 74 | */ 75 | return input => 76 | `${input.charAt(0) === '.' ? '.' : ''}${_camelCase(input)}`; 77 | case 'dashes': 78 | /** 79 | * only replaces `-` that are followed by letters 80 | * 81 | * `.foo__bar--baz` -> `.foo__barBaz` 82 | */ 83 | return str => 84 | str.replace(/-+(\w)/g, (_, firstLetter) => 85 | firstLetter.toUpperCase(), 86 | ); 87 | default: 88 | return x => x; 89 | } 90 | } 91 | 92 | export function isImportLineMatch( 93 | line: string, 94 | matches: RegExpExecArray, 95 | current: number, 96 | ): boolean { 97 | if (matches === null) { 98 | return false; 99 | } 100 | 101 | const start1 = line.indexOf(matches[1]) + 1; 102 | const start2 = line.indexOf(matches[2]) + 1; 103 | 104 | // check current character is between match words 105 | return ( 106 | (current > start2 && current < start2 + matches[2].length) || 107 | (current > start1 && current < start1 + matches[1].length) 108 | ); 109 | } 110 | 111 | /** 112 | * Finds the position of the className in filePath 113 | */ 114 | export async function getPosition( 115 | filePath: string, 116 | className: string, 117 | camelCaseConfig: CamelCaseValues, 118 | ): Promise { 119 | const classDict = await filePathToClassnameDict( 120 | filePath, 121 | getTransformer(camelCaseConfig), 122 | ); 123 | const target = classDict[`.${className}`]; 124 | 125 | return target 126 | ? Position.create(target.position.line - 1, target.position.column) 127 | : null; 128 | } 129 | 130 | export function getWords( 131 | line: string, 132 | position: Position, 133 | ): [string, string] | null { 134 | const headText = line.slice(0, position.character); 135 | const startIndex = headText.search(/[a-z0-9\._]*$/i); 136 | // not found or not clicking object field 137 | if (startIndex === -1 || headText.slice(startIndex).indexOf('.') === -1) { 138 | // check if this is a subscript expression instead 139 | const startIndex = headText.search(/[a-z0-9"'_\[\-]*$/i); 140 | if ( 141 | startIndex === -1 || 142 | headText.slice(startIndex).indexOf('[') === -1 143 | ) { 144 | return null; 145 | } 146 | 147 | const match = /^([a-z0-9_\-\['"]*)/i.exec(line.slice(startIndex)); 148 | if (match === null) { 149 | return null; 150 | } 151 | 152 | const [styles, className] = match[1].split('['); 153 | 154 | // remove wrapping quotes around class name (both `'` or `"`) 155 | const unwrappedName = className.substring(1, className.length - 1); 156 | 157 | return [styles, unwrappedName] as [string, string]; 158 | } 159 | 160 | const match = /^([a-z0-9\._]*)/i.exec(line.slice(startIndex)); 161 | if (match === null) { 162 | return null; 163 | } 164 | 165 | return match[1].split('.') as [string, string]; 166 | } 167 | 168 | type ClassnamePostion = { 169 | line: number; 170 | column: number; 171 | }; 172 | 173 | export type Classname = { 174 | position: ClassnamePostion; 175 | declarations: string[]; 176 | comments: string[]; 177 | }; 178 | 179 | type ClassnameDict = Record; 180 | 181 | export const log = (...args: unknown[]) => { 182 | const timestamp = new Date().toLocaleTimeString('en-GB', {hour12: false}); 183 | const msg = args 184 | .map(x => 185 | typeof x === 'object' ? `\n${JSON.stringify(x, null, 2)}` : x, 186 | ) 187 | .join('\n\t'); 188 | 189 | fs.appendFileSync('/tmp/log-cssmodules', `\n[${timestamp}] ${msg}\n`); 190 | }; 191 | 192 | const sanitizeSelector = (selector: string) => 193 | selector 194 | .replace(/\\n|\\t/g, '') 195 | .replace(/\s+/, ' ') 196 | .trim(); 197 | 198 | type LazyLoadPostcssParser = () => Parser; 199 | 200 | const PostcssInst = postcss([]); 201 | 202 | const concatSelectors = ( 203 | parentSelectors: string[], 204 | nodeSelectors: string[], 205 | ): string[] => { 206 | // if parent is AtRule 207 | if (parentSelectors.length === 0) return nodeSelectors; 208 | 209 | return ([] as string[]).concat( 210 | ...parentSelectors.map(ps => 211 | nodeSelectors.map( 212 | /** 213 | * No need to replace for children separated by spaces 214 | * 215 | * .parent { 216 | * color: red; 217 | * 218 | * & .child { 219 | * ^^^^^^^^ no need to do the replace here, 220 | * since no new classnames are created 221 | * color: pink; 222 | * } 223 | * } 224 | */ 225 | s => (/&[a-z0-1-_]/i.test(s) ? s.replace('&', ps) : s), 226 | ), 227 | ), 228 | ); 229 | }; 230 | 231 | function getParentRule(node: Node): undefined | Node { 232 | const {parent} = node; 233 | if (!parent) return undefined; 234 | if (parent.type === 'rule') return parent; 235 | 236 | return getParentRule(parent); 237 | } 238 | 239 | /** 240 | * input `'./path/to/styles.css'` 241 | * 242 | * output 243 | * 244 | * ```js 245 | * { 246 | * '.foo': { 247 | * declarations: [], 248 | * position: { 249 | * line: 10, 250 | * column: 5, 251 | * }, 252 | * }, 253 | * '.bar': { 254 | * declarations: ['width: 52px'], 255 | * position: { 256 | * line: 22, 257 | * column: 1, 258 | * } 259 | * } 260 | * } 261 | * ``` 262 | */ 263 | export async function filePathToClassnameDict( 264 | filepath: string, 265 | classnameTransformer: StringTransformer, 266 | ): Promise { 267 | const content = fs.readFileSync(filepath, {encoding: 'utf8'}); 268 | const EOL = getEOL(content); 269 | const {ext} = path.parse(filepath); 270 | 271 | /** 272 | * only load the parses once they are needed 273 | */ 274 | const parsers: Record = { 275 | '.less': () => require('postcss-less'), 276 | '.scss': () => require('postcss-scss'), 277 | '.sass': () => require('postcss-sass'), 278 | }; 279 | 280 | const getParser = parsers[ext]; 281 | 282 | /** 283 | * Postcss does not expose this option though typescript types 284 | * This is why we are doing this naughty thingy 285 | */ 286 | const hiddenOption = {hideNothingWarning: true} as Record; 287 | const postcssOptions: ProcessOptions = { 288 | map: false, 289 | from: filepath, 290 | ...hiddenOption, 291 | ...(typeof getParser === 'function' ? {parser: getParser()} : {}), 292 | }; 293 | 294 | const ast = await PostcssInst.process(content, postcssOptions); 295 | // TODO: root.walkRules and for each rule gather info about parents 296 | const dict: ClassnameDict = {}; 297 | 298 | const visitedNodes = new Map([]); 299 | const stack = [...ast.root.nodes]; 300 | let commentStack: Comment[] = []; 301 | 302 | while (stack.length) { 303 | const node = stack.shift(); 304 | if (node === undefined) continue; 305 | if (node.type === 'comment') { 306 | commentStack.push(node); 307 | continue; 308 | } 309 | if (node.type === 'atrule') { 310 | if (node.name.toLowerCase() === 'media' && node.nodes) { 311 | stack.unshift(...node.nodes); 312 | } 313 | commentStack = []; 314 | continue; 315 | } 316 | if (node.type !== 'rule') continue; 317 | 318 | const selectors = node.selector.split(',').map(sanitizeSelector); 319 | 320 | selectors.forEach(sels => { 321 | const classNameRe = /\.([-0-9a-z_\p{Emoji_Presentation}])+/giu; 322 | if (node.parent === ast.root) { 323 | const match = sels.match(classNameRe); 324 | match?.forEach(name => { 325 | if (name in dict) return; 326 | 327 | if (node.source === undefined) return; 328 | 329 | const column = node.source.start?.column || 0; 330 | const line = node.source.start?.line || 0; 331 | 332 | const diff = node.selector.indexOf(name); 333 | const diffStr = node.selector.slice(0, diff); 334 | const lines = diffStr.split(EOL); 335 | const lastLine = lines[lines.length - 1]; 336 | 337 | dict[classnameTransformer(name)] = { 338 | declarations: node.nodes.reduce((acc, x) => { 339 | if (x.type === 'decl') { 340 | acc.push(`${x.prop}: ${x.value};`); 341 | } 342 | return acc; 343 | }, []), 344 | position: { 345 | column: column + lastLine.length, 346 | line: line + lines.length - 1, 347 | }, 348 | comments: commentStack.map(x => x.text), 349 | }; 350 | commentStack = []; 351 | }); 352 | 353 | visitedNodes.set(node, {selectors}); 354 | } else { 355 | if (node.parent === undefined) return; 356 | 357 | const parent = getParentRule(node); 358 | const knownParent = parent && visitedNodes.get(parent); 359 | 360 | const finishedSelectors: string[] = knownParent 361 | ? concatSelectors(knownParent.selectors, selectors) 362 | : selectors; 363 | 364 | const finishedSelectorsAndClassNames = finishedSelectors.map( 365 | finsihedSel => finsihedSel.match(classNameRe), 366 | ); 367 | 368 | finishedSelectorsAndClassNames.forEach(fscl => 369 | fscl?.forEach(classname => { 370 | if (classname in dict) return; 371 | if (node.source === undefined) return; 372 | 373 | const column = node.source.start?.column || 0; 374 | const line = node.source.start?.line || 0; 375 | 376 | // TODO: refine location to specific line by the classname's last characters 377 | dict[classnameTransformer(classname)] = { 378 | declarations: node.nodes.reduce( 379 | (acc, x) => { 380 | if (x.type === 'decl') { 381 | acc.push(`${x.prop}: ${x.value};`); 382 | } 383 | return acc; 384 | }, 385 | [], 386 | ), 387 | position: { 388 | column: column, 389 | line: line, 390 | }, 391 | comments: commentStack.map(x => x.text), 392 | }; 393 | commentStack = []; 394 | }), 395 | ); 396 | 397 | visitedNodes.set(node, {selectors: finishedSelectors}); 398 | } 399 | }); 400 | 401 | stack.push(...node.nodes); 402 | } 403 | 404 | return dict; 405 | } 406 | 407 | /** 408 | * Get all classnames from the file contents 409 | */ 410 | export async function getAllClassNames( 411 | filePath: string, 412 | keyword: string, 413 | classnameTransformer: StringTransformer, 414 | ): Promise { 415 | const classes = await filePathToClassnameDict( 416 | filePath, 417 | classnameTransformer, 418 | ); 419 | const classList = Object.keys(classes).map(x => x.slice(1)); 420 | 421 | return keyword !== '' 422 | ? classList.filter(item => item.includes(keyword)) 423 | : classList; 424 | } 425 | 426 | export function stringifyClassname( 427 | classname: string, 428 | declarations: string[], 429 | comments: string[], 430 | EOL: string, 431 | ): string { 432 | const commentString = comments.length 433 | ? comments 434 | .map(x => { 435 | const lines = x.split(EOL); 436 | if (lines.length < 2) { 437 | return `/*${x} */`; 438 | } 439 | return [ 440 | `/*${lines[0]}`, 441 | ...lines.slice(1).map(y => ` ${y.trimStart()}`), 442 | ' */', 443 | ].join(EOL); 444 | }) 445 | .join(EOL) + EOL 446 | : ''; 447 | return ( 448 | commentString + 449 | [ 450 | `.${classname} {${declarations.length ? '' : '}'}`, 451 | ...declarations.map(x => ` ${x}`), 452 | ...(declarations.length ? ['}'] : []), 453 | ].join(EOL) 454 | ); 455 | } 456 | 457 | // https://github.com/wkillerud/some-sass/blob/main/vscode-extension/src/utils/string.ts 458 | export function getEOL(text: string): string { 459 | for (let i = 0; i < text.length; i++) { 460 | const ch = text.charAt(i); 461 | if (ch === '\r') { 462 | if (i + 1 < text.length && text.charAt(i + 1) === '\n') { 463 | return '\r\n'; 464 | } 465 | return '\r'; 466 | } 467 | if (ch === '\n') { 468 | return '\n'; 469 | } 470 | } 471 | return '\n'; 472 | } 473 | -------------------------------------------------------------------------------- /src/utils/resolveAliasedImport.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import JSON5 from 'json5'; 4 | 5 | import {lilconfigSync} from 'lilconfig'; 6 | import {resolveJson5File} from './resolveJson5File'; 7 | 8 | const validate = { 9 | string: (x: unknown): x is string => typeof x === 'string', 10 | tsconfigPaths: (x: unknown): x is TsconfigPaths => { 11 | if (typeof x !== 'object' || x == null || Array.isArray(x)) { 12 | return false; 13 | } 14 | 15 | const paths = x as Record; 16 | 17 | const isValid = Object.values(paths).every(value => { 18 | return ( 19 | Array.isArray(value) && 20 | value.length > 0 && 21 | value.every(validate.string) 22 | ); 23 | }); 24 | 25 | return isValid; 26 | }, 27 | }; 28 | 29 | type TsconfigPaths = Record; 30 | 31 | /** 32 | * Attempts to resolve aliased file paths using tsconfig or jsconfig 33 | * 34 | * returns null if paths could not be resolved, absolute filepath otherwise 35 | * @see https://www.typescriptlang.org/tsconfig#paths 36 | */ 37 | export const resolveAliasedImport = ({ 38 | location, 39 | importFilepath, 40 | }: { 41 | /** 42 | * direcotry where the file with import is located 43 | * @example "/Users/foo/project/components/Button" 44 | */ 45 | location: string; 46 | /** 47 | * 48 | * @example "@/utils/style.module.css" 49 | */ 50 | importFilepath: string; 51 | }): string | null => { 52 | const searcher = lilconfigSync('', { 53 | searchPlaces: ['tsconfig.json', 'jsconfig.json'], 54 | loaders: { 55 | '.json': (_, content) => JSON5.parse(content), 56 | }, 57 | }); 58 | let config = searcher.search(location); 59 | 60 | if (config == null) { 61 | return null; 62 | } 63 | 64 | let configLocation = path.dirname(config.filepath); 65 | 66 | let paths: unknown = config.config?.compilerOptions?.paths; 67 | let pathsBase = configLocation; 68 | 69 | let potentialBaseUrl: unknown = config.config?.compilerOptions?.baseUrl; 70 | let baseUrl = validate.string(potentialBaseUrl) 71 | ? path.resolve(configLocation, potentialBaseUrl) 72 | : null; 73 | 74 | let depth = 0; 75 | while ((!paths || !baseUrl) && config.config?.extends && depth++ < 10) { 76 | config = resolveJson5File({ 77 | path: config.config.extends, 78 | base: configLocation, 79 | }); 80 | if (config == null) { 81 | return null; 82 | } 83 | configLocation = path.dirname(config.filepath); 84 | if (!paths && config.config?.compilerOptions?.paths) { 85 | paths = config.config.compilerOptions.paths; 86 | pathsBase = configLocation; 87 | } 88 | if (!baseUrl && config.config?.compilerOptions?.baseUrl) { 89 | potentialBaseUrl = config.config.compilerOptions.baseUrl; 90 | baseUrl = validate.string(potentialBaseUrl) 91 | ? path.resolve(configLocation, potentialBaseUrl) 92 | : null; 93 | } 94 | } 95 | 96 | if (validate.tsconfigPaths(paths)) { 97 | baseUrl = baseUrl || pathsBase; 98 | 99 | for (const alias in paths) { 100 | const aliasRe = new RegExp(alias.replace('*', '(.+)'), ''); 101 | 102 | const aliasMatch = importFilepath.match(aliasRe); 103 | 104 | if (aliasMatch == null) continue; 105 | 106 | for (const potentialAliasLocation of paths[alias]) { 107 | const resolvedFileLocation = path.resolve( 108 | baseUrl, 109 | potentialAliasLocation 110 | // "./utils/*" -> "./utils/style.module.css" 111 | .replace('*', aliasMatch[1]), 112 | ); 113 | 114 | if (!fs.existsSync(resolvedFileLocation)) continue; 115 | 116 | return resolvedFileLocation; 117 | } 118 | } 119 | } 120 | 121 | // if paths is defined, but no paths match 122 | // then baseUrl will not fallback to "." 123 | // if not using paths to find an alias, baseUrl must be defined 124 | // so here we only try and resolve the file if baseUrl is explcitly set and valid 125 | // i.e. if no baseUrl is provided 126 | // then no imports relative to baseUrl on its own are allowed, only relative to paths 127 | if (baseUrl) { 128 | const resolvedFileLocation = path.resolve(baseUrl, importFilepath); 129 | 130 | if (fs.existsSync(resolvedFileLocation)) { 131 | return resolvedFileLocation; 132 | } 133 | } 134 | 135 | return null; 136 | }; 137 | -------------------------------------------------------------------------------- /src/utils/resolveJson5File.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import JSON5 from 'json5'; 3 | 4 | import type {LilconfigResult} from 'lilconfig'; 5 | 6 | /** 7 | * Attempts to resolve the path to a json5 file using node.js resolution rules 8 | * 9 | * returns null if file could not be resolved, or if JSON5 parsing fails 10 | * @see https://www.typescriptlang.org/tsconfig/#extends 11 | */ 12 | export const resolveJson5File = ({ 13 | path, 14 | base, 15 | }: { 16 | /** 17 | * path to the json5 file 18 | * @example "../tsconfig.json" 19 | */ 20 | path: string; 21 | /** 22 | * directory where the file with import is located 23 | * @example "/Users/foo/project/components" 24 | */ 25 | base: string; 26 | }): LilconfigResult => { 27 | try { 28 | const filepath = require.resolve(path, {paths: [base]}); 29 | const content = fs.readFileSync(filepath, 'utf8'); 30 | const isEmpty = content.trim() === ''; 31 | const config = isEmpty ? {} : JSON5.parse(content); 32 | return {filepath, isEmpty, config}; 33 | } catch (e) { 34 | return null; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "rootDir": "src", 5 | "composite": true, 6 | "declaration": true, 7 | "target": "es6", 8 | "module": "commonjs", 9 | "esModuleInterop": true, 10 | "lib": ["es2017"], 11 | "skipLibCheck": true, 12 | "strict": true 13 | }, 14 | "include": ["src"], 15 | "exclude": ["src/spec"] 16 | } 17 | --------------------------------------------------------------------------------