diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts
index efb12a34..a086612e 100644
--- a/packages/tailwindcss-language-server/src/tw.ts
+++ b/packages/tailwindcss-language-server/src/tw.ts
@@ -48,6 +48,8 @@ import { ProjectLocator, type ProjectConfig } from './project-locator'
import type { TailwindCssSettings } from '@tailwindcss/language-service/src/util/state'
import { createResolver, Resolver } from './resolver'
import { retry } from './util/retry'
+import { analyzeDocument } from '@tailwindcss/language-service/src/scopes/analyze'
+import type { AnyScope } from '@tailwindcss/language-service/src/scopes/scope'
const TRIGGER_CHARACTERS = [
// class attributes
@@ -764,12 +766,17 @@ export class TW {
private onRequest(
method: '@/tailwindCSS/sortSelection',
params: { uri: string; classLists: string[] },
- ): { error: string } | { classLists: string[] }
+ ): Promise<{ error: string } | { classLists: string[] }>
private onRequest(
method: '@/tailwindCSS/getProject',
params: { uri: string },
- ): { version: string } | null
- private onRequest(method: string, params: any): any {
+ ): Promise<{ version: string } | null>
+ private onRequest(
+ method: '@/tailwindCSS/scopes/get',
+ params: { uri: string },
+ ): Promise<{ scopes: AnyScope[] } | null>
+
+ private async onRequest(method: string, params: any): Promise {
if (method === '@/tailwindCSS/sortSelection') {
let project = this.getProject({ uri: params.uri })
if (!project) {
@@ -791,6 +798,24 @@ export class TW {
version: project.state.version,
}
}
+
+ if (method === '@/tailwindCSS/scopes/get') {
+ console.log('Get scopes plz')
+
+ let doc = this.documentService.getDocument(params.uri)
+ if (!doc) return { scopes: [] }
+
+ let project = this.getProject({ uri: params.uri })
+ if (!project) return { scopes: [] }
+ if (!project.enabled()) return { scopes: [] }
+ if (!project.state.enabled) return { scopes: [] }
+
+ let tree = await analyzeDocument(project.state, doc)
+
+ return {
+ scopes: tree.all(),
+ }
+ }
}
private updateCapabilities() {
diff --git a/packages/tailwindcss-language-server/tsconfig.json b/packages/tailwindcss-language-server/tsconfig.json
index f69c060b..36b69d94 100755
--- a/packages/tailwindcss-language-server/tsconfig.json
+++ b/packages/tailwindcss-language-server/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
- "target": "ES2018",
+ "target": "ES2022",
"lib": ["ES2022"],
"rootDir": "..",
"sourceMap": true,
diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json
index 0cbdde25..0cd4294d 100644
--- a/packages/tailwindcss-language-service/package.json
+++ b/packages/tailwindcss-language-service/package.json
@@ -44,9 +44,11 @@
"@types/line-column": "^1.0.2",
"@types/node": "^18.19.33",
"@types/stringify-object": "^4.0.5",
+ "dedent": "^1.5.3",
"esbuild": "^0.25.0",
"esbuild-node-externals": "^1.9.0",
"minimist": "^1.2.8",
+ "mitata": "^1.0.34",
"tslib": "2.2.0",
"typescript": "^5.3.3",
"vitest": "^1.6.1"
diff --git a/packages/tailwindcss-language-service/src/documents/document.ts b/packages/tailwindcss-language-service/src/documents/document.ts
new file mode 100644
index 00000000..79336c36
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/documents/document.ts
@@ -0,0 +1,117 @@
+import type { Position, TextDocument } from 'vscode-languageserver-textdocument'
+import type { Span, State } from '../util/state'
+import { ScopeTree } from '../scopes/tree'
+import { ScopePath, walkScope } from '../scopes/walk'
+import { ScopeContext } from '../scopes/scope'
+import { LanguageBoundary } from '../util/getLanguageBoundaries'
+import { isWithinRange } from '../util/isWithinRange'
+
+export type DocumentContext = 'html' | 'css' | 'js' | 'other' | (string & {})
+
+export class VirtualDocument {
+ /**
+ * A getter for the global state of the server. This state is shared across
+ * all virtual documents and may change arbitrarily over time.
+ *
+ * This is a getter to prevent stale references from being held.
+ */
+ private state: () => State
+
+ /**
+ * The "text document" that contains the content of this document and all of
+ * its embedded documents.
+ */
+ private storage: TextDocument
+
+ /**
+ * Conseptual boundaries of the document where different languages are used
+ *
+ * This is used to determine how the document is structured and what parts
+ * are relevant to the current operation.
+ */
+ private boundaries: LanguageBoundary[]
+
+ /**
+ * A description of this document's "interesting" parts
+ *
+ * This is used to determine how the document is structured and what parts
+ * are relevant to the current operation.
+ *
+ * For example, we can use this to get all class lists in a document, to
+ * determine if the cursor is in a class name, or what part of a document is
+ * considered "CSS" inside HTML or Vue documents.
+ */
+ private scopes: ScopeTree
+
+ constructor(
+ state: () => State,
+ storage: TextDocument,
+ boundaries: LanguageBoundary[],
+ scopes: ScopeTree,
+ ) {
+ this.state = state
+ this.storage = storage
+ this.boundaries = boundaries
+ this.scopes = scopes
+ }
+
+ /**
+ * The current content of the document
+ */
+ public get contents() {
+ let spans: Span[] = []
+
+ walkScope(this.scopes.all(), {
+ enter(node) {
+ if (node.kind !== 'comment') return
+ spans.push(node.source.scope)
+ },
+ })
+
+ // TODO: Drop comment removal once all features only query scopes
+ let text = this.storage.getText()
+
+ // Replace all comment nodes with whitespace
+ let tmp = ''
+ let last = 0
+ for (let [start, end] of spans) {
+ tmp += text.slice(last, start)
+ tmp += text.slice(start, end).replace(/./gs, (char) => (char === '\n' ? '\n' : ' '))
+ last = end
+ }
+
+ tmp += text.slice(last)
+
+ return tmp
+ }
+
+ /**
+ * Find the inner-most scope containing a given cursor position.
+ */
+ public boundaryAt(cursor: Position): LanguageBoundary | null {
+ for (let boundary of this.boundaries) {
+ if (isWithinRange(cursor, boundary.range)) {
+ return boundary
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Find the inner-most scope containing a given cursor position.
+ */
+ public scopeAt(cursor: Position): ScopePath {
+ return this.scopes.at(this.storage.offsetAt(cursor))
+ }
+
+ /**
+ * Find the inner-most scope containing a given cursor position.
+ */
+ public contextAt(cursor: Position): ScopeContext {
+ let scope = this.scopes.closestAt('context', this.storage.offsetAt(cursor))
+ if (!scope) throw new Error('Unreachable')
+
+ return scope
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/documents/manager.ts b/packages/tailwindcss-language-service/src/documents/manager.ts
new file mode 100644
index 00000000..d3c2c48b
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/documents/manager.ts
@@ -0,0 +1,44 @@
+import type { TextDocument } from 'vscode-languageserver-textdocument'
+import type { State } from '../util/state'
+import { VirtualDocument } from './document'
+import { analyzeDocument } from '../scopes/analyze'
+import { getDocumentLanguages, getLanguageBoundaries } from '../util/getLanguageBoundaries'
+
+export class DocumentManager {
+ /**
+ * A getter for the global state of the server. This state is shared across
+ * all virtual documents and may change arbitrarily over time.
+
+ * This is a getter to prevent stale references from being held.
+ */
+ private state: () => State
+
+ /**
+ * A map of document URIs to their respective virtual documents.
+ */
+ private store = new Map()
+
+ constructor(state: () => State) {
+ this.state = state
+ }
+
+ get(uri: TextDocument): Promise
+ get(uri: string): Promise
+
+ async get(doc: TextDocument | string): Promise {
+ if (typeof doc === 'string') {
+ return this.store.get(doc) ?? null
+ }
+
+ let found = this.store.get(doc.uri)
+ if (found) return found
+
+ let state = this.state()
+ let scopes = await analyzeDocument(state, doc)
+ let boundaries = getDocumentLanguages(state, doc)
+ found = new VirtualDocument(this.state, doc, boundaries, scopes)
+ this.store.set(doc.uri, found)
+
+ return found
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/scopes/analyze.test.ts b/packages/tailwindcss-language-service/src/scopes/analyze.test.ts
new file mode 100644
index 00000000..fc329eda
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/analyze.test.ts
@@ -0,0 +1,447 @@
+import dedent from 'dedent'
+import { test } from 'vitest'
+import { analyzeDocument } from './analyze'
+import { State } from '../util/state'
+import { TextDocument } from 'vscode-languageserver-textdocument'
+import { ScopeTree } from './tree'
+import { printScopes } from './walk'
+
+const html = dedent
+const css = dedent
+const content = html`
+
+
+
+
+
+ Hello, world!
+
+
+
+`
+
+interface Options {
+ lang: string
+ content: string
+}
+
+async function analyze(opts: Options) {
+ let state: State = {
+ enabled: true,
+ blocklist: [],
+ editor: {
+ userLanguages: {},
+ getConfiguration: async () =>
+ ({
+ editor: { tabSize: 2 },
+ tailwindCSS: {
+ classAttributes: ['class', 'className'],
+ experimental: {
+ classRegex: [],
+ },
+ },
+ }) as any,
+ } as any,
+ }
+
+ function wrap(tree: ScopeTree) {
+ return Object.create(tree, {
+ toString: {
+ value: () => printScopes(tree.all(), opts.content),
+ },
+ })
+ }
+
+ let scopes = await analyzeDocument(
+ state,
+ TextDocument.create(`file://test/test.${opts.lang}`, opts.lang, 0, opts.content),
+ )
+
+ return Object.create(scopes, {
+ toString: {
+ value: () => printScopes(scopes.all(), opts.content),
+ },
+ })
+}
+
+test('analyze', async ({ expect }) => {
+ let scopes = await analyze({ lang: 'html', content })
+
+ expect(scopes.toString()).toMatchInlineSnapshot(`
+ "
+ context [0-20]: "\\n \\n "
+ - syntax: html
+ - lang: html
+ context [20-135]: "\\n \\n ..."
+ - syntax: html
+ - lang: html
+ class.list [179-199]: "bg-red-500 underline"
+ class.name [179-189]: "bg-red-500"
+ class.name [190-199]: "underline"
+ context [225-350]: "\\n \\n..."
+ - syntax: html
+ - lang: html
+ "
+ `)
+})
+
+test('analyze partial at-rules', async ({ expect }) => {
+ let scopes = await analyze({
+ lang: 'css',
+ content: css`
+ .foo {
+ @apply bg-red-500 text-white;
+ }
+
+ @vari /* */;
+ @apply /* */;
+ @theme inline;
+ `,
+ })
+
+ expect(scopes.toString()).toMatchInlineSnapshot(`
+ "
+ context [0-83]: ".foo {\\n @apply bg-r..."
+ - syntax: css
+ - lang: css
+ css.at-rule [9-37]: "@apply bg-red-500 te..."
+ - name [9-15]: "@apply"
+ - params [16-37]: "bg-red-500 text-whit..."
+ - body (none)
+ class.list [16-37]: "bg-red-500 text-whit..."
+ class.name [16-26]: "bg-red-500"
+ class.name [27-37]: "text-white"
+ css.at-rule [42-53]: "@vari /* */"
+ - name [42-47]: "@vari"
+ - params [48-53]: "/* */"
+ - body (none)
+ css.at-rule [55-67]: "@apply /* */"
+ - name [55-61]: "@apply"
+ - params [62-67]: "/* */"
+ - body (none)
+ class.list [66-67]: "/"
+ class.name [66-66]: ""
+ class.name [67-67]: ""
+ css.at-rule [69-82]: "@theme inline"
+ - name [69-75]: "@theme"
+ - params [76-82]: "inline"
+ - body (none)
+ "
+ `)
+})
+
+test('@utility has its own scope', async ({ expect }) => {
+ let scopes = await analyze({
+ lang: 'css',
+ content: css`
+ @utility foo {
+ color: red;
+ }
+
+ @utility bar-* {
+ color: --value(number);
+ }
+ `,
+ })
+
+ expect(scopes.toString()).toMatchInlineSnapshot(`
+ "
+ context [0-76]: "@utility foo {\\n col..."
+ - syntax: css
+ - lang: css
+ css.at-rule [0-29]: "@utility foo {\\n col..."
+ - name [0-8]: "@utility"
+ - params [9-13]: "foo "
+ - body [13-29]: "{\\n color: red;\\n"
+ css.at-rule.utility [0-29]: "@utility foo {\\n col..."
+ - name [9-12]: "foo"
+ - kind: static
+ css.at-rule [32-75]: "@utility bar-* {\\n c..."
+ - name [32-40]: "@utility"
+ - params [41-47]: "bar-* "
+ - body [47-75]: "{\\n color: --value(n..."
+ css.at-rule.utility [32-75]: "@utility bar-* {\\n c..."
+ - name [41-44]: "bar"
+ - kind: functional
+ "
+ `)
+})
+
+test('@import has its own scope', async ({ expect }) => {
+ let scopes = await analyze({
+ lang: 'css',
+ content: css`
+ @import './foo.css';
+ @import url('./foo.css');
+ @reference "./foo.css";
+ @import './foo.css' source(none);
+ @import './foo.css' source('./foo');
+ @import './foo.css' source('./foo') theme(inline);
+ @import './foo.css' theme(inline);
+ @import './foo.css' theme(inline) source(none);
+ @import './foo.css' theme();
+ @import './foo.css' theme(inline reference default);
+ `,
+ })
+
+ expect(scopes.toString()).toMatchInlineSnapshot(`
+ "
+ context [0-357]: "@import './foo.css';..."
+ - syntax: css
+ - lang: css
+ css.at-rule [0-19]: "@import './foo.css'"
+ - name [0-7]: "@import"
+ - params [8-19]: "'./foo.css'"
+ - body (none)
+ css.at-rule.import [0-19]: "@import './foo.css'"
+ - url [9-18]: "./foo.css"
+ - sourceUrl (none)
+ css.at-rule [21-45]: "@import url('./foo.c..."
+ - name [21-28]: "@import"
+ - params [29-45]: "url('./foo.css')"
+ - body (none)
+ css.at-rule.import [21-45]: "@import url('./foo.c..."
+ - url [34-43]: "./foo.css"
+ - sourceUrl (none)
+ css.at-rule [47-69]: "@reference "./foo.cs..."
+ - name [47-57]: "@reference"
+ - params [58-69]: ""./foo.css""
+ - body (none)
+ css.at-rule [71-103]: "@import './foo.css' ..."
+ - name [71-78]: "@import"
+ - params [79-103]: "'./foo.css' source(n..."
+ - body (none)
+ css.at-rule.import [71-103]: "@import './foo.css' ..."
+ - url [80-89]: "./foo.css"
+ - sourceUrl [98-102]: "none"
+ css.at-rule [105-140]: "@import './foo.css' ..."
+ - name [105-112]: "@import"
+ - params [113-140]: "'./foo.css' source('..."
+ - body (none)
+ css.at-rule.import [105-140]: "@import './foo.css' ..."
+ - url [114-123]: "./foo.css"
+ - sourceUrl [133-138]: "./foo"
+ css.at-rule [142-191]: "@import './foo.css' ..."
+ - name [142-149]: "@import"
+ - params [150-191]: "'./foo.css' source('..."
+ - body (none)
+ css.at-rule.import [142-191]: "@import './foo.css' ..."
+ - url [151-160]: "./foo.css"
+ - sourceUrl [170-175]: "./foo"
+ theme.option.list [184-190]: "inline"
+ theme.option.name [184-190]: "inline"
+ css.at-rule [193-226]: "@import './foo.css' ..."
+ - name [193-200]: "@import"
+ - params [201-226]: "'./foo.css' theme(in..."
+ - body (none)
+ css.at-rule.import [193-226]: "@import './foo.css' ..."
+ - url [202-211]: "./foo.css"
+ - sourceUrl (none)
+ theme.option.list [219-225]: "inline"
+ theme.option.name [219-225]: "inline"
+ css.at-rule [228-274]: "@import './foo.css' ..."
+ - name [228-235]: "@import"
+ - params [236-274]: "'./foo.css' theme(in..."
+ - body (none)
+ css.at-rule.import [228-274]: "@import './foo.css' ..."
+ - url [237-246]: "./foo.css"
+ - sourceUrl [269-273]: "none"
+ theme.option.list [254-260]: "inline"
+ theme.option.name [254-260]: "inline"
+ css.at-rule [276-303]: "@import './foo.css' ..."
+ - name [276-283]: "@import"
+ - params [284-303]: "'./foo.css' theme()"
+ - body (none)
+ css.at-rule.import [276-303]: "@import './foo.css' ..."
+ - url [285-294]: "./foo.css"
+ - sourceUrl (none)
+ theme.option.list [302-302]: ""
+ theme.option.name [302-302]: ""
+ css.at-rule [305-356]: "@import './foo.css' ..."
+ - name [305-312]: "@import"
+ - params [313-356]: "'./foo.css' theme(in..."
+ - body (none)
+ css.at-rule.import [305-356]: "@import './foo.css' ..."
+ - url [314-323]: "./foo.css"
+ - sourceUrl (none)
+ theme.option.list [331-355]: "inline reference def..."
+ theme.option.name [331-337]: "inline"
+ theme.option.name [338-347]: "reference"
+ theme.option.name [348-355]: "default"
+ "
+ `)
+})
+
+test('helper functions have their own scopes', async ({ expect }) => {
+ let scopes = await analyze({
+ lang: 'css',
+ content: css`
+ .foo {
+ color: theme(--color-red-500);
+ background: --alpha(var(--color-red-500));
+ }
+ `,
+ })
+
+ expect(scopes.toString()).toMatchInlineSnapshot(`
+ "
+ context [0-86]: ".foo {\\n color: them..."
+ - syntax: css
+ - lang: css
+ css.fn [16-38]: "theme(--color-red-50..."
+ - name [16-21]: "theme"
+ - params [22-37]: "--color-red-500"
+ css.fn [54-83]: "--alpha(var(--color-..."
+ - name [54-61]: "--alpha"
+ - params [62-82]: "var(--color-red-500)"
+ "
+ `)
+})
+
+test('helper functions have their own scopes', async ({ expect }) => {
+ let scopes = await analyze({
+ lang: 'html',
+ content: html`
+
+
+
+ `,
+ })
+
+ expect(scopes.toString()).toMatchInlineSnapshot(`
+ "
+ context [0-9]: "\\n "
+ - syntax: html
+ - lang: html
+ context [9-124]: "\\n"
+ - syntax: html
+ - lang: html
+ "
+ `)
+})
+
+test('ScopeTree#at', async ({ expect }) => {
+ let scopes = await analyze({ lang: 'html', content })
+
+ for (let i = 0; i < 21; ++i) {
+ expect(scopes.at(i).at(-1)).toMatchObject({ kind: 'context', meta: { syntax: 'html' } })
+ }
+
+ for (let i = 21; i < 34; ++i) {
+ expect(scopes.at(i).at(-1)).toMatchObject({ kind: 'context', meta: { syntax: 'css' } })
+ }
+
+ for (let i = 34; i < 62; ++i) {
+ expect(scopes.at(i).at(-1)).toMatchObject({ kind: 'css.at-rule.import' })
+ }
+ for (let i = 62; i < 69; ++i) {
+ expect(scopes.at(i).at(-1)).toMatchObject({ kind: 'theme.option.name' })
+ }
+
+ expect(scopes.toString()).toMatchInlineSnapshot(`
+ "
+ context [0-20]: "\\n \\n "
+ - syntax: html
+ - lang: html
+ context [20-135]: "\\n \\n ..."
+ - syntax: html
+ - lang: html
+ class.list [179-199]: "bg-red-500 underline"
+ class.name [179-189]: "bg-red-500"
+ class.name [190-199]: "underline"
+ context [225-350]: "\\n \\n..."
+ - syntax: html
+ - lang: html
+ "
+ `)
+})
diff --git a/packages/tailwindcss-language-service/src/scopes/analyze.ts b/packages/tailwindcss-language-service/src/scopes/analyze.ts
new file mode 100644
index 00000000..7744509d
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/analyze.ts
@@ -0,0 +1,430 @@
+import type { TextDocument } from 'vscode-languageserver-textdocument'
+import type { DocumentClassList, Span, State } from '../util/state'
+import type {
+ ScopeContext,
+ ScopeAtRule,
+ ScopeFn,
+ ScopeClassList,
+ ScopeClassName,
+ ScopeAtImport,
+ ScopeThemeOptionList,
+} from './scope'
+import { ScopeTree } from './tree'
+import { getDocumentLanguages, LanguageBoundary } from '../util/getLanguageBoundaries'
+import { findClassListsInRange, getClassNamesInClassList } from '../util/find'
+import { segment } from '../util/segment'
+import { optimizeScopes } from './walk'
+import { isSemicolonlessCssLanguage } from '../util/languages'
+
+export async function analyzeDocument(state: State, doc: TextDocument): Promise {
+ let roots: ScopeContext[] = []
+
+ let boundaries = getDocumentLanguages(state, doc)
+
+ for await (let root of analyzeBoundaries(state, doc, boundaries)) {
+ roots.push(root)
+ }
+
+ // Turn sibling nodes into children
+ // TODO: Remove the need for this
+ // This is because we're using existing functions that analyze larger
+ // blocks of text instead of smaller slices
+ optimizeScopes(roots)
+
+ return new ScopeTree(roots)
+}
+
+export async function* analyzeBoundaries(
+ state: State,
+ doc: TextDocument,
+ boundaries: LanguageBoundary[],
+): AsyncIterable {
+ for (let boundary of boundaries) {
+ let meta: ScopeContext['meta'] = {
+ syntax: 'html',
+ lang: 'html',
+ semi: false,
+ }
+
+ let root: ScopeContext = {
+ kind: 'context',
+ children: [],
+ meta,
+ source: {
+ scope: boundary.span,
+ },
+ }
+
+ if (boundary.type === 'html') {
+ meta.syntax = 'html'
+ meta.lang = boundary.lang ?? 'html'
+ } else if (boundary.type === 'js' || boundary.type === 'jsx') {
+ meta.syntax = 'js'
+ meta.lang = boundary.lang ?? 'js'
+ } else if (boundary.type === 'css') {
+ meta.syntax = 'css'
+ meta.lang = boundary.lang ?? 'css'
+ meta.semi = !isSemicolonlessCssLanguage(meta.lang)
+ } else {
+ meta.syntax = 'other'
+ meta.lang = boundary.lang ?? boundary.type
+ }
+
+ for await (let scope of analyzeClassLists(state, doc, boundary)) {
+ root.children.push(scope)
+ }
+
+ let slice = doc.getText(boundary.range)
+ for (let scope of analyzeAtRules(boundary, slice)) {
+ root.children.push(scope)
+ }
+
+ for (let scope of analyzeHelperFns(boundary, slice)) {
+ root.children.push(scope)
+ }
+
+ yield root
+ }
+}
+
+/**
+ * Find all class lists and classes within the given block of text
+ */
+async function* analyzeClassLists(
+ state: State,
+ doc: TextDocument,
+ boundary: LanguageBoundary,
+): AsyncIterable {
+ let classLists: DocumentClassList[] = []
+
+ if (boundary.type === 'html') {
+ classLists = await findClassListsInRange(state, doc, boundary.range, 'html')
+ } else if (boundary.type === 'js') {
+ classLists = await findClassListsInRange(state, doc, boundary.range, 'jsx')
+ } else if (boundary.type === 'jsx') {
+ classLists = await findClassListsInRange(state, doc, boundary.range, 'jsx')
+ } else if (boundary.type === 'css') {
+ classLists = await findClassListsInRange(state, doc, boundary.range, 'css')
+ }
+
+ for (let classList of classLists) {
+ let listScope: ScopeClassList = {
+ kind: 'class.list',
+ children: [],
+ source: { scope: classList.span },
+ }
+
+ let classNames = getClassNamesInClassList(classList, state.blocklist)
+
+ for (let className of classNames) {
+ listScope.children.push({
+ kind: 'class.name',
+ children: [],
+ source: { scope: className.span },
+ })
+ }
+
+ yield listScope
+ }
+}
+
+const AT_RULE = /(?@[a-z-]+)\s*(?[^;{]*)(?:[;{]|$)/dg
+
+interface AtRuleDescriptor {
+ name: Span
+ params: Span
+ body: Span | null
+}
+
+/**
+ * Find all class lists and classes within the given block of text
+ */
+function* analyzeAtRules(boundary: LanguageBoundary, slice: string): Iterable {
+ if (boundary.type !== 'css') return
+
+ let rules: AtRuleDescriptor[] = []
+
+ // Look for at-rules like `@apply` and `@screen`
+ // These _may not be complete_ but that's okay
+ // We just want to know that they're there and where they are
+ for (let match of slice.matchAll(AT_RULE)) {
+ rules.push({
+ name: match.indices!.groups!.name,
+ params: match.indices!.groups!.params,
+ body: null,
+ })
+ }
+
+ for (let rule of rules) {
+ // Scan forward from each at-rule to find the body
+ // This is a bit naive but it's good enough for now
+ let depth = 0
+ for (let i = rule.params[1]; i < slice.length; ++i) {
+ if (depth === 0 && slice[i] === ';') {
+ break
+ }
+
+ //
+ else if (slice[i] === '{') {
+ depth += 1
+
+ if (depth === 1) {
+ rule.body = [i, i]
+ }
+ }
+
+ //
+ else if (slice[i] === '}') {
+ depth -= 1
+
+ if (depth === 0) {
+ rule.body![1] = i
+ break
+ }
+ }
+ }
+
+ for (let scope of analyzeAtRule(boundary, slice, rule)) {
+ yield scope
+ }
+ }
+}
+
+function* analyzeAtRule(
+ boundary: LanguageBoundary,
+ slice: string,
+ desc: AtRuleDescriptor,
+): Iterable {
+ let name = slice.slice(desc.name[0], desc.name[1])
+ let params = slice.slice(desc.params[0], desc.params[1]).trim()
+ let parts = segment(params, ' ')
+
+ // Offset spans so they're document-relative
+ let offset = boundary.span[0]
+ let overallSpan: Span = [desc.name[0], desc.body ? desc.body[1] : desc.params[1]]
+ let nameSpan: Span = [desc.name[0], desc.name[1]]
+ let paramsSpan: Span = [desc.params[0], desc.params[1]]
+ let bodySpan: Span | null = desc.body ? [desc.body[0], desc.body[1]] : null
+
+ overallSpan[0] += offset
+ overallSpan[1] += offset
+ nameSpan[0] += offset
+ nameSpan[1] += offset
+ paramsSpan[0] += offset
+ paramsSpan[1] += offset
+
+ if (bodySpan) {
+ bodySpan[0] += offset
+ bodySpan[1] += offset
+ }
+
+ let scope: ScopeAtRule = {
+ kind: 'css.at-rule',
+ children: [],
+
+ source: {
+ scope: overallSpan,
+ name: nameSpan,
+ params: paramsSpan,
+ body: bodySpan,
+ },
+ }
+
+ // Emit generic scopes specific to certain at-rules
+ if (name === '@utility') {
+ let name = params
+ let functional = false
+ if (name.endsWith('-*')) {
+ functional = true
+ name = name.slice(0, -2)
+ }
+
+ scope.children.push({
+ kind: 'css.at-rule.utility',
+ children: [],
+ meta: {
+ kind: functional ? 'functional' : 'static',
+ },
+ source: {
+ scope: overallSpan,
+ name: [paramsSpan[0], paramsSpan[0] + name.length],
+ },
+ })
+ }
+
+ // `@import` statements are special
+ else if (name === '@import') {
+ let importScope: ScopeAtImport = {
+ kind: 'css.at-rule.import',
+ children: [],
+ source: {
+ scope: overallSpan,
+ url: null,
+ sourceUrl: null,
+ },
+ }
+
+ scope.children.push(importScope)
+
+ let start = 0
+
+ for (let part of parts) {
+ let offset = start
+ let length = part.length + 1
+ let type: string | null = null
+ let quotable = true
+
+ if (part.startsWith('url(')) {
+ type = 'url'
+ quotable = true
+ part = part.slice(4)
+ offset += 4
+
+ if (part.endsWith(')')) {
+ part = part.slice(0, -1)
+ }
+ }
+
+ //
+ else if (part.startsWith('source(')) {
+ type = 'source-url'
+ quotable = true
+ part = part.slice(7)
+ offset += 7
+
+ if (part.endsWith(')')) {
+ part = part.slice(0, -1)
+ }
+ }
+
+ //
+ else if (part.startsWith('theme(')) {
+ type = 'theme-options'
+ part = part.slice(6)
+ offset += 6
+ quotable = false
+
+ if (part.endsWith(')')) {
+ part = part.slice(0, -1)
+ }
+ }
+
+ if (quotable && part.startsWith('"')) {
+ type ??= 'url'
+ part = part.slice(1)
+ offset += 1
+
+ if (part.endsWith('"')) {
+ part = part.slice(0, -1)
+ }
+ }
+
+ //
+ else if (quotable && part.startsWith("'")) {
+ type ??= 'url'
+ part = part.slice(1)
+ offset += 1
+
+ if (part.endsWith("'")) {
+ part = part.slice(0, -1)
+ }
+ }
+
+ if (type === 'url') {
+ importScope.source.url = [paramsSpan[0] + offset, paramsSpan[0] + offset + part.length]
+ }
+
+ //
+ else if (type === 'source-url') {
+ importScope.source.sourceUrl = [
+ paramsSpan[0] + offset,
+ paramsSpan[0] + offset + part.length,
+ ]
+ }
+
+ //
+ else if (type === 'theme-options') {
+ let optionListScope: ScopeThemeOptionList = {
+ kind: 'theme.option.list',
+ children: [],
+ source: {
+ scope: [paramsSpan[0] + offset, paramsSpan[0] + offset + part.length],
+ },
+ }
+
+ importScope.children.push(optionListScope)
+
+ let options = segment(part, ' ')
+
+ let start = offset
+
+ for (let option of options) {
+ let offset = start
+
+ optionListScope.children.push({
+ kind: 'theme.option.name',
+ children: [],
+ source: {
+ scope: [paramsSpan[0] + offset, paramsSpan[0] + offset + option.length],
+ },
+ })
+
+ start += option.length + 1
+ }
+ }
+
+ start += length
+ }
+ }
+
+ yield scope
+}
+
+const HELPER_FN = /(?config|theme|--theme|--spacing|--alpha)\s*\(/dg
+
+/**
+ * Find all class lists and classes within the given block of text
+ */
+function* analyzeHelperFns(boundary: LanguageBoundary, slice: string): Iterable {
+ if (boundary.type !== 'css') return
+
+ // Look for helper functions in CSS
+ for (let match of slice.matchAll(HELPER_FN)) {
+ let groups = match.indices!.groups!
+
+ let source: ScopeFn['source'] = {
+ scope: [groups.name[0], groups.name[1]],
+ name: [groups.name[0], groups.name[1]],
+ params: [groups.name[1], groups.name[1]],
+ }
+
+ // Scan forward from each fn to find the end of the params
+ let depth = 0
+ for (let i = source.name[1]; i < slice.length; ++i) {
+ if (depth === 0 && slice[i] === ';') {
+ break
+ } else if (slice[i] === '(') {
+ depth += 1
+
+ if (depth === 1) {
+ source.params = [i + 1, i + 1]
+ }
+ } else if (slice[i] === ')') {
+ depth -= 1
+
+ if (depth === 0) {
+ source.params[1] = i
+ break
+ }
+ }
+ }
+
+ source.scope[1] = source.params[1] + 1
+
+ yield {
+ kind: 'css.fn',
+ children: [],
+ source,
+ }
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/scopes/classes/scan.ts b/packages/tailwindcss-language-service/src/scopes/classes/scan.ts
new file mode 100644
index 00000000..0a6cfb46
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/classes/scan.ts
@@ -0,0 +1,22 @@
+import { ScopeClassList } from '../scope'
+
+export function scanClassList(input: string, scope: ScopeClassList) {
+ let classList = input.slice(scope.source.scope[0], scope.source.scope[1])
+ let parts = classList.split(/(\s+)/)
+
+ let index = scope.source.scope[0]
+
+ for (let i = 0; i < parts.length; i++) {
+ let length = parts[i].length
+
+ if (i % 2 === 0) {
+ scope.children.push({
+ kind: 'class.name',
+ source: { scope: [index, index + length] },
+ children: [],
+ })
+ }
+
+ index += length
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/scopes/html/scan.test.ts b/packages/tailwindcss-language-service/src/scopes/html/scan.test.ts
new file mode 100644
index 00000000..641f54d3
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/html/scan.test.ts
@@ -0,0 +1,189 @@
+import dedent from 'dedent'
+import { test } from 'vitest'
+import { scanHtml } from './scan'
+
+test('parses HTML', async ({ expect }) => {
+ let input = dedent`
+
+
+
+ `
+
+ let scope = scanHtml({
+ input,
+ offset: 0,
+ classAttributes: [],
+ })
+
+ expect(scope).toEqual({
+ kind: 'context',
+ source: {
+ scope: [0, 84],
+ },
+ meta: {
+ lang: 'html',
+ syntax: 'html',
+ },
+ children: [
+ {
+ kind: 'context',
+ source: {
+ scope: [32, 68],
+ },
+ meta: {
+ lang: 'js',
+ syntax: 'js',
+ },
+ children: [],
+ },
+ ],
+ })
+})
+
+test('Identifies HTML comments', async ({ expect }) => {
+ let input = dedent`
+
+
+
+ `
+
+ let scope = scanHtml({
+ input,
+ offset: 0,
+ classAttributes: [],
+ })
+
+ expect(scope).toEqual({
+ children: [
+ {
+ kind: 'comment',
+ source: { scope: [24, 52] },
+ children: [],
+ },
+ ],
+ kind: 'context',
+ meta: {
+ lang: 'html',
+ syntax: 'html',
+ },
+ source: {
+ scope: [0, 59],
+ },
+ })
+})
+
+test('Identifies class attributes', async ({ expect }) => {
+ let input = dedent`
+
+
+
+
+
+
+
+ `
+
+ let scope = scanHtml({
+ input,
+ offset: 0,
+ classAttributes: ['class', 'className'],
+ })
+
+ expect(scope).toEqual({
+ kind: 'context',
+ source: {
+ scope: [0, 275],
+ },
+ meta: {
+ lang: 'html',
+ syntax: 'html',
+ },
+ children: [
+ {
+ kind: 'class.attr',
+ meta: { static: true },
+ source: { scope: [12, 16] },
+ children: [],
+ },
+ {
+ kind: 'class.attr',
+ meta: { static: false },
+ source: { scope: [35, 41] },
+ children: [],
+ },
+ {
+ kind: 'class.attr',
+ meta: { static: false },
+ source: { scope: [68, 74] },
+ children: [],
+ },
+ {
+ kind: 'class.attr',
+ meta: { static: false },
+ source: { scope: [102, 108] },
+ children: [],
+ },
+ {
+ kind: 'class.attr',
+ meta: { static: true },
+ source: { scope: [137, 143] },
+ children: [],
+ },
+ {
+ kind: 'class.attr',
+ meta: { static: false },
+ source: { scope: [176, 256] },
+ children: [],
+ },
+ ],
+ })
+})
+
+test('quotes ignore element detection', async ({ expect }) => {
+ let input = dedent`
+
+
+
+ `
+
+ let scope = scanHtml({
+ input,
+ offset: 0,
+ classAttributes: ['class', 'className'],
+ })
+
+ expect(scope).toEqual({
+ kind: 'context',
+ source: {
+ scope: [0, 67],
+ },
+ meta: {
+ lang: 'html',
+ syntax: 'html',
+ },
+ children: [
+ {
+ kind: 'class.attr',
+ meta: { static: true },
+ source: { scope: [12, 16] },
+ children: [],
+ },
+ {
+ kind: 'class.attr',
+ meta: { static: true },
+ source: { scope: [34, 51] },
+ children: [],
+ },
+ ],
+ })
+})
diff --git a/packages/tailwindcss-language-service/src/scopes/html/scan.ts b/packages/tailwindcss-language-service/src/scopes/html/scan.ts
new file mode 100644
index 00000000..d410b294
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/html/scan.ts
@@ -0,0 +1,149 @@
+import { ScopeClassAttribute, ScopeComment, ScopeContext } from '../scope'
+import { createHtmlStream, StreamOptions } from './stream'
+
+function newContext(start: number): ScopeContext {
+ return {
+ kind: 'context',
+ source: {
+ scope: [start, start],
+ },
+ meta: {
+ syntax: 'html',
+ lang: 'html',
+ },
+ children: [],
+ }
+}
+
+function newComment(start: number): ScopeComment {
+ return {
+ kind: 'comment',
+ source: {
+ scope: [start, start],
+ },
+ children: [],
+ }
+}
+
+function newClassAttr(start: number, end: number): ScopeClassAttribute {
+ return {
+ kind: 'class.attr',
+ meta: {
+ static: true,
+ },
+ source: {
+ scope: [start, end],
+ },
+ children: [],
+ }
+}
+
+const enum State {
+ Idle,
+ InComment,
+ WaitForTagOpen,
+ WaitForTagClose,
+}
+
+interface ScanOptions extends StreamOptions {
+ /** A list of attributes which will get `class.attr` scopes */
+ classAttributes: string[]
+}
+
+export function scanHtml({ input, offset, classAttributes }: ScanOptions): ScopeContext {
+ // Compile a regex to match class attributes in the form of:
+ // - class
+ // - [class]
+ // - :class
+ // - :[class]
+ let patternAttrs = classAttributes.flatMap((x) => [x, `\\[${x}\\]`]).flatMap((x) => [x, `:${x}`])
+ let isClassAttr = new RegExp(`^(${patternAttrs.join('|')})$`, 'i')
+
+ let root = newContext(0)
+ root.source.scope[1] = input.length
+
+ let state = State.Idle
+ let context: ScopeContext = newContext(0)
+ let comment: ScopeComment = newComment(0)
+ let currentTag = ''
+ let currentAttr = ''
+
+ for (let event of createHtmlStream({ input, offset })) {
+ // Element attributes
+ if (event.kind === 'attr-name') {
+ currentAttr = input.slice(event.span[0], event.span[1])
+ }
+
+ // Attribute values
+ else if (event.kind === 'attr-value' || event.kind === 'attr-expr') {
+ let value = input.slice(event.span[0], event.span[1])
+
+ if (currentAttr === 'lang' || currentAttr === 'type') {
+ context.meta.lang = value
+ continue
+ }
+
+ if (classAttributes.length && isClassAttr.test(currentAttr)) {
+ let scope = newClassAttr(event.span[0], event.span[1])
+ if (event.kind === 'attr-expr') {
+ scope.meta.static = false
+ } else if (currentAttr[0] === ':') {
+ scope.meta.static = false
+ } else if (currentAttr[0] === '[' && currentAttr[currentAttr.length - 1] === ']') {
+ scope.meta.static = false
+ }
+
+ root.children.push(scope)
+ }
+ }
+
+ // Comments
+ else if (event.kind === 'comment-start') {
+ comment = newComment(event.span[0])
+ state = State.InComment
+ } else if (event.kind === 'comment-end') {
+ if (state === State.InComment) {
+ comment.source.scope[1] = event.span[1]
+ root.children.push(comment)
+ state = State.Idle
+ }
+ }
+
+ // Elements
+ else if (event.kind === 'element-start') {
+ let tag = input.slice(event.span[0], event.span[1])
+ if (tag === '
+ /// ^^^^^^^^
+ | { kind: 'element-start'; span: Span }
+
+ /// The end of an element definition
+ ///
+ /// ^
+ | { kind: 'element-end'; span: Span }
+
+ /// An attribute name
+ ///
+ /// ^^^^
+ | { kind: 'attr-name'; span: Span }
+
+ /// An attribute value
+ ///
+ /// ^^^^^
+ | { kind: 'attr-value'; span: Span }
+
+ /// An attribute value expression
+ ///
+ /// ^^^^^
+ | { kind: 'attr-expr'; span: Span }
+
+export interface StreamOptions {
+ /** The HTML to scan */
+ input: string
+
+ /** A character offset noting where `input` starts in the parent document */
+ offset: number
+}
+
+export function createHtmlStream({ input, offset }: StreamOptions): Iterable {
+ const enum State {
+ Idle,
+ Attrs,
+ Comment,
+ }
+
+ let state = State.Idle
+ let events: Event[] = []
+
+ next: for (let i = 0; i < input.length; ++i) {
+ let char = input.charCodeAt(i)
+
+ if (state === State.Idle) {
+ if (char === TAG_START) {
+ for (let j = i; j < input.length; ++j) {
+ let peek = input.charCodeAt(j)
+
+ if (peek === TAG_END) {
+ events.push({ kind: 'element-start', span: [i, j] })
+ events.push({ kind: 'element-end', span: [j, j + 1] })
+ i = j
+ break
+ } else if (peek === EXCLAIMATION && input.startsWith('!--', j)) {
+ events.push({ kind: 'comment-start', span: [i, j + 3] })
+ state = State.Comment
+ i = j + 3
+ break
+ } else if (
+ peek === WS_SPACE ||
+ peek === WS_TAB ||
+ peek === WS_NEWLINE ||
+ peek === WS_RETURN ||
+ peek === WS_FEED
+ ) {
+ events.push({ kind: 'element-start', span: [i, j] })
+ state = State.Attrs
+ i = j
+ break
+ }
+ }
+ }
+ }
+
+ //
+ else if (state === State.Comment) {
+ for (let k = i; k < input.length; ++k) {
+ if (input.startsWith('-->', k)) {
+ events.push({ kind: 'comment-end', span: [k, k + 3] })
+ state = State.Idle
+ i = k + 2
+ break
+ }
+ }
+ }
+
+ //
+ else if (state === State.Attrs) {
+ if (char === TAG_END) {
+ events.push({ kind: 'element-end', span: [i, i + 1] })
+ state = State.Idle
+ i += 1
+ continue
+ }
+
+ //
+ else if (
+ char === WS_SPACE ||
+ char === WS_TAB ||
+ char === WS_NEWLINE ||
+ char === WS_RETURN ||
+ char === WS_FEED
+ ) {
+ continue
+ }
+
+ for (let j = i; j < input.length; ++j) {
+ let peek = input.charCodeAt(j)
+
+ if (peek === EQUALS) {
+ events.push({ kind: 'attr-name', span: [i, j] })
+ i = j
+ continue next
+ }
+
+ //
+ else if (peek === TAG_END) {
+ events.push({ kind: 'element-end', span: [i, j + 1] })
+ state = State.Idle
+ i = j + 1
+ continue next
+ }
+
+ // quoted
+ else if (peek === QUOTE_SINGLE) {
+ for (let k = j + 1; k < input.length; ++k) {
+ let peek = input.charCodeAt(k)
+ if (peek === QUOTE_SINGLE) {
+ events.push({ kind: 'attr-value', span: [j + 1, k] })
+ i = k
+ continue next
+ }
+ }
+ } else if (peek === QUOTE_DOUBLE) {
+ for (let k = j + 1; k < input.length; ++k) {
+ let peek = input.charCodeAt(k)
+ if (peek === QUOTE_DOUBLE) {
+ events.push({ kind: 'attr-value', span: [j + 1, k] })
+ i = k
+ continue next
+ }
+ }
+ } else if (peek === QUOTE_TICK) {
+ for (let k = j + 1; k < input.length; ++k) {
+ let peek = input.charCodeAt(k)
+ if (peek === QUOTE_TICK) {
+ events.push({ kind: 'attr-value', span: [j + 1, k] })
+ i = k
+ continue next
+ }
+ }
+ }
+
+ // expressions
+ else if (peek === CURLY_OPEN) {
+ let depth = 1
+
+ for (let k = j + 1; k < input.length; ++k) {
+ let peek = input.charCodeAt(k)
+ if (peek === CURLY_OPEN) {
+ depth += 1
+ } else if (peek === CURLY_CLOSE) {
+ depth -= 1
+
+ if (depth === 0) {
+ events.push({ kind: 'attr-expr', span: [j + 1, k] })
+ i = k
+ continue next
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for (let event of events) {
+ event.span[0] += offset
+ event.span[1] += offset
+ }
+
+ return events
+}
diff --git a/packages/tailwindcss-language-service/src/scopes/scope.ts b/packages/tailwindcss-language-service/src/scopes/scope.ts
new file mode 100644
index 00000000..11210cf7
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/scope.ts
@@ -0,0 +1,396 @@
+import type { Span } from '../util/state'
+
+/**
+ * Text that may be interpreted as a particular kind of document
+ *
+ * Examples:
+ * - `html`: HTML, Vue, Angular, Svlete, etc…
+ * - `css`: CSS, SCSS, Less, Stylus, etc…
+ * - `js`: JS, JSX, TS, TSX, etc…
+ - - `other`: Any other, unknown language
+ */
+export interface ScopeContext {
+ kind: 'context'
+ children: AnyScope[]
+
+ meta: {
+ /*
+ * A high-level description of the tsyntax this language uses
+ *
+ * - `html`: An HTML-like language
+ *
+ * Examples:
+ * - HTML
+ * - Angular
+ * - Vue
+ * - Svelte
+ *
+ * - `css`: A CSS-like language
+ *
+ * Examples:
+ * - CSS
+ * - SCSS
+ * - Less
+ * - Sass
+ * - Stylus
+ *
+ * - `js`: A JavaScript-like language
+ *
+ * These share a lot of similarities with HTML-like languages but contain
+ * additional syntax which can be used to embed other languages within them.
+ *
+ * Examples:
+ * - JavaScript / JSX
+ * - TypeScript / TSX
+ *
+ * - `other`: Any other, unknown language syntax
+ *
+ * Languages that do not fit into the above categories are mostly ignored
+ * by the language server and treated as plain text. Detecting classes in a
+ * language like this only works for custom patterns.
+ */
+ syntax: 'html' | 'css' | 'js' | 'other'
+
+ /**
+ * The specific language contained within this text. This may be an identifier
+ * provided to the language server by the client or it may be inferred from
+ * the text itself in the case of embedded languages.
+ */
+ lang: string
+
+ /**
+ * Whether or not the language uses semicolons
+ *
+ * This is only relevant for CSS-style languages at the moment
+ *
+ * TODO: Remove this. We should use information about the language to build
+ * the right scope tree meaing this information is relevant when parsing
+ * and does not need to be stored in the tree.
+ */
+ semi: boolean
+ }
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Text that should be treated as a comment whether single line or multi-line
+ *
+ * Examples:
+ * ```css
+ * /* This is a comment * /
+ * ```
+ *
+ * ```html
+ *
+ * ```
+ *
+ * ```js
+ * // This is a comment
+ * /* This is a comment * /
+ * ```
+ */
+export interface ScopeComment {
+ kind: 'comment'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Text that represents a class attribute
+ *
+ * This generally contains a single class list but may contain multiple if the
+ * attribute is being interpolated
+ *
+ * ```
+ *
+ * ^^^^^^^^^^^^^^^^^^^^^^
+ *
+ * ^^^^^^^^^^^^^^^^^^^^^^^^
+ *
+ * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ * ```
+ */
+export interface ScopeClassAttribute {
+ kind: 'class.attr'
+ children: AnyScope[]
+
+ meta: {
+ static: boolean
+ }
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Text that may contain one or more classes in a space-separated list
+ *
+ * ```
+ *
+ * ^^^^^^^^^^^^^^^^^^^^^^
+ * @apply bg-blue-500 text-white;
+ * ^^^^^^^^^^^^^^^^^^^^^^
+ * ```
+ */
+export interface ScopeClassList {
+ kind: 'class.list'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Text that represents a single class
+ *
+ * ```
+ *
+ * ^^^^^^^^^^^ ^^^^^^^^^^
+ * @apply bg-blue-500 text-white;
+ * ^^^^^^^^^^^ ^^^^^^^^^^
+ * ```
+ */
+export interface ScopeClassName {
+ kind: 'class.name'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Represents an at-rule in CSS
+ *
+ * ```
+ * @media (min-width: 600px) { ... }
+ * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ * @apply bg-blue-500 text-white;
+ * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ * ```
+ */
+export interface ScopeAtRule {
+ kind: 'css.at-rule'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+
+ // Marks the name of an at-rule
+ // @media (min-width: 600px) { ... }
+ // ^^^^^^
+ name: Span
+
+ // Marks the parameters of an at-rule
+ // @media (min-width: 600px) { ... }
+ // ^^^^^^^^^^^^^^^^^^
+ params: Span
+
+ // Marks the body of an at-rule
+ // @media (min-width: 600px) { ... }
+ // ^^^^^
+ body: Span | null
+ }
+}
+
+/**
+ * Text that represents a single class
+ *
+ * ```
+ * @utility hero { ... }
+ * ````
+ */
+export interface ScopeAtUtility {
+ kind: 'css.at-rule.utility'
+ children: AnyScope[]
+
+ meta: {
+ kind: 'static' | 'functional'
+ }
+
+ source: {
+ scope: Span
+
+ /**
+ * The "root" of the utility
+ *
+ * ```
+ * @utility hero { ... }
+ * ^^^^
+ * @utility hero-* { ... }
+ * ^^^^
+ * ````
+ */
+ name: Span
+ }
+}
+
+/**
+ * Text that represents a single class
+ *
+ * ```
+ * @import "./some-file.css";
+ * ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ * ````
+ */
+export interface ScopeAtImport {
+ kind: 'css.at-rule.import'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+
+ // Marks the url of an import statement
+ // @import "./some-file.css";
+ // ^^^^^^^^^^^^^^^
+ // @reference "./some-file.css";
+ // ^^^^^^^^^^^^^^^
+ // @import url("./some-file.css");
+ // ^^^^^^^^^^^^^^^^^
+ url: Span | null
+
+ // Marks an import statement's source url
+ // @import "./some-file.css" source("./foo");
+ // ^^^^^
+ // @import "./some-file.css" source(none);
+ // ^^^^
+ sourceUrl: Span | null
+ }
+}
+
+/**
+ * Text that represents an individual theme option
+ *
+ * ```
+ * Marks an import statement's theme option list
+ * @import "./some-file.css" theme(inline reference default);
+ * ^^^^^^^^^^^^^^^^^^^^^^^^
+ * @theme inline default { ... }
+ * ^^^^^^^^^^^^^^
+ * ```
+ */
+export interface ScopeThemeOptionList {
+ kind: 'theme.option.list'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Text that represents an individual theme option
+ *
+ * ```
+ * @import "./some-file.css" theme(inline);
+ * ^^^^^^
+ * @import "./some-file.css" theme(default);
+ * ^^^^^^^
+ * @import "./some-file.css" reference;
+ * ^^^^^^^^^
+ * @import "./some-file.css" prefix(tw);
+ * ^^^^^^^^^^
+ * @theme inline default;
+ * ^^^^^^ ^^^^^^^
+ * @theme prefix(tw);
+ * ^^^^^^^^^^
+ * ```
+ */
+export interface ScopeThemeOptionName {
+ kind: 'theme.option.name'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Text that represents an individual theme option
+ *
+ * ```
+ * @import "./some-file.css" prefix(tw);
+ * ^^
+ * @theme prefix(tw);
+ * ^^
+ * ```
+ */
+export interface ScopeThemePrefix {
+ kind: 'theme.prefix'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+ }
+}
+
+/**
+ * Represents a function in CSS
+ *
+ * Note: Only helper functions are marked with socpes currently
+ *
+ * ```
+ * color: theme(--color-red-500);
+ * ^^^^^^^^^^^^^^^^^^^^^^
+ * ```
+ */
+export interface ScopeFn {
+ kind: 'css.fn'
+ children: AnyScope[]
+
+ source: {
+ scope: Span
+
+ /**
+ * Marks the function's name
+ *
+ * ```
+ * color: theme(--color-red-500);
+ * ^^^^^
+ * color: --alpha(var(--color-red-500));
+ * ^^^^^^^
+ * ```
+ */
+ name: Span
+
+ /**
+ * Marks the function's parameters
+ *
+ * ```
+ * Note: Only helper functions are marked with socpes
+ * color: theme(--color-red-500);
+ * ^^^^^^^^^^^^^^^
+ * color: --alpha(var(--color-red-500));
+ * ^^^^^^^^^^^^^^^^^^^^
+ * ```
+ */
+ params: Span
+ }
+}
+
+export type ScopeKind = keyof ScopeMap
+export type Scope
= ScopeMap[K]
+export type AnyScope = ScopeMap[ScopeKind]
+
+type ScopeMap = {
+ context: ScopeContext
+ comment: ScopeComment
+ 'class.attr': ScopeClassAttribute
+ 'class.list': ScopeClassList
+ 'class.name': ScopeClassName
+ 'css.at-rule': ScopeAtRule
+ 'css.at-rule.utility': ScopeAtUtility
+ 'css.at-rule.import': ScopeAtImport
+ 'theme.option.list': ScopeThemeOptionList
+ 'theme.option.name': ScopeThemeOptionName
+ 'theme.prefix': ScopeThemePrefix
+ 'css.fn': ScopeFn
+}
diff --git a/packages/tailwindcss-language-service/src/scopes/throughput.ts b/packages/tailwindcss-language-service/src/scopes/throughput.ts
new file mode 100644
index 00000000..095bd730
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/throughput.ts
@@ -0,0 +1,40 @@
+interface Throughput {
+ rate: number
+ elapsed: bigint
+ toString(): string
+}
+
+export function computeThroughput(
+ iterations: number,
+ memoryBaseline: number,
+ cb: () => void,
+): Throughput {
+ let start = process.hrtime.bigint()
+ for (let i = 0; i < iterations; i++) {
+ cb()
+ }
+ let elapsed = process.hrtime.bigint() - start
+ let memorySize = iterations * memoryBaseline
+
+ let rate = Number(memorySize) / (Number(elapsed) / 1e9)
+
+ return {
+ rate,
+ elapsed,
+ toString() {
+ return `${formatByteSize(rate)}/s over ${Number(elapsed) / 1e9}s`
+ },
+ }
+}
+
+function formatByteSize(size: number): string {
+ let units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+ let unit = 1000
+ let i = 0
+ while (size > unit) {
+ size /= unit
+ i++
+ }
+
+ return `${size.toFixed(2)} ${units[i]}`
+}
diff --git a/packages/tailwindcss-language-service/src/scopes/tree.bench.ts b/packages/tailwindcss-language-service/src/scopes/tree.bench.ts
new file mode 100644
index 00000000..b0eb437d
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/tree.bench.ts
@@ -0,0 +1,128 @@
+import { run, bench } from 'mitata'
+
+import { ScopeTree } from './tree'
+import { Span } from '../util/state'
+import { AnyScope, ScopeContext } from './scope'
+
+function createScopes(lists: number, classes: number) {
+ let classNames = ['underline', 'flex', 'bg-red-500', 'text-white', 'p-2']
+
+ let contextSpan: Span = [0, 0]
+ let context: ScopeContext = {
+ kind: 'context',
+ children: [],
+ meta: {
+ syntax: 'html',
+ lang: 'html',
+ },
+ source: { scope: contextSpan },
+ }
+
+ let offset = 0
+
+ // Create a dummy set of scopes representing a HTML file like this:
+ //
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ context.html
+ // ^^^^^^^^^^^^^^^^^^^^^^ class.list
+ // ^^^^^^^^^^^ ^^^^^^^^^^ class.name
+ for (let i = 0; i < lists; ++i) {
+ //
+ // ^
+ offset += randomClass.length + 1
+ }
+
+ //
+ // ^
+ offset += 0
+
+ // Mark the end of the class list
+ list.source.scope[1] = offset
+ attr.source.scope[1] = offset
+ }
+
+ //
+ // ^^^^^^^^
+ offset += 8
+
+ context.source.scope[1] = offset
+
+ return new ScopeTree([context])
+}
+
+// let scopes1e1 = createScopes(1e1, 1e2)
+// let length1e1 = scopes1e1.at(0)[0].source.scope[1]
+// bench('scope#at (10 lists, 100 classes)', () => {
+// scopes1e1.at(Math.ceil(Math.random() * length1e1))
+// })
+
+// let scopes1e2 = createScopes(1e2, 1e2)
+// let length1e2 = scopes1e2.at(0)[0].source.scope[1]
+// bench('scope#at (100 lists, 10,000 classes)', () => {
+// scopes1e2.at(Math.ceil(Math.random() * length1e2))
+// })
+
+// let scopes1e3 = createScopes(1e3, 1e2)
+// let length1e3 = scopes1e3.at(0)[0].source.scope[1]
+// bench('scope#at (1,000 lists, 100,000 classes)', () => {
+// scopes1e3.at(Math.ceil(Math.random() * length1e3))
+// })
+
+// let scopes1e4 = createScopes(1e4, 1e2)
+// let length1e4 = scopes1e4.at(0)[0].source.scope[1]
+// bench('scope#at (10,000 lists, 1,000,000 classes)', () => {
+// scopes1e4.at(Math.ceil(Math.random() * length1e4))
+// })
+
+// let scopes1e5 = createScopes(1e5, 1e2)
+// let length1e5 = scopes1e5.at(0)[0].source.scope[1]
+// bench('scope#at (100,000 lists, 10,000,000 classes)', () => {
+// scopes1e5.at(Math.ceil(Math.random() * length1e5))
+// })
+
+let scopes1e6 = createScopes(1e6, 1e2)
+let length1e6 = scopes1e6.at(0)[0].source.scope[1]
+bench('scope#at (1,000,000 lists, 100,000,000 classes)', () => {
+ scopes1e6.at(Math.ceil(Math.random() * length1e6))
+})
+
+// let scopes1e7 = createScopes(1e7, 1e2)
+// let length1e7 = scopes1e7.at(0)[0].source.scope[1]
+// bench('scope#at (1,000,000 lists, 100,000,000 classes)', () => {
+// scopes1e7.at(Math.ceil(Math.random() * length1e7))
+// })
+
+// let scopes1e8 = createScopes(1e8, 1e2)
+// let length1e8 = scopes1e8.at(0)[0].source.scope[1]
+// bench('scope#at (100,000,000 items)', () => {
+// scopes1e8.at(Math.ceil(Math.random() * length1e8))
+// })
+
+await run()
diff --git a/packages/tailwindcss-language-service/src/scopes/tree.ts b/packages/tailwindcss-language-service/src/scopes/tree.ts
new file mode 100644
index 00000000..aa7092ca
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/tree.ts
@@ -0,0 +1,159 @@
+import type { AnyScope, Scope, ScopeKind } from './scope'
+import { walkScope, type ScopePath } from './walk'
+
+/**
+ * A tree of scopes that represent information about a document
+ */
+export class ScopeTree {
+ /**
+ * A list of "root" scopes in a document
+ *
+ * This list is guaranteed to be sorted in asending order
+ *
+ * TODO: This should be a single root ScopeContext that identifies the document as a whole
+ */
+ private roots: AnyScope[]
+
+ /**
+ * Preconditions:
+ * - Scopes ascending order relative to their start pos, recursively
+ * - Parent scopes entirely encapsulate their children
+ * - No sibling scopes at any depth can overlap
+ * - No scope may appear more than once in the tree
+ * - No scope may be an ancestor of itself (i.e. no cycles)
+ *
+ * @param roots
+ */
+ constructor(roots: AnyScope[] = []) {
+ this.roots = roots
+ }
+
+ /**
+ * Get the path to a specific scope in the tree if it exists
+ */
+ pathTo(scope: AnyScope): ScopePath {
+ let path = this.at(scope.source.scope[0])
+ return path.slice(0, path.indexOf(scope) + 1)
+ }
+
+ /**
+ * Get the scope active at a specific position in the document
+ *
+ * For example, given this position in some HTML:
+ * ```html
+ *
+ * ^
+ * ```
+ *
+ * We know the following scopes are active:
+ * ```
+ * `context` [0, 36]
+ * `class.attr` [12, 34]
+ * `class.list` [12, 34]
+ * `class.name` [12, 23]
+ * ```
+ *
+ * The path to the inner-most active scope is returned:
+ * - 0: `context` [0, 36]
+ * - 1: `class.attr` [12, 34]
+ * - 2: `class.list` [12, 34]
+ * - 3: `class.name` [12, 23]
+ */
+ public at(pos: number): ScopePath {
+ let path: ScopePath = []
+
+ let nodes = this.roots
+
+ next: while (true) {
+ let low = 0
+ let high = nodes.length - 1
+
+ while (low <= high) {
+ let mid = (low + high) >> 1
+ let node = nodes[mid]
+
+ let span = node.source.scope
+ if (pos < span[0]) {
+ high = mid - 1
+ } else if (pos > span[1]) {
+ low = mid + 1
+ } else {
+ // Record the scope and move to the next level
+ path.push(node)
+ nodes = node.children
+ continue next
+ }
+ }
+
+ break
+ }
+
+ return Array.from(path)
+ }
+
+ /**
+ * Gets the inner-most scope of a specific kind active at a position
+ */
+ public closestAt
(kind: K, pos: number): Scope | null {
+ let path = this.at(pos)
+
+ for (let i = path.length - 1; i >= 0; i--) {
+ let scope = path[i]
+ if (scope.kind === kind) return scope as Scope
+ }
+
+ return null
+ }
+
+ /**
+ * A list of all active scopes
+ */
+ public all(): AnyScope[] {
+ return this.roots
+ }
+
+ /**
+ * Return an ordered list of all scopes applied to the text
+ */
+ public description(text?: string): string {
+ let indent = ' '
+
+ let str = ''
+ str += '\n'
+
+ walkScope(this.roots, {
+ enter(scope, { depth }) {
+ let span = scope.source.scope
+
+ str += indent.repeat(depth)
+ str += '['
+ str += span[0]
+ str += ', '
+ str += span[1]
+ str += '] '
+ str += scope.kind
+
+ if (text) {
+ str += ' "'
+
+ let length = span[1] - span[0]
+
+ if (length > 20) {
+ str += text.slice(span[0], span[0] + 20).replaceAll('\n', '\\n')
+ str += '...'
+ } else {
+ str += text.slice(span[0], span[1]).replaceAll('\n', '\\n')
+ }
+
+ str += '"'
+ }
+
+ str += '\n'
+ },
+ })
+
+ str += '\n'
+
+ return str
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/scopes/walk.ts b/packages/tailwindcss-language-service/src/scopes/walk.ts
new file mode 100644
index 00000000..755e542a
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/scopes/walk.ts
@@ -0,0 +1,204 @@
+import type { AnyScope } from './scope'
+
+/**
+ * The path from the root up to and including the active scope
+ */
+export type ScopePath = AnyScope[]
+
+export interface ScopeUtils {
+ path: ScopePath
+ depth: number
+}
+
+export interface ScopeVisitorFn {
+ (scope: AnyScope, utils: ScopeUtils): T
+}
+
+export interface ScopeVisitor {
+ enter?: ScopeVisitorFn
+ exit?: ScopeVisitorFn
+}
+
+export function walkScope(
+ node: AnyScope | Iterable,
+ visit: ScopeVisitor,
+ path: ScopePath = [],
+): void {
+ let roots = Array.isArray(node) ? node : [node]
+
+ for (let node of roots) {
+ let utils: ScopeUtils = {
+ path,
+ depth: path.length,
+ }
+
+ path.push(node)
+ visit.enter?.(node, utils)
+ utils.depth += 1
+ for (let child of node.children) {
+ walkScope(child, visit, path)
+ }
+ utils.depth -= 1
+ visit.exit?.(node, utils)
+ path.pop()
+ }
+}
+
+export function optimizeScopes(nodes: AnyScope[]) {
+ nestSiblings(nodes)
+ eliminateScopes(nodes)
+}
+
+/**
+ * Eliminate unncessary scopes from the tree
+ *
+ * TODO: The parsing should become smarter and this function should be removed
+ */
+export function eliminateScopes(nodes: AnyScope[]): AnyScope[] {
+ walkScope(nodes, {
+ enter(scope, { path }) {
+ if (scope.kind !== 'css.fn') return
+
+ let parent = path[path.length - 2]
+
+ if (!parent) return
+ if (parent.kind !== 'css.at-rule.import') return
+
+ parent.children.splice(parent.children.indexOf(scope), 1, ...scope.children)
+ },
+ })
+
+ return nodes
+}
+
+/**
+ * Convert siblings nodes into children of previous nodes if they are contained within them
+ */
+export function nestSiblings(nodes: AnyScope[]) {
+ nodes.sort((a, z) => {
+ return a.source.scope[0] - z.source.scope[0] || z.source.scope[1] - a.source.scope[1]
+ })
+
+ walkScope(nodes, {
+ enter(scope) {
+ // Sort the children to guarantee parents appear before children
+ scope.children.sort((a, z) => {
+ return a.source.scope[0] - z.source.scope[0] || z.source.scope[1] - a.source.scope[1]
+ })
+
+ let children = scope.children
+ if (children.length <= 1) return
+
+ for (let i = children.length - 1; i > 0; i--) {
+ let current = children[i]
+
+ // Find the closest containing parent
+ for (let j = i - 1; j >= 0; j--) {
+ let potential = children[j]
+
+ if (
+ current.source.scope[0] >= potential.source.scope[0] &&
+ current.source.scope[1] <= potential.source.scope[1]
+ ) {
+ // Remove the current node
+ children.splice(i, 1)
+
+ // and insert it as the child of the containing node
+ potential.children.push(current)
+
+ // Stop after finding the first containing parent
+ break
+ }
+ }
+ }
+ },
+ })
+}
+
+/**
+ * Convert a list of scopes to a string representation
+ */
+export function printScopes(nodes: AnyScope[], text?: string) {
+ let indent = ' '
+
+ let str = ''
+ str += '\n'
+
+ function printText(span: [number, number]) {
+ str += '"'
+
+ let length = span[1] - span[0]
+
+ if (length > 20) {
+ str += text!.slice(span[0], span[0] + 20).replaceAll('\n', '\\n')
+ str += '...'
+ } else {
+ str += text!.slice(span[0], span[1]).replaceAll('\n', '\\n')
+ }
+
+ str += '"'
+ }
+
+ walkScope(nodes, {
+ enter(scope, { depth }) {
+ let span = scope.source.scope
+
+ str += indent.repeat(depth)
+ str += scope.kind
+
+ str += ' ['
+ str += span[0]
+ str += '-'
+ str += span[1]
+ str += ']:'
+
+ if (text) {
+ str += ' '
+ printText(span)
+ }
+
+ str += '\n'
+
+ for (let [name, span] of Object.entries(scope.source)) {
+ if (name === 'scope') continue
+
+ str += indent.repeat(depth + 1)
+ str += '- '
+ str += name
+ str += ' '
+
+ if (span === null) {
+ str += '(none)'
+ str += '\n'
+ continue
+ }
+
+ str += '['
+ str += span[0]
+ str += '-'
+ str += span[1]
+ str += ']:'
+
+ if (text) {
+ str += ' '
+ printText(span)
+ }
+
+ str += '\n'
+ }
+
+ let meta: Record = 'meta' in scope ? scope.meta : {}
+
+ for (let [name, value] of Object.entries(meta)) {
+ str += indent.repeat(depth + 1)
+ str += '- '
+ str += name
+ str += ': '
+ str += value
+ str += '\n'
+ }
+ },
+ })
+
+ return str
+}
diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts
index f38d7a16..9e18aecb 100644
--- a/packages/tailwindcss-language-service/src/util/find.ts
+++ b/packages/tailwindcss-language-service/src/util/find.ts
@@ -33,7 +33,7 @@ export function findLast(re: RegExp, str: string): RegExpMatchArray {
}
export function getClassNamesInClassList(
- { classList, range, important }: DocumentClassList,
+ { classList, span, range, important }: DocumentClassList,
blocklist: State['blocklist'],
): DocumentClassName[] {
const parts = classList.split(/(\s+)/)
@@ -41,13 +41,16 @@ export function getClassNamesInClassList(
let index = 0
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0 && !blocklist.includes(parts[i])) {
+ const classNameSpan = [index, index + parts[i].length]
const start = indexToPosition(classList, index)
const end = indexToPosition(classList, index + parts[i].length)
names.push({
className: parts[i],
+ span: [span[0] + classNameSpan[0], span[0] + classNameSpan[1]],
classList: {
classList,
range,
+ span,
important,
},
relativeRange: {
@@ -107,11 +110,19 @@ export function findClassListsInCssRange(
const matches = findAll(regex, text)
const globalStart: Position = range ? range.start : { line: 0, character: 0 }
+ const rangeStartOffset = doc.offsetAt(globalStart)
+
return matches.map((match) => {
- const start = indexToPosition(text, match.index + match[1].length)
- const end = indexToPosition(text, match.index + match[1].length + match.groups.classList.length)
+ let span = [
+ match.index + match[1].length,
+ match.index + match[1].length + match.groups.classList.length,
+ ] as [number, number]
+
+ const start = indexToPosition(text, span[0])
+ const end = indexToPosition(text, span[1])
return {
classList: match.groups.classList,
+ span: [rangeStartOffset + span[0], rangeStartOffset + span[1]],
important: Boolean(match.groups.important),
range: {
start: {
@@ -143,6 +154,7 @@ async function findCustomClassLists(
for (let match of customClassesIn({ text, filters: regexes })) {
result.push({
classList: match.classList,
+ span: match.range,
range: {
start: doc.positionAt(match.range[0]),
end: doc.positionAt(match.range[1]),
@@ -179,6 +191,8 @@ export async function findClassListsInHtmlRange(
const result: DocumentClassList[] = []
+ const rangeStartOffset = doc.offsetAt(range?.start || { line: 0, character: 0 })
+
matches.forEach((match) => {
const subtext = text.substr(match.index + match[0].length - 1)
@@ -234,17 +248,17 @@ export async function findClassListsInHtmlRange(
const after = value.match(/\s*$/)
const afterOffset = after === null ? 0 : -after[0].length
- const start = indexToPosition(
- text,
+ let span = [
match.index + match[0].length - 1 + offset + beforeOffset,
- )
- const end = indexToPosition(
- text,
match.index + match[0].length - 1 + offset + value.length + afterOffset,
- )
+ ]
+
+ const start = indexToPosition(text, span[0])
+ const end = indexToPosition(text, span[1])
return {
classList: value.substr(beforeOffset, value.length + afterOffset),
+ span: [rangeStartOffset + span[0], rangeStartOffset + span[1]] as [number, number],
range: {
start: {
line: (range?.start.line || 0) + start.line,
@@ -356,6 +370,8 @@ export function findHelperFunctionsInRange(
text,
)
+ let rangeStartOffset = range?.start ? doc.offsetAt(range.start) : 0
+
// Eliminate matches that are on an `@import`
matches = matches.filter((match) => {
// Scan backwards to see if we're in an `@import` statement
@@ -422,6 +438,16 @@ export function findHelperFunctionsInRange(
range,
),
},
+ spans: {
+ full: [
+ rangeStartOffset + startIndex,
+ rangeStartOffset + startIndex + match.groups.path.length,
+ ],
+ path: [
+ rangeStartOffset + startIndex + quotesBefore.length,
+ rangeStartOffset + startIndex + quotesBefore.length + path.length,
+ ],
+ },
}
})
}
diff --git a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
index 42a4a495..5ca4fbf9 100644
--- a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
+++ b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
@@ -4,14 +4,15 @@ import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html'
import type { State } from './state'
import { indexToPosition } from './find'
import { isJsDoc } from './js'
-import moo from 'moo'
+import moo, { Rules } from 'moo'
import Cache from 'tmp-cache'
import { getTextWithoutComments } from './doc'
-import { isCssLanguage } from './css'
+import { isCssDoc, isCssLanguage } from './css'
export type LanguageBoundary = {
type: 'html' | 'js' | 'jsx' | 'css' | (string & {})
range: Range
+ span: [number, number]
lang?: string
}
@@ -29,9 +30,11 @@ let jsxScriptTypes = [
'text/babel',
]
+type States = { [x: string]: Rules }
+
let text = { text: { match: /[^]/, lineBreaks: true } }
-let states = {
+let states: States = {
main: {
cssBlockStart: { match: /