Xml: `positions` decode option that puts `line` and `column` on `StartElement` events

クローズ
#10,400 コメント 0 件 リアクション 0 件 担当者 1 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@effect-bot がすでに取り組んでいます。

2026年11月5日 から。

  • #10406 @effect-bot による — マージ済み

評価

この issue はまだ評価されていません。

説明

What is the problem this feature would solve?

Xml.decodeString and Xml.decode emit events without a line or column, so a consumer that rejects a decoded value cannot report where in the input that value was. Only an XmlError carries a position, and a document the decoder accepts never raises one.

Ran against effect 4.1.2 (506eef1) on Bun 1.4.2.

Decoding a synthetic document and reading the events:

import { Effect, Stream } from "effect"
import { Xml } from "effect/encoding"

const xml = `<order id="7">
  <item sku="a1"><price>12.50</price></item>
  <item sku="b2"><price>twelve</price></item>
</order>`

const events = await Effect.runPromise(
  Stream.make(xml).pipe(Stream.pipeThroughChannel(Xml.decodeString()), Stream.runCollect)
)
const starts = events.filter((e) => e._tag === "StartElement")
console.log("StartElement keys:", Object.keys(starts[0]))
const priceText = events[events.findLastIndex((e) => e._tag === "StartElement" && e.name === "price") + 1]
console.log("price text:", JSON.stringify(priceText), "-> Number:", Number((priceText as Xml.Text).text))
StartElement keys: [ "_tag", "name", "attributes" ]
price text: {"_tag":"Text","text":"twelve"} -> Number: NaN

Control. A syntax error in the same shape of document is positioned: decoding <order>\n <item sku="a1">\n</order> fails with XmlError Syntax, line 3, column 1, the < of </order>, message end tag </order> does not match <item> at line 3, column 1.

This is the documented contract. line and column belong to XmlError, and decode and decodeString set them on errors:
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L43-L45

StartElement has _tag, name and attributes:
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L133-L137

The parser already tracks the line and column of the document offset it has counted to, and uses them to position errors:
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L1037-L1045

Use case (synthetic). A consumer reads each <item> into a typed record: sku from the start tag's attributes, price from the Text event under <price> through Schema.NumberFromString. When "twelve" is rejected, the consumer can name the element path (order/item[2]/price) but not line 3, column 18, where the <price> start tag is, because nothing in the events says where it was. The path names the value in the decoded model; a line and column name it in the file its author edits, which is what a validation report or an editor jump points at, and the parser is the only component that holds an event's document offset at the moment it emits the event. For a document streamed through Xml.decode from a file or a response body, the text is no longer available to search, so the position is lost for good.

What is the feature you are proposing to solve the problem?

Request: a positions option on DecodeOptions. When it is true, decode and decodeString set line and column on every StartElement event, at the < of the start tag, the offset at which the parser already positions the tag's own Limit errors. The start tag is the anchor because every element has one, and because a text node is one token that can hold references and CDATA sections, so a single position on Text would not name its characters. Both fields are one-based and follow the rules XmlError already documents: columns count UTF-16 code units, lines end at LF, CR or CRLF, and a leading byte order mark is not counted.

Markup is parsed from at most maxTokenSize + 1 characters and a start tag's Limit checks run before it is pushed, so maxTokenSize and maxDepth are enforced before a positioned event exists, as today:
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L1156-L1159
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L1365-L1371

The fields are declared optional on StartElement and are absent when the option is not set. StartElement events are object literals with exactly _tag, name and attributes; without the option the same literal is pushed, so the keys are absent rather than set to undefined, and existing events are unchanged. The encoders and Xml.elements read name and attributes by field and the module never spreads an event, so they are unchanged, and a consumer that constructs StartElement values for encode needs no new fields:
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L1741
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L1937

Always-present fields would change the shape of every StartElement the default path emits, so the fields are opt-in. The option changes which fields StartElement carries and nothing else: the same events are pushed at the same points, and the position is read from the line, lineStart and counted fields the parser already keeps for XmlError, so no input is retained beyond what parse holds today:
https://github.com/Effect-TS/effect/blob/506eef143bcc1750ce58920aac1e45da7265d103/packages/effect/src/encoding/Xml.ts#L967-L970

On the documented side the change is one bullet in the DecodeOptions doc block and two optional fields on StartElement.

export interface DecodeOptions {
  readonly maxTokenSize?: number | undefined
  readonly maxDepth?: number | undefined
  readonly positions?: boolean | undefined
}

