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

ota-meshi / astro-eslint-parser / 23473969535

24 Mar 2026 05:02AM UTC coverage: 81.636% (+0.1%) from 81.518%
23473969535

Pull #423

github

web-flow
Merge 3ffaedc03 into 0a7e8ab94
Pull Request #423: fix: make comparable offset remapping work in browser environments

558 of 735 branches covered (75.92%)

Branch coverage included in aggregate %.

17 of 18 new or added lines in 1 file covered. (94.44%)

1358 of 1612 relevant lines covered (84.24%)

24467.09 hits per line

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

81.16
/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
    let byteOffsets: number[] | undefined,
131
      codeUnitOffsets: number[] | undefined;
132

133
    for (let index = 0, byteOffset = 0; index < code.length; ) {
3✔
134
      const codePoint = code.codePointAt(index)!;
606✔
135
      const codeUnitLength = codePoint > 0xffff ? 2 : 1;
606!
136
      const nextIndex = index + codeUnitLength;
606✔
137
      const nextByteOffset = byteOffset + getUTF8ByteLength(codePoint);
606✔
138

139
      if (byteOffsets) {
606✔
140
        byteOffsets.push(nextByteOffset);
390✔
141
        codeUnitOffsets!.push(nextIndex);
390✔
142
      } else if (codePoint > 0x7f) {
216✔
143
        // Lazily allocate the remap tables only when byte/code-unit offsets
144
        // diverge, while still avoiding a dedicated ASCII-only pre-scan.
145
        byteOffsets = [0];
3✔
146
        codeUnitOffsets = [0];
3✔
147
        for (let asciiOffset = 1; asciiOffset <= index; asciiOffset++) {
3✔
148
          byteOffsets.push(asciiOffset);
213✔
149
          codeUnitOffsets.push(asciiOffset);
213✔
150
        }
151
        byteOffsets.push(nextByteOffset);
3✔
152
        codeUnitOffsets.push(nextIndex);
3✔
153
      }
154

155
      index = nextIndex;
606✔
156
      byteOffset = nextByteOffset;
606✔
157
    }
158

159
    // Fast path: ASCII text has identical byte/code-unit offsets.
160
    if (!byteOffsets || !codeUnitOffsets) {
3!
NEW
161
      return (offset: number) => offset;
×
162
    }
163

164
    return (offset: number) => {
3✔
165
      // Find the nearest code-unit boundary that corresponds to the compiler's
166
      // byte offset. We only use this for ordering comparisons, so remapping
167
      // the start offset to its matching string position is sufficient.
168
      const index =
12✔
169
        sortedLastIndex(byteOffsets, (target) => target - offset) - 1;
84✔
170
      return codeUnitOffsets[Math.max(index, 0)];
12✔
171
    };
172
  }
173

174
  /**
175
   * Get UTF-8 byte length for code point.
176
   */
177
  function getUTF8ByteLength(codePoint: number): number {
178
    if (codePoint <= 0x7f) {
606✔
179
      return 1;
570✔
180
    }
181
    if (codePoint <= 0x7ff) {
36!
182
      return 2;
×
183
    }
184
    if (codePoint <= 0xffff) {
36✔
185
      return 3;
36✔
186
    }
187
    return 4;
×
188
  }
189
}
190

191
/**
192
 * Adjust <body> element node
193
 */
194
function adjustHTMLBody(
195
  ast: RootNode,
196
  htmlElement: ElementNode,
197
  htmlEnd: number,
198
  hasTokenAfterHtmlEnd: boolean,
199
  bodyElement: ElementNode,
200
  ctx: Context,
201
  isOffsetAfter: (offset: number, threshold: number) => boolean,
202
) {
203
  const bodyEnd = ctx.code.indexOf("</body");
30✔
204
  if (bodyEnd == null) {
30!
205
    return;
×
206
  }
207
  const hasTokenAfter = Boolean(ctx.code.slice(bodyEnd + 7, htmlEnd).trim());
30✔
208
  if (!hasTokenAfter && !hasTokenAfterHtmlEnd) {
30✔
209
    return;
18✔
210
  }
211
  const children = [...bodyElement.children];
12✔
212
  for (const child of children) {
12✔
213
    const offset = child.position?.start.offset;
24✔
214
    if (offset != null && isOffsetAfter(offset, bodyEnd)) {
24!
215
      if (hasTokenAfterHtmlEnd && isOffsetAfter(offset, htmlEnd)) {
×
216
        bodyElement.children.splice(bodyElement.children.indexOf(child), 1);
×
217
        ast.children.push(child);
×
218
      } else if (hasTokenAfter) {
×
219
        bodyElement.children.splice(bodyElement.children.indexOf(child), 1);
×
220
        htmlElement.children.push(child);
×
221
      }
222
    }
223
  }
224
}
225

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

