Xml: `positions` decode option that puts `line` and `column` on `StartElement` events
メンテナーはふだん 1 日以内に返信
評価
この 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 ofdecodeanddecodeStringor a distinct positioned event type, rather than as optional fields onStartElement. Optional fields are the smaller change; a consumer that set the option narrows withe.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.lineandXmlError.column: set only when decoding fails. A document the decoder accepts but that holds a value the consumer rejects decodes without anXmlError, so there is no position to read.Xml.elements:Elementcarriesname,attributesandchildrenand 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 plaindecodeStringon 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 plaindecodeStringwith 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
decodeStringover 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 serveXml.decodeover 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 を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
Effect-TS/effect のほかの issue
-
sql-pg: pgvector columns fail with "Invalid UTF-8 in text value"対応中かも @effect-bot が今日担当しました。 オープンbug
Effect-TS/effect#10420 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
cli: completions break for subcommand names with `:`対応中かも @effect-bot が今日担当しました。 オープン
Effect-TS/effect#10416 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
McpServer.layerStdio: native EOF cancellation policy for active tool handlers対応中かも @effect-bot が今日担当しました。 オープン
Effect-TS/effect#10412 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
add OAuth authorization support to HTTP McpServer対応中かも @effect-bot が今日担当しました。 オープンenhancement
Effect-TS/effect#10402 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
effect/ai: support the MCP Apps extension (`io.modelcontextprotocol/ui`)対応中かも @effect-bot が 1 日前に担当しました。 オープン
Effect-TS/effect#10399 · コメント 2 件 · リアクション 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
Effect-TS/effect の issue をすべて見る
似ている issue
-
Navbar avatar and name stay stale after saving profile changes対応中かも @alexechoi が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
marmelab/atomic-crm#416 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
epam/ai-dial-react-file-manager#99 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
canonical/craft-actions#146 ·
メンテナーはふだん 2 日以内に返信
-
feature-request
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
bluesky-social/social-app#12343 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
lucide-icons/lucide#5165 ·
メンテナーはふだん 1 日以内に返信