Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions lsp/src/typescript-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ describe("extractTypeScriptBlocks", () => {
).toEqual([]);
});

it("matches tag names in any ASCII case, as an HTML parser does", () => {
const html =
'<SCRIPT type="text/typescript">let a = 1;</Script ><TEXTAREA><script type="text/typescript">let b;</script></TextArea><sCrIpT type="text/typescript">let c = 1;</SCRIPT>';
expect(extractTypeScriptBlocks(html).map((block) => block.content)).toEqual(
["let a = 1;", "let c = 1;"],
);
});

it("places a block after text whose lowercase is longer, such as a dotted capital I", () => {
// "İ".toLowerCase() is two characters: offsets taken from a lowercased copy of the page land past the block.
const before = "<p>İstanbul İzmir</p>\n";
const html = `${before}<script type="text/typescript">const city: string = "x";</script>`;
const [block] = extractTypeScriptBlocks(html);
expect(block.content).toBe('const city: string = "x";');
expect(
html.slice(block.contentStart, block.contentStart + block.content.length),
).toBe(block.content);
const [script] = extractPageScopeScripts(
html.replace("text/typescript", "text/javascript"),
);
expect(script.content).toBe('const city: string = "x";');
});

it("skips the scan when the file never mentions text/typescript", () => {
expect(mayHaveTypeScriptBlocks("<script>let a;</script>")).toBe(false);
expect(mayHaveTypeScriptBlocks('<script type="Text/TypeScript">')).toBe(
Expand Down
47 changes: 33 additions & 14 deletions lsp/src/typescript-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,29 @@ function findOpenTagEnd(html: string, from: number): number {
return -1;
}

/** The index of `</name` (followed by a tag-name end) at or after `from`, or -1. */
function findCloseTag(lower: string, from: number, name: string): number {
const needle = `</${name}`;
for (let at = lower.indexOf(needle, from); at >= 0;) {
if (isTagNameEnd(lower[at + needle.length])) return at;
at = lower.indexOf(needle, at + 1);
}
/**
* Whether `html` holds `name` (lowercase ASCII letters) at `at`, in any ASCII case, as an HTML parser compares tag
* names. Setting bit 0x20 lowercases an ASCII letter and leaves every other code unequal to a lowercase letter.
*/
function hasTagNameAt(html: string, at: number, name: string): boolean {
if (at + name.length > html.length) return false;
for (let k = 0; k < name.length; k++)
if ((html.charCodeAt(at + k) | 0x20) !== name.charCodeAt(k)) return false;
return true;
}

/** The index of `</name` (any ASCII case, followed by a tag-name end) at or after `from`, or -1. */
function findCloseTag(html: string, from: number, name: string): number {
for (
let at = html.indexOf("</", from);
at >= 0;
at = html.indexOf("</", at + 2)
)
if (
hasTagNameAt(html, at + 2, name) &&
isTagNameEnd(html[at + 2 + name.length])
)
return at;
return -1;
}

Expand All @@ -105,25 +121,28 @@ interface ScriptElement {
/**
* Every `<script>` element of an HTML file, in source order. A script inside an HTML comment or another raw-text
* element, and one whose tag or element never closes, is not one.
*
* Tag names are compared in place, in ASCII case only. Lowercasing the page instead copied all of it on every scan
* (the language server scans on each keystroke), and Unicode lowercasing can change a string's length ("İ" becomes
* two characters), which moved every offset after it.
*/
function scanScripts(html: string): ScriptElement[] {
const scripts: ScriptElement[] = [];
const lower = html.toLowerCase();
let i = 0;
while (i < html.length) {
const lt = lower.indexOf("<", i);
const lt = html.indexOf("<", i);
if (lt < 0) break;
if (lower.startsWith("<!--", lt)) {
const end = lower.indexOf("-->", lt + 4);
if (html.startsWith("<!--", lt)) {
const end = html.indexOf("-->", lt + 4);
// An unclosed comment runs to the end of the file, as in a browser.
if (end < 0) break;
i = end + 3;
continue;
}
const name = RAW_TEXT_ELEMENTS.find(
(candidate) =>
lower.startsWith(candidate, lt + 1) &&
isTagNameEnd(lower[lt + 1 + candidate.length]),
hasTagNameAt(html, lt + 1, candidate) &&
isTagNameEnd(html[lt + 1 + candidate.length]),
);
if (!name) {
i = lt + 1;
Expand All @@ -132,7 +151,7 @@ function scanScripts(html: string): ScriptElement[] {
const tagEnd = findOpenTagEnd(html, lt + 1 + name.length);
if (tagEnd < 0) break;
const contentStart = tagEnd + 1;
const close = findCloseTag(lower, contentStart, name);
const close = findCloseTag(html, contentStart, name);
// An element that never closes swallows the rest of the file; nothing after it is a tag.
if (close < 0) break;
if (name === "script") {
Expand Down
50 changes: 38 additions & 12 deletions lsp/src/typescript-service.linear-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,26 @@ afterAll(() => fixture.remove());
// cross it for some fixtures: 8,000/32,000 unclosed-comment units are 56 KB/224 KB.
const SMALL_SCAN_CHARACTERS = 256_000;
// A fixture that is one long run of text is scanned by a native search at memory speed, so its cost per character
// is the cost of a cache miss. At 256 KB against 1 MB the small input fits a CI runner's per-core cache and the
// large one does not, and linear work measured a growth of 12.6 there. At 4 MB against 16 MB both are past it.
// is the CPU's cost of reading memory, which changes where an input stops fitting a cache. Where that is depends on
// the runner: the same linear search read 3.0 at 256 KB against 1 MB, 5.2 to 5.6 at 1 MB against 4 MB and 4.4 at 4 MB
// against 16 MB on one CI runner, and checks of it at 4 MB against 16 MB read 12.2 to 16.6 on others. So such a
// check is measured against a native search over the same two texts (`MEMORY_BOUND_REFERENCE`), which pays the same
// cost of reading them: a linear scan grows about as much as it does (1), and work that rescans the text for each
// character about four times as much. Both texts stay past the large-object boundary, and large enough that each run
// is a few milliseconds of reading.
const MEMORY_BOUND_SCAN_CHARACTERS = 4_000_000;
/** One native pass over all of `text`: U+0001 never occurs in the fixtures. */
const MEMORY_BOUND_REFERENCE = (text: string) => text.indexOf("\u0001");
/** How much more a memory-bound scan grows than reading its texts does. Linear reads about 1, quadratic about 4. */
const MAX_GROWTH_OVER_READING = 2;
const scanGrowth = (
html: (count: number) => string,
scan: (text: string) => unknown,
smallCharacters = SMALL_SCAN_CHARACTERS,
memoryBound = false,
): number => {
const smallCharacters = memoryBound
? MEMORY_BOUND_SCAN_CHARACTERS
: SMALL_SCAN_CHARACTERS;
const prefixLength = html(0).length;
const unitLength = html(1).length - prefixLength;
expect(unitLength).toBeGreaterThan(0);
Expand All @@ -38,12 +50,25 @@ const scanGrowth = (
const large = html(4 * smallCount);
expect(small.length).toBeGreaterThanOrEqual(smallCharacters);
expect(large.length - prefixLength).toBe(4 * (small.length - prefixLength));
return growthOf(
if (memoryBound) expect(large.includes("\u0001")).toBe(false);
const growth = growthOf(
() => scan(small),
() => scan(large),
);
if (!memoryBound) return growth;
return (
growth /
growthOf(
() => MEMORY_BOUND_REFERENCE(small),
() => MEMORY_BOUND_REFERENCE(large),
)
);
};

/** The bar a scan's growth must stay under: over reading its texts for a memory-bound one, 8 otherwise. */
const maxScanGrowth = (memoryBound = false) =>
memoryBound ? MAX_GROWTH_OVER_READING : 8;

/** A page of `count` client blocks, each with a little typed code. */
const pageOf = (count: number, edited: string) =>
Array.from(
Expand Down Expand Up @@ -86,7 +111,8 @@ describe("TypeScript block diagnostics: linear time", () => {
LINEAR_TIME_TIMEOUT_MS,
);

const scans: Array<[string, (n: number) => string, number?]> = [
// The third entry marks a memory-bound fixture (see MEMORY_BOUND_SCAN_CHARACTERS).
const scans: Array<[string, (n: number) => string, boolean?]> = [
[
"script tags that never close",
(n) => `text/typescript ${'<script type="text/typescript">'.repeat(n)}`,
Expand All @@ -98,7 +124,7 @@ describe("TypeScript block diagnostics: linear time", () => {
[
"a quote that never ends",
(n) => `text/typescript <script type="${"a".repeat(n)}`,
MEMORY_BOUND_SCAN_CHARACTERS,
true,
],
[
"many comments",
Expand All @@ -121,24 +147,24 @@ describe("TypeScript block diagnostics: linear time", () => {

it.each(scans)(
"finds blocks in linear time with %s",
(_label, html, smallCharacters) => {
(_label, html, memoryBound) => {
expect(
scanGrowth(html, extractTypeScriptBlocks, smallCharacters),
).toBeLessThan(8);
scanGrowth(html, extractTypeScriptBlocks, memoryBound),
).toBeLessThan(maxScanGrowth(memoryBound));
},
LINEAR_TIME_TIMEOUT_MS,
);

it.each(scans)(
"finds the page's classic scripts in linear time with %s",
(_label, html, smallCharacters) => {
(_label, html, memoryBound) => {
expect(
scanGrowth(
(n) => html(n).replaceAll("text/typescript", "text/javascript"),
extractPageScopeScripts,
smallCharacters,
memoryBound,
),
).toBeLessThan(8);
).toBeLessThan(maxScanGrowth(memoryBound));
},
LINEAR_TIME_TIMEOUT_MS,
);
Expand Down
Loading