296
            node.value = ctx.code.slice(node.position!.start.offset, start);
×
297
          }
298
        }
299
        if (node.position!.end) {
1,845✔
300
          node.position!.end.offset = start;
1,845✔
301
        }
302
      } else if (node.type === "expression") {
193✔
303
        start = node.position!.start.offset = tokenIndex(ctx, "{", start);
174✔
304
        start += 1;
174✔
305
      } else if (node.type === "doctype") {
19!
306
        if (!node.position) {
19✔
307
          node.position = { start: {}, end: {} } as any;
19✔
308
        }
309
        if (!node.position!.end) {
19!
310
          node.position!.end = {} as any;
×
311
        }
312
        start = node.position!.start.offset = tokenIndex(ctx, "<!", start);
19✔
313
        start += 2;
19✔
314
        start = node.position!.end!.offset = ctx.code.indexOf(">", start) + 1;
19✔
315
      } else if (node.type === "root") {
×
316
        // noop
317
      }
318
    },
319
    (node, [parent]) => {
320
      if (node.type === "attribute") {
4,016✔
321
        const attributes = (parent as TagLikeNode).attributes;
659✔
322
        if (attributes[attributes.length - 1] === node) {
659✔
323
          start = calcStartTagEndOffset(parent as TagLikeNode, ctx);
436✔
324
        }
325
      } else if (node.type === "expression") {
3,357✔
326
        start = tokenIndex(ctx, "}", start) + 1;
174✔
327
      } else if (
3,183✔
328
        node.type === "fragment" ||
10,847✔
329
        node.type === "element" ||
330
        node.type === "component" ||
331
        node.type === "custom-element"
332
      ) {
333
        if (!getSelfClosingTag(node, ctx)) {
1,024✔
334
          const closeTagStart = tokenIndexSafe(
742✔
335
            ctx.code,
336
            `</${node.name}`,
337
            start,
338
          );
339
          if (closeTagStart != null) {
742✔
340
            start = closeTagStart + 2 + node.name.length;
739✔
341
            start = tokenIndex(ctx, ">", start) + 1;
739✔
342
          }
343
        }
344
      } else {
345
        return;
2,159✔
346
      }
347
      if (node.position!.end) {
1,857✔
348
        node.position!.end.offset = start;
1,147✔
349
      }
350
    },
351
  );
352
}
353

354
/**
355
 * Fix locations
356
 */
357
function fixLocationForAttr(node: AttributeNode, ctx: Context, start: number) {
358
  if (node.kind === "empty") {
659✔
359
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
54✔
360
  } else if (node.kind === "quoted") {
605✔
361
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
444✔
362
  } else if (node.kind === "expression") {
161✔
363
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
131✔
364
  } else if (node.kind === "shorthand") {
30✔
365
    node.position!.start.offset = tokenIndex(ctx, "{", start);
15✔
366
  } else if (node.kind === "spread") {
15✔
367
    node.position!.start.offset = tokenIndex(ctx, "{", start);
12✔
368
  } else if (node.kind === "template-literal") {
3!
369
    node.position!.start.offset = tokenIndex(ctx, node.name, start);
3✔
370
  } else {
371
    throw new ParseError(
×
372
      `Unknown attr kind: ${node.kind}`,
373
      node.position!.start.offset,
374
      ctx,
375
    );
376
  }
377
}
378

379
/**
380
 * Get token index
381
 */
382
function tokenIndex(ctx: Context, token: string, position: number): number {
383
  const index = tokenIndexSafe(ctx.code, token, position);
3,301✔
384
  if (index == null) {
3,301!
385
    const start =
386
      token.trim() === token ? skipSpaces(ctx.code, position) : position;
×
387
    throw new ParseError(
×
388
      `Unknown token at ${start}, expected: ${JSON.stringify(
389
        token,
390
      )}, actual: ${JSON.stringify(ctx.code.slice(start, start + 10))}`,
391
      start,
392
      ctx,
393
    );
394
  }
395
  return index;
3,301✔
396
}
397

398
/**
399
 * Get token index
400
 */
401
function tokenIndexSafe(
402
  string: string,
403
  token: string,
404
  position: number,
405
): number | null {
406
  const index =
407
    token.trim() === token ? skipSpaces(string, position) : position;
5,765✔
408
  if (string.startsWith(token, index)) {
5,765✔
409
    return index;
5,762✔
410
  }
411
  return null;
3✔
412
}
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