• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ota-meshi / astro-eslint-parser / 23473590695

24 Mar 2026 04:47AM UTC coverage: 81.518% (+0.07%) from 81.449%
23473590695

push

github

web-flow
fix: handle multibyte offsets when adjusting nodes after `</html>` (#421)

* fix: handle multibyte offsets when adjusting nodes after `</html>`

* Create green-fans-move.md

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

553 of 730 branches covered (75.75%)

Branch coverage included in aggregate %.

34 of 43 new or added lines in 1 file covered. (79.07%)

1348 of 1602 relevant lines covered (84.14%)

24618.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.14
/src/parser/astro-parser/parse.ts
1
import type {
306✔
2
  AttributeNode,
3
  ParentNode,
4
  TagLikeNode,
5
  ElementNode,
6
  RootNode,
7
  ParseResult,
8
} from "./types";
9
// @ts-expect-error -- Type bug?
10
import * as service from "astrojs-compiler-sync";
1✔
11
import {
12
  calcAttributeEndOffset,
13
  calcCommentEndOffset,
14
  getSelfClosingTag,
15
  calcStartTagEndOffset,
16
  skipSpaces,
17
  walk,
18
} from "../../astro";
1✔
19
import type { Context } from "../../context";
20
import { ParseError } from "../../errors";
1✔
21
import { sortedLastIndex } from "../../util";
1✔
22

23
/**
24
 * Parse code by `@astrojs/compiler`
25
 */
26
export function parse(code: string, ctx: Context): ParseResult {
27
  const result = service.parse(code, { position: true });
303✔
28

29
  for (const { code, text, location, severity } of result.diagnostics || []) {
303!
30
    if (severity === 1 /* Error */) {
9!
31
      ctx.originalAST = result.ast;
×
32
      throw new ParseError(`${text} [${code}]`, location, ctx);
×
33
    }
34
  }
35
  if (!result.ast.children) {
303✔
36
    // If the source code is empty, the children property may not be available.
37
    result.ast.children = [];
2✔
38
  }
39

40
  const htmlElement = result.ast.children.find(
303✔
41
    (n): n is ElementNode => n.type === "element" && n.name === "html",
1,050✔
42
  );
43
  if (!(result as any)._adjusted) {
303✔
44
    if (htmlElement) {
303✔
45
      adjustHTML(result.ast, htmlElement, ctx);
42✔
46
    }
47
    fixLocations(result.ast, ctx);
303✔
48
    (result as any)._adjusted = true;
303✔
49
  }
50
  return result;
303✔
51
}
52

53
/**
54
 * Adjust <html> element node
55
 */
56
function adjustHTML(ast: RootNode, htmlElement: ElementNode, ctx: Context) {
57
  const htmlEnd = ctx.code.indexOf("</html");
42✔
58
  if (htmlEnd < 0) {
42!
59
    return;
×
60
  }
61
  // `@astrojs/compiler` may report `position.offset` as UTF-8 byte offsets.
62
  // By contrast, `ctx.code.indexOf()` and every offset we compute from the
63
  // JavaScript source string are UTF-16 code-unit offsets.
64
  //
65
  // That mismatch only matters here because `adjustHTML()` compares compiler
66
  // child positions against `</body>` / `</html>` offsets derived from
67
  // `ctx.code`. If multibyte characters appear before those nodes, comparing
68
  // the raw values makes nodes inside `<body>` look as if they were already
69
  // after `</body>` or `</html>`, which then moves them to the wrong parent.
70
  //
71
  // Keep the fix local to this adjustment logic: we only need a comparable
72
  // offset when deciding whether the compiler attached a node under `<body>`,
73
  // `<html>`, or the root by mistake.
74
  const isOffsetAfter = buildComparableOffsetComparator(ctx.code);
42✔
75
  const hasTokenAfter = Boolean(ctx.code.slice(htmlEnd + 7).trim());
42✔
76
  const children = [...htmlElement.children];
42✔
77
  for (const child of children) {
42✔
78
    const offset = child.position?.start.offset;
174✔
79
    if (hasTokenAfter && offset != null) {
174✔
80
      if (isOffsetAfter(offset, htmlEnd)) {
57!
81
        htmlElement.children.splice(htmlElement.children.indexOf(child), 1);
×
82
        ast.children.push(child);
×
83
      }
84
    }
85
    if (child.type === "element" && child.name === "body") {
174✔
86
      adjustHTMLBody(
30✔
87
        ast,
88
        htmlElement,
89
        htmlEnd,
90
        hasTokenAfter,
91
        child,
92
        ctx,
93
        isOffsetAfter,
94
      );
95
    }
96
  }
97

98
  /**
99
   * Build a comparator used only for matching compiler offsets against
100
   * positions derived from `ctx.code`.
101
   *
102
   * The raw offset check is important for laziness: if the compiler offset is
103
   * already before the threshold, remapping cannot make it jump forward past
104
   * that threshold, so we can reject it without any byte/code-unit work.
105
   */
106
  function buildComparableOffsetComparator(code: string) {
107
    let remapOffset: ((offset: number) => number) | undefined;
108
    const comparableOffsetCache = new Map<number, number>();
42✔
109

110
    return (offset: number, threshold: number) => {
42✔
111
      if (threshold > offset) {
81✔
112
        return false;
69✔
113
      }
114
      let comparableOffset = comparableOffsetCache.get(offset);
12✔
115
      if (comparableOffset == null) {
12✔
116
        // Delay building the remapper until the first comparison that cannot
117
        // be rejected by raw offsets alone.
118
        remapOffset ||= buildComparableOffsetRemapper(code);
12✔
119
        comparableOffset = remapOffset(offset);
12✔
120
        comparableOffsetCache.set(offset, comparableOffset);
12✔
121
      }
122
      return threshold <= comparableOffset;
12✔
123
    };
124
  }
125

126
  /**
127
   * Build remapper used only for comparing compiler offsets with `ctx.code`.
128
   */
129
  function buildComparableOffsetRemapper(code: string) {
130
    // Fast path: ASCII text has identical byte/code-unit offsets.
131
    if (Buffer.byteLength(code, "utf8") === code.length) {
3!
NEW
132
      return (offset: number) => offset;
×
133
    }
134

135
    const byteOffsets = [0];
3✔
136
    const codeUnitOffsets = [0];
3✔
137

138
    for (let index = 0, byteOffset = 0; index < code.length; ) {
3✔
139
      const codePoint = code.codePointAt(index)!;
606✔
140
      index += codePoint > 0xffff ? 2 : 1;
606!
141
      byteOffset += getUTF8ByteLength(codePoint);
606✔
142
      byteOffsets.push(byteOffset);
606✔
143
      codeUnitOffsets.push(index);
606✔
144
    }
145

146
    return (offset: number) => {
3✔
147
      // Find the nearest code-unit boundary that corresponds to the compiler's
148
      // byte offset. We only use this for ordering comparisons, so remapping
149
      // the start offset to its matching string position is sufficient.
150
      const index =
12✔
151
        sortedLastIndex(byteOffsets, (target) => target - offset) - 1;
84✔
152
      return codeUnitOffsets[Math.max(index, 0)];
12✔
153
    };
154
  }
155

156
  /**
157
   * Get UTF-8 byte length for code point.
158
   */
159
  function getUTF8ByteLength(codePoint: number): number {
160
    if (codePoint <= 0x7f) {
606✔
161
      return 1;
570✔
162
    }
163
    if (codePoint <= 0x7ff) {
36!
NEW
164
      return 2;
×
165
    }
166
    if (codePoint <= 0xffff) {
36✔
167
      return 3;
36✔
168
    }
NEW
169
    return 4;
×
170
  }
171
}
172

173
/**
174
 * Adjust <body> element node
175
 */
176
function adjustHTMLBody(
177
  ast: RootNode,
178
  htmlElement: ElementNode,
179
  htmlEnd: number,
180
  hasTokenAfterHtmlEnd: boolean,
181
  bodyElement: ElementNode,
182
  ctx: Context,
183
  isOffsetAfter: (offset: number, threshold: number) => boolean,
184
) {
185
  const bodyEnd = ctx.code.indexOf("</body");
30✔
186
  if (bodyEnd == null) {
30!
187
    return;
×
188
  }
189
  const hasTokenAfter = Boolean(ctx.code.slice(bodyEnd + 7, htmlEnd).trim());
30✔
190
  if (!hasTokenAfter && !hasTokenAfterHtmlEnd) {
30✔
191
    return;
18✔
192
  }
193
  const children = [...bodyElement.children];
12✔
194
  for (const child of children) {
12✔
195
    const offset = child.position?.start.offset;
24✔
196
    if (offset != null && isOffsetAfter(offset, bodyEnd)) {
24!
NEW
197
      if (hasTokenAfterHtmlEnd && isOffsetAfter(offset, htmlEnd)) {
×
NEW
198
        bodyElement.children.splice(bodyElement.children.indexOf(child), 1);
×
NEW
199
        ast.children.push(child);
×
NEW
200
      } else if (hasTokenAfter) {
×
NEW
201
        bodyElement.children.splice(bodyElement.children.indexOf(child), 1);
×
NEW
202
        htmlElement.children.push(child);
×
203
      }
204
    }
205
  }
206
}
207

208
/**
209
 * Fix locations
210
 */
211
function fixLocations(node: ParentNode, ctx: Context): void {
212
  // FIXME: Adjust because the parser does not return the correct location.
213
  let start = 0;
303✔
214
  walk(
303✔
215
    node,
216
    ctx.code,
217
    // eslint-disable-next-line complexity -- X(
218
    (node, [parent]) => {
219
      if (node.type === "frontmatter") {
4,016✔
220
        start = node.position!.start.offset = tokenIndex(ctx, "---", start);
217✔
221
        if (!node.position!.end) {
217!
222
          node.position!.end = {} as any;
×
223
        }
224
        start = node.position!.end!.offset =
217✔
225
          tokenIndex(ctx, "---", start + 3 + node.value.length) + 3;
226
      } else if (
3,799✔
227
        node.type === "fragment" ||
13,311✔
228
        node.type === "element" ||
229
        node.type === "component" ||
230
        node.type === "custom-element"
231
      ) {
232
        if (!node.position) {
1,024✔
233
          node.position = { start: {}, end: {} } as any;
24✔
234
        }
235
        start = node.position!.start.offset = tokenIndex(ctx, "<", start);
1,024✔
236
        start += 1;
1,024✔
237
        start += node.name.length;
1,024✔
238
        if (!node.attributes.length) {
1,024✔
239
          start = calcStartTagEndOffset(node, ctx);
588✔
240
        }
241
      } else if (node.type === "attribute") {
2,775✔
242
        fixLocationForAttr(node, ctx, start);
659✔
243
        start = calcAttributeEndOffset(node, ctx);
659✔
244
        if (node.position!.end) {
659!
245
          node.position!.end.offset = start;
×
246
        }
247
      } else if (node.type === "comment") {
2,116✔
248
        node.position!.start.offset = tokenIndex(ctx, "<!--", start);
78✔
249
        start = calcCommentEndOffset(node, ctx);
78✔
250
        if (node.position!.end) {
78✔
251
          node.position!.end.offset = start;
78✔
252
        }
253
      } else if (node.type === "text") {
2,038✔
254
        if (
1,845✔
255
          parent.type === "element" &&
3,802✔
256
          (parent.name === "script" || parent.name === "style")
257
        ) {
258
          node.position!.start.offset = start;
123✔
259
          start = ctx.code.indexOf(`</${parent.name}`, start);
123✔
260
          if (start < 0) {
123!
261
            start = ctx.code.length;
×
262
          }
263
        } else {
264
          const index = tokenIndexSafe(ctx.code, node.value, start);
1,722✔
265
          if (index != null) {
1,722!
266
            start = node.position!.start.offset = index;
1,722✔
267
            start += node.value.length;
1,722✔
268
          } else {
269
            // FIXME: Some white space may be removed.
270
            node.position!.start.offset = start;
×
271
            const value = node.value.replace(/\s+/gu, "");
×
272
            for (const char of value) {
×
273
              const index = tokenIndex(ctx, char, start);
×
274
              start = index + 1;
×
275
            }
276
            start = skipSpaces(ctx.code, start);
×
277

278
            node.value = ctx.code.slice(node.position!.start.offset, start);
×
279
          }
280
        }
281
        if (node.position!.end) {
1,845✔
282
          node.position!.end.offset = start;
1,845✔
283
        }
284
      } else if (node.type === "expression") {
193✔
285
        start = node.position!.start.offset = tokenIndex(ctx, "{", start);
174✔
286
        start += 1;
174✔
287
      } else if (node.type === "doctype") {
19!
288
        if (!node.position) {
19✔
289
          node.position = { start: {}, end: {} } as any;
19✔
290
        }
291
        if (!node.position!.end) {
19!
292
          node.position!.end = {} as any;
×
293
        }
294
        start = node.position!.start.offset = tokenIndex(ctx, "<!", start);
19✔
295
        start += 2;
19✔
296
        start = node.position!.end!.offset = ctx.code.indexOf(">", start) + 1;
19✔
297
      } else if (node.type === "root") {
×
298
        // noop
299
      }
300
    },
301
    (node, [parent]) => {
302
      if (node.type === "attribute") {
4,016✔
303
        const attributes = (parent as TagLikeNode).attributes;
659✔
304
        if (attributes[attributes.length - 1] === node) {
659✔
305
          start = calcStartTagEndOffset(parent as TagLikeNode, ctx);
436✔
306
        }
307
      } else if (node.type === "expression") {
3,357✔
308
        start = tokenIndex(ctx, "}", start) + 1;
174✔
309
      } else if (
3,183✔
310
        node.type === "fragment" ||
10,847✔
311
        node.type === "element" ||
312
        node.type === "component" ||
313
        node.type === "custom-element"
314
      ) {
315
        if (!getSelfClosingTag(node, ctx)) {
1,024✔
316
          const closeTagStart = tokenIndexSafe(
742✔
317
            ctx.code,
318
            `</${node.name}`,
319
            start,
320
          );
321
          if (closeTagStart != null) {
742✔
322
            start = closeTagStart + 2 + node.name.length;
739✔
323
            start = tokenIndex(ctx, ">", start) + 1;
739✔
324
          }
325
        }
326
      } else {
327
        return;
2,159✔
328
      }
329
      if (node.position!.end) {
1,857✔
330
        node.position!.end.offset = start;
1,147✔
331
      }
332
    },
333
  );
334
}
335

336
/**
337
 * Fix locations
338
 */
339
function fixLocationForAttr(node: AttributeNode, ctx: Context, start: number) {
340
  if (node.kind === "empty") {
659✔
341
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
54✔
342
  } else if (node.kind === "quoted") {
605✔
343
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
444✔
344
  } else if (node.kind === "expression") {
161✔
345
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
131✔
346
  } else if (node.kind === "shorthand") {
30✔
347
    node.position!.start.offset = tokenIndex(ctx, "{", start);
15✔
348
  } else if (node.kind === "spread") {
15✔
349
    node.position!.start.offset = tokenIndex(ctx, "{", start);
12✔
350
  } else if (node.kind === "template-literal") {
3!
351
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
3✔
352
  } else {
353
    throw new ParseError(
×
354
      `Unknown attr kind: ${node.kind}`,
355
      node.position!.start.offset,
356
      ctx,
357
    );
358
  }
359
}
360

361
/**
362
 * Get token index
363
 */
364
function tokenIndex(ctx: Context, token: string, position: number): number {
365
  const index = tokenIndexSafe(ctx.code, token, position);
3,301✔
366
  if (index == null) {
3,301!
367
    const start =
368
      token.trim() === token ? skipSpaces(ctx.code, position) : position;
×
369
    throw new ParseError(
×
370
      `Unknown token at ${start}, expected: ${JSON.stringify(
371
        token,
372
      )}, actual: ${JSON.stringify(ctx.code.slice(start, start + 10))}`,
373
      start,
374
      ctx,
375
    );
376
  }
377
  return index;
3,301✔
378
}
379

380
/**
381
 * Get token index
382
 */
383
function tokenIndexSafe(
384
  string: string,
385
  token: string,
386
  position: number,
387
): number | null {
388
  const index =
389
    token.trim() === token ? skipSpaces(string, position) : position;
5,765✔
390
  if (string.startsWith(token, index)) {
5,765✔
391
    return index;
5,762✔
392
  }
393
  return null;
3✔
394
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc