@adecore/editor-core 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/offsets.ts CHANGED
@@ -14,3 +14,27 @@ export function mapOffset(offset: number, edits: readonly TextEdit[], stayBefore
14
14
  }
15
15
  return offset + delta;
16
16
  }
17
+
18
+ /* Reuse this index across selections while retaining both endpoint affinities. */
19
+ export function offsetMapper(edits: readonly TextEdit[]): (offset: number, stayBefore?: boolean) => number {
20
+ const deltas = new Float64Array(edits.length + 1);
21
+ for (let index = 0; index < edits.length; index++) {
22
+ const edit = edits[index]!;
23
+ deltas[index + 1] = deltas[index]! + edit.text.length - (edit.to - edit.from);
24
+ }
25
+ return (offset, stayBefore = false) => {
26
+ let low = 0,
27
+ high = edits.length;
28
+ while (low < high) {
29
+ const middle = (low + high) >>> 1;
30
+ const edit = edits[middle]!;
31
+ if (edit.to < offset || (edit.to === offset && !(stayBefore && edit.from === edit.to))) {
32
+ low = middle + 1;
33
+ } else {
34
+ high = middle;
35
+ }
36
+ }
37
+ const edit = edits[low];
38
+ return edit !== undefined && edit.from <= offset && offset < edit.to ? edit.from + deltas[low]! + edit.text.length : offset + deltas[low]!;
39
+ };
40
+ }
@@ -0,0 +1,104 @@
1
+ import type { TypingContext } from './typing-context.ts';
2
+
3
+ const TAG = /<(\/?)([a-z][\w:-]*)(?=[\s/>]|$)/iy;
4
+ const PHP = /<\?(?:php\b|=)/y;
5
+
6
+ interface ScriptLexer {
7
+ initial(): TypingContext;
8
+ step(state: TypingContext, text: string, at: number, end: number, offset: number): number;
9
+ }
10
+
11
+ function finishTag(state: TypingContext, selfClosing: boolean, script: ScriptLexer, start: number): void {
12
+ state.markup = !selfClosing && state.element !== undefined ? 'raw' : undefined;
13
+ state.markupQuote = undefined;
14
+ state.embed = state.markup === 'raw' && state.element === 'script' ? script.initial() : undefined;
15
+ if (state.embed !== undefined) {
16
+ state.embed.embedStart = start;
17
+ }
18
+ if (selfClosing) {
19
+ state.element = undefined;
20
+ }
21
+ }
22
+
23
+ export function scanPhpMarkup(state: TypingContext, text: string, from: number, end: number, offset: number, script: ScriptLexer): number {
24
+ for (let at = from; at < end; at++) {
25
+ const char = text[at]!;
26
+ if (char === '<' && text[at + 1] === '?') {
27
+ PHP.lastIndex = at;
28
+ const open = PHP.exec(text);
29
+ if (open !== null) {
30
+ if (state.markup === 'equals') {
31
+ state.markup = 'value';
32
+ }
33
+ state.mode = 'code';
34
+ state.expression = true;
35
+ state.word = '';
36
+ return at + open[0].length - 1;
37
+ }
38
+ }
39
+ if (state.markup === 'comment') {
40
+ if (text.startsWith('-->', at) && at + 3 <= end) {
41
+ state.markup = undefined;
42
+ at += 2;
43
+ }
44
+ continue;
45
+ }
46
+ if (state.markup === 'value' && state.markupQuote !== undefined) {
47
+ if (char === state.markupQuote) {
48
+ state.markup = 'tag';
49
+ state.markupQuote = undefined;
50
+ }
51
+ continue;
52
+ }
53
+ if (char === '<') {
54
+ if (state.markup !== 'raw' && text.startsWith('<!--', at)) {
55
+ // HTML also closes these two comments immediately, without a later -->.
56
+ const empty = text.startsWith('<!-->', at) ? 5 : text.startsWith('<!--->', at) ? 6 : 0;
57
+ state.markup = empty > 0 && at + empty <= end ? undefined : 'comment';
58
+ state.element = undefined;
59
+ at += (empty || 4) - 1;
60
+ continue;
61
+ }
62
+ TAG.lastIndex = at;
63
+ const tag = TAG.exec(text);
64
+ const name = tag?.[2]?.toLowerCase();
65
+ if (state.markup === 'raw' && (tag?.[1] !== '/' || name !== state.element)) {
66
+ if (state.embed !== undefined) {
67
+ at = script.step(state.embed, text, at, end, offset);
68
+ }
69
+ continue;
70
+ }
71
+ state.embed = undefined;
72
+ state.element = tag?.[1] !== '/' && (name === 'script' || name === 'style' || name === 'textarea' || name === 'title') ? name : undefined;
73
+ state.markup = tag === null ? undefined : 'tag';
74
+ state.markupQuote = undefined;
75
+ if (tag !== null) {
76
+ at += tag[0].length - 1;
77
+ }
78
+ continue;
79
+ }
80
+ if (state.embed !== undefined) {
81
+ at = script.step(state.embed, text, at, end, offset);
82
+ continue;
83
+ }
84
+ if (state.markup === undefined || state.markup === 'raw') {
85
+ continue;
86
+ }
87
+ if (char === '>') {
88
+ finishTag(state, false, script, offset + at + 1);
89
+ } else if (char === '/' && text[at + 1] === '>' && at + 2 <= end) {
90
+ finishTag(state, true, script, offset + at + 2);
91
+ at++;
92
+ } else if (/\s/u.test(char)) {
93
+ if (state.markup === 'value') {
94
+ state.markup = 'tag';
95
+ }
96
+ } else if (state.markup === 'equals') {
97
+ state.markup = 'value';
98
+ state.markupQuote = char === '"' || char === "'" ? char : undefined;
99
+ } else if (state.markup === 'tag' && char === '=') {
100
+ state.markup = 'equals';
101
+ }
102
+ }
103
+ return end - 1;
104
+ }
package/src/rope.ts CHANGED
@@ -7,14 +7,14 @@ export interface DocumentLine {
7
7
  }
