├── .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 |
--------------------------------------------------------------------------------