diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e572b9a86..e5d753c62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,18 @@ jobs: - name: Install dependencies run: pnpm install + - name: Run syntax tests + run: | + cd packages/tailwindcss-language-syntax && + pnpm run build && + pnpm run test + + - name: Run service tests + run: | + cd packages/tailwindcss-language-service && + pnpm run build && + pnpm run test + - name: Run tests run: | cd packages/tailwindcss-language-server && diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs index 128426bed..68ba778bb 100644 --- a/packages/tailwindcss-language-service/scripts/build.mjs +++ b/packages/tailwindcss-language-service/scripts/build.mjs @@ -3,8 +3,9 @@ import { spawnSync } from 'node:child_process' import esbuild from 'esbuild' import minimist from 'minimist' import { nodeExternalsPlugin } from 'esbuild-node-externals' +import { fileURLToPath } from 'node:url' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const args = minimist(process.argv.slice(2), { boolean: ['watch', 'minify'], @@ -30,7 +31,13 @@ let build = await esbuild.context({ // Call the tsc command to generate the types spawnSync( 'tsc', - ['-p', path.resolve(__dirname, './tsconfig.build.json'), '--emitDeclarationOnly', '--outDir', path.resolve(__dirname, '../dist')], + [ + '-p', + path.resolve(__dirname, './tsconfig.build.json'), + '--emitDeclarationOnly', + '--outDir', + path.resolve(__dirname, '../dist'), + ], { stdio: 'inherit', }, diff --git a/packages/tailwindcss-language-syntax/package.json b/packages/tailwindcss-language-syntax/package.json new file mode 100644 index 000000000..5c2166f22 --- /dev/null +++ b/packages/tailwindcss-language-syntax/package.json @@ -0,0 +1,16 @@ +{ + "name": "@tailwindcss/language-syntax", + "version": "0.0.0", + "private": true, + "scripts": { + "test": "vitest", + "build": " " + }, + "devDependencies": { + "@types/node": "^18.19.33", + "dedent": "^1.5.3", + "vitest": "^3.1.4", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.2.0" + } +} diff --git a/packages/tailwindcss-language-syntax/syntaxes/css.json b/packages/tailwindcss-language-syntax/syntaxes/css.json new file mode 100644 index 000000000..79ef22eb4 --- /dev/null +++ b/packages/tailwindcss-language-syntax/syntaxes/css.json @@ -0,0 +1,1871 @@ +{ + "__tailwind_notes__": [ + "This was copied from VSCode and is used for testing purposes", + "https://github.com/microsoft/vscode/blob/2e59a779912bc1b7b2505ae52aff3a7648c24857/extensions/css/syntaxes/css.tmLanguage.json", + "It is covered by the MIT license:", + "https://github.com/microsoft/vscode/blob/2e59a779912bc1b7b2505ae52aff3a7648c24857/LICENSE.txt" + ], + "information_for_contributors": [ + "This file has been converted from https://github.com/microsoft/vscode-css/blob/master/grammars/css.cson", + "If you want to provide a fix or improvement, please create a pull request against the original repository.", + "Once accepted there, we are happy to receive an update request." + ], + "version": "https://github.com/microsoft/vscode-css/commit/a927fe2f73927bf5c25d0b0c4dd0e63d69fd8887", + "name": "CSS", + "scopeName": "source.css", + "patterns": [ + { + "include": "#comment-block" + }, + { + "include": "#escapes" + }, + { + "include": "#combinators" + }, + { + "include": "#selector" + }, + { + "include": "#at-rules" + }, + { + "include": "#rule-list" + } + ], + "repository": { + "at-rules": { + "patterns": [ + { + "begin": "\\A(?:\\xEF\\xBB\\xBF)?(?i:(?=\\s*@charset\\b))", + "end": ";|(?=$)", + "endCaptures": { + "0": { + "name": "punctuation.terminator.rule.css" + } + }, + "name": "meta.at-rule.charset.css", + "patterns": [ + { + "captures": { + "1": { + "name": "invalid.illegal.not-lowercase.charset.css" + }, + "2": { + "name": "invalid.illegal.leading-whitespace.charset.css" + }, + "3": { + "name": "invalid.illegal.no-whitespace.charset.css" + }, + "4": { + "name": "invalid.illegal.whitespace.charset.css" + }, + "5": { + "name": "invalid.illegal.not-double-quoted.charset.css" + }, + "6": { + "name": "invalid.illegal.unclosed-string.charset.css" + }, + "7": { + "name": "invalid.illegal.unexpected-characters.charset.css" + } + }, + "match": "(?x) # Possible errors:\n\\G\n((?!@charset)@\\w+) # Not lowercase (@charset is case-sensitive)\n|\n\\G(\\s+) # Preceding whitespace\n|\n(@charset\\S[^;]*) # No whitespace after @charset\n|\n(?<=@charset) # Before quoted charset name\n(\\x20{2,}|\\t+) # More than one space used, or a tab\n|\n(?<=@charset\\x20) # Beginning of charset name\n([^\";]+) # Not double-quoted\n|\n(\"[^\"]+$) # Unclosed quote\n|\n(?<=\") # After charset name\n([^;]+) # Unexpected junk instead of semicolon" + }, + { + "captures": { + "1": { + "name": "keyword.control.at-rule.charset.css" + }, + "2": { + "name": "punctuation.definition.keyword.css" + } + }, + "match": "((@)charset)(?=\\s)" + }, + { + "begin": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.css" + } + }, + "end": "\"|$", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.css" + } + }, + "name": "string.quoted.double.css", + "patterns": [ + { + "begin": "(?:\\G|^)(?=(?:[^\"])+$)", + "end": "$", + "name": "invalid.illegal.unclosed.string.css" + } + ] + } + ] + }, + { + "begin": "(?i)((@)import)(?:\\s+|$|(?=['\"]|/\\*))", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.import.css" + }, + "2": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": ";", + "endCaptures": { + "0": { + "name": "punctuation.terminator.rule.css" + } + }, + "name": "meta.at-rule.import.css", + "patterns": [ + { + "begin": "\\G\\s*(?=/\\*)", + "end": "(?<=\\*/)\\s*", + "patterns": [ + { + "include": "#comment-block" + } + ] + }, + { + "include": "#string" + }, + { + "include": "#url" + }, + { + "include": "#media-query-list" + } + ] + }, + { + "begin": "(?i)((@)font-face)(?=\\s*|{|/\\*|$)", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.font-face.css" + }, + "2": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": "(?!\\G)", + "name": "meta.at-rule.font-face.css", + "patterns": [ + { + "include": "#comment-block" + }, + { + "include": "#escapes" + }, + { + "include": "#rule-list" + } + ] + }, + { + "begin": "(?i)(@)page(?=[\\s:{]|/\\*|$)", + "captures": { + "0": { + "name": "keyword.control.at-rule.page.css" + }, + "1": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": "(?=\\s*($|[:{;]))", + "name": "meta.at-rule.page.css", + "patterns": [ + { + "include": "#rule-list" + } + ] + }, + { + "begin": "(?i)(?=@media(\\s|\\(|/\\*|$))", + "end": "(?<=})(?!\\G)", + "patterns": [ + { + "begin": "(?i)\\G(@)media", + "beginCaptures": { + "0": { + "name": "keyword.control.at-rule.media.css" + }, + "1": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": "(?=\\s*[{;])", + "name": "meta.at-rule.media.header.css", + "patterns": [ + { + "include": "#media-query-list" + } + ] + }, + { + "begin": "{", + "beginCaptures": { + "0": { + "name": "punctuation.section.media.begin.bracket.curly.css" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.section.media.end.bracket.curly.css" + } + }, + "name": "meta.at-rule.media.body.css", + "patterns": [ + { + "include": "$self" + } + ] + } + ] + }, + { + "begin": "(?i)(?=@counter-style([\\s'\"{;]|/\\*|$))", + "end": "(?<=})(?!\\G)", + "patterns": [ + { + "begin": "(?i)\\G(@)counter-style", + "beginCaptures": { + "0": { + "name": "keyword.control.at-rule.counter-style.css" + }, + "1": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": "(?=\\s*{)", + "name": "meta.at-rule.counter-style.header.css", + "patterns": [ + { + "include": "#comment-block" + }, + { + "include": "#escapes" + }, + { + "captures": { + "0": { + "patterns": [ + { + "include": "#escapes" + } + ] + } + }, + "match": "(?x)\n(?:[-a-zA-Z_] | [^\\x00-\\x7F]) # First letter\n(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier\n |\\\\(?:[0-9a-fA-F]{1,6}|.)\n)*", + "name": "variable.parameter.style-name.css" + } + ] + }, + { + "begin": "{", + "beginCaptures": { + "0": { + "name": "punctuation.section.property-list.begin.bracket.curly.css" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.section.property-list.end.bracket.curly.css" + } + }, + "name": "meta.at-rule.counter-style.body.css", + "patterns": [ + { + "include": "#comment-block" + }, + { + "include": "#escapes" + }, + { + "include": "#rule-list-innards" + } + ] + } + ] + }, + { + "begin": "(?i)(?=@document([\\s'\"{;]|/\\*|$))", + "end": "(?<=})(?!\\G)", + "patterns": [ + { + "begin": "(?i)\\G(@)document", + "beginCaptures": { + "0": { + "name": "keyword.control.at-rule.document.css" + }, + "1": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": "(?=\\s*[{;])", + "name": "meta.at-rule.document.header.css", + "patterns": [ + { + "begin": "(?i)(?>>", + "name": "invalid.deprecated.combinator.css" + }, + { + "match": ">>|>|\\+|~", + "name": "keyword.operator.combinator.css" + } + ] + }, + "commas": { + "match": ",", + "name": "punctuation.separator.list.comma.css" + }, + "comment-block": { + "begin": "/\\*", + "beginCaptures": { + "0": { + "name": "punctuation.definition.comment.begin.css" + } + }, + "end": "\\*/", + "endCaptures": { + "0": { + "name": "punctuation.definition.comment.end.css" + } + }, + "name": "comment.block.css" + }, + "escapes": { + "patterns": [ + { + "match": "\\\\[0-9a-fA-F]{1,6}", + "name": "constant.character.escape.codepoint.css" + }, + { + "begin": "\\\\$\\s*", + "end": "^(?<:=]|\\)|/\\*) # Terminates cleanly" + }, + "media-feature-keywords": { + "match": "(?xi)\n(?<=^|\\s|:|\\*/)\n(?: portrait # Orientation\n | landscape\n | progressive # Scan types\n | interlace\n | fullscreen # Display modes\n | standalone\n | minimal-ui\n | browser\n | hover\n)\n(?=\\s|\\)|$)", + "name": "support.constant.property-value.css" + }, + "media-query": { + "begin": "\\G", + "end": "(?=\\s*[{;])", + "patterns": [ + { + "include": "#comment-block" + }, + { + "include": "#escapes" + }, + { + "include": "#media-types" + }, + { + "match": "(?i)(?<=\\s|^|,|\\*/)(only|not)(?=\\s|{|/\\*|$)", + "name": "keyword.operator.logical.$1.media.css" + }, + { + "match": "(?i)(?<=\\s|^|\\*/|\\))and(?=\\s|/\\*|$)", + "name": "keyword.operator.logical.and.media.css" + }, + { + "match": ",(?:(?:\\s*,)+|(?=\\s*[;){]))", + "name": "invalid.illegal.comma.css" + }, + { + "include": "#commas" + }, + { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "punctuation.definition.parameters.begin.bracket.round.css" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.parameters.end.bracket.round.css" + } + }, + "patterns": [ + { + "include": "#media-features" + }, + { + "include": "#media-feature-keywords" + }, + { + "match": ":", + "name": "punctuation.separator.key-value.css" + }, + { + "match": ">=|<=|=|<|>", + "name": "keyword.operator.comparison.css" + }, + { + "captures": { + "1": { + "name": "constant.numeric.css" + }, + "2": { + "name": "keyword.operator.arithmetic.css" + }, + "3": { + "name": "constant.numeric.css" + } + }, + "match": "(\\d+)\\s*(/)\\s*(\\d+)", + "name": "meta.ratio.css" + }, + { + "include": "#numeric-values" + }, + { + "include": "#comment-block" + } + ] + } + ] + }, + "media-query-list": { + "begin": "(?=\\s*[^{;])", + "end": "(?=\\s*[{;])", + "patterns": [ + { + "include": "#media-query" + } + ] + }, + "media-types": { + "captures": { + "1": { + "name": "support.constant.media.css" + }, + "2": { + "name": "invalid.deprecated.constant.media.css" + } + }, + "match": "(?xi)\n(?<=^|\\s|,|\\*/)\n(?:\n # Valid media types\n (all|print|screen|speech)\n |\n # Deprecated in Media Queries 4: http://dev.w3.org/csswg/mediaqueries/#media-types\n (aural|braille|embossed|handheld|projection|tty|tv)\n)\n(?=$|[{,\\s;]|/\\*)" + }, + "numeric-values": { + "patterns": [ + { + "captures": { + "1": { + "name": "punctuation.definition.constant.css" + } + }, + "match": "(#)(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\\b", + "name": "constant.other.color.rgb-value.hex.css" + }, + { + "captures": { + "1": { + "name": "keyword.other.unit.percentage.css" + }, + "2": { + "name": "keyword.other.unit.${2:/downcase}.css" + } + }, + "match": "(?xi) (?+~|] # - Followed by another selector\n | /\\* # - Followed by a block comment\n )\n |\n # Name contains unescaped ASCII symbol\n (?: # Check for acceptable preceding characters\n [-a-zA-Z_0-9]|[^\\x00-\\x7F] # - Valid selector character\n | \\\\(?:[0-9a-fA-F]{1,6}|.) # - Escape sequence\n )*\n (?: # Invalid punctuation\n [!\"'%&(*;+~|] # - Another selector\n | /\\* # - A block comment\n)", + "name": "entity.other.attribute-name.class.css" + }, + { + "captures": { + "1": { + "name": "punctuation.definition.entity.css" + }, + "2": { + "patterns": [ + { + "include": "#escapes" + } + ] + } + }, + "match": "(?x)\n(\\#)\n(\n -?\n (?![0-9])\n (?:[-a-zA-Z0-9_]|[^\\x00-\\x7F]|\\\\(?:[0-9a-fA-F]{1,6}|.))+\n)\n(?=$|[\\s,.\\#)\\[:{>+~|]|/\\*)", + "name": "entity.other.attribute-name.id.css" + }, + { + "begin": "\\[", + "beginCaptures": { + "0": { + "name": "punctuation.definition.entity.begin.bracket.square.css" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "punctuation.definition.entity.end.bracket.square.css" + } + }, + "name": "meta.attribute-selector.css", + "patterns": [ + { + "include": "#comment-block" + }, + { + "include": "#string" + }, + { + "captures": { + "1": { + "name": "storage.modifier.ignore-case.css" + } + }, + "match": "(?<=[\"'\\s]|^|\\*/)\\s*([iI])\\s*(?=[\\s\\]]|/\\*|$)" + }, + { + "captures": { + "1": { + "name": "string.unquoted.attribute-value.css", + "patterns": [ + { + "include": "#escapes" + } + ] + } + }, + "match": "(?x)(?<==)\\s*((?!/\\*)(?:[^\\\\\"'\\s\\]]|\\\\.)+)" + }, + { + "include": "#escapes" + }, + { + "match": "[~|^$*]?=", + "name": "keyword.operator.pattern.css" + }, + { + "match": "\\|", + "name": "punctuation.separator.css" + }, + { + "captures": { + "1": { + "name": "entity.other.namespace-prefix.css", + "patterns": [ + { + "include": "#escapes" + } + ] + } + }, + "match": "(?x)\n# Qualified namespace prefix\n( -?(?!\\d)(?:[\\w-]|[^\\x00-\\x7F]|\\\\(?:[0-9a-fA-F]{1,6}|.))+\n| \\*\n)\n# Lookahead to ensure there's a valid identifier ahead\n(?=\n \\| (?!\\s|=|$|\\])\n (?: -?(?!\\d)\n | [\\\\\\w-]\n | [^\\x00-\\x7F]\n )\n)" + }, + { + "captures": { + "1": { + "name": "entity.other.attribute-name.css", + "patterns": [ + { + "include": "#escapes" + } + ] + } + }, + "match": "(?x)\n(-?(?!\\d)(?>[\\w-]|[^\\x00-\\x7F]|\\\\(?:[0-9a-fA-F]{1,6}|.))+)\n\\s*\n(?=[~|^\\]$*=]|/\\*)" + } + ] + }, + { + "include": "#pseudo-classes" + }, + { + "include": "#pseudo-elements" + }, + { + "include": "#functional-pseudo-classes" + }, + { + "match": "(?x) (?\\s,.\\#|){:\\[]|/\\*|$)", + "name": "entity.name.tag.css" + }, + "unicode-range": { + "captures": { + "0": { + "name": "constant.other.unicode-range.css" + }, + "1": { + "name": "punctuation.separator.dash.unicode-range.css" + } + }, + "match": "(? extends Map { + constructor(private factory: (key: T, self: DefaultMap) => V) { + super() + } + + get(key: T): V { + let value = super.get(key) + + if (value === undefined) { + value = this.factory(key, this) + this.set(key, value) + } + + return value + } +} diff --git a/packages/tailwindcss-language-syntax/tests/scopes.ts b/packages/tailwindcss-language-syntax/tests/scopes.ts new file mode 100644 index 000000000..eec85ef51 --- /dev/null +++ b/packages/tailwindcss-language-syntax/tests/scopes.ts @@ -0,0 +1,41 @@ +export interface ScopeEntry { + content: Promise<{ default: object }> + inject: string[] +} + +export const KNOWN_SCOPES: Record = { + 'source.css': { + content: import('../syntaxes/css.json'), + inject: [ + 'tailwindcss.at-rules.injection', + 'tailwindcss.at-apply.injection', + 'tailwindcss.theme-fn.injection', + 'tailwindcss.screen-fn.injection', + ], + }, + + 'source.css.tailwind': { + content: import('../../vscode-tailwindcss/syntaxes/source.css.tailwind.tmLanguage.json'), + inject: [], + }, + + 'tailwindcss.at-apply.injection': { + content: import('../../vscode-tailwindcss/syntaxes/at-apply.tmLanguage.json'), + inject: [], + }, + + 'tailwindcss.at-rules.injection': { + content: import('../../vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json'), + inject: [], + }, + + 'tailwindcss.theme-fn.injection': { + content: import('../../vscode-tailwindcss/syntaxes/theme-fn.tmLanguage.json'), + inject: [], + }, + + 'tailwindcss.screen-fn.injection': { + content: import('../../vscode-tailwindcss/syntaxes/screen-fn.tmLanguage.json'), + inject: [], + }, +} diff --git a/packages/tailwindcss-language-syntax/tests/syntax.test.ts b/packages/tailwindcss-language-syntax/tests/syntax.test.ts new file mode 100644 index 000000000..fa68eab3a --- /dev/null +++ b/packages/tailwindcss-language-syntax/tests/syntax.test.ts @@ -0,0 +1,282 @@ +import { test } from 'vitest' +import dedent, { type Dedent } from 'dedent' +import { loadGrammar } from './utils' + +const css: Dedent = dedent + +let grammar = await loadGrammar() + +test('@theme', async ({ expect }) => { + let result = await grammar.tokenize(css` + @theme { + --color: red; + } + @theme static { + --color: red; + } + @theme inline deprecated { + --color: red; + } + @theme prefix(tw) inline { + --color: red; + } + + @theme { + --spacing: initial; + --color-*: initial; + --animate-pulse: 1s pulse infinite; + + @keyframes pulse { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } + } + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@import', async ({ expect }) => { + let result = await grammar.tokenize(css` + @import './test.css'; + + @import './test.css' prefix(tw); + @import './test.css' layer(utilities) prefix(tw); + + @import './test.css' source(none); + @import './test.css' source('./foo'); + @import './test.css' layer(utilities) source('./foo'); + + @import './test.css' theme(static); + @import './test.css' theme(static default inline); + @import './test.css' theme(reference deprecated); + @import './test.css' theme(prefix(tw) reference); + @import './test.css' theme(default invalid reference); + + @reference './test.css'; + + @reference './test.css' prefix(tw); + @reference './test.css' layer(utilities) prefix(tw); + + @reference './test.css' source(none); + @reference './test.css' source('./foo'); + @reference './test.css' layer(utilities) source('./foo'); + + @reference './test.css' theme(static); + @reference './test.css' theme(static default inline); + @reference './test.css' theme(reference deprecated); + @reference './test.css' theme(prefix(tw) reference); + @reference './test.css' theme(default invalid reference); + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@plugin statement', async ({ expect }) => { + let result = await grammar.tokenize(css` + @plugin "./foo"; + @plugin "./bar"; + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@plugin with options', async ({ expect }) => { + let result = await grammar.tokenize(css` + @import 'tailwindcss'; + @plugin "testing" { + color: red; + } + + html, + body { + color: red; + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@config statement', async ({ expect }) => { + let result = await grammar.tokenize(css` + @config "./foo"; + @config "./bar"; + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@tailwind', async ({ expect }) => { + let result = await grammar.tokenize(css` + @tailwind base; + @tailwind components; + @tailwind utilities; + @tailwind utilities source(none); + @tailwind utilities source("./**/*"); + @tailwind screens; + @tailwind variants; + @tailwind unknown; + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@source', async ({ expect }) => { + let result = await grammar.tokenize(css` + @source "./dir"; + @source "./file.ts"; + @source "./dir/**/file-{a,b}.ts"; + @source not "./dir"; + @source not "./file.ts"; + @source not "./dir/**/file-{a,b}.ts"; + + @source inline("flex"); + @source inline("flex bg-red-{50,{100..900..100},950}"); + @source not inline("flex"); + @source not inline("flex bg-red-{50,{100..900..100},950}"); + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@layer', async ({ expect }) => { + let result = await grammar.tokenize(css` + @layer theme, base, components, utilities; + @layer utilities { + .custom { + width: 12px; + } + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@utility', async ({ expect }) => { + let result = await grammar.tokenize(css` + @utility custom { + width: 12px; + } + + @utility functional-* { + width: calc(--value(number) * 1px); + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('--value(…)', async ({ expect }) => { + let result = await grammar.tokenize(css` + @utility functional-* { + width: --value( + --size, + 'literal', + integer, + number, + percentage, + ratio, + [integer], + [number], + [percentage], + [ratio] + ); + + height: --modifier( + --size, + 'literal', + integer, + number, + percentage, + ratio, + [integer], + [number], + [percentage], + [ratio] + ); + + color: --alpha(--value([color]) / --modifier(number)); + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@variant', async ({ expect }) => { + let result = await grammar.tokenize(css` + @variant dark { + .foo { + color: white; + } + } + + .bar { + @variant dark { + color: white; + } + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('@custom-variant', async ({ expect }) => { + let result = await grammar.tokenize(css` + @custom-variant dark (&:is(.dark, .dark *)); + @custom-variant dark { + &:is(.dark, .dark *) { + @slot; + } + } + @custom-variant around { + color: ''; + &::before, + &::after { + @slot; + } + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('legacy: @responsive', async ({ expect }) => { + let result = await grammar.tokenize(css` + @responsive { + .foo { + color: red; + } + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('legacy: @variants', async ({ expect }) => { + let result = await grammar.tokenize(css` + @variants hover, focus { + .foo { + color: red; + } + } + `) + + expect(result.toString()).toMatchSnapshot() +}) + +test('legacy: @screen', async ({ expect }) => { + let result = await grammar.tokenize(css` + @screen sm { + .foo { + color: red; + } + } + `) + + expect(result.toString()).toMatchSnapshot() +}) diff --git a/packages/tailwindcss-language-syntax/tests/utils.ts b/packages/tailwindcss-language-syntax/tests/utils.ts new file mode 100644 index 000000000..cd6d3ccd1 --- /dev/null +++ b/packages/tailwindcss-language-syntax/tests/utils.ts @@ -0,0 +1,150 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import vsctm from 'vscode-textmate' +import oniguruma from 'vscode-oniguruma' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { KNOWN_SCOPES } from './scopes' +import { DefaultMap } from './default-map' + +const require = createRequire(import.meta.url) + +export interface TokenizeResult { + toString(): string +} + +export interface TokenizedScope { + /** + * The name of the scope + */ + name: string + + /** + * An ordered list of ranges as they appear, one per token + */ + ranges: [start: number, end: number][] +} + +export interface Grammar { + tokenize(text: string, scope?: string): Promise +} + +// 1. Each line has a list of scopes +// 2. Each scope has a set of ranges, one per token +// 3. If two consecutive scopes have identical range lists they can be merged + +// @utility custom { +// ^^^^^^^^^^^^^^^^^ 11 tok: source.css.tailwind +// ^^^^^^^^ 2 tok: keyword.control.at-rule.utility.tailwind +// ^ 1 tok: punctuation.definition.keyword.css +// ^^^^^^ 6 tok: variable.parameter.utility.tailwind +// ^ 1 tok: meta.at-rule.utility.body.tailwind punctuation.section.utility.begin.bracket.curly.tailwind + +function tokenizeText(grammar: vsctm.IGrammar, text: string): TokenizeResult { + let str = '' + + let results: [string, vsctm.ITokenizeLineResult][] = [] + + let ruleStack = vsctm.INITIAL + let maxEndIndex = 0 + for (let line of text.split(/\r\n|\r|\n/g)) { + let result = grammar.tokenizeLine(line, ruleStack) + ruleStack = result.ruleStack + maxEndIndex = Math.max(maxEndIndex, ...result.tokens.map((t) => t.endIndex)) + results.push([line, result]) + } + + for (let [line, result] of results) { + // 1. Collect the scope information for this line + let scopes = new DefaultMap((name) => ({ name, ranges: [] })) + + for (let token of result.tokens) { + let range = [token.startIndex, token.endIndex] as [number, number] + for (let name of token.scopes) { + scopes.get(name).ranges.push(range) + } + } + + let maxTokenCount = Math.max(...Array.from(scopes.values(), (s) => s.ranges.length)) + let tokenCountSpace = Math.max(2, maxTokenCount.toString().length) + + // 2. Write information to the output + str += '\n' + str += line + + let lastRangeKey = '' + + for (let scope of scopes.values()) { + let currentRangeKey = scope.ranges.map((r) => `${r[0]}:${r[1]}`).join(',') + if (lastRangeKey === currentRangeKey) { + str += ' ' + str += scope.name + continue + } + lastRangeKey = currentRangeKey + + str += '\n' + + let lastRangeEnd = 0 + for (let range of scope.ranges) { + str += ' '.repeat(range[0] - lastRangeEnd) + str += '^'.repeat(range[1] - range[0]) + lastRangeEnd = range[1] + } + str += ' '.repeat(maxEndIndex - lastRangeEnd) + + str += ' ' + str += scope.ranges.length.toString().padStart(tokenCountSpace) + str += ': ' + str += scope.name + } + + str += '\n' + } + + return { + toString: () => str, + } +} + +export async function loadGrammar() { + let wasm = await readFile(require.resolve('vscode-oniguruma/release/onig.wasm')) + await oniguruma.loadWASM(wasm) + + let registry = new vsctm.Registry({ + onigLib: Promise.resolve({ + createOnigScanner: (patterns) => new oniguruma.OnigScanner(patterns), + createOnigString: (s) => new oniguruma.OnigString(s), + }), + + async loadGrammar(scope) { + let meta = KNOWN_SCOPES[scope] + if (!meta) throw new Error(`Unknown scope name: ${scope}`) + + let grammar = await meta.content.then((m) => m.default) + + return vsctm.parseRawGrammar(JSON.stringify(grammar), `${scope}.json`) + }, + + getInjections(scope) { + let parts = scope.split('.') + + let injections: string[] = [] + for (let i = 1; i <= parts.length; i++) { + let subscope = parts.slice(0, i).join('.') + injections.push(...(KNOWN_SCOPES[subscope]?.inject ?? [])) + } + + return injections + }, + }) + + async function tokenize(text: string, scope?: string): Promise { + let grammar = await registry.loadGrammar(scope ?? 'source.css.tailwind') + return tokenizeText(grammar, text) + } + + return { + tokenize, + } +} diff --git a/packages/tailwindcss-language-syntax/tsconfig.json b/packages/tailwindcss-language-syntax/tsconfig.json new file mode 100755 index 000000000..7d01f0c54 --- /dev/null +++ b/packages/tailwindcss-language-syntax/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ES2022", + "lib": ["ES2022"], + "rootDir": "..", + "moduleResolution": "node", + "esModuleInterop": true, + "allowJs": true, + "resolveJsonModule": true, + "baseUrl": ".." + }, + "include": ["tests"] +} diff --git a/packages/tailwindcss-language-syntax/vitest.config.ts b/packages/tailwindcss-language-syntax/vitest.config.ts new file mode 100644 index 000000000..a079f2e6c --- /dev/null +++ b/packages/tailwindcss-language-syntax/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + testTimeout: 15000, + }, +}) diff --git a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json index 6f09958f8..0e074444d 100644 --- a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json +++ b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json @@ -82,7 +82,7 @@ "include": "#source-fn" }, { - "match": "[^\\s;]+?", + "match": "[^\\s;]+", "name": "variable.parameter.tailwind.tailwind" } ] @@ -103,7 +103,7 @@ "include": "source.css#comment-block" }, { - "match": "[^\\s{]+?", + "match": "[^\\s{]+", "name": "variable.parameter.screen.tailwind" }, { @@ -144,7 +144,7 @@ "include": "source.css#comment-block" }, { - "match": "[^\\s{;,]+?", + "match": "[^\\s{;,]+", "name": "variable.parameter.layer.tailwind" }, { @@ -189,8 +189,30 @@ }, "end": "(?<=}|;)(?!\\G)", "patterns": [ + { "include": "#theme-options" }, { - "include": "source.css#rule-list" + "match": "[^{\\s]+", + "name": "invalid.illegal.theme-option.css" + }, + { + "begin": "{", + "beginCaptures": { + "0": { + "name": "punctuation.section.theme.begin.bracket.curly.tailwind" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.section.theme.end.bracket.curly.tailwind" + } + }, + "name": "meta.at-rule.theme.body.tailwind", + "patterns": [ + { + "include": "#property-list" + } + ] } ] }, @@ -204,7 +226,7 @@ "name": "punctuation.definition.keyword.tailwind" } }, - "end": ";", + "end": ";|(?=[@{])", "endCaptures": { "0": { "name": "punctuation.terminator.rule.css" @@ -257,8 +279,15 @@ "match": "none(?=;)", "name": "invalid.illegal.invalid-source.css" }, + { + "match": "not(?=\\s)", + "name": "support.constant.not.css" + }, { "include": "source.css#string" + }, + { + "include": "#inline-fn" } ] }, @@ -303,7 +332,7 @@ "include": "source.css#commas" }, { - "match": "[^\\s{,]+?", + "match": "[^\\s{,]+", "name": "variable.parameter.variants.tailwind" }, { @@ -341,7 +370,7 @@ "end": "(?<=})(?!\\G)", "patterns": [ { - "match": "[^\\s{,]+?", + "match": "[^\\s{,]+", "name": "variable.parameter.utility.tailwind" }, { @@ -360,14 +389,14 @@ "name": "meta.at-rule.utility.body.tailwind", "patterns": [ { - "include": "source.css#rule-list" + "include": "source.css#rule-list-innards" } ] } ] }, { - "begin": "(?i)((@)variant)(?=[\\s{(]|$)", + "begin": "(?i)((@)(?:custom-)?variant)(?=[\\s{(]|$)", "beginCaptures": { "1": { "name": "keyword.control.at-rule.variant.tailwind" @@ -379,7 +408,7 @@ "end": "(?<=[};])(?!\\G)", "patterns": [ { - "match": "[^\\s({;,]+?", + "match": "[^\\s({;,]+", "name": "variable.parameter.variant.tailwind" }, { @@ -561,13 +590,13 @@ } ] }, - "theme-meta-fn": { + "inline-fn": { "patterns": [ { - "begin": "(?i)(?:\\s*)(?=12'} @@ -1589,6 +1639,10 @@ packages: resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} engines: {node: '>=12.0.0'} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -1607,6 +1661,14 @@ packages: picomatch: optional: true + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2484,6 +2546,9 @@ packages: std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2576,6 +2641,10 @@ packages: resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + tinypool@1.0.2: resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2696,6 +2765,11 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-node@3.1.4: + resolution: {integrity: sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-tsconfig-paths@4.3.2: resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} peerDependencies: @@ -2763,6 +2837,34 @@ packages: jsdom: optional: true + vitest@3.1.4: + resolution: {integrity: sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.4 + '@vitest/ui': 3.1.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-css-languageservice@6.3.3: resolution: {integrity: sha512-xXa+ftMPv6JxRgzkvPwZuDCafIdwDW3kyijGcfij1a2qBVScr2qli6MfgJzYm/AMYdbHq9I/4hdpKV0Thim2EA==} @@ -2814,6 +2916,12 @@ packages: resolution: {integrity: sha512-eUt8f1z2N2IEUDBsKaNapkz7jl5QpskN2Y0G01T/ItMxBxw1fJwvtySGB9QMecatne8jFIWJGWI61dWjyTLQsw==} hasBin: true + vscode-oniguruma@2.0.1: + resolution: {integrity: sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==} + + vscode-textmate@9.2.0: + resolution: {integrity: sha512-rkvG4SraZQaPSN/5XjwKswdU0OP9MF28QjrYzUBbhb8QyG3ljB1Ky996m++jiI7KdiAP2CkBiQZd9pqEDTClqA==} + vscode-uri@3.0.2: resolution: {integrity: sha512-jkjy6pjU1fxUvI51P+gCsxg1u2n8LSt0W6KrCNQceaziKzff74GoWmjVG46KieVzybO1sttPQmYfrwSHey7GUA==} @@ -3388,6 +3496,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 + '@vitest/expect@3.1.4': + dependencies: + '@vitest/spy': 3.1.4 + '@vitest/utils': 3.1.4 + chai: 5.2.0 + tinyrainbow: 2.0.0 + '@vitest/mocker@3.0.9(vite@5.4.14(@types/node@18.19.43))': dependencies: '@vitest/spy': 3.0.9 @@ -3396,31 +3511,64 @@ snapshots: optionalDependencies: vite: 5.4.14(@types/node@18.19.43) + '@vitest/mocker@3.1.4(vite@5.4.14(@types/node@18.19.43))': + dependencies: + '@vitest/spy': 3.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 5.4.14(@types/node@18.19.43) + '@vitest/pretty-format@3.0.9': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@3.1.4': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/runner@3.0.9': dependencies: '@vitest/utils': 3.0.9 pathe: 2.0.3 + '@vitest/runner@3.1.4': + dependencies: + '@vitest/utils': 3.1.4 + pathe: 2.0.3 + '@vitest/snapshot@3.0.9': dependencies: '@vitest/pretty-format': 3.0.9 magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/snapshot@3.1.4': + dependencies: + '@vitest/pretty-format': 3.1.4 + magic-string: 0.30.17 + pathe: 2.0.3 + '@vitest/spy@3.0.9': dependencies: tinyspy: 3.0.2 + '@vitest/spy@3.1.4': + dependencies: + tinyspy: 3.0.2 + '@vitest/utils@3.0.9': dependencies: '@vitest/pretty-format': 3.0.9 loupe: 3.1.3 tinyrainbow: 2.0.0 + '@vitest/utils@3.1.4': + dependencies: + '@vitest/pretty-format': 3.1.4 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + '@vscode/l10n@0.0.18': {} '@vscode/vsce@2.21.1': @@ -3807,6 +3955,8 @@ snapshots: es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} + esbuild-node-externals@1.14.0(esbuild@0.25.0): dependencies: esbuild: 0.25.0 @@ -3880,6 +4030,8 @@ snapshots: expect-type@1.2.0: {} + expect-type@1.2.1: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3900,6 +4052,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -4770,6 +4926,8 @@ snapshots: std-env@3.8.0: {} + std-env@3.9.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -4899,6 +5057,11 @@ snapshots: fdir: 6.4.3(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + tinypool@1.0.2: {} tinyrainbow@2.0.0: {} @@ -5001,6 +5164,24 @@ snapshots: - supports-color - terser + vite-node@3.1.4(@types/node@18.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.0 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.14(@types/node@18.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@5.4.14(@types/node@18.19.43)): dependencies: debug: 4.3.6 @@ -5056,6 +5237,42 @@ snapshots: - supports-color - terser + vitest@3.1.4(@types/node@18.19.43): + dependencies: + '@vitest/expect': 3.1.4 + '@vitest/mocker': 3.1.4(vite@5.4.14(@types/node@18.19.43)) + '@vitest/pretty-format': 3.1.4 + '@vitest/runner': 3.1.4 + '@vitest/snapshot': 3.1.4 + '@vitest/spy': 3.1.4 + '@vitest/utils': 3.1.4 + chai: 5.2.0 + debug: 4.4.0 + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 5.4.14(@types/node@18.19.43) + vite-node: 3.1.4(@types/node@18.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 18.19.43 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vscode-css-languageservice@6.3.3: dependencies: '@vscode/l10n': 0.0.18 @@ -5110,6 +5327,10 @@ snapshots: dependencies: vscode-languageserver-protocol: 3.17.3 + vscode-oniguruma@2.0.1: {} + + vscode-textmate@9.2.0: {} + vscode-uri@3.0.2: {} vscode-uri@3.0.8: {}