diff --git a/.gitignore b/.gitignore index 49f8fe0..11b86d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ out/ out/** +tsconfig.tsbuildinfo docs/ diff --git a/AGENTS.md b/AGENTS.md index bc13715..cc7e0e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,139 +1,87 @@ -## Issue Tracking with bd (beads) +# CSS Variable LSP - Project Knowledge -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +**Generated:** 2026-03-23 +**Commit:** 5277576 +**Branch:** chore/update-deps -### Why bd? +## Overview -- Dependency-aware: Track blockers and relationships between issues -- Git-friendly: Auto-syncs to JSONL for version control -- Agent-optimized: JSON output, ready work detection, discovered-from links -- Prevents duplicate tracking systems and confusion +CSS Language Server for CSS custom properties. Indexes variables across workspace, provides completions, hover, diagnostics, go-to-definition, rename, and color support. -### Quick Start +**Stack:** TypeScript + vscode-languageserver + css-tree + node-html-parser -**Check for ready work:** +## Structure -```bash -bd ready --json -``` - -**Create new issues:** - -```bash -bd create "Issue title" -t bug|feature|task -p 0-4 --json -bd create "Issue title" -p 1 --deps discovered-from:bd-123 --json -``` - -**Claim and update:** - -```bash -bd update bd-42 --status in_progress --json -bd update bd-42 --priority 1 --json ``` - -**Complete work:** - -```bash -bd close bd-42 --reason "Completed" --json +css-lsp/ +├── src/ # 13 source files +│ ├── server.ts # LSP entry + all handlers +│ ├── cssVariableManager.ts # Core: variable index, cascade resolution +│ ├── colorProvider.ts # Color decorations/picker +│ ├── colorService.ts # Color parsing/formatting +│ ├── colorVariableFeature.ts # Color replacement suggestions +│ ├── completionContext.ts # Property-aware completions +│ ├── specificity.ts # CSS cascade ordering +│ ├── domTree.ts # HTML DOM for selector matching +│ ├── flags.ts # CLI flag definitions +│ ├── initialize.ts # LSP capability builder +│ ├── logger.ts # Structured logging +│ ├── pathDisplay.ts # Path formatting (relative/absolute) +│ └── runtimeConfig.ts # Runtime config builder +├── tests/ # 27 test files (*.test.ts) +├── out/ # Compiled output +├── docs/ # Example files +└── package.json # bin: css-variable-lsp ``` -### Issue Types +## Where to Look -- `bug` - Something broken -- `feature` - New functionality -- `task` - Work item (tests, docs, refactoring) -- `epic` - Large feature with subtasks -- `chore` - Maintenance (dependencies, tooling) +| Task | Location | Notes | +|------|----------|-------| +| LSP protocol handlers | `src/server.ts` | 900+ lines, all onXxx handlers | +| Variable parsing/indexing | `src/cssVariableManager.ts` | Core state manager | +| Color features | `src/colorService.ts`, `colorProvider.ts`, `colorVariableFeature.ts` | 3-file domain | +| Cascade/hover logic | `src/server.ts:611-635` | Sort by !important > specificity > source order | +| CLI/config | `src/flags.ts` | Declarative flag registry | +| Tests | `tests/*.test.ts` | Node.js native test runner | -### Priorities +## CODE MAP -- `0` - Critical (security, data loss, broken builds) -- `1` - High (major features, important bugs) -- `2` - Medium (default, nice-to-have) -- `3` - Low (polish, optimization) -- `4` - Backlog (future ideas) +| Symbol | Type | Location | Role | +|--------|------|----------|------| +| `connection` | const | server.ts:58 | LSP connection instance | +| `CssVariableManager` | class | cssVariableManager.ts:122 | Central state + parsing | +| `onCompletion` | handler | server.ts:489 | Var completions + color suggestions | +| `onHover` | handler | server.ts:564 | Cascade-aware hover | +| `resolveVariableColor` | method | cssVariableManager.ts:975 | Recursive color resolution | +| `calculateSpecificity` | function | specificity.ts:25 | CSS cascade ordering | +| `parseColor` | function | colorService.ts:11 | Hex/rgb/hsl/named parsing | -### Workflow for AI Agents +## Conventions -1. **Check ready work**: `bd ready` shows unblocked issues -2. **Claim your task**: `bd update --status in_progress` -3. **Work on it**: Implement, test, document -4. **Discover new work?** Create linked issue: - - `bd create "Found bug" -p 1 --deps discovered-from:` -5. **Complete**: `bd close --reason "Done"` -6. **Commit together**: Always commit the `.beads/issues.jsonl` file together with the code changes so issue state stays in sync with code state +- **Strict TypeScript** — `strict: true` in tsconfig +- **Named exports only** — No default exports +- **Structured logging** — Logger interface with `debug/info/warn/error` +- **Error handling** — Log via `logger.error()`, never throw in production +- **Tests** — Node.js native `node:test`, `strict as assert` from `node:assert` -### Auto-Sync +## Anti-Patterns (This Project) -bd automatically syncs with git: +- **No forbidden comments** — Project relies on TypeScript strict mode +- **No ESLint** — Empty `.prettierrc` uses defaults only +- **Flat src/ structure** — All 13 files at root level -- Exports to `.beads/issues.jsonl` after changes (5s debounce) -- Imports from JSONL when newer (e.g., after `git pull`) -- No manual export/import needed! - -### MCP Server (Recommended) - -If using Claude or MCP-compatible clients, install the beads MCP server: +## Commands ```bash -pip install beads-mcp +npm run compile # tsc -b → out/ +npm test # All 27 test files +npm run perf # Performance tests (CSS_LSP_PERF=1) ``` -Add to MCP config (e.g., `~/.config/claude/config.json`): - -```json -{ - "beads": { - "command": "beads-mcp", - "args": [] - } -} -``` - -Then use `mcp__beads__*` functions instead of CLI commands. - -### Managing AI-Generated Planning Documents - -AI assistants often create planning and design documents during development: - -- PLAN.md, IMPLEMENTATION.md, ARCHITECTURE.md -- DESIGN.md, CODEBASE_SUMMARY.md, INTEGRATION_PLAN.md -- TESTING_GUIDE.md, TECHNICAL_DESIGN.md, and similar files - -**Best Practice: Use a dedicated directory for these ephemeral files** - -**Recommended approach:** - -- Create a `history/` directory in the project root -- Store ALL AI-generated planning/design docs in `history/` -- Keep the repository root clean and focused on permanent project files -- Only access `history/` when explicitly asked to review past planning - -**Example .gitignore entry (optional):** - -``` -# AI planning documents (ephemeral) -history/ -``` - -**Benefits:** - -- ✅ Clean repository root -- ✅ Clear separation between ephemeral and permanent documentation -- ✅ Easy to exclude from version control if desired -- ✅ Preserves planning history for archeological research -- ✅ Reduces noise when browsing the project - -### Important Rules - -- ✅ Use bd for ALL task tracking -- ✅ Always use `--json` flag for programmatic use -- ✅ Link discovered work with `discovered-from` dependencies -- ✅ Check `bd ready` before asking "what should I work on?" -- ✅ Store AI planning docs in `history/` directory -- ❌ Do NOT create markdown TODO lists -- ❌ Do NOT use external issue trackers -- ❌ Do NOT duplicate tracking systems -- ❌ Do NOT clutter repo root with planning documents +## Notes -For more details, see README.md and QUICKSTART.md. +- `server.ts` is monolithic (900+ lines) — contains ALL LSP handlers +- Color index (`colorIndexDirty` flag) provides O(1) color lookups +- Cascade resolution: !important > inline > specificity > source order +- `cssVariableManager.ts` is the brain — imported by all feature modules diff --git a/README.md b/README.md index ad16e90..e3dbb6e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ This is a standalone LSP server. Configure it in any LSP client. Command-line flags: - `--no-color-preview` +- `--no-color-replacement-diagnostics` (disable "replace literal with CSS variable" suggestions) - `--color-only-variables` (show colors only on `var(--...)` usages) - `--lookup-files ","` (comma-separated list of glob patterns) - `--lookup-file ""` (repeatable) @@ -59,6 +60,9 @@ Command-line flags: Environment variables: +- `CSS_LSP_COLOR_REPLACEMENT_DIAGNOSTICS=0` (disable "replace literal with CSS variable" suggestions) + + - `CSS_LSP_COLOR_ONLY_VARIABLES=1` (same as `--color-only-variables`) - `CSS_LSP_LOOKUP_FILES` (comma-separated glob patterns; ignored if CLI lookup flags are provided) - `CSS_LSP_IGNORE_GLOBS` (comma-separated glob patterns; ignored if CLI ignore flags are provided) diff --git a/package-lock.json b/package-lock.json index a216ab3..55de435 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "css-variable-lsp", - "version": "1.0.16", + "version": "1.0.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "css-variable-lsp", - "version": "1.0.16", + "version": "1.0.19", "license": "GPL-3.0", "dependencies": { - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "glob": "^12.0.0", - "node-html-parser": "^7.0.1", + "node-html-parser": "^7.1.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" @@ -21,7 +21,7 @@ }, "devDependencies": { "@types/css-tree": "^2.3.11", - "@types/node": "^24.10.1", + "@types/node": "^24.12.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" }, @@ -42,42 +42,13 @@ "node": ">=12" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@jridgewell/resolve-uri": { @@ -144,20 +115,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -168,9 +138,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -180,30 +150,6 @@ "node": ">=0.4.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -211,30 +157,33 @@ "dev": true, "license": "MIT" }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=7.0.0" + "node": "18 || 20 || >=22" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -273,13 +222,13 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -298,9 +247,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -362,18 +311,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -434,15 +371,6 @@ "he": "bin/he" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -450,12 +378,12 @@ "license": "ISC" }, "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@isaacs/cliui": "^9.0.0" }, "engines": { "node": "20 || >=22" @@ -465,9 +393,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -481,39 +409,39 @@ "license": "ISC" }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/node-html-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", - "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", "license": "MIT", "dependencies": { "css-select": "^5.1.0", @@ -548,16 +476,16 @@ } }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -605,102 +533,6 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -751,7 +583,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -838,97 +669,6 @@ "node": ">= 8" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 7194f14..9f93af6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "css-variable-lsp", - "version": "1.0.18", + "version": "1.0.20-beta.1", "description": "A CSS Language Server for CSS Variables", "license": "GPL-3.0", "repository": { @@ -19,17 +19,17 @@ }, "devDependencies": { "@types/css-tree": "^2.3.11", - "@types/node": "^24.10.1", - "typescript": "^5.9.3", - "ts-node": "^10.9.2" + "@types/node": "^24.12.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" }, "dependencies": { - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "glob": "^12.0.0", + "node-html-parser": "^7.1.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8", - "node-html-parser": "^7.0.1" + "vscode-uri": "^3.0.8" }, "engines": { "node": "*" diff --git a/src/colorService.ts b/src/colorService.ts index 73b50fd..7b226a0 100644 --- a/src/colorService.ts +++ b/src/colorService.ts @@ -171,41 +171,85 @@ function parseHex(hex: string): Color | null { } function parseRgb(value: string): Color | null { - const match = value.match( - /rgba?\(([\d\s\.]+),?\s*([\d\s\.]+),?\s*([\d\s\.]+)(?:,?\s*\/?,?\s*([\d\s\.]+))?\)/, - ); - if (!match) return null; - - const r = parseFloat(match[1]) / 255; - const g = parseFloat(match[2]) / 255; - const b = parseFloat(match[3]) / 255; - let a = 1; + const start = value.indexOf("("); + const end = value.lastIndexOf(")"); + if (start === -1 || end === -1 || end <= start) { + return null; + } + + const inner = value.slice(start + 1, end).trim(); + const [rgbPart, alphaPart] = splitAlphaPart(inner); + const parts = rgbPart.split(/[\s,]+/).filter(Boolean); + if (parts.length !== 3 && parts.length !== 4) { + return null; + } + + const r = parseFloat(parts[0]) / 255; + const g = parseFloat(parts[1]) / 255; + const b = parseFloat(parts[2]) / 255; + const a = alphaPart + ? parseAlpha(alphaPart) + : parts.length === 4 + ? parseAlpha(parts[3]) + : 1; - if (match[4]) { - a = parseFloat(match[4]); + if ([r, g, b, a].some((component) => Number.isNaN(component))) { + return null; } return { red: r, green: g, blue: b, alpha: a }; } function parseHsl(value: string): Color | null { - const match = value.match( - /hsla?\(([\d\s\.]+)(?:deg)?,?\s*([\d\s\.]+)%?,?\s*([\d\s\.]+)%?(?:,?\s*\/?,?\s*([\d\s\.]+))?\)/, - ); - if (!match) return null; - - const h = parseFloat(match[1]) / 360; - const s = parseFloat(match[2]) / 100; - const l = parseFloat(match[3]) / 100; - let a = 1; + const start = value.indexOf("("); + const end = value.lastIndexOf(")"); + if (start === -1 || end === -1 || end <= start) { + return null; + } + + const inner = value.slice(start + 1, end).trim(); + const [hslPart, alphaPart] = splitAlphaPart(inner); + const parts = hslPart.split(/[\s,]+/).filter(Boolean); + if (parts.length !== 3 && parts.length !== 4) { + return null; + } - if (match[4]) { - a = parseFloat(match[4]); + const h = parseFloat(parts[0]) / 360; + const s = parseFloat(parts[1]) / 100; + const l = parseFloat(parts[2]) / 100; + const a = alphaPart + ? parseAlpha(alphaPart) + : parts.length === 4 + ? parseAlpha(parts[3]) + : 1; + + if ([h, s, l, a].some((component) => Number.isNaN(component))) { + return null; } return hslToRgb(h, s, l, a); } +function splitAlphaPart(value: string): [string, string | null] { + const slashIndex = value.indexOf("/"); + if (slashIndex === -1) { + return [value.trim(), null]; + } + + return [ + value.slice(0, slashIndex).trim(), + value.slice(slashIndex + 1).trim(), + ]; +} + +function parseAlpha(value: string): number { + if (value.endsWith("%")) { + return parseFloat(value) / 100; + } + + return parseFloat(value); +} + function hslToRgb(h: number, s: number, l: number, a: number): Color { let r, g, b; @@ -231,6 +275,14 @@ function hslToRgb(h: number, s: number, l: number, a: number): Color { return { red: r, green: g, blue: b, alpha: a }; } +export function getNormalizedColorKey(color: Color): string { + return formatColorAsHex(color).toLowerCase(); +} + +export function colorsEqual(a: Color, b: Color): boolean { + return getNormalizedColorKey(a) === getNormalizedColorKey(b); +} + function parseNamedColor(name: string): Color | null { const colors: { [key: string]: string } = { black: "#000000", diff --git a/src/colorVariableFeature.ts b/src/colorVariableFeature.ts new file mode 100644 index 0000000..a7cc574 --- /dev/null +++ b/src/colorVariableFeature.ts @@ -0,0 +1,227 @@ +import { + CodeAction, + CodeActionKind, + CompletionItem, + CompletionItemKind, + Diagnostic, + DiagnosticSeverity, + Position, + Range, + TextEdit, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { CssColorLiteral, CssVariable, CssVariableManager } from "./cssVariableManager"; +import { Logger } from "./logger"; + +export const COLOR_REPLACEMENT_DIAGNOSTIC_CODE = "replace-with-css-variable"; + +export interface ColorReplacementDiagnosticData { + kind: typeof COLOR_REPLACEMENT_DIAGNOSTIC_CODE; + variableNames: string[]; +} + +export interface CompletionDisplayOptions { + formatLocation(uri: string): string; +} + +export function collectColorReplacementDiagnostics( + document: TextDocument, + cssVariableManager: CssVariableManager, + logger: Logger +): Diagnostic[] { + try { + return cssVariableManager + .getDocumentColorLiterals(document.uri) + .flatMap((literal) => { + if (literal.variableName) { + return []; + } + + const matches = getMatchingVariables(literal, cssVariableManager); + if (matches.length === 0) { + return []; + } + + const variableNames = matches.map((match) => match.name); + const message = + variableNames.length === 1 + ? `Literal color can be replaced with matching CSS variable '${variableNames[0]}'` + : `Literal color can be replaced with matching CSS variables: ${variableNames.map((n) => `'${n}'`).join(", ")}`; + + return [ + { + severity: DiagnosticSeverity.Information, + range: literal.range, + message, + source: "css-variable-lsp", + code: COLOR_REPLACEMENT_DIAGNOSTIC_CODE, + data: { + kind: COLOR_REPLACEMENT_DIAGNOSTIC_CODE, + variableNames, + } satisfies ColorReplacementDiagnosticData, + }, + ]; + }); + } catch (error) { + logger.error("collectColorReplacementDiagnostics", { error }); + return []; + } +} +export function getColorReplacementCompletionItems( + document: TextDocument, + position: Position, + cssVariableManager: CssVariableManager, + displayOptions: CompletionDisplayOptions, + logger: Logger +): CompletionItem[] { + try { + const literal = findColorLiteralAtPosition(document, position, cssVariableManager); + if (!literal) { + return []; + } + + return getMatchingVariables(literal, cssVariableManager).map((match) => + createColorReplacementCompletionItem(document, literal.range, match, displayOptions) + ); + } catch (error) { + logger.error("getColorReplacementCompletionItems", { error }); + return []; + } +} +export function getColorReplacementCodeActions( + document: TextDocument, + diagnostics: Diagnostic[], + logger: Logger +): CodeAction[] { + try { + const actions: CodeAction[] = []; + + for (const diagnostic of diagnostics) { + if (diagnostic.code !== COLOR_REPLACEMENT_DIAGNOSTIC_CODE) { + continue; + } + + const data = diagnostic.data as ColorReplacementDiagnosticData | undefined; + if (!isValidDiagnosticData(data)) { + logger.error("getColorReplacementCodeActions", { diagnostic: diagnostic.data }); + continue; + } + + const variableNames = data.variableNames; + + for (const variableName of variableNames) { + actions.push({ + title: `Replace with var(${variableName})`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [document.uri]: [ + TextEdit.replace(diagnostic.range, `var(${variableName})`), + ], + }, + }, + }); + } + } + + return actions; + } catch (error) { + logger.error("getColorReplacementCodeActions", { error }); + return []; + } +} +function findColorLiteralAtPosition( + document: TextDocument, + position: Position, + cssVariableManager: CssVariableManager +): CssColorLiteral | null { + const offset = document.offsetAt(position); + const targetLine = position.line; + + const lineLiterals = cssVariableManager.getDocumentColorLiteralsByLine( + document.uri, + targetLine + ); + + for (const literal of lineLiterals) { + const start = document.offsetAt(literal.range.start); + const end = document.offsetAt(literal.range.end); + if (!isRangeValid(literal.range) || start > end) { + continue; + } + if (offset >= start && offset <= end) { + return literal; + } + } + + return null; +} + +function isValidDiagnosticData(data: unknown): data is ColorReplacementDiagnosticData { + if (typeof data !== "object" || data === null) { + return false; + } + const obj = data as Record; + return ( + obj.kind === COLOR_REPLACEMENT_DIAGNOSTIC_CODE && + Array.isArray(obj.variableNames) && + obj.variableNames.every((v) => typeof v === "string") + ); +} + +function isRangeValid(range: Range): boolean { + if (range.start.line < 0 || range.start.character < 0) { + return false; + } + if (range.end.line < 0 || range.end.character < 0) { + return false; + } + if (range.start.line > range.end.line) { + return false; + } + if (range.start.line === range.end.line && range.start.character > range.end.character) { + return false; + } + return true; +} + +function getMatchingVariables( + literal: CssColorLiteral, + cssVariableManager: CssVariableManager +): CssVariable[] { + return cssVariableManager.getVariablesByColor(literal.color, { + excludeName: literal.variableName, + }); +} + +function createColorReplacementCompletionItem( + _document: TextDocument, + range: Range, + match: CssVariable, + displayOptions: CompletionDisplayOptions +): CompletionItem { + return { + label: `var(${match.name})`, + kind: CompletionItemKind.Variable, + detail: match.value, + documentation: `Defined in ${displayOptions.formatLocation(match.uri)}`, + textEdit: TextEdit.replace(range, `var(${match.name})`), + filterText: `var(${match.name})`, + sortText: match.name, + }; +} + +// Check if cursor position is on a CSS variable definition +export function isPositionOnDefinition( + document: TextDocument, + definitions: CssVariable[], + position: Position, +): boolean { + const cursorOffset = document.offsetAt(position); + return definitions.some((def) => { + const start = document.offsetAt(def.range.start); + const end = document.offsetAt(def.range.end); + return cursorOffset >= start && cursorOffset <= end; + }); +} diff --git a/src/cssVariableManager.ts b/src/cssVariableManager.ts index 1999a06..c414c6e 100644 --- a/src/cssVariableManager.ts +++ b/src/cssVariableManager.ts @@ -7,9 +7,10 @@ import * as csstree from "css-tree"; import { DOMTree, DOMNodeInfo } from "./domTree"; import { parse } from "node-html-parser"; import { Color } from "vscode-languageserver/node"; -import { parseColor } from "./colorService"; +import { getNormalizedColorKey, parseColor } from "./colorService"; import { calculateSpecificity, compareSpecificity } from "./specificity"; import * as path from "path"; +import { Logger } from "./logger"; export interface CssVariable { name: string; @@ -33,9 +34,13 @@ export interface CssVariableUsage { domNode?: DOMNodeInfo; // DOM node if usage is in HTML } -export interface Logger { - log(message: string): void; - error(message: string): void; +export interface CssColorLiteral { + uri: string; + range: Range; + value: string; + color: Color; + propertyName: string; + variableName?: string; } const DEFAULT_LOOKUP_FILES = [ @@ -117,25 +122,17 @@ function normalizeUri(uri: string): string { export class CssVariableManager { private variables: Map = new Map(); private usages: Map = new Map(); + private colorLiterals: Map> = new Map(); private domTrees: Map = new Map(); // URI -> DOM tree + private colorIndex: Map> = new Map(); // color key -> variable names + private colorIndexDirty: boolean = true; private logger: Logger; private lookupFiles: string[]; private ignoreGlobs: string[]; private lookupExtensions: Map; - constructor(logger?: Logger, lookupFiles?: string[], ignoreGlobs?: string[]) { - this.logger = logger || { - log: (message: string) => { - // Only log to console in debug mode - if (process.env.CSS_LSP_DEBUG) { - console.log(message); - } - }, - error: (message: string) => { - // Always log errors - console.error(message); - }, - }; + constructor(logger: Logger, lookupFiles?: string[], ignoreGlobs?: string[]) { + this.logger = logger; const normalizedLookupFiles = normalizeGlobList(lookupFiles); const normalizedIgnoreGlobs = normalizeGlobList(ignoreGlobs); @@ -161,6 +158,35 @@ export class CssVariableManager { return extensions; } + /** + * Rebuild the color index for O(1) color lookups. + * Should be called when variables change. + */ + private rebuildColorIndex(): void { + this.colorIndex.clear(); + + for (const name of this.variables.keys()) { + const resolvedColor = this.resolveVariableColor(name); + if (resolvedColor) { + const key = getNormalizedColorKey(resolvedColor); + if (!this.colorIndex.has(key)) { + this.colorIndex.set(key, new Set()); + } + this.colorIndex.get(key)!.add(name); + } + } + + this.colorIndexDirty = false; + } + /** + * Ensure the color index is up to date. + */ + private ensureColorIndex(): void { + if (this.colorIndexDirty) { + this.rebuildColorIndex(); + } + } + private resolveLanguageId(filePath: string): string | null { const ext = path.extname(filePath); if (!ext) { @@ -205,9 +231,7 @@ export class CssVariableManager { absolute: true, }); - this.logger.log( - `[css-lsp] Scanned ${folder}: found ${files.length} files` - ); + this.logger.debug("scanFolder", { folder, fileCount: files.length }); allFiles.push(...files); } @@ -227,9 +251,7 @@ export class CssVariableManager { this.parseContent(content, fileUri, languageId); } catch (error) { - this.logger.error( - `[css-lsp] Error scanning file ${filePath}: ${error}` - ); + this.logger.error("scanFileError", { filePath, error: String(error) }); } processedFiles++; @@ -243,9 +265,7 @@ export class CssVariableManager { } } - this.logger.log( - `[css-lsp] Workspace scan complete. Processed ${totalFiles} files.` - ); + this.logger.debug("workspaceScanComplete", { totalFiles }); } public parseDocument(document: TextDocument): void { @@ -267,7 +287,7 @@ export class CssVariableManager { const domTree = new DOMTree(text); this.domTrees.set(uri, domTree); } catch (error) { - this.logger.error(`Error parsing HTML for ${uri}: ${error}`); + this.logger.error("parseHtmlError", { uri, error: String(error) }); } // Use node-html-parser to extract style blocks and inline styles @@ -329,7 +349,7 @@ export class CssVariableManager { } } } catch (error) { - this.logger.error(`Error parsing HTML content for ${uri}: ${error}`); + this.logger.error("parseHtmlContentError", { uri, error: String(error) }); } } else { // CSS, SCSS, SASS, LESS @@ -348,9 +368,7 @@ export class CssVariableManager { const ast = csstree.parse(text, { positions: true, onParseError: (error) => { - this.logger.log( - `[css-lsp] CSS Parse Error in ${uri}: ${error.message}` - ); + this.logger.debug("cssParseError", { uri, error: error.message }); }, }); @@ -409,12 +427,10 @@ export class CssVariableManager { // Capture valueRange from node.value location let valueRange: Range | undefined; if (node.value && node.value.loc) { - // Get the raw text from the value node - const valueStartOffset = offset + node.value.loc.start.offset; - const valueEndOffset = offset + node.value.loc.end.offset; + // Get the raw text from the value node (relative to CSS text) const rawValueText = text.substring( - valueStartOffset, - valueEndOffset + node.value.loc.start.offset, + node.value.loc.end.offset ); // Trim leading/trailing whitespace to get the actual value position @@ -423,11 +439,12 @@ export class CssVariableManager { const trailingWhitespace = rawValueText.length - rawValueText.trimEnd().length; + // Calculate document positions (absolute position in document) const valueStartPos = document.positionAt( - valueStartOffset + leadingWhitespace + offset + node.value.loc.start.offset + leadingWhitespace ); const valueEndPos = document.positionAt( - valueEndOffset - trailingWhitespace + offset + node.value.loc.end.offset - trailingWhitespace ); valueRange = Range.create(valueStartPos, valueEndPos); } @@ -449,9 +466,20 @@ export class CssVariableManager { this.variables.set(name, []); } this.variables.get(name)?.push(variable); + this.colorIndexDirty = true; } } + if (node.type === "Declaration" && node.value) { + this.collectColorLiteralsFromDeclaration( + node, + uri, + document, + text, + offset + ); + } + if (node.type === "Function" && node.name === "var") { const children = node.children; if (children && children.first) { @@ -510,7 +538,7 @@ export class CssVariableManager { }, }); } catch (e) { - this.logger.error(`Error parsing CSS in ${uri}: ${e}`); + this.logger.error("parseCssError", { uri, error: String(e) }); } } @@ -530,9 +558,7 @@ export class CssVariableManager { context: "declarationList", positions: true, onParseError: (error) => { - this.logger.log( - `[css-lsp] Inline Style Parse Error in ${uri}: ${error.message}` - ); + this.logger.debug("inlineStyleParseError", { uri, error: error.message }); }, }); @@ -609,9 +635,20 @@ export class CssVariableManager { this.variables.set(name, []); } this.variables.get(name)?.push(variable); + this.colorIndexDirty = true; } } + if (node.type === "Declaration" && node.value) { + this.collectColorLiteralsFromDeclaration( + node, + uri, + document, + text, + offset + ); + } + if (node.type === "Function" && node.name === "var") { const children = node.children; if (children && children.first) { @@ -665,17 +702,124 @@ export class CssVariableManager { }, }); } catch (e) { - this.logger.error(`Error parsing inline style in ${uri}: ${e}`); + this.logger.error("parseInlineStyleError", { uri, error: String(e) }); + } + } + + private collectColorLiteralsFromDeclaration( + declaration: csstree.Declaration, + uri: string, + document: TextDocument, + text: string, + offset: number + ): void { + const lineMap = this.colorLiterals.get(normalizeUri(uri)) || new Map(); + + this.collectColorLiteralsFromValue( + declaration.value, + uri, + document, + text, + offset, + declaration.property, + lineMap + ); + + if (declaration.value.type === "Raw" && declaration.value.loc) { + try { + const rawAst = csstree.parse(declaration.value.value, { + context: "value", + positions: true, + }); + this.collectColorLiteralsFromValue( + rawAst, + uri, + document, + declaration.value.value, + offset + declaration.value.loc.start.offset, + declaration.property, + lineMap + ); + } catch (error) { + this.logger.debug("rawValueParseError", { uri, error: String(error) }); + } } + + this.colorLiterals.set(normalizeUri(uri), lineMap); + } + + private collectColorLiteralsFromValue( + valueNode: csstree.CssNode, + uri: string, + document: TextDocument, + sourceText: string, + baseOffset: number, + propertyName: string, + lineMap: Map + ): void { + csstree.walk(valueNode, { + enter: (node: csstree.CssNode) => { + if (node.type === "Function" && node.name === "var") { + return csstree.walk.skip; + } + + if ( + node.type !== "Hash" && + node.type !== "Function" && + node.type !== "Identifier" + ) { + return; + } + + if (!node.loc) { + return; + } + + const value = csstree.generate(node).trim(); + const color = parseColor(value, { allowNamedColors: true }); + if (!color) { + return; + } + + const rawValueText = sourceText.substring( + node.loc.start.offset, + node.loc.end.offset + ); + const leadingWhitespace = + rawValueText.length - rawValueText.trimStart().length; + const trailingWhitespace = + rawValueText.length - rawValueText.trimEnd().length; + + const startOffset = + baseOffset + node.loc.start.offset + leadingWhitespace; + const endOffset = baseOffset + node.loc.end.offset - trailingWhitespace; + + const range = Range.create( + document.positionAt(startOffset), + document.positionAt(endOffset) + ); + + const line = range.start.line; + if (!lineMap.has(line)) { + lineMap.set(line, []); + } + lineMap.get(line)!.push({ + uri, + range, + value, + color, + propertyName, + variableName: propertyName.startsWith("--") ? propertyName : undefined, + }); + }, + }); } public async updateFile(uri: string): Promise { try { const filePath = URI.parse(uri).fsPath; if (!fs.existsSync(filePath)) { - this.logger.log( - `[css-lsp] File ${uri} does not exist on disk, removing from manager.` - ); + this.logger.debug("fileNotFound", { uri }); this.removeFile(uri); return; } @@ -693,9 +837,9 @@ export class CssVariableManager { } this.parseContent(content, uri, languageId); - this.logger.log(`[css-lsp] Updated file ${uri} from disk.`); + this.logger.debug("fileUpdatedFromDisk", { uri }); } catch (error) { - this.logger.error(`[css-lsp] Error updating file ${uri}: ${error}`); + this.logger.error("fileUpdateError", { uri, error: String(error) }); } } @@ -703,6 +847,7 @@ export class CssVariableManager { const normalizedUri = normalizeUri(uri); this.clearDocumentVariables(normalizedUri); this.clearDocumentUsages(normalizedUri); + this.clearDocumentColorLiterals(normalizedUri); this.clearDocumentDOMTree(normalizedUri); } @@ -718,8 +863,8 @@ export class CssVariableManager { this.variables.set(name, filtered); } } + this.colorIndexDirty = true; } - public clearDocumentUsages(uri: string): void { const normalizedUri = normalizeUri(uri); for (const [name, usgs] of this.usages.entries()) { @@ -734,6 +879,10 @@ export class CssVariableManager { } } + public clearDocumentColorLiterals(uri: string): void { + this.colorLiterals.delete(normalizeUri(uri)); + } + public clearDocumentDOMTree(uri: string): void { this.domTrees.delete(uri); } @@ -755,6 +904,16 @@ export class CssVariableManager { return this.usages.get(name) || []; } + public getDocumentColorLiterals(uri: string): CssColorLiteral[] { + const lineMap = this.colorLiterals.get(normalizeUri(uri)); + if (!lineMap) { return []; } + return Array.from(lineMap.values()).flat(); + } + + public getDocumentColorLiteralsByLine(uri: string, line: number): CssColorLiteral[] { + return this.colorLiterals.get(normalizeUri(uri))?.get(line) ?? []; + } + /** * Get all references (definitions + usages) for a variable */ @@ -787,6 +946,27 @@ export class CssVariableManager { return this.domTrees.get(uri); } + public getVariablesByColor( + color: Color, + options: { excludeName?: string } = {} + ): CssVariable[] { + this.ensureColorIndex(); + const key = getNormalizedColorKey(color); + const names = this.colorIndex.get(key) || new Set(); + + const matches: CssVariable[] = []; + for (const name of names) { + if (options.excludeName && name === options.excludeName) { + continue; + } + const winningDefinition = this.getWinningVariableDefinition(name); + if (winningDefinition) { + matches.push(winningDefinition); + } + } + + return matches.toSorted((a, b) => a.name.localeCompare(b.name)); + } /** * Resolve a variable name to a Color if possible. * Handles recursive variable references: var(--a) -> var(--b) -> #fff @@ -809,44 +989,49 @@ export class CssVariableManager { // Apply CSS cascade rules to find the winning definition // Sort by cascade rules: !important > specificity > source order - const sortedVars = [...variables].sort((a, b) => { - // !important always wins (unless both are !important) + const variable = this.getWinningVariableDefinition(name); + if (!variable) { + return null; + } + let value = variable.value; + + // Check if it's a reference to another variable + const recursiveMatch = value.match( + /var\(\s*(--[\w-]+)\s*(?:,\s*[^)]+)?\s*\)/ + ); + if (recursiveMatch) { + return this.resolveVariableColor(recursiveMatch[1], context, seen); + } + + return parseColor(value, { allowNamedColors: true }); + } + + private getWinningVariableDefinition(name: string): CssVariable | null { + const variables = this.getVariables(name); + if (variables.length === 0) { + return null; + } + + return [...variables].sort((a, b) => { if (a.important !== b.important) { return a.important ? -1 : 1; } - // Inline styles win over non-inline styles const aInline = a.inline ?? false; const bInline = b.inline ?? false; if (aInline !== bInline) { return aInline ? -1 : 1; } - // After !important, check specificity const specA = calculateSpecificity(a.selector); const specB = calculateSpecificity(b.selector); const specCompare = compareSpecificity(specA, specB); if (specCompare !== 0) { - return -specCompare; // Negative for descending order + return -specCompare; } - // Equal specificity - later in source wins return b.sourcePosition - a.sourcePosition; - }); - - // Use the winning definition (first after sort) - const variable = sortedVars[0]; - let value = variable.value; - - // Check if it's a reference to another variable - const recursiveMatch = value.match( - /var\(\s*(--[\w-]+)\s*(?:,\s*[^)]+)?\s*\)/ - ); - if (recursiveMatch) { - return this.resolveVariableColor(recursiveMatch[1], context, seen); - } - - return parseColor(value, { allowNamedColors: true }); + })[0]; } } diff --git a/src/domTree.ts b/src/domTree.ts index c9e8f7f..b67aa0b 100644 --- a/src/domTree.ts +++ b/src/domTree.ts @@ -17,10 +17,8 @@ export interface DOMNodeInfo { export class DOMTree { private root: ParsedHTMLElement; - private htmlText: string; constructor(htmlText: string) { - this.htmlText = htmlText; this.root = parse(htmlText, { lowerCaseTagName: true, comment: false, diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 0000000..14c2d86 --- /dev/null +++ b/src/flags.ts @@ -0,0 +1,270 @@ +export type PathDisplayMode = "relative" | "absolute" | "abbreviated"; +export type UndefinedVarFallbackMode = "warning" | "info" | "off"; + +export interface RuntimeConfig { + enableColorProvider: boolean; + colorOnlyOnVariables: boolean; + enableColorReplacementDiagnostics: boolean; + lookupFiles: string[] | undefined; + ignoreGlobs: string[] | undefined; + pathDisplayMode: PathDisplayMode; + pathDisplayAbbrevLength: number; + undefinedVarFallback: UndefinedVarFallbackMode; +} + +// ============================================================================ +// Flag Definitions (Declarative) +// ============================================================================ +// To add a new flag: +// 1. Add it to RuntimeConfig interface above +// 2. Add a flag definition here +// 3. Add parsing logic in buildRuntimeConfig() +// 4. Add tests + +interface BoolFlagDef { + kind: "bool"; + positive: string; + negative: string; + default: boolean; + envKey?: string; +} + +interface OptInFlagDef { + kind: "optIn"; + flag: string; + envKey: string; + default: boolean; +} + +interface EnumFlagDef { + kind: "enum"; + flag: string; + envKey: string; + values: readonly T[]; + default: T; + aliases?: Record; +} + +interface PathDisplayFlagDef { + kind: "pathDisplay"; + flag: string; + envKey: string; + defaultMode: PathDisplayMode; + defaultLength: number; +} + +interface IntFlagDef { + kind: "int"; + flag: string; + envKey: string; + default: number; +} + +interface ListFlagDef { + kind: "list"; + primaryFlag: string; + secondaryFlag: string; + envKey: string; +} + +type FlagDef = BoolFlagDef | OptInFlagDef | EnumFlagDef | PathDisplayFlagDef | IntFlagDef | ListFlagDef; + +// ============================================================================ +// Flag Registry +// ============================================================================ + +const FLAGS = { + enableColorProvider: { + kind: "bool" as const, + positive: "--color-preview", + negative: "--no-color-preview", + default: true, + }, + colorOnlyOnVariables: { + kind: "optIn" as const, + flag: "--color-only-variables", + envKey: "CSS_LSP_COLOR_ONLY_VARIABLES", + default: false, + }, + enableColorReplacementDiagnostics: { + kind: "bool" as const, + positive: "--color-replacement-diagnostics", + negative: "--no-color-replacement-diagnostics", + default: true, + envKey: "CSS_LSP_COLOR_REPLACEMENT_DIAGNOSTICS", + }, + lookupFiles: { + kind: "list" as const, + primaryFlag: "--lookup-files", + secondaryFlag: "--lookup-file", + envKey: "CSS_LSP_LOOKUP_FILES", + }, + ignoreGlobs: { + kind: "list" as const, + primaryFlag: "--ignore-globs", + secondaryFlag: "--ignore-glob", + envKey: "CSS_LSP_IGNORE_GLOBS", + }, + pathDisplay: { + kind: "pathDisplay" as const, + flag: "--path-display", + envKey: "CSS_LSP_PATH_DISPLAY", + defaultMode: "relative" as PathDisplayMode, + defaultLength: 1, + }, + pathDisplayLength: { + kind: "int" as const, + flag: "--path-display-length", + envKey: "CSS_LSP_PATH_DISPLAY_LENGTH", + default: 1, + }, + undefinedVarFallback: { + kind: "enum" as const, + flag: "--undefined-var-fallback", + envKey: "CSS_LSP_UNDEFINED_VAR_FALLBACK", + values: ["warning", "info", "off"] as const, + default: "warning" as UndefinedVarFallbackMode, + aliases: { + warn: "warning", + information: "info", + omit: "off", + none: "off", + disable: "off", + disabled: "off", + }, + }, +} as const satisfies Record; + +// ============================================================================ +// Parsing Utilities +// ============================================================================ + +function getArgValue(argv: string[], name: string): string | null { + const flag = `--${name}`; + const directIndex = argv.indexOf(flag); + if (directIndex !== -1) { + const candidate = argv[directIndex + 1]; + if (candidate && !candidate.startsWith("-")) return candidate; + return null; + } + const prefix = `${flag}=`; + const withEquals = argv.find((arg) => arg.startsWith(prefix)); + if (withEquals) return withEquals.slice(prefix.length); + return null; +} + +function parseOptionalInt(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? null : parsed; +} + +function splitList(value: string): string[] { + return value.split(",").map((e) => e.trim()).filter(Boolean); +} + +function parseBool(flag: BoolFlagDef, argv: string[], env: NodeJS.ProcessEnv): boolean { + if (argv.includes(flag.negative)) return false; + if (argv.includes(flag.positive)) return true; + if (flag.envKey) { + if (env[flag.envKey] === "0") return false; + if (env[flag.envKey] === "1") return true; + } + return flag.default; +} + +function parseOptIn(flag: OptInFlagDef, argv: string[], env: NodeJS.ProcessEnv): boolean { + if (argv.includes(flag.flag)) return true; + if (env[flag.envKey] === "1") return true; + return flag.default; +} + +function parseEnum(flag: EnumFlagDef, argv: string[], env: NodeJS.ProcessEnv): T { + const argValue = getArgValue(argv, flag.flag.replace("--", "")); + const envValue = env[flag.envKey]; + const raw = argValue ?? envValue ?? null; + if (!raw) return flag.default; + + const normalized = raw.toLowerCase(); + if (flag.aliases) { + const aliased = flag.aliases[normalized]; + if (aliased && flag.values.includes(aliased)) return aliased; + } + const match = flag.values.find((v) => v.toLowerCase() === normalized); + return match ?? flag.default; +} + +function parsePathDisplay(flag: PathDisplayFlagDef, argv: string[], env: NodeJS.ProcessEnv): { mode: PathDisplayMode; combinedLength: number | null } { + const argValue = getArgValue(argv, flag.flag.replace("--", "")); + const envValue = env[flag.envKey]; + const raw = argValue ?? envValue ?? null; + + let mode = flag.defaultMode; + let combinedLength: number | null = null; + + if (raw) { + const [modePart, lengthPart] = raw.split(":", 2); + const normalizedMode = modePart?.toLowerCase(); + if (normalizedMode === "relative") mode = "relative"; + else if (normalizedMode === "absolute") mode = "absolute"; + else if (normalizedMode === "abbreviated" || normalizedMode === "abbr" || normalizedMode === "fish") mode = "abbreviated"; + if (lengthPart) { + const parsed = parseOptionalInt(lengthPart); + if (parsed !== null) combinedLength = parsed; + } + } + + return { mode, combinedLength }; +} + +function parseList(flag: ListFlagDef, argv: string[], env: NodeJS.ProcessEnv): string[] | undefined { + const cliValues: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if ((arg === flag.primaryFlag || arg === flag.secondaryFlag) && argv[i + 1] && !argv[i + 1].startsWith("-")) { + cliValues.push(...splitList(argv[i + 1])); + i++; + continue; + } + if (arg.startsWith(`${flag.primaryFlag}=`)) { + cliValues.push(...splitList(arg.slice(flag.primaryFlag.length + 1))); + continue; + } + if (arg.startsWith(`${flag.secondaryFlag}=`)) { + cliValues.push(...splitList(arg.slice(flag.secondaryFlag.length + 1))); + } + } + if (cliValues.length > 0) return cliValues; + const envValue = env[flag.envKey]; + if (envValue) { + const values = splitList(envValue); + if (values.length > 0) return values; + } + return undefined; +} + +// ============================================================================ +// Config Builder +// ============================================================================ + +export function buildRuntimeConfig(argv: string[], env: NodeJS.ProcessEnv): RuntimeConfig { + const pathDisplay = parsePathDisplay(FLAGS.pathDisplay, argv, env); + const pathDisplayLengthArg = getArgValue(argv, "path-display-length"); + const pathDisplayLengthEnv = env.CSS_LSP_PATH_DISPLAY_LENGTH; + const abbrevLengthRaw = parseOptionalInt(pathDisplayLengthArg ?? pathDisplayLengthEnv) ?? pathDisplay.combinedLength; + const pathDisplayAbbrevLength = Math.max(0, abbrevLengthRaw ?? 1); + + return { + enableColorProvider: parseBool(FLAGS.enableColorProvider, argv, env), + colorOnlyOnVariables: parseOptIn(FLAGS.colorOnlyOnVariables, argv, env), + enableColorReplacementDiagnostics: parseBool(FLAGS.enableColorReplacementDiagnostics, argv, env), + lookupFiles: parseList(FLAGS.lookupFiles, argv, env), + ignoreGlobs: parseList(FLAGS.ignoreGlobs, argv, env), + pathDisplayMode: pathDisplay.mode, + pathDisplayAbbrevLength, + undefinedVarFallback: parseEnum(FLAGS.undefinedVarFallback, argv, env), + }; +} + +// Export for testing +export { FLAGS }; diff --git a/src/initialize.ts b/src/initialize.ts index 4f2170e..b18ee10 100644 --- a/src/initialize.ts +++ b/src/initialize.ts @@ -12,7 +12,7 @@ export function buildInitializeResult( textDocumentSync: TextDocumentSyncKind.Incremental, completionProvider: { resolveProvider: true, - triggerCharacters: ["-"], + triggerCharacters: ["-", "#", "("], }, definitionProvider: true, hoverProvider: true, @@ -21,6 +21,7 @@ export function buildInitializeResult( documentSymbolProvider: true, workspaceSymbolProvider: true, colorProvider: enableColorProvider, + codeActionProvider: true, }, }; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..0ca0892 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,44 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface Logger { + debug(label: string, payload?: unknown): void; + info(label: string, payload?: unknown): void; + warn(label: string, payload?: unknown): void; + error(label: string, payload?: unknown): void; +} + +function formatMessage(level: LogLevel, label: string, payload?: unknown): string { + const prefix = `[css-lsp][${level}]`; + const meta = payload !== undefined ? ` ${JSON.stringify(payload)}` : ""; + return `${prefix} ${label}${meta}`; +} + +export function createLogger(envVar = "CSS_LSP_DEBUG"): Logger { + const isDebug = !!process.env[envVar]; + + return { + debug: (label, payload) => { + if (isDebug) { + console.log(formatMessage("debug", label, payload)); + } + }, + info: (label, payload) => { + if (isDebug) { + console.log(formatMessage("info", label, payload)); + } + }, + warn: (label, payload) => { + if (isDebug) { + console.log(formatMessage("warn", label, payload)); + } + }, + error: (label, payload) => { + if (payload instanceof Error && payload.stack) { + console.error(formatMessage("error", label, payload.message)); + console.error(payload.stack); + } else { + console.error(formatMessage("error", label, payload)); + } + }, + }; +} diff --git a/src/runtimeConfig.ts b/src/runtimeConfig.ts index ca7dfc9..cb99d18 100644 --- a/src/runtimeConfig.ts +++ b/src/runtimeConfig.ts @@ -4,6 +4,7 @@ export type UndefinedVarFallbackMode = "warning" | "info" | "off"; export interface RuntimeConfig { enableColorProvider: boolean; colorOnlyOnVariables: boolean; + enableColorReplacementDiagnostics: boolean; lookupFiles: string[] | undefined; ignoreGlobs: string[] | undefined; pathDisplayMode: PathDisplayMode; @@ -11,240 +12,4 @@ export interface RuntimeConfig { undefinedVarFallback: UndefinedVarFallbackMode; } -function getArgValue(argv: string[], name: string): string | null { - const flag = `--${name}`; - const directIndex = argv.indexOf(flag); - if (directIndex !== -1) { - const candidate = argv[directIndex + 1]; - if (candidate && !candidate.startsWith("-")) { - return candidate; - } - return null; - } - - const prefix = `${flag}=`; - const withEquals = argv.find((arg) => arg.startsWith(prefix)); - if (withEquals) { - return withEquals.slice(prefix.length); - } - - return null; -} - -function parseOptionalInt(value: string | null | undefined): number | null { - if (!value) { - return null; - } - const parsed = Number.parseInt(value, 10); - if (Number.isNaN(parsed)) { - return null; - } - return parsed; -} - -function normalizePathDisplayMode( - value: string | null | undefined, -): PathDisplayMode | null { - if (!value) { - return null; - } - - switch (value.toLowerCase()) { - case "relative": - return "relative"; - case "absolute": - return "absolute"; - case "abbreviated": - case "abbr": - case "fish": - return "abbreviated"; - default: - return null; - } -} - -function normalizeUndefinedVarFallbackMode( - value: string | null | undefined, -): UndefinedVarFallbackMode | null { - if (!value) { - return null; - } - - switch (value.toLowerCase()) { - case "warn": - case "warning": - return "warning"; - case "info": - case "information": - return "info"; - case "off": - case "omit": - case "none": - case "disable": - case "disabled": - return "off"; - default: - return null; - } -} - -function parsePathDisplay(value: string | null | undefined): { - mode: PathDisplayMode | null; - abbrevLength: number | null; -} { - if (!value) { - return { mode: null, abbrevLength: null }; - } - - const [modePart, lengthPart] = value.split(":", 2); - const mode = normalizePathDisplayMode(modePart); - const abbrevLength = parseOptionalInt(lengthPart); - - return { mode, abbrevLength }; -} - -function splitLookupList(value: string): string[] { - return value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function resolveLookupFiles( - argv: string[], - env: NodeJS.ProcessEnv, -): string[] | undefined { - const cliFiles: string[] = []; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if ( - arg === "--lookup-files" && - argv[i + 1] && - !argv[i + 1].startsWith("-") - ) { - cliFiles.push(...splitLookupList(argv[i + 1])); - i++; - continue; - } - if (arg.startsWith("--lookup-files=")) { - cliFiles.push(...splitLookupList(arg.slice("--lookup-files=".length))); - continue; - } - if ( - arg === "--lookup-file" && - argv[i + 1] && - !argv[i + 1].startsWith("-") - ) { - cliFiles.push(argv[i + 1]); - i++; - continue; - } - if (arg.startsWith("--lookup-file=")) { - cliFiles.push(arg.slice("--lookup-file=".length)); - } - } - - if (cliFiles.length > 0) { - return cliFiles; - } - - const envValue = env.CSS_LSP_LOOKUP_FILES; - if (envValue) { - const envFiles = splitLookupList(envValue); - if (envFiles.length > 0) { - return envFiles; - } - } - - return undefined; -} - -function resolveIgnoreGlobs( - argv: string[], - env: NodeJS.ProcessEnv, -): string[] | undefined { - const cliGlobs: string[] = []; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if ( - arg === "--ignore-globs" && - argv[i + 1] && - !argv[i + 1].startsWith("-") - ) { - cliGlobs.push(...splitLookupList(argv[i + 1])); - i++; - continue; - } - if (arg.startsWith("--ignore-globs=")) { - cliGlobs.push(...splitLookupList(arg.slice("--ignore-globs=".length))); - continue; - } - if ( - arg === "--ignore-glob" && - argv[i + 1] && - !argv[i + 1].startsWith("-") - ) { - cliGlobs.push(argv[i + 1]); - i++; - continue; - } - if (arg.startsWith("--ignore-glob=")) { - cliGlobs.push(arg.slice("--ignore-glob=".length)); - } - } - - if (cliGlobs.length > 0) { - return cliGlobs; - } - - const envValue = env.CSS_LSP_IGNORE_GLOBS; - if (envValue) { - const envGlobs = splitLookupList(envValue); - if (envGlobs.length > 0) { - return envGlobs; - } - } - - return undefined; -} - -export function buildRuntimeConfig( - argv: string[], - env: NodeJS.ProcessEnv, -): RuntimeConfig { - const enableColorProvider = !argv.includes("--no-color-preview"); - const colorOnlyOnVariables = - argv.includes("--color-only-variables") || - env.CSS_LSP_COLOR_ONLY_VARIABLES === "1"; - const lookupFiles = resolveLookupFiles(argv, env); - const ignoreGlobs = resolveIgnoreGlobs(argv, env); - const pathDisplayArg = getArgValue(argv, "path-display"); - const pathDisplayEnv = env.CSS_LSP_PATH_DISPLAY; - const parsedPathDisplay = parsePathDisplay(pathDisplayArg ?? pathDisplayEnv); - const pathDisplayMode: PathDisplayMode = - parsedPathDisplay.mode ?? "relative"; - const pathDisplayLengthArg = getArgValue(argv, "path-display-length"); - const pathDisplayLengthEnv = env.CSS_LSP_PATH_DISPLAY_LENGTH; - const abbrevLengthRaw = - parseOptionalInt(pathDisplayLengthArg ?? pathDisplayLengthEnv) ?? - parsedPathDisplay.abbrevLength; - const pathDisplayAbbrevLength = Math.max(0, abbrevLengthRaw ?? 1); - const undefinedVarFallbackArg = getArgValue(argv, "undefined-var-fallback"); - const undefinedVarFallbackEnv = env.CSS_LSP_UNDEFINED_VAR_FALLBACK; - const undefinedVarFallback = - normalizeUndefinedVarFallbackMode( - undefinedVarFallbackArg ?? undefinedVarFallbackEnv, - ) ?? "warning"; - - return { - enableColorProvider, - colorOnlyOnVariables, - lookupFiles, - ignoreGlobs, - pathDisplayMode, - pathDisplayAbbrevLength, - undefinedVarFallback, - }; -} +export { buildRuntimeConfig } from "./flags"; diff --git a/src/server.ts b/src/server.ts index 772cec1..6339eb6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { + CodeAction, createConnection, TextDocuments, Diagnostic, @@ -28,9 +29,16 @@ import { collectColorPresentations, collectDocumentColors, } from "./colorProvider"; +import { + collectColorReplacementDiagnostics, + getColorReplacementCodeActions, + getColorReplacementCompletionItems, + isPositionOnDefinition, +} from "./colorVariableFeature"; import { buildInitializeResult } from "./initialize"; import { formatUriForDisplay, toNormalizedFsPath } from "./pathDisplay"; import { buildRuntimeConfig } from "./runtimeConfig"; +import { createLogger } from "./logger"; import { calculateSpecificity, compareSpecificity, @@ -40,17 +48,18 @@ import { const runtimeConfig = buildRuntimeConfig(process.argv.slice(2), process.env); -// Create a connection for the server, using Node's IPC as a transport. +// Create a connection for the server. +// Use stdio transport when --stdio flag is passed (CLI usage). +// Otherwise, let the library auto-detect transport from argv: +// --node-ipc for IPC (VS Code extension), +// --socket or --pipe for other transports. // Also include all preview / proposed LSP features. -const connection = createConnection(ProposedFeatures.all); +const useStdio = process.argv.includes('--stdio'); +const connection = useStdio + ? createConnection(ProposedFeatures.all, process.stdin, process.stdout) + : createConnection(ProposedFeatures.all); -function logDebug(label: string, payload: unknown) { - // Only log in debug mode (set CSS_LSP_DEBUG=1 environment variable) - if (process.env.CSS_LSP_DEBUG) { - const message = `[css-lsp] ${label} ${JSON.stringify(payload)}`; - connection.console.log(message); - } -} +const logger = createLogger(); function updateWorkspaceFolderPaths(folders?: Array<{ uri: string }>): void { if (!folders) { @@ -68,7 +77,7 @@ function updateWorkspaceFolderPaths(folders?: Array<{ uri: string }>): void { // Create a simple text document manager. const documents: TextDocuments = new TextDocuments(TextDocument); const cssVariableManager = new CssVariableManager( - connection.console, + logger, runtimeConfig.lookupFiles, runtimeConfig.ignoreGlobs, ); @@ -79,10 +88,7 @@ let workspaceFolderPaths: string[] = []; let rootFolderPath: string | null = null; connection.onInitialize((params: InitializeParams) => { - logDebug("initialize", { - rootUri: params.rootUri, - // rootPath is deprecated and optional in InitializeParams - rootPath: params.rootPath, + logger.debug("initialize", { workspaceFolders: params.workspaceFolders, capabilities: params.capabilities, }); @@ -97,13 +103,17 @@ connection.onInitialize((params: InitializeParams) => { capabilities.textDocument.publishDiagnostics && capabilities.textDocument.publishDiagnostics.relatedInformation ); + // eslint-disable-next-line @typescript-eslint/no-deprecated if (params.rootUri) { try { + // eslint-disable-next-line @typescript-eslint/no-deprecated rootFolderPath = path.normalize(URI.parse(params.rootUri).fsPath); } catch { rootFolderPath = null; } + // eslint-disable-next-line @typescript-eslint/no-deprecated } else if (params.rootPath) { + // eslint-disable-next-line @typescript-eslint/no-deprecated rootFolderPath = path.normalize(params.rootPath); } updateWorkspaceFolderPaths(params.workspaceFolders || undefined); @@ -117,7 +127,7 @@ connection.onInitialize((params: InitializeParams) => { connection.onInitialized(async () => { if (hasWorkspaceFolderCapability) { connection.workspace.onDidChangeWorkspaceFolders((_event) => { - connection.console.log("Workspace folder change event received."); + logger.debug("workspaceFolderChanged"); void connection.workspace.getWorkspaceFolders().then((folders) => { updateWorkspaceFolderPaths(folders || undefined); }); @@ -128,27 +138,21 @@ connection.onInitialized(async () => { const workspaceFolders = await connection.workspace.getWorkspaceFolders(); if (workspaceFolders) { updateWorkspaceFolderPaths(workspaceFolders || undefined); - connection.console.log("Scanning workspace for CSS variables..."); + logger.info("scanStarted"); const folderUris = workspaceFolders.map((f) => f.uri); - // Scan with progress callback that logs to console let lastLoggedPercentage = 0; await cssVariableManager.scanWorkspace(folderUris, (current, total) => { const percentage = Math.round((current / total) * 100); - // Log progress every 20% to avoid spam if (percentage - lastLoggedPercentage >= 20 || current === total) { - connection.console.log( - `Scanning CSS files: ${current}/${total} (${percentage}%)`, - ); + logger.info("scanProgress", { current, total, percentage }); lastLoggedPercentage = percentage; } }); const totalVars = cssVariableManager.getAllVariables().length; - connection.console.log( - `Workspace scan complete. Found ${totalVars} CSS variables.`, - ); + logger.info("scanComplete", { totalVars }); // Validate all open documents after workspace scan documents.all().forEach(validateTextDocument); @@ -157,7 +161,15 @@ connection.onInitialized(async () => { // Handle document close events documents.onDidClose(async (e) => { - connection.console.log(`[css-lsp] Document closed: ${e.document.uri}`); + logger.debug("documentClosed", { uri: e.document.uri }); + + // Clear any pending validation timeout to prevent memory leak + const existingTimeout = validationTimeouts.get(e.document.uri); + if (existingTimeout) { + clearTimeout(existingTimeout); + validationTimeouts.delete(e.document.uri); + } + // When a document is closed, we need to revert to the file system version // instead of removing it completely (which would break workspace files). // This handles cases where the editor had unsaved changes. @@ -187,18 +199,48 @@ function scheduleValidation(textDocument: TextDocument): void { validationTimeouts.set(uri, timeout); } -function scheduleValidateAllOpenDocuments(excludeUri?: string): void { +function getAffectedDocuments(editedUri: string): Set { + const affected = new Set(); + + // Get variables defined in the edited file + const definedVars = cssVariableManager.getDocumentDefinitions(editedUri); + + // Find all documents that use those variables + for (const def of definedVars) { + const usages = cssVariableManager.getVariableUsages(def.name); + for (const usage of usages) { + affected.add(usage.uri); + } + } + + // Also include documents with color literals (might match new variables) + for (const doc of documents.all()) { + if (cssVariableManager.getDocumentColorLiterals(doc.uri).length > 0) { + affected.add(doc.uri); + } + } + + return affected; +} + +function scheduleValidateAllOpenDocuments(excludeUri: string): void { if (validateAllTimeout) { clearTimeout(validateAllTimeout); } validateAllTimeout = setTimeout(() => { - documents.all().forEach((document) => { - if (excludeUri && document.uri === excludeUri) { - return; + const affectedUris = getAffectedDocuments(excludeUri); + + for (const uri of affectedUris) { + if (uri === excludeUri) { + continue; } - validateTextDocument(document); - }); + const doc = documents.get(uri); + if (doc) { + validateTextDocument(doc); + } + } + validateAllTimeout = null; }, 300); } @@ -259,14 +301,22 @@ async function validateTextDocument(textDocument: TextDocument): Promise { } } + if (runtimeConfig.enableColorReplacementDiagnostics) { + diagnostics.push( + ...collectColorReplacementDiagnostics( + textDocument, + cssVariableManager, + logger, + ), + ); + } + // Send diagnostics to the client connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); } connection.onDidChangeWatchedFiles(async (change) => { - // Monitored files have changed in the client - connection.console.log("Received file change event"); - logDebug("didChangeWatchedFiles", change); + logger.debug("didChangeWatchedFiles", { change }); for (const fileEvent of change.changes) { if (fileEvent.type === FileChangeType.Deleted) { @@ -448,51 +498,61 @@ connection.onCompletion( document, textDocumentPosition.position, ); - if (!completionContext) { - return []; - } - const propertyName = completionContext.propertyName; - - const variables = cssVariableManager.getAllVariables(); - // Deduplicate by name - const uniqueVars = new Map(); - variables.forEach((v) => { - if (!uniqueVars.has(v.name)) { - uniqueVars.set(v.name, v); - } - }); + if (completionContext) { + const propertyName = completionContext.propertyName; - // Score and filter variables based on property context - const scoredVars = Array.from(uniqueVars.values()).map((v) => ({ - variable: v, - score: scoreVariableRelevance(v.name, propertyName), - })); - - // Filter out score 0 (not relevant) and sort by score (higher first) - const filteredAndSorted = scoredVars - .filter((sv) => sv.score !== 0) - .sort((a, b) => { - // Sort by score (descending) - if (a.score !== b.score) { - return b.score - a.score; + const variables = cssVariableManager.getAllVariables(); + const uniqueVars = new Map(); + variables.forEach((v) => { + if (!uniqueVars.has(v.name)) { + uniqueVars.set(v.name, v); } - // Same score: alphabetical order - return a.variable.name.localeCompare(b.variable.name); }); - return filteredAndSorted.map((sv) => ({ - label: sv.variable.name, - kind: CompletionItemKind.Variable, - detail: sv.variable.value, - documentation: `Defined in ${formatUriForDisplay(sv.variable.uri, { - mode: runtimeConfig.pathDisplayMode, - abbrevLength: runtimeConfig.pathDisplayAbbrevLength, - workspaceFolderPaths, - rootFolderPath, - })}`, - insertText: sv.variable.name, - })); + const scoredVars = Array.from(uniqueVars.values()).map((v) => ({ + variable: v, + score: scoreVariableRelevance(v.name, propertyName), + })); + + const filteredAndSorted = scoredVars + .filter((sv) => sv.score !== 0) + .sort((a, b) => { + if (a.score !== b.score) { + return b.score - a.score; + } + return a.variable.name.localeCompare(b.variable.name); + }); + + return filteredAndSorted.map((sv) => ({ + label: sv.variable.name, + kind: CompletionItemKind.Variable, + detail: sv.variable.value, + documentation: `Defined in ${formatUriForDisplay(sv.variable.uri, { + mode: runtimeConfig.pathDisplayMode, + abbrevLength: runtimeConfig.pathDisplayAbbrevLength, + workspaceFolderPaths, + rootFolderPath, + })}`, + insertText: sv.variable.name, + })); + } + + return getColorReplacementCompletionItems( + document, + textDocumentPosition.position, + cssVariableManager, + { + formatLocation: (uri) => + formatUriForDisplay(uri, { + mode: runtimeConfig.pathDisplayMode, + abbrevLength: runtimeConfig.pathDisplayAbbrevLength, + workspaceFolderPaths, + rootFolderPath, + }), + }, + logger, + ); }, ); @@ -768,6 +828,25 @@ connection.onRenameRequest((params) => { return null; }); +connection.onCodeAction((params): CodeAction[] => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return []; + } + + // Skip replacement suggestions when cursor is on a CSS variable definition + const definitions = cssVariableManager.getDocumentDefinitions(document.uri); + if (isPositionOnDefinition(document, definitions, params.range.start)) { + return []; + } + + return getColorReplacementCodeActions( + document, + params.context.diagnostics, + logger, + ); +}); + // Document symbols handler connection.onDocumentSymbol((params) => { const document = documents.get(params.textDocument.uri); diff --git a/src/specificity.ts b/src/specificity.ts index 7ec14de..f97b3f1 100644 --- a/src/specificity.ts +++ b/src/specificity.ts @@ -155,16 +155,59 @@ export function matchesContext( return true; } - // For now, we use a simplified approach: - // If the usage context contains the definition selector, it might apply - // This handles cases like "div" applying to "div.class" - const defParts = definitionSelector.split(/[\s>+~]/); - const usageParts = usageContext.split(/[\s>+~]/); - - // Check if any part of the definition is in the usage - return defParts.some((defPart) => - usageParts.some( - (usagePart) => usagePart.includes(defPart) || defPart.includes(usagePart), - ), - ); + // For compound selectors like "div.class", check if the base selectors match + const defParts = definitionSelector.split(/[\s>+~]/).filter((p) => p.trim()); + const usageParts = usageContext.split(/[\s>+~]/).filter((p) => p.trim()); + + // Each part of the definition must have a corresponding matching part in usage + // For compound selectors like "div.button", both "div" AND ".button" must match + for (const defPart of defParts) { + const hasMatch = usageParts.some((usagePart) => selectorPartMatches(defPart, usagePart)); + if (!hasMatch) { + return false; + } + } + + return true; +} + +/** + * Check if a single selector part matches another selector part. + * Handles element selectors, class selectors, ID selectors, and attribute selectors. + * Returns true only if both parts are the same selector type and name. + */ +function selectorPartMatches(def: string, usage: string): boolean { + // Determine selector types (before any normalization) + const isDefClass = def.startsWith("."); + const isDefId = def.startsWith("#"); + const isDefAttr = def.startsWith("["); + const isDefElement = !isDefClass && !isDefId && !isDefAttr; + + const isUsageClass = usage.startsWith("."); + const isUsageId = usage.startsWith("#"); + const isUsageAttr = usage.startsWith("["); + const isUsageElement = !isUsageClass && !isUsageId && !isUsageAttr; + + // Element selectors should only match element selectors + if (isDefElement && isUsageElement) { + return def === usage; + } + + // Class selectors should only match class selectors + if (isDefClass && isUsageClass) { + return def === usage; + } + + // ID selectors should only match ID selectors + if (isDefId && isUsageId) { + return def === usage; + } + + // Attribute selectors should only match attribute selectors + if (isDefAttr && isUsageAttr) { + return def === usage; + } + + // Cross-type matching is not allowed + return false; } diff --git a/tests/cascadeAndInline.test.ts b/tests/cascadeAndInline.test.ts index e8cb4a7..c8f523f 100644 --- a/tests/cascadeAndInline.test.ts +++ b/tests/cascadeAndInline.test.ts @@ -2,13 +2,14 @@ import { test } from "node:test"; import { strict as assert } from "node:assert"; import { CssVariableManager } from "../src/cssVariableManager"; import { TextDocument } from "vscode-languageserver-textdocument"; +import { silentLogger } from "./helpers/silentLogger"; function createDoc(uri: string, content: string, languageId: string = "css") { return TextDocument.create(uri, languageId, 1, content); } test("!important tracking", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ":root { --color: red !important; } div { --color: blue; }"; const doc = createDoc("file:///test.css", css); manager.parseDocument(doc); @@ -26,7 +27,7 @@ test("!important tracking", () => { }); test("source order tracking", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ":root { --a: first; } :root { --a: second; }"; const doc = createDoc("file:///test.css", css); manager.parseDocument(doc); @@ -37,7 +38,7 @@ test("source order tracking", () => { }); test("inline style parsing", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const html = '
'; const doc = createDoc("file:///test.html", html, "html"); @@ -52,7 +53,7 @@ test("inline style parsing", () => { }); test("inline style definitions are tracked", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const html = '
'; const doc = createDoc("file:///test.html", html, "html"); @@ -65,7 +66,7 @@ test("inline style definitions are tracked", () => { }); test("combined cascade tracking", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --x: root; } div { --x: div; } diff --git a/tests/colorCascadeResolution.test.ts b/tests/colorCascadeResolution.test.ts index 92bafb5..47f572d 100644 --- a/tests/colorCascadeResolution.test.ts +++ b/tests/colorCascadeResolution.test.ts @@ -2,13 +2,14 @@ import { test } from "node:test"; import { strict as assert } from "node:assert"; import { CssVariableManager } from "../src/cssVariableManager"; import { TextDocument } from "vscode-languageserver-textdocument"; +import { silentLogger } from "./helpers/silentLogger"; function createDoc(uri: string, content: string, languageId: string = "css") { return TextDocument.create(uri, languageId, 1, content); } test("color resolution respects !important flag", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --color: #ff0000; } div { --color: #0000ff !important; } @@ -25,7 +26,7 @@ test("color resolution respects !important flag", () => { }); test("color resolution uses specificity when no !important", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --color: #ff0000; } div { --color: #00ff00; } @@ -42,7 +43,7 @@ test("color resolution uses specificity when no !important", () => { }); test("color resolution uses source order for equal specificity", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --color: #ff0000; } :root { --color: #0000ff; } @@ -58,7 +59,7 @@ test("color resolution uses source order for equal specificity", () => { }); test("color resolution handles variable references", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --primary: #ff0000; @@ -75,7 +76,7 @@ test("color resolution handles variable references", () => { }); test("color resolution detects circular references", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --a: var(--b); @@ -89,7 +90,7 @@ test("color resolution detects circular references", () => { }); test("color resolution handles multi-level variable chains", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --base: #00ff00; @@ -108,7 +109,7 @@ test("color resolution handles multi-level variable chains", () => { }); test("color resolution combines cascade rules correctly", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --color: #ff0000; } div { --color: #00ff00; } @@ -127,7 +128,7 @@ test("color resolution combines cascade rules correctly", () => { }); test("color resolution works across multiple files", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent( ":root { --primary: #ff0000; }", @@ -151,7 +152,7 @@ test("color resolution works across multiple files", () => { }); test("color resolution with rgba values", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --transparent-red: rgba(255, 0, 0, 0.5); @@ -168,7 +169,7 @@ test("color resolution with rgba values", () => { }); test("color resolution with hsl values", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --hsl-color: hsl(120, 100%, 50%); @@ -185,7 +186,7 @@ test("color resolution with hsl values", () => { }); test("color resolution returns null for non-color values", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --spacing: 10px; @@ -205,7 +206,7 @@ test("color resolution returns null for non-color values", () => { }); test("color resolution with !important overrides higher specificity", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` #high-specificity { --color: #ff0000; } div { --color: #0000ff !important; } diff --git a/tests/colorFormatting.test.ts b/tests/colorFormatting.test.ts index 0aac194..3893c37 100644 --- a/tests/colorFormatting.test.ts +++ b/tests/colorFormatting.test.ts @@ -9,6 +9,7 @@ import { import { CssVariableManager } from "../src/cssVariableManager"; import { TextDocument } from "vscode-languageserver-textdocument"; import { Range } from "vscode-languageserver/node"; +import { silentLogger } from "./helpers/silentLogger"; test("color formatting", () => { const red = { red: 1, green: 0, blue: 0, alpha: 1 }; @@ -39,7 +40,7 @@ test("parseColor ignores named colors by default", () => { }); test("value range calculation", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const css = ` :root { --simple: red; @@ -75,3 +76,45 @@ test("value range calculation", () => { } assert.strictEqual(getText(complex.valueRange), "rgb(0,0,0)"); }); + + +test("value range calculation in HTML style blocks", () => { + const manager = new CssVariableManager(silentLogger); + const html = ` + +`; + const document = TextDocument.create("file:///test.html", "html", 1, html); + manager.parseDocument(document); + + const simple = manager.getVariables("--simple")[0]; + const spaced = manager.getVariables("--spaced")[0]; + const complex = manager.getVariables("--complex")[0]; + + const getText = (range: Range) => { + const start = document.offsetAt(range.start); + const end = document.offsetAt(range.end); + return html.substring(start, end); + }; + + if (!simple.valueRange) { + throw new Error("Expected valueRange for --simple"); + } + assert.strictEqual(getText(simple.valueRange).trim(), "red"); + + if (!spaced.valueRange) { + throw new Error("Expected valueRange for --spaced"); + } + assert.strictEqual(getText(spaced.valueRange).trim(), "blue"); + + if (!complex.valueRange) { + throw new Error("Expected valueRange for --complex"); + } + assert.strictEqual(getText(complex.valueRange).trim(), "rgb(0,0,0)"); +}); + diff --git a/tests/colorProvider.test.ts b/tests/colorProvider.test.ts index 42d8d91..64a1d8b 100644 --- a/tests/colorProvider.test.ts +++ b/tests/colorProvider.test.ts @@ -6,13 +6,14 @@ import { collectColorPresentations, collectDocumentColors, } from "../src/colorProvider"; +import { silentLogger } from "./helpers/silentLogger"; function createDoc(uri: string, content: string, languageId: string = "css") { return TextDocument.create(uri, languageId, 1, content); } test("collectDocumentColors returns empty when disabled", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const uri = "file:///colors.css"; const css = ":root { --primary: #ff0000; }"; const doc = createDoc(uri, css); @@ -27,7 +28,7 @@ test("collectDocumentColors returns empty when disabled", () => { }); test("collectDocumentColors respects onlyVariables flag", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const uri = "file:///colors-usage.css"; const css = ` :root { --primary: #ff0000; } @@ -65,7 +66,7 @@ test("collectColorPresentations honors enabled flag", () => { }); test("collectDocumentColors resolves named color variables", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const uri = "file:///colors-named.css"; const css = ` :root { --primary: red; } diff --git a/tests/colorVariableFeature.test.ts b/tests/colorVariableFeature.test.ts new file mode 100644 index 0000000..3d7f4b4 --- /dev/null +++ b/tests/colorVariableFeature.test.ts @@ -0,0 +1,205 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { DiagnosticSeverity } from "vscode-languageserver/node"; +import { CssVariableManager } from "../src/cssVariableManager"; +import { + collectColorReplacementDiagnostics, + getColorReplacementCodeActions, + getColorReplacementCompletionItems, + isPositionOnDefinition, +} from "../src/colorVariableFeature"; +import { silentLogger } from "./helpers/silentLogger"; + +function createDoc(uri: string, content: string, languageId: string = "css") { + return TextDocument.create(uri, languageId, 1, content); +} + +test("variables match by normalized color value", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent( + ":root { --white: #fff; --paper: rgb(255 255 255); --accent: #0d6efd; }", + "file:///vars.css", + "css" + ); + + const matches = manager.getVariablesByColor( + { red: 1, green: 1, blue: 1, alpha: 1 }, + {} + ); + + assert.deepEqual( + matches.map((match) => match.name), + ["--paper", "--white"] + ); +}); + +test("document color literals are collected from compound values and skip var()", () => { + const manager = new CssVariableManager(silentLogger); + const doc = createDoc( + "file:///test.css", + ".card { background: linear-gradient(#fff, rgb(255 255 255), var(--paper)); box-shadow: 0 0 2px white; }" + ); + + manager.parseDocument(doc); + const literals = manager.getDocumentColorLiterals(doc.uri); + + assert.deepEqual( + literals.map((literal) => literal.value), + ["#fff", "rgb(255 255 255)", "white"] + ); +}); + +test("diagnostics are informational and preserve multiple variable options", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent( + ":root { --white: #fff; --paper: rgb(255 255 255); }", + "file:///vars.css", + "css" + ); + const doc = createDoc("file:///test.css", ".title { color: white; }"); + + manager.parseDocument(doc); + const diagnostics = collectColorReplacementDiagnostics(doc, manager, silentLogger); + + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].severity, DiagnosticSeverity.Information); + assert.match(diagnostics[0].message, /matching CSS variables: '--paper', '--white'/); +}); + +test("diagnostics are not shown on variable definitions", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent( + ":root { --white: #fff; --paper: rgb(255 255 255); }", + "file:///vars.css", + "css" + ); + const doc = createDoc("file:///test.css", ":root { --white: #fff; }"); + + manager.parseDocument(doc); + const diagnostics = collectColorReplacementDiagnostics(doc, manager, silentLogger); + + // No diagnostics on definitions (only on usages) + assert.strictEqual(diagnostics.length, 0); +}); + +test("diagnostic message includes variable name when only one match", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent(":root { --white: #fff; }", "file:///vars.css", "css"); + const doc = createDoc("file:///test.css", ".title { color: white; }"); + + manager.parseDocument(doc); + const diagnostics = collectColorReplacementDiagnostics(doc, manager, silentLogger); + + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].severity, DiagnosticSeverity.Information); + assert.match(diagnostics[0].message, /matching CSS variable '--white'/); +}); + +test("completion items replace the full literal color token", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent(":root { --white: #fff; }", "file:///vars.css", "css"); + + const source = ".title { color: white; }"; + const doc = createDoc("file:///test.css", source); + manager.parseDocument(doc); + + const completions = getColorReplacementCompletionItems( + doc, + doc.positionAt(source.indexOf("white") + 2), + manager, + { formatLocation: (uri) => uri }, + silentLogger + ); + + assert.strictEqual(completions.length, 1); + assert.strictEqual(completions[0].label, "var(--white)"); + assert.deepEqual(completions[0].textEdit, { + range: { + start: doc.positionAt(source.indexOf("white")), + end: doc.positionAt(source.indexOf("white") + "white".length), + }, + newText: "var(--white)", + }); +}); + +test("code actions return one quick fix per matching variable", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent( + ":root { --white: #fff; --paper: rgb(255 255 255); }", + "file:///vars.css", + "css" + ); + const source = ".title { color: white; }"; + const doc = createDoc("file:///test.css", source); + + manager.parseDocument(doc); + const diagnostics = collectColorReplacementDiagnostics(doc, manager, silentLogger); + const actions = getColorReplacementCodeActions(doc, diagnostics, silentLogger); + + assert.strictEqual(actions.length, 2); + assert.deepEqual( + actions.map((action) => action.title), + ["Replace with var(--paper)", "Replace with var(--white)"] + ); +}); + +test("literal detection works in html and less documents", () => { + const manager = new CssVariableManager(silentLogger); + const htmlDoc = createDoc( + "file:///test.html", + '
', + "html" + ); + const lessDoc = createDoc( + "file:///test.less", + ".box { color: white; border-color: #fff; }", + "less" + ); + + manager.parseDocument(htmlDoc); + manager.parseDocument(lessDoc); + + assert.ok(manager.getDocumentColorLiterals(htmlDoc.uri).length >= 2); + assert.strictEqual(manager.getDocumentColorLiterals(lessDoc.uri).length, 2); +}); + +test("isPositionOnDefinition returns true when cursor is on definition", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent(":root { --white: #fff; }", "file:///vars.css", "css"); + + const doc = createDoc("file:///test.css", ":root { --white: #fff; }"); + manager.parseDocument(doc); + + const definitions = manager.getDocumentDefinitions(doc.uri); + + // Position at start of --white (after ":root { ") + const pos = doc.positionAt(8); + assert.strictEqual(isPositionOnDefinition(doc, definitions, pos), true); +}); + +test("isPositionOnDefinition returns false when cursor is not on definition", () => { + const manager = new CssVariableManager(silentLogger); + manager.parseContent(":root { --white: #fff; }", "file:///vars.css", "css"); + + const doc = createDoc("file:///test.css", ".title { color: white; }"); + manager.parseDocument(doc); + + const definitions = manager.getDocumentDefinitions(doc.uri); + + // Position at "white" in color: white + const pos = doc.positionAt(16); + assert.strictEqual(isPositionOnDefinition(doc, definitions, pos), false); +}); + +test("isPositionOnDefinition returns false for empty definitions", () => { + const manager = new CssVariableManager(silentLogger); + const doc = createDoc("file:///test.css", ".title { color: white; }"); + manager.parseDocument(doc); + + const definitions = manager.getDocumentDefinitions(doc.uri); + assert.strictEqual(definitions.length, 0); + + const pos = doc.positionAt(5); + assert.strictEqual(isPositionOnDefinition(doc, definitions, pos), false); +}); diff --git a/tests/contextualCompletion.test.ts b/tests/contextualCompletion.test.ts index 7b23b06..6405227 100644 --- a/tests/contextualCompletion.test.ts +++ b/tests/contextualCompletion.test.ts @@ -4,6 +4,7 @@ import { CssVariableManager } from "../src/cssVariableManager"; import { TextDocument } from "vscode-languageserver-textdocument"; import { CompletionItem } from "vscode-languageserver/node"; import { getCssCompletionContext } from "../src/completionContext"; +import { silentLogger } from "./helpers/silentLogger"; function createDoc(uri: string, content: string, languageId: string = "css") { return TextDocument.create(uri, languageId, 1, content); @@ -41,7 +42,7 @@ function getCompletionsAt( } test("completion suggests variables inside var()", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --primary: red; --secondary: blue; }", "file:///vars.css", "css"); const completions = getCompletionsAt(manager, ".btn { color: var(--|) }"); @@ -52,7 +53,7 @@ test("completion suggests variables inside var()", () => { }); test("no completion after property colon without var()", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --bg-color: white; }", "file:///vars.css", "css"); const completions = getCompletionsAt(manager, ".box { background: | }"); @@ -61,7 +62,7 @@ test("no completion after property colon without var()", () => { }); test("completion works in multi-value properties", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --spacing: 10px; }", "file:///vars.css", "css"); const completions = getCompletionsAt(manager, ".box { padding: 5px var(--|) }"); @@ -71,7 +72,7 @@ test("completion works in multi-value properties", () => { }); test("no completion in property name position", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --color: red; }", "file:///vars.css", "css"); const completions = getCompletionsAt(manager, ".selector { col| }"); @@ -80,7 +81,7 @@ test("no completion in property name position", () => { }); test("no completion in selector position", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --color: red; }", "file:///vars.css", "css"); const completions = getCompletionsAt(manager, ".my-class| { color: red; }"); @@ -89,7 +90,7 @@ test("no completion in selector position", () => { }); test("completion works in HTML style attribute", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --text-color: black; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -103,7 +104,7 @@ test("completion works in HTML style attribute", () => { }); test("completion works in HTML style block", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --text-color: black; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -117,7 +118,7 @@ test("completion works in HTML style block", () => { }); test("no completion in HTML outside style context", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --color: red; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -130,7 +131,7 @@ test("no completion in HTML outside style context", () => { }); test("completion works in non-CSS language when using var()", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --color: red; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -144,7 +145,7 @@ test("completion works in non-CSS language when using var()", () => { }); test("no completion in non-CSS language without var()", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --color: red; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -157,7 +158,7 @@ test("no completion in non-CSS language without var()", () => { }); test("no completion in selector pseudo-class", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --color: red; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -169,7 +170,7 @@ test("no completion in selector pseudo-class", () => { }); test("completion works after semicolon in declaration block", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --primary: red; --secondary: blue; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -181,7 +182,7 @@ test("completion works after semicolon in declaration block", () => { }); test("completion works in nested var() fallback", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --fallback: gray; }", "file:///vars.css", "css"); const completions = getCompletionsAt(manager, ".box { color: var(--primary, var(--|)) }"); @@ -190,7 +191,7 @@ test("completion works in nested var() fallback", () => { }); test("no completion in var() fallback value", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --fallback: gray; }", "file:///vars.css", "css"); const completions = getCompletionsAt( @@ -202,7 +203,7 @@ test("no completion in var() fallback value", () => { }); test("completion works across multiple lines", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --margin: 20px; }", "file:///vars.css", "css"); const content = `.container { @@ -217,7 +218,7 @@ test("completion works across multiple lines", () => { }); test("completion shows variable values in detail", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); manager.parseContent(":root { --theme-color: #ff5500; }", "file:///vars.css", "css"); const completions = getCompletionsAt(manager, ".btn { color: var(--|) }"); diff --git a/tests/cssVariableManager.test.ts b/tests/cssVariableManager.test.ts index 644d958..ee7f66a 100644 --- a/tests/cssVariableManager.test.ts +++ b/tests/cssVariableManager.test.ts @@ -2,13 +2,14 @@ import { test } from "node:test"; import { strict as assert } from "node:assert"; import { CssVariableManager } from "../src/cssVariableManager"; import { TextDocument } from "vscode-languageserver-textdocument"; +import { silentLogger } from "./helpers/silentLogger"; function createDoc(uri: string, content: string, languageId: string = "css") { return TextDocument.create(uri, languageId, 1, content); } test("basic CSS extraction", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const doc = createDoc("file:///test.css", ":root { --main-color: red; }"); manager.parseDocument(doc); const vars = manager.getAllVariables(); @@ -18,7 +19,7 @@ test("basic CSS extraction", () => { }); test("HTML style extraction", () => { - const manager = new CssVariableManager(); + const manager = new CssVariableManager(silentLogger); const htmlContent = `