@@ -38,6 +38,11 @@ import {
3838 RESERVED_KEY_IS_ARRAY ,
3939 TYPE_SUBTYPE_SEPARATOR ,
4040} from "../shared/structural.js" ;
41+ import {
42+ getPositionMap ,
43+ setPositionMap ,
44+ type PositionMap ,
45+ } from "./yaml-positions.js" ;
4146import {
4247 ATTR_SUBTYPE_STRING ,
4348 ATTR_SUBTYPE_CLASS ,
@@ -99,6 +104,11 @@ function desugarNode(
99104 const rawKey = entries [ 0 ] ! ;
100105 const rawBody = ( input as Record < string , unknown > ) [ rawKey ] ;
101106
107+ // FR5b — capture the wrapper-level position-by-key map BEFORE re-keying.
108+ // The author's raw key (with `[]` suffix and possibly omitted subType) is
109+ // the lookup key; the desugar's canonical key is what we emit.
110+ const wrapperPositions = getPositionMap ( input ) ;
111+
102112 // Rule 4: a trailing "[]" on the key → isArray.
103113 let key = rawKey ;
104114 let isArray = false ;
@@ -111,7 +121,10 @@ function desugarNode(
111121 const canonicalKey = resolveKey ( key , registry , errors , path ) ;
112122
113123 // Rule 2: a scalar body → { name: <scalar> }.
114- const body = desugarBody ( rawBody , registry , canonicalKey , errors , path ) ;
124+ // FR5b — propagate the wrapper-key's position into the synthesized body
125+ // when the input body was a scalar (no body-side positions to inherit).
126+ const wrapperKeyPos = wrapperPositions ?. [ rawKey ] ;
127+ const body = desugarBody ( rawBody , registry , canonicalKey , errors , path , wrapperKeyPos ) ;
115128
116129 // Rule 4 (cont.): stamp isArray onto the canonical body.
117130 if ( isArray ) body [ RESERVED_KEY_IS_ARRAY ] = true ;
@@ -131,7 +144,16 @@ function desugarNode(
131144 }
132145 // A non-array `children` value is left untouched — buildTree reports it.
133146
134- return { [ canonicalKey ] : body } ;
147+ // FR5b — emit a wrapper-level position-by-key map for the canonical wrapper
148+ // so buildTree's per-child iteration can read the position via the same
149+ // lookup it uses for JSON input. The single key transformation is
150+ // rawKey → canonicalKey (Rule 1 fuses the subType, Rule 4 strips `[]`).
151+ const outWrapper : Record < string , unknown > = { [ canonicalKey ] : body } ;
152+ if ( wrapperKeyPos !== undefined ) {
153+ setPositionMap ( outWrapper , { [ canonicalKey ] : wrapperKeyPos } ) ;
154+ }
155+
156+ return outWrapper ;
135157}
136158
137159// Rule 1 — resolve a possibly-bare key to a fused `type.subType` token.
@@ -169,16 +191,30 @@ function desugarBody(
169191 canonicalKey : string ,
170192 errors : CollectedError [ ] ,
171193 path : string ,
194+ /** FR5b — position of the WRAPPER key (the `field.string:` line). Used to
195+ * back-fill `yamlPosition` on synthesized bodies (Rule 2's scalar lift) +
196+ * empty bodies; for mapping bodies we use the body's own position-by-key
197+ * map. */
198+ wrapperKeyPos : { line : number ; col : number } | undefined ,
172199) : Record < string , unknown > {
173200 if (
174201 typeof rawBody === "string" ||
175202 typeof rawBody === "number" ||
176203 typeof rawBody === "boolean"
177204 ) {
178- return { [ RESERVED_KEY_NAME ] : rawBody } ;
205+ // FR5b — the synthesized `{ name: rawBody }` has no YAML-side
206+ // counterpart; we attribute the `name` slot to the wrapper-key's
207+ // position (the only YAML position that meaningfully belongs to this
208+ // synthesis).
209+ const out : Record < string , unknown > = { [ RESERVED_KEY_NAME ] : rawBody } ;
210+ if ( wrapperKeyPos !== undefined ) {
211+ setPositionMap ( out , { [ RESERVED_KEY_NAME ] : wrapperKeyPos } ) ;
212+ }
213+ return out ;
179214 }
180215 if ( rawBody === null || rawBody === undefined ) {
181216 // An empty body (`field.string:` with nothing after) → an empty node.
217+ // No body keys to position; the wrapper carries the node's position.
182218 return { } ;
183219 }
184220 if ( Array . isArray ( rawBody ) ) {
@@ -192,20 +228,38 @@ function desugarBody(
192228 // Rule D2 (type-coercion guard).
193229 const src = rawBody as Record < string , unknown > ;
194230 const out : Record < string , unknown > = { } ;
231+ // FR5b — translate the body's position-by-key map across the sigil-free
232+ // rewrite. A bare `filterable` key in the source maps to `@filterable` in
233+ // the canonical body; the YAML position belongs to BOTH names (the YAML
234+ // author only wrote one). We re-key the position map to match the canonical
235+ // body's keys so buildTree's per-attr inspection (FR5b follow-ups, e.g.
236+ // ERR_BAD_ATTR_VALUE) can find the position via the canonical key.
237+ const srcPositions = getPositionMap ( src ) ;
238+ const outPositions : PositionMap = { } ;
239+ let hasOutPositions = false ;
195240 const schemaIndex = attrSchemaIndex ( registry , canonicalKey ) ;
196241 for ( const key of Object . keys ( src ) ) {
242+ let outKey : string ;
197243 if ( RESERVED_KEYS . has ( key ) || key . startsWith ( ATTR_PREFIX ) ) {
198244 out [ key ] = src [ key ] ;
245+ outKey = key ;
199246 // D2 also applies to author-written @-keys (the awkward form).
200247 const attrName = key . startsWith ( ATTR_PREFIX ) ? key . slice ( ATTR_PREFIX . length ) : "" ;
201248 if ( attrName !== "" && ! RESERVED_KEYS . has ( attrName ) ) {
202249 checkCoercion ( attrName , src [ key ] , schemaIndex , errors , path ) ;
203250 }
204251 } else {
205- out [ `${ ATTR_PREFIX } ${ key } ` ] = src [ key ] ;
252+ outKey = `${ ATTR_PREFIX } ${ key } ` ;
253+ out [ outKey ] = src [ key ] ;
206254 checkCoercion ( key , src [ key ] , schemaIndex , errors , path ) ;
207255 }
256+ const pos = srcPositions ?. [ key ] ;
257+ if ( pos !== undefined ) {
258+ outPositions [ outKey ] = pos ;
259+ hasOutPositions = true ;
260+ }
208261 }
262+ if ( hasOutPositions ) setPositionMap ( out , outPositions ) ;
209263 return out ;
210264}
211265
0 commit comments