8
8
 
9
9
  /*
10
- * A persistent AVL tree of text chunks of at most `chunkSize` UTF-16 code units. Every node knows
10
+ * A persistent AVL tree of text chunks of at most `ROPE_CHUNK_SIZE` UTF-16 code units. Every node knows
11
11
  * its length and its LF count, a leaf also where its LFs are, so offsets and line numbers resolve
12
12
  * without scanning.
13
13
  */
14
14
  type Leaf = { readonly text: string; readonly breaks: readonly number[]; readonly length: number; readonly lines: number; readonly height: number };
15
15
  type Branch = { readonly left: Node; readonly right: Node; readonly length: number; readonly lines: number; readonly height: number };
16
16
  type Node = Leaf | Branch;
17
- const chunkSize = 2048;
17
+ export const ROPE_CHUNK_SIZE = 2048;
18
18
 
19
19
  function leaf(text: string): Leaf | null {
20
20
  if (!text.length) {
@@ -56,7 +56,7 @@ function join(left: Node | null, right: Node | null): Node | null {
56
56
  if (!right) {
57
57
  return left;
58
58
  }
59
- if ('text' in left && 'text' in right && left.length + right.length <= chunkSize) {
59
+ if ('text' in left && 'text' in right && left.length + right.length <= ROPE_CHUNK_SIZE) {
60
60
  return leaf(left.text + right.text);
61
61
  }
62
62
  if (left.height > right.height + 1 && 'left' in left) {
@@ -88,8 +88,8 @@ function split(node: Node | null, offset: number): [Node | null, Node | null] {
88
88
 
89
89
  function build(text: string): Node | null {
90
90
  const leaves: Node[] = [];
91
- for (let offset = 0; offset < text.length; offset += chunkSize) {
92
- leaves.push(leaf(text.slice(offset, offset + chunkSize))!);
91
+ for (let offset = 0; offset < text.length; offset += ROPE_CHUNK_SIZE) {
92
+ leaves.push(leaf(text.slice(offset, offset + ROPE_CHUNK_SIZE))!);
93
93
  }
94
94
  const tree = (from: number, to: number): Node | null => {
95
95
  if (from === to) {
@@ -187,6 +187,36 @@ export class TextRope {
187
187
  return new TextRope(join(join(before, build(text)), after));
188
188
  }
189
189
 
190
+ /* Dense validated batches rebuild once, rather than repeatedly splitting and rescanning leaves. */
191
+ rebuild(edits: readonly { from: number; to: number; text: string }[]): TextRope {
192
+ const chunks = chunksOf(this.root, 0, this.length);
193
+ let chunk = chunks.next();
194
+ let column = 0;
195
+ let offset = 0;
196
+ const pieces: string[] = [];
197
+ const advance = (end: number, keep: boolean): void => {
198
+ while (offset < end && !chunk.done) {
199
+ const count = Math.min(end - offset, chunk.value.length - column);
200
+ if (keep) {
201
+ pieces.push(chunk.value.slice(column, column + count));
202
+ }
203
+ offset += count;
204
+ column += count;
205
+ if (column === chunk.value.length) {
206
+ chunk = chunks.next();
207
+ column = 0;
208
+ }
209
+ }
210
+ };
211
+ for (const edit of edits) {
212
+ advance(edit.from, true);
213
+ pieces.push(edit.text);
214
+ advance(edit.to, false);
215
+ }
216
+ advance(this.length, true);
217
+ return TextRope.from(pieces.join(''));
218
+ }
219
+
190
220
  /* Whether both ropes hold the same text in `[from, to)`, which has to be the same range of both. */
191
221
  equalsRange(other: TextRope, from: number, to: number): boolean {
192
222
  if (this.root === other.root) {
@@ -0,0 +1,43 @@
1
+ import type { TypingContext } from './typing-context.ts';
2
+
3
+ export function scriptSource(text: string, start: number, complete: boolean, context: (offset: number) => TypingContext | null): string | undefined {
4
+ const events = /<\?(?:php\b|=)|\?>|<\/script(?=[\s/>]|$)/gi;
5
+ const chunks: string[] = [];
6
+ let cursor = 0;
7
+ let island: number | undefined;
8
+ const finish = (end: number): string => {
9
+ chunks.push(text.slice(cursor, island ?? end));
10
+ if (island !== undefined) {
11
+ chunks.push(' '.repeat(end - island));
12
+ }
13
+ return chunks.join('');
14
+ };
15
+ for (const match of text.matchAll(events)) {
16
+ const state = context(start + match.index);
17
+ if (state === null) {
18
+ return undefined;
19
+ }
20
+ const inScript = state.embedded === 'javascript' && state.embedStart === start;
21
+ if (/^<\?(?:php\b|=)/.test(match[0])) {
22
+ if (inScript) {
23
+ island = match.index;
24
+ }
25
+ } else if (match[0] === '?>') {
26
+ if (island !== undefined && state.embed?.embedStart === start && (state.mode === 'code' || state.mode === 'line-comment')) {
27
+ let end = match.index + 2;
28
+ if (text.startsWith('\r\n', end)) {
29
+ end += 2;
30
+ } else if (text[end] === '\n') {
31
+ end++;
32
+ }
33
+ // Spaces preserve document offsets without treating PHP source newlines as script text.
34
+ chunks.push(text.slice(cursor, island), ' '.repeat(end - island));
35
+ cursor = end;
36
+ island = undefined;
37
+ }
38
+ } else if (match[0].startsWith('</') && inScript) {
39
+ return finish(match.index);
40
+ }
41
+ }
42
+ return complete ? finish(text.length) : undefined;
43
+ }
package/src/search.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { clampInteger } from './rope.ts';
1
2
  import { replaceWithCaseRespect } from './preserve-case.ts';
2
3
 
3
4
  export interface FindOptions {
@@ -40,8 +41,14 @@ function characterBefore(text: string, offset: number): string {
40
41
 
41
42
  /* Invalid bounds throw `RangeError`, an invalid expression `SyntaxError`. A zero-width match advances by one code point. */
42
43
  export function findMatches(text: string, query: string, revision: number, options: FindOptions = {}): FindMatch[] {
44
+ return Array.from(iterateMatches(text, query, revision, options));
45
+ }
46
+
47
+ /* Validation and regex execution start when the iterator advances. */
48
+ export function* iterateMatches(text: string, query: string, revision: number, options: FindOptions = {}): Generator<FindMatch, void, unknown> {
49
+ options = { ...options };
43
50
  if (!query && !options.regex) {
44
- return [];
51
+ return;
45
52
  }
46
53
  const from = options.from ?? 0;
47
54
  const to = options.to ?? text.length;
@@ -55,8 +62,8 @@ export function findMatches(text: string, query: string, revision: number, optio
55
62
  const source = options.regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
63
  const expression = new RegExp(source, `gmu${options.caseSensitive ? '' : 'i'}`);
57
64
  expression.lastIndex = from;
58
- const matches: FindMatch[] = [];
59
- while (matches.length < maximum) {
65
+ let count = 0;
66
+ while (count < maximum) {
60
67
  const match = expression.exec(text);
61
68
  if (!match || match.index > to) {
62
69
  break;
@@ -66,7 +73,8 @@ export function findMatches(text: string, query: string, revision: number, optio
66
73
  !options.wholeWord ||
67
74
  (!wordCharacter.test(characterBefore(text, match.index)) && !wordCharacter.test(String.fromCodePoint(text.codePointAt(end) ?? 0)));
68
75
  if (match.index >= from && end <= to && !splitsSurrogate(text, match.index) && !splitsSurrogate(text, end) && wholeWord) {
69
- matches.push({
76
+ count++;
77
+ yield {
70
78
  from: match.index,
71
79
  to: end,
72
80
  text: match[0],
@@ -74,23 +82,65 @@ export function findMatches(text: string, query: string, revision: number, optio
74
82
  groups: match.groups ? { ...match.groups } : undefined,
75
83
  regex: options.regex === true,
76
84
  revision
77
- });
85
+ };
78
86
  }
79
87
  if (!match[0].length) {
80
88
  expression.lastIndex = match.index + ((text.codePointAt(match.index) ?? 0) > 0xffff ? 2 : 1);
81
89
  }
82
90
  }
83
- return matches;
91
+ }
92
+
93
+ /* Keeps the original match grid; starting RegExp at the caret would create overlapping matches. */
94
+ export function findMatchFrom(text: string, query: string, revision: number, offset: number, options: FindNextOptions = {}): FindMatch | null {
95
+ const position = clampInteger(offset, text.length);
96
+ let first: FindMatch | null = null;
97
+ let last: FindMatch | null = null;
98
+ let previous: FindMatch | null = null;
99
+ for (const match of iterateMatches(text, query, revision, options)) {
100
+ first ??= match;
101
+ last = match;
102
+ if (!options.backwards && match.from >= position) {
103
+ return match;
104
+ }
105
+ if (options.backwards) {
106
+ if (match.to <= position) {
107
+ previous = match;
108
+ } else if (previous !== null || options.wrap === false) {
109
+ return previous;
110
+ }
111
+ }
112
+ }
113
+ return options.backwards ? (previous ?? (options.wrap === false ? null : last)) : options.wrap === false ? null : first;
84
114
  }
85
115
 
86
116
  /* Expands the JavaScript `$` patterns of a regex replacement, and gives it the case of the match when asked. Literal replacements are returned as they are. */
87
- export function replacementText(text: string, match: FindMatch, replacement: string, literal = false, preserveCase = false): string {
88
- const expanded = literal || !match.regex ? replacement : expandPatterns(text, match, replacement);
89
- return preserveCase ? replaceWithCaseRespect(expanded, match.text) : expanded;
117
+ export function replacementText(text: string, match: FindMatch, replacement: string, literal = false, preserveCase = false, maxCharacters = Infinity): string {
118
+ if (maxCharacters !== Infinity && (!Number.isSafeInteger(maxCharacters) || maxCharacters < 0)) {
119
+ throw new RangeError('The replacement character limit must be nonnegative.');
120
+ }
121
+ const expanded = literal || !match.regex ? replacement : expandPatterns(text, match, replacement, maxCharacters);
122
+ if (expanded.length > maxCharacters) {
123
+ throw new RangeError('Replacement output exceeds its character limit.');
124
+ }
125
+ const result = preserveCase ? replaceWithCaseRespect(expanded, match.text) : expanded;
126
+ if (result.length > maxCharacters) {
127
+ throw new RangeError('Replacement output exceeds its character limit.');
128
+ }
129
+ return result;
90
130
  }
91
131
 
92
- function expandPatterns(text: string, match: FindMatch, replacement: string): string {
93
- return replacement.replace(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g, (token: string, group: string) => {
132
+ function expandPatterns(text: string, match: FindMatch, replacement: string, maxCharacters: number): string {
133
+ const pieces: string[] = [];
134
+ let characters = 0,
135
+ cursor = 0;
136
+ const append = (value: string): void => {
137
+ characters += value.length;
138
+ if (characters > maxCharacters) {
139
+ throw new RangeError('Replacement output exceeds its character limit.');
140
+ }
141
+ pieces.push(value);
142
+ };
143
+ const expand = (token: string, group: string): string => {
94
144
  if (group === '$') {
95
145
  return '$';
96
146
  }
@@ -115,5 +165,12 @@ function expandPatterns(text: string, match: FindMatch, replacement: string): st
115
165
  return (match.captures[first - 1] ?? '') + group[1];
116
166
  }
117
167
  return token;
118
- });
168
+ };
169
+ for (const found of replacement.matchAll(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g)) {
170
+ append(replacement.slice(cursor, found.index));
171
+ append(expand(found[0], found[1]!));
172
+ cursor = found.index + found[0].length;
173
+ }
174
+ append(replacement.slice(cursor));
175
+ return pieces.join('');
119
176
  }
package/src/types.ts CHANGED
@@ -49,6 +49,8 @@ export interface EditorSnapshot {
49
49
  /* The same change in order, as a sequence a language server can follow; present when the text changed. */
50
50
  contentEdits?: readonly ContentEdit[];
51
51
  source?: ChangeSource;
52
+ /* The edit is what a person's typing did, a line break from Enter included, though it ran as a command. */
53
+ typed?: boolean;
52
54
  }
53
55
 
54
56
  export interface EditOptions {
@@ -57,6 +59,10 @@ export interface EditOptions {
57
59
  source?: ChangeSource;
58
60
  /* Edits with the same group join one undo step until a selection change or an undo closes it. */
59
61
  historyGroup?: string;
62
+ /* Joins the undo step before it when the document is exactly as that step left it, so one undo takes both back. */
63
+ joinPrevious?: boolean;
64
+ /* Reported on the snapshot: the edit is what a person's typing did, though it ran as a command. */
65
+ typed?: boolean;
60
66
  }
61
67
 
62
68
  export type EditorCommand =
@@ -13,6 +13,7 @@ import {
13
13
  startsExpressionAfter,
14
14
  startsWord
15
15
  } from './lexical.ts';
16
+ import { scanPhpMarkup } from './php-markup.ts';
16
17
  import type { DocumentLine } from './rope.ts';
17
18
 
18
19
  export interface TypingBracket {
@@ -43,6 +44,15 @@ export interface TypingContext {
43
44
  word: string;
44
45
  /* Where the comment the lexer is in began, in the document. */
45
46
  commentStart?: number;
47
+ /* PHP preserves these fields while code inside an attribute or raw block runs. */
48
+ markup?: 'tag' | 'equals' | 'value' | 'comment' | 'raw';
49
+ markupQuote?: '"' | "'";
50
+ element?: 'script' | 'style' | 'textarea' | 'title';
51
+ embed?: TypingContext;
52
+ embedded?: 'javascript';
53
+ embedStart?: number;
54
+ /* Only a scan result keeps this; PHP consumes the newline immediately after a closing tag. */
55
+ phpClose?: number;
46
56
  }
47
57
 
48
58
  interface Rules {
@@ -58,6 +68,12 @@ interface Rules {
58
68
 
59
69
  const PHP_HEREDOC = /^<<<[ \t]*(["']?)([A-Za-z_]\w*)\1[ \t]*$/;
60
70
  const PHP_OPEN = /<\?(?:php\b|=)/;
71
+ const PHP_INITIAL_LINES = 64;
72
+ const PHP_INITIAL_CHARACTERS = 64 * 1024;
73
+ // Quoted PHP tags within a bare code fragment must not turn its prelude into output.
74
+ const PHP_FRAGMENT_START =
75
+ /^(?:[$'"#]|\/[/*]|(?:abstract|class|const|declare|echo|enum|final|for|foreach|function|if|include(?:_once)?|interface|namespace|print|readonly|require(?:_once)?|return|switch|throw|trait|try|use|while)\b|[A-Za-z_]\w*\s*(?:\(|::))/;
76
+
61
77
  const controlWords = /^(for|for-await|if|while|switch|catch|with)$/;
62
78
  const blockWords = /^(else|try|finally|do)$/;
63
79
 
@@ -151,6 +167,7 @@ function scanCode(state: TypingContext, rules: Rules, text: string, at: number,
151
167
  }
152
168
  }
153
169
  if (rules.php && char === '?' && next === '>') {
170
+ state.phpClose = offset + at + 2;
154
171
  state.mode = 'html';
155
172
  return at + 1;
156
173
  }
@@ -214,9 +231,34 @@ function closeBracket(state: TypingContext, char: string): void {
214
231
  state.word = bracket.control ? 'control-close' : '';
215
232
  }
216
233
 
234
+ let scriptRules: Rules | undefined;
235
+
236
+ function stepScript(state: TypingContext, text: string, at: number, end: number, offset: number): number {
237
+ if (state.mode === 'line-comment') {
238
+ return at;
239
+ }
240
+ if (state.mode === 'block-comment') {
241
+ return scanComment(state, text, at, end);
242
+ }
243
+ return state.mode === 'code' ? scanCode(state, (scriptRules ??= rulesOf('javascript')), text, at, end, offset) : scanLiteral(state, text, at, end, offset);
244
+ }
245
+
246
+ const scriptLexer = { initial, step: stepScript };
247
+
248
+ function normalizeLine(state: TypingContext, multilineQuotes: boolean): void {
249
+ if (state.mode === 'line-comment' || state.mode === 'regex' || (state.mode === 'quote' && !multilineQuotes && !state.escaped)) {
250
+ state.mode = 'code';
251
+ state.quote = undefined;
252
+ state.commentStart = undefined;
253
+ state.expression = true;
254
+ }
255
+ state.escaped = false;
256
+ }
257
+
217
258
  /* Lexes `text` up to `end`, from the state the line started in. `offset` is where the line starts in the document. */
218
259
  function scan(text: string, end: number, start: TypingContext, rules: Rules, offset: number): TypingContext {
219
- const state = { ...start };
260
+ // An embedded lexer must not mutate the script state cached at the line start.
261
+ const state = { ...start, embed: start.embed === undefined ? undefined : { ...start.embed } };
220
262
  for (let at = 0; at < end; at++) {
221
263
  if (state.mode === 'line-comment') {
222
264
  // A PHP line comment ends at its line break and at a closing tag.
@@ -224,18 +266,12 @@ function scan(text: string, end: number, start: TypingContext, rules: Rules, off
224
266
  if (tag < 0 || tag >= end) {
225
267
  break;
226
268
  }
269
+ state.phpClose = offset + tag + 2;
227
270
  state.mode = 'html';
228
271
  state.commentStart = undefined;
229
272
  at = tag + 1;
230
273
  } else if (state.mode === 'html') {
231
- const open = PHP_OPEN.exec(text.slice(at));
232
- if (open === null || at + open.index >= end) {
233
- break;
234
- }
235
- state.mode = 'code';
236
- state.expression = true;
237
- state.word = '';
238
- at += open.index + open[0].length - 1;
274
+ at = scanPhpMarkup(state, text, at, end, offset, scriptLexer);
239
275
  } else if (state.mode === 'heredoc') {
240
276
  const closing = new RegExp(`^[\\t ]*${state.quote}(?![\\w])`).exec(text);
241
277
  if (closing === null) {
@@ -265,7 +301,13 @@ function sameState(left: TypingContext, right: TypingContext): boolean {
265
301
  left.bracket === right.bracket &&
266
302
  left.expression === right.expression &&
267
303
  left.word === right.word &&
268
- left.commentStart === right.commentStart
304
+ left.commentStart === right.commentStart &&
305
+ left.markup === right.markup &&
306
+ left.markupQuote === right.markupQuote &&
307
+ left.element === right.element &&
308
+ left.phpClose === right.phpClose &&
309
+ left.embedStart === right.embedStart &&
310
+ (left.embed === right.embed || (left.embed !== undefined && right.embed !== undefined && sameState(left.embed, right.embed)))
269
311
  );
270
312
  }
271
313
 
@@ -274,6 +316,7 @@ export class TypingContexts {
274
316
  private readonly line: (index: number) => DocumentLine;
275
317
  private states: TypingContext[] = [initial()];
276
318
  private language = '';
319
+ private decidingLine = 0;
277
320
 
278
321
  constructor(line: (index: number) => DocumentLine) {
279
322
  this.line = line;
@@ -281,16 +324,34 @@ export class TypingContexts {
281
324
 
282
325
  invalidate(line = 0): void {
283
326
  this.states.length = Math.min(this.states.length, Math.max(1, line + 1));
284
- if (line === 0) {
327
+ if (line <= this.decidingLine) {
285
328
  this.language = '';
286
329
  }
287
330
  }
288
331
 
289
- /* A PHP file that opens with markup, and not with a tag, starts outside the PHP. */
290
332
  private initialState(language: string): TypingContext {
291
333
  const state = initial();
292
- if (language === 'php' && /^\s*<(?![?<])/.test(this.line(0).text)) {
293
- state.mode = 'html';
334
+ this.decidingLine = 0;
335
+ if (language !== 'php') {
336
+ return state;
337
+ }
338
+ let remaining = PHP_INITIAL_CHARACTERS;
339
+ for (let line = 0; line < PHP_INITIAL_LINES && remaining > 0; line++) {
340
+ this.decidingLine = line;
341
+ const source = this.line(line);
342
+ const sample = source.text.slice(0, remaining);
343
+ remaining -= sample.length;
344
+ const text = sample.trimStart();
345
+ if (text !== '') {
346
+ const open = PHP_OPEN.exec(text);
347
+ if (/^<(?![?<])/.test(text) || (open !== null && open.index > 0 && !PHP_FRAGMENT_START.test(text))) {
348
+ state.mode = 'html';
349
+ }
350
+ return state;
351
+ }
352
+ if (source.end === source.next) {
353
+ break;
354
+ }
294
355
  }
295
356
  return state;
296
357
  }
@@ -306,17 +367,19 @@ export class TypingContexts {
306
367
  const previous = this.states.at(-1)!;
307
368
  const source = this.line(this.states.length - 1);
308
369
  const next = scan(source.text, source.text.length, previous, rules, source.start);
309
- // Only PHP and Rust strings run past a line break without a trailing backslash.
310
- if (next.mode === 'line-comment' || next.mode === 'regex' || (next.mode === 'quote' && !/^(php|rust|rs)$/i.test(id) && !next.escaped)) {
311
- next.mode = 'code';
312
- next.quote = undefined;
313
- next.commentStart = undefined;
314
- next.expression = true;
370
+ // A PHP island's source newlines are not newlines in the emitted script.
371
+ if (next.mode === 'html' && next.embed !== undefined && next.phpClose !== source.end) {
372
+ normalizeLine(next.embed, false);
373
+ }
374
+ next.phpClose = undefined;
375
+ normalizeLine(next, /^(php|rust|rs)$/i.test(id));
376
+ if (next.mode === 'html' && next.markup === 'value' && next.markupQuote === undefined) {
377
+ next.markup = 'tag';
315
378
  }
316
- next.escaped = false;
317
379
  this.states.push(sameState(next, previous) ? previous : next);
318
380
  }
319
381
  const source = this.line(line);
320
- return scan(source.text, column, this.states[line], rules, source.start);
382
+ const state = scan(source.text, column, this.states[line], rules, source.start);
383
+ return state.mode === 'html' && state.embed !== undefined ? { ...state.embed, markup: 'raw', element: 'script', embedded: 'javascript' } : state;
321
384
  }
322
385
  }