export interface StartElement {
  readonly _tag: "StartElement"
  readonly name: string
  readonly attributes: Readonly<Record<string, string>>
  readonly line?: number | undefined
  readonly column?: number | undefined
}

Not requested. Positions on Text, EndElement, Comment, ProcessingInstruction, Declaration or Doctype; end positions or byte offsets; positions on Element from Xml.elements; any change to the encoders, to XmlError or to the default output of decode and decodeString.

Feasibility. Tested in a scratch copy at 506eef1: the acceptance check passes, the existing Xml.test.ts cases stay green (21 of 21), the default path pushes the same literal as before behind one added boolean test per start tag and measured within noise of the unpatched copy (0.81x to 1.29x over three runs), and with positions: true a synthetic 400 KB document decoded in 4096-character chunks took 1.5x the default time (median of 10 alternating rounds), the scratch copy reading each position with the error path's count, which advances counted forward only and is advanced to where parsing stopped at the end of every chunk, so each character is counted once per decode and no chunk is rescanned.

Not confirmed.

  • Whether leaving events without positions was a deliberate choice in #9420. Its description and review comments do not say.
  • Whether the fields should be typed as present under positions: true, through an overload of decode and decodeString or a distinct positioned event type, rather than as optional fields on StartElement. Optional fields are the smaller change; a consumer that set the option narrows with e.line !== undefined.

Acceptance check. A test in packages/effect/test/encoding/Xml.test.ts that fails at 506eef1 with Property 'line' does not exist on type 'StartElement' and passes after the change:

it.effect("start tags carry line and column when positions is true, however the input is chunked", () =>
  Effect.gen(function*() {
    // synthetic
    const xml = "<?xml version=\"1.0\"?>\r\n<!-- c -->\n<order id=\"7\">\r  <item sku=\"a1\"><price>12.50</price></item>\n  <item sku=\"b2\"><price>twelve</price></item>\n</order>"
    const expected = [["order", 3, 1], ["item", 4, 3], ["price", 4, 18], ["item", 5, 3], ["price", 5, 18]]
    for (const chunks of splits(xml)) {
      const events = yield* decodeString(chunks, { positions: true })
      const positions = events.flatMap((e) => e._tag === "StartElement" ? [[e.name, e.line, e.column]] : [])
      assert.deepStrictEqual(positions, expected, JSON.stringify(chunks))
    }
    const plain = yield* decodeString([xml])
    for (const event of plain) {
      assert.isFalse("line" in event)
      assert.isFalse("column" in event)
    }
    const positioned = yield* decodeString([xml], { positions: true })
    assert.deepStrictEqual(
      positioned.map((e) => e._tag === "StartElement" ? { _tag: e._tag, name: e.name, attributes: e.attributes } : e),
      plain
    )
  }))

decodeString and splits are the helpers that file already defines; splits yields the input one character per chunk and at every two-way split, so a tag split in its name and a CR split from its LF are covered.

What alternatives have you considered?
  • XmlError.line and XmlError.column: set only when decoding fails. A document the decoder accepts but that holds a value the consumer rejects decodes without an XmlError, so there is no position to read.
  • Xml.elements: Element carries name, attributes and children and no position, and it drops the chunk structure, so the position cannot be recovered from the outside either.
  • Counting lines outside the decoder, using the contract that each output chunk holds the events completed by one input chunk: re-chunk the input at every <, pull the events for each piece, and attribute them to the line count at that piece. Measured against plain decodeString on Bun 1.4.2: 2.48x and 2.89x the time on a 4,888-character document fed as one chunk and in 4096-character chunks, and 3.56x on a synthetic 437 KB document. Feeding the same pieces to plain decodeString with no counting at all already costs 3.00x, so about three quarters of the cost is the per-piece pull round trip through the channel, which cannot be avoided from outside the channel: one piece per < is the only way to attribute an event to its input.
  • Locating the position after the fact: keep the decoded text, walk the element path back to the start tag with a second scanner, and read its line and column. Serves decodeString over a string already in memory and costs nothing until a value is rejected. It needs every chunk retained until its record is accepted, so it does not serve Xml.decode over a byte stream, and the second scanner has to agree with the parser on every edge of the grammar (comments, CDATA sections, references, attribute quoting) to land on the same <.

Related. #9420, which added the module with line and column on XmlError.


AI disclosure: Written by AI and reviewed by the filer before filing.

  • Provider: Anthropic
  • Model: Fable 5.1
主要言語
TypeScript
スター
16.7k
フォーク
808
平均マージ
11時間 40分
マージ済み PR(30日)
827

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

Effect-TS/effect のほかの issue

Effect-TS/effect の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。