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: / ({
+ ...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
+ ///
+