From e03cbbaa586045b3f4ea4d0acf92ced56a001ccd Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 5 Feb 2025 21:02:58 -0500 Subject: [PATCH 01/19] Collect offset-based range information --- .../src/util/find.ts | 44 +++++++++++++++---- .../src/util/getLanguageBoundaries.ts | 33 +++++++++++--- .../src/util/state.ts | 13 ++++++ 3 files changed, 76 insertions(+), 14 deletions(-) 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..35a3b3af 100644 --- a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts +++ b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts @@ -4,7 +4,7 @@ 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' @@ -12,6 +12,7 @@ import { 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: /\s])/, push: 'cssBlock' }, jsBlockStart: { match: ' Date: Wed, 5 Mar 2025 19:13:32 -0500 Subject: [PATCH 02/19] Add virtual document abstraction wip wip --- .../src/documents/document.ts | 75 +++++++++++++++++++ .../src/documents/manager.ts | 44 +++++++++++ .../src/util/getLanguageBoundaries.ts | 32 +++++++- 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/tailwindcss-language-service/src/documents/document.ts create mode 100644 packages/tailwindcss-language-service/src/documents/manager.ts 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..e82efc04 --- /dev/null +++ b/packages/tailwindcss-language-service/src/documents/document.ts @@ -0,0 +1,75 @@ +import type { Position, TextDocument } from 'vscode-languageserver-textdocument' +import type { State } from '../util/state' +import { LanguageBoundary } from '../util/getLanguageBoundaries' +import { isWithinRange } from '../util/isWithinRange' +import { getTextWithoutComments } from '../util/doc' + +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[] + + constructor( + state: () => State, + storage: TextDocument, + boundaries: LanguageBoundary[], + ) { + this.state = state + this.storage = storage + this.boundaries = boundaries + } + + /** + * The current content of the document + */ + public get contents() { + // Replace all comment nodes with whitespace + let tmp = '' + + let type = this.boundaries[0].type + if (type === 'html') { + tmp = getTextWithoutComments(this.storage, 'html') + } else if (type === 'css') { + tmp = getTextWithoutComments(this.storage, 'css') + } else if (type === 'js') { + tmp = getTextWithoutComments(this.storage, 'js') + } else if (type === 'jsx') { + tmp = getTextWithoutComments(this.storage, 'jsx') + } + + 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 + } +} 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/util/getLanguageBoundaries.ts b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts index 35a3b3af..5ca4fbf9 100644 --- a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts +++ b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts @@ -7,7 +7,7 @@ import { isJsDoc } from './js' 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 & {}) @@ -146,6 +146,36 @@ export function clearLanguageBoundariesCache() { cache.clear() } +/** + * @deprecated + */ +export function getDocumentLanguages(state: State, doc: TextDocument): LanguageBoundary[] { + let boundaries = getLanguageBoundaries(state, doc) + if (boundaries) return boundaries + + // If we get here we most likely have non-HTML document in a single language + let type = doc.languageId + + if (isCssDoc(state, doc)) { + type = 'css' + } else if (isHtmlDoc(state, doc)) { + type = 'html' + } else if (isJsDoc(state, doc)) { + type = 'js' + } + + let text = doc.getText() + + return [ + { + type, + lang: doc.languageId, + span: [0, text.length], + range: { start: doc.positionAt(0), end: doc.positionAt(text.length) }, + }, + ] +} + export function getLanguageBoundaries( state: State, doc: TextDocument, From 38b37aede52a213330da903e30322d92831ba605 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 5 Mar 2025 19:40:10 -0500 Subject: [PATCH 03/19] Collect `Scope` metadata about document content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Here we introduce the concept of a `Scope` which is an abstract marker for a region of content in a document. A `Scope` can be used to mark a region of text with specific metadata that can be used to drive various features in the editor. For example, a `Scope` might be used to represent things like whether a region of text is HTML or CSS; whether or not we're inside a comment, string literal, or other language-specific construct; or whether or not we're looking at a class list (regardless of the language), etc… --- .../src/scopes/analyze.ts | 67 ++++++ .../src/scopes/scope.ts | 84 ++++++++ .../src/scopes/tree.ts | 159 ++++++++++++++ .../src/scopes/walk.ts | 204 ++++++++++++++++++ 4 files changed, 514 insertions(+) create mode 100644 packages/tailwindcss-language-service/src/scopes/analyze.ts create mode 100644 packages/tailwindcss-language-service/src/scopes/scope.ts create mode 100644 packages/tailwindcss-language-service/src/scopes/tree.ts create mode 100644 packages/tailwindcss-language-service/src/scopes/walk.ts 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..540eedb2 --- /dev/null +++ b/packages/tailwindcss-language-service/src/scopes/analyze.ts @@ -0,0 +1,67 @@ +import type { TextDocument } from 'vscode-languageserver-textdocument' +import type { Span, State } from '../util/state' +import type { + ScopeContext, +} from './scope' +import { ScopeTree } from './tree' +import { getDocumentLanguages, LanguageBoundary } from '../util/getLanguageBoundaries' +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 + } + + yield root + } +} 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..4f662baf --- /dev/null +++ b/packages/tailwindcss-language-service/src/scopes/scope.ts @@ -0,0 +1,84 @@ +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 + } +} + +export type ScopeKind = keyof ScopeMap +export type Scope = ScopeMap[K] +export type AnyScope = ScopeMap[ScopeKind] + +type ScopeMap = { + context: ScopeContext +} 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 +} From 6161358d0c0e5f9ecc15d1c9a028c475395dee2f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 5 Mar 2025 19:46:36 -0500 Subject: [PATCH 04/19] virtual doc scopes --- .../src/documents/document.ts | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/tailwindcss-language-service/src/documents/document.ts b/packages/tailwindcss-language-service/src/documents/document.ts index e82efc04..79336c36 100644 --- a/packages/tailwindcss-language-service/src/documents/document.ts +++ b/packages/tailwindcss-language-service/src/documents/document.ts @@ -1,8 +1,10 @@ import type { Position, TextDocument } from 'vscode-languageserver-textdocument' -import type { State } from '../util/state' +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' -import { getTextWithoutComments } from '../util/doc' export type DocumentContext = 'html' | 'css' | 'js' | 'other' | (string & {}) @@ -29,34 +31,57 @@ export class VirtualDocument { */ 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 type = this.boundaries[0].type - if (type === 'html') { - tmp = getTextWithoutComments(this.storage, 'html') - } else if (type === 'css') { - tmp = getTextWithoutComments(this.storage, 'css') - } else if (type === 'js') { - tmp = getTextWithoutComments(this.storage, 'js') - } else if (type === 'jsx') { - tmp = getTextWithoutComments(this.storage, 'jsx') + 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 } @@ -72,4 +97,21 @@ export class VirtualDocument { 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 + } } From 83640ae4eaca17e4c0df92c408cbb853ae6f509f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Feb 2025 20:44:20 -0500 Subject: [PATCH 05/19] Collect `Scope`s about class attrs, lists, and names --- .../src/scopes/analyze.ts | 46 ++++++++++++- .../src/scopes/scope.ts | 69 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-service/src/scopes/analyze.ts b/packages/tailwindcss-language-service/src/scopes/analyze.ts index 540eedb2..cf940b9e 100644 --- a/packages/tailwindcss-language-service/src/scopes/analyze.ts +++ b/packages/tailwindcss-language-service/src/scopes/analyze.ts @@ -1,10 +1,13 @@ import type { TextDocument } from 'vscode-languageserver-textdocument' -import type { Span, State } from '../util/state' +import type { DocumentClassList, Span, State } from '../util/state' import type { ScopeContext, + ScopeClassList, + ScopeClassName, } from './scope' import { ScopeTree } from './tree' import { getDocumentLanguages, LanguageBoundary } from '../util/getLanguageBoundaries' +import { findClassListsInRange, getClassNamesInClassList } from '../util/find' import { optimizeScopes } from './walk' import { isSemicolonlessCssLanguage } from '../util/languages' @@ -65,3 +68,44 @@ export async function* analyzeBoundaries( 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 + } +} diff --git a/packages/tailwindcss-language-service/src/scopes/scope.ts b/packages/tailwindcss-language-service/src/scopes/scope.ts index 4f662baf..09246b89 100644 --- a/packages/tailwindcss-language-service/src/scopes/scope.ts +++ b/packages/tailwindcss-language-service/src/scopes/scope.ts @@ -75,10 +75,79 @@ export interface ScopeContext { } } +/** + * 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 + } +} + export type ScopeKind = keyof ScopeMap export type Scope = ScopeMap[K] export type AnyScope = ScopeMap[ScopeKind] type ScopeMap = { context: ScopeContext + 'class.attr': ScopeClassAttribute + 'class.list': ScopeClassList + 'class.name': ScopeClassName } From 81a3b5f5b6a3cc223cb2137e2da24944d0b747cf Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 28 Feb 2025 18:34:53 -0500 Subject: [PATCH 06/19] Add class list parser --- .../src/scopes/classes/scan.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 packages/tailwindcss-language-service/src/scopes/classes/scan.ts 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 + } +} From 645a7e6707ed17d02802d6a32bfcbd8c38e9b07d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 28 Feb 2025 18:34:54 -0500 Subject: [PATCH 07/19] Add HTML document parser This reads an HTML document and produces a `ScopeContext` with all HTML-like features analyzed --- .../src/scopes/html/scan.test.ts | 189 +++++++++++++++ .../src/scopes/html/scan.ts | 149 ++++++++++++ .../src/scopes/html/stream.test.ts | 227 ++++++++++++++++++ .../src/scopes/html/stream.ts | 220 +++++++++++++++++ 4 files changed, 785 insertions(+) create mode 100644 packages/tailwindcss-language-service/src/scopes/html/scan.test.ts create mode 100644 packages/tailwindcss-language-service/src/scopes/html/scan.ts create mode 100644 packages/tailwindcss-language-service/src/scopes/html/stream.test.ts create mode 100644 packages/tailwindcss-language-service/src/scopes/html/stream.ts 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 === ' ({ + ...event, + slice: input.slice(...event.span), + })) +} + +test('parses HTML', async ({ expect }) => { + let input = dedent` +
+ +
+ ` + + expect(extract(input)).toEqual([ + { + kind: 'element-start', + span: [0, 4], + slice: '', + }, + { + kind: 'element-start', + span: [24, 29], + slice: '', + }, + { + kind: 'element-start', + span: [30, 36], + slice: '', + }, + { + kind: 'element-start', + span: [38, 43], + slice: '', + }, + ]) +}) + +test('Identifies HTML comments', async ({ expect }) => { + let input = dedent` +
+ +
+ ` + + expect(extract(input)).toEqual([ + { + kind: 'element-start', + span: [0, 4], + slice: '', + }, + { + kind: 'comment-start', + span: [24, 28], + slice: '', + }, + { + kind: 'element-start', + span: [53, 58], + slice: '', + }, + ]) +}) + +test('lots of attributes', async ({ expect }) => { + let input = dedent` +
+ + + +
+ ` + + expect(extract(input)).toMatchInlineSnapshot([ + { + kind: 'element-start', + slice: '', + span: [17, 18], + }, + { + kind: 'element-start', + slice: '', + span: [42, 43], + }, + { + kind: 'element-start', + slice: '', + span: [75, 76], + }, + { + kind: 'element-start', + slice: '', + span: [109, 110], + }, + { + kind: 'element-start', + slice: '', + span: [123, 124], + }, + ]) +}) diff --git a/packages/tailwindcss-language-service/src/scopes/html/stream.ts b/packages/tailwindcss-language-service/src/scopes/html/stream.ts new file mode 100644 index 00000000..db4c9f01 --- /dev/null +++ b/packages/tailwindcss-language-service/src/scopes/html/stream.ts @@ -0,0 +1,220 @@ +import { Span } from '../../util/state' + +const QUOTE_SINGLE = "'".charCodeAt(0) +const QUOTE_DOUBLE = '"'.charCodeAt(0) +const QUOTE_TICK = '`'.charCodeAt(0) +const CURLY_OPEN = '{'.charCodeAt(0) +const CURLY_CLOSE = '}'.charCodeAt(0) +const TAG_END = '>'.charCodeAt(0) +const TAG_START = '<'.charCodeAt(0) +const EXCLAIMATION = '!'.charCodeAt(0) +const EQUALS = '='.charCodeAt(0) + +const WS_SPACE = ' '.charCodeAt(0) +const WS_TAB = '\t'.charCodeAt(0) +const WS_NEWLINE = '\n'.charCodeAt(0) +const WS_RETURN = '\r'.charCodeAt(0) +const WS_FEED = '\x0C'.charCodeAt(0) + +/// Represents a piece of information about a tag in an HTML document +export type Event = + /// The start of a comment + /// + /// ^^^^ + | { kind: 'comment-start'; span: Span } + + /// The end of a comment + /// + /// ^^^ + | { kind: 'comment-end'; span: Span } + + /// The start of a tag + /// + /// ^^^^^^^^ + | { kind: 'element-start'; span: Span } + + /// The end of an element definition + /// + /// ^ + | { kind: 'element-end'; span: Span } + + /// An attribute name + /// + + +` + +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/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/tsconfig.json b/packages/tailwindcss-language-service/tsconfig.json index 605ece3c..f415bf43 100644 --- a/packages/tailwindcss-language-service/tsconfig.json +++ b/packages/tailwindcss-language-service/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["src", "../../types"], - "exclude": ["src/**/*.test.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.bench.ts"], "compilerOptions": { "module": "NodeNext", "lib": ["ES2022"], From f691a1f4aba458c61e1c80dc47906e384afab246 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 5 Feb 2025 22:41:14 -0500 Subject: [PATCH 16/19] Update lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1048aaa..4d9143db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@types/stringify-object': specifier: ^4.0.5 version: 4.0.5 + dedent: + specifier: ^1.5.3 + version: 1.5.3 esbuild: specifier: ^0.25.0 version: 0.25.0 From 06cfc371f9e3fe24c067610bb2095479daf2125a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 28 Feb 2025 18:28:09 -0500 Subject: [PATCH 17/19] wip --- packages/tailwindcss-language-service/package.json | 1 + pnpm-lock.yaml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json index ec0e8f06..0cd4294d 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -48,6 +48,7 @@ "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/pnpm-lock.yaml b/pnpm-lock.yaml index 4d9143db..fc228251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,6 +321,9 @@ importers: minimist: specifier: ^1.2.8 version: 1.2.8 + mitata: + specifier: ^1.0.34 + version: 1.0.34 tslib: specifier: 2.2.0 version: 2.2.0 @@ -1881,6 +1884,9 @@ packages: resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} engines: {node: '>=16 || 14 >=14.17'} + mitata@1.0.34: + resolution: {integrity: sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -4180,6 +4186,8 @@ snapshots: minipass@7.0.4: {} + mitata@1.0.34: {} + mkdirp-classic@0.5.3: optional: true From 50c8940a119aefee404137a31c1cca8302ed9f63 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 1 Mar 2025 08:02:41 -0500 Subject: [PATCH 18/19] Add debug UI for viewing document scopes --- .../tailwindcss-language-server/src/tw.ts | 31 ++- packages/vscode-tailwindcss/package.json | 8 + packages/vscode-tailwindcss/src/extension.ts | 7 + packages/vscode-tailwindcss/src/scopes.ts | 229 ++++++++++++++++++ 4 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 packages/vscode-tailwindcss/src/scopes.ts 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/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 08906fbd..df1e2189 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -334,6 +334,14 @@ "description": "Traces the communication between VS Code and the Tailwind CSS Language Server." } } + }, + "views": { + "explorer": [ + { + "id": "scopes", + "name": "Tailwind CSS Scopes" + } + ] } }, "scripts": { diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index c0c7b9e7..1cad0d35 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -37,6 +37,7 @@ import { CONFIG_GLOB, CSS_GLOB } from '@tailwindcss/language-server/src/lib/cons import braces from 'braces' import normalizePath from 'normalize-path' import * as servers from './servers/index' +import { registerScopeProvider } from './scopes' const colorNames = Object.keys(namedColors) @@ -186,6 +187,12 @@ export async function activate(context: ExtensionContext) { await commands.executeCommand('setContext', 'tailwindCSS.hasOutputChannel', true) + registerScopeProvider({ + get client() { + return currentClient + }, + }) + outputChannel.appendLine(`Locating server…`) let module = context.asAbsolutePath(path.join('dist', 'server.js')) diff --git a/packages/vscode-tailwindcss/src/scopes.ts b/packages/vscode-tailwindcss/src/scopes.ts new file mode 100644 index 00000000..d5974db3 --- /dev/null +++ b/packages/vscode-tailwindcss/src/scopes.ts @@ -0,0 +1,229 @@ +import * as vscode from 'vscode' +import { ScopeTree } from '@tailwindcss/language-service/src/scopes/tree' +import type { AnyScope } from '@tailwindcss/language-service/src/scopes/scope' +import { LanguageClient } from 'vscode-languageclient/node' + +interface ScopeOptions { + tree: ScopeTree + cursor: number +} + +interface AnySource { + name: string + span: [number, number] | null | undefined +} + +type TreeData = AnyScope | AnySource + +class ScopeProvider implements vscode.TreeDataProvider { + private tree: ScopeTree + private cursor: number + + constructor() { + this.tree = new ScopeTree([]) + } + + getParent(element: TreeData): vscode.ProviderResult { + if ('name' in element) { + if (!element.span) return null + + let path = this.tree.at(element.span[0]) + let parent = path.at(-1) + if (!parent) return null + + return parent + } + + let path = this.tree.pathTo(element) + let index = path.indexOf(element) + if (index === -1) return null + + let parent = path[index - 1] + if (!parent) return null + + return parent + } + + getTreeItem(scope: TreeData): vscode.TreeItem { + if ('name' in scope) { + return new SourceItem(scope) + } + + let isOpen = scope.source.scope[0] <= this.cursor && this.cursor <= scope.source.scope[1] + + let state = + scope.children.length > 0 + ? isOpen + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + + return new ScopeItem(scope, state) + } + + async getChildren(element?: TreeData): Promise { + if (!element) { + return this.tree.all() + } + + if ('name' in element) return [] + + let children: TreeData[] = [] + + for (let [name, span] of Object.entries(element.source)) { + if (name === 'scope') continue + children.push({ name, span }) + } + + children.push(...element.children) + + return children + } + + update(options: Partial) { + this.tree = options.tree ?? this.tree + this.cursor = options.cursor ?? this.cursor + this._onDidChangeTreeData.fire() + } + + private _onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter() + readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event +} + +class ScopeItem extends vscode.TreeItem { + constructor(scope: AnyScope, state: vscode.TreeItemCollapsibleState) { + let label: vscode.TreeItemLabel = { + label: `${scope.kind} [${scope.source.scope[0]}-${scope.source.scope[1]}]`, + } + + super(label, state) + this.iconPath = new vscode.ThemeIcon('code') + this.tooltip = new vscode.MarkdownString( + `\`\`\`json\n${JSON.stringify({ ...scope, children: undefined }, null, 2)}\n\`\`\``, + ) + } +} + +class SourceItem extends vscode.TreeItem { + constructor(source: AnySource) { + let label: vscode.TreeItemLabel = { + label: `- ${source.name}: `, + } + + if (source.span) { + label.label += `[${source.span[0]}-${source.span[1]}]` + } else { + label.label += '(none)' + } + + super(label, vscode.TreeItemCollapsibleState.None) + this.iconPath = new vscode.ThemeIcon('code') + } +} + +interface ScopeProviderOptions { + readonly client: Promise | null +} + +export function registerScopeProvider(opts: ScopeProviderOptions): vscode.Disposable { + let trees: Map = new Map() + let emptyTree = new ScopeTree([]) + let scopeProvider = new ScopeProvider() + let disposables: vscode.Disposable[] = [] + + let treeView = vscode.window.createTreeView('scopes', { + treeDataProvider: scopeProvider, + }) + + disposables.push(treeView) + + vscode.workspace.onDidChangeTextDocument( + async (event) => { + if (event.document !== vscode.window.activeTextEditor.document) return + if (!opts.client) return + + let client = await opts.client + + interface ScopesGetResponse { + scopes: AnyScope[] + } + + let response = await client.sendRequest('@/tailwindCSS/scopes/get', { + uri: event.document.uri.toString(), + }) + + let tree = new ScopeTree(response.scopes) + trees.set(event.document.uri.toString(), tree) + + await refresh() + }, + null, + disposables, + ) + + vscode.window.onDidChangeActiveTextEditor( + async () => { + await refresh() + }, + null, + disposables, + ) + + vscode.window.onDidChangeTextEditorSelection( + async (event) => { + if (event.textEditor !== vscode.window.activeTextEditor) return + + let editor = event.textEditor + let cursor = editor.document.offsetAt(editor.selection.active) + let tree = trees.get(editor.document.uri.toString()) ?? emptyTree + let scope = tree.at(cursor).at(-1) + + if (scope) { + treeView.reveal(scope, { + // select: false, + // focus: true, + expand: true, + }) + } + }, + null, + disposables, + ) + + async function refresh() { + if (!opts.client) return + + let editor = vscode.window.activeTextEditor + let cursor = editor.document.offsetAt(editor.selection.active) + + scopeProvider.update({ + tree: trees.get(editor.document.uri.toString()) ?? emptyTree, + cursor, + }) + } + + let decoration = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0, 0, 0, 0.1)', + }) + + disposables.push(decoration) + + function decorationForScope(scope: AnyScope) { + let depth = 0 + for (let tree of trees.values()) { + let path = tree.pathTo(scope) + if (path.length > 0) { + depth = path.length + break + } + } + + return decoration + } + + return new vscode.Disposable(() => { + disposables.forEach((disposable) => disposable.dispose()) + }) +} From e1db3c2d1ae9ae6a54eb3e12524e493b3d06d2d1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 1 Mar 2025 12:30:39 -0500 Subject: [PATCH 19/19] wip --- packages/tailwindcss-language-server/tsconfig.json | 2 +- packages/vscode-tailwindcss/tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/vscode-tailwindcss/tsconfig.json b/packages/vscode-tailwindcss/tsconfig.json index 4f3a8ec5..3b30d56e 100755 --- a/packages/vscode-tailwindcss/tsconfig.json +++ b/packages/vscode-tailwindcss/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "commonjs", - "target": "es6", + "target": "ES2022", "lib": ["ES2022"], "rootDir": "..", "sourceMap": true,