parseman 0.61.3 → 0.61.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3628,7 +3628,7 @@ function regex(pattern, flags = "") {
3628
3628
  }
3629
3629
 
3630
3630
  // src/version.ts
3631
- var PARSEMAN_VERSION = "0.61.3";
3631
+ var PARSEMAN_VERSION = "0.61.4";
3632
3632
 
3633
3633
  // src/table/lex-body.ts
3634
3634
  function buildLexBodyRecognizer(spec, constants) {
package/dist/version.d.ts CHANGED
@@ -13,5 +13,5 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export declare const PARSEMAN_VERSION = "0.61.3";
16
+ export declare const PARSEMAN_VERSION = "0.61.4";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.61.3",
3
+ "version": "0.61.4",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -21,6 +21,7 @@
21
21
  import * as fs from 'node:fs'
22
22
  import * as path from 'node:path'
23
23
  import { createUnplugin } from 'unplugin'
24
+ import type { UnpluginBuildContext } from 'unplugin'
24
25
  import { parseSync } from 'oxc-parser'
25
26
  import { ResolverFactory } from 'oxc-resolver'
26
27
  import MagicString from 'magic-string'
@@ -183,13 +184,29 @@ export type ParsecraftPluginOptions = {
183
184
 
184
185
  /** A corpus a build parses to rank selective-assembly sites — see `assemblyProfile`. */
185
186
  export type AssemblyProfile = {
186
- /** The rule each corpus file is parsed from, e.g. `'Stylesheet'`. A grammar
187
- * without this rule keeps the static ranking. */
187
+ /** The rule each corpus file is parsed from, e.g. `'Stylesheet'`. A grammar without
188
+ * this rule keeps the static ranking. A build fails when grammars were profiled and
189
+ * none has the rule. A direct `transformMacro` caller gets `assemblyProfile:
190
+ * 'no-entry'` on the result instead, and has to read it. */
188
191
  readonly entry: string
189
192
  /** Paths of representative inputs. At least one must have content, and each that
190
193
  * does has to parse in full without recovery, or the build fails. A file with only
191
- * whitespace counts as empty. */
194
+ * whitespace counts as empty. A callback the corpus doesn't reach is never run, so
195
+ * nothing checks it. */
192
196
  readonly files: readonly string[]
197
+ /**
198
+ * The values of names a grammar's reducers and gates import, for the corpus run.
199
+ * Their source runs at build time with no module around it, so a grammar whose
200
+ * callbacks read imported helpers passes those helpers here. See
201
+ * `docs/guide/macro-mode.md` for what this does and doesn't cover.
202
+ *
203
+ * It's one flat namespace, so two modules can't bind one name to different helpers.
204
+ * A name that's already a global fails the build. A helper named like a global and
205
+ * left out of `scope` silently uses the global. A direct `node()` builder doesn't
206
+ * run in the corpus run: a plain node stands in for it, so a helper only a builder
207
+ * reads is neither needed here nor checked.
208
+ */
209
+ readonly scope?: Readonly<Record<string, unknown>>
193
210
  }
194
211
 
195
212
  /** Corpus file contents, re-read when a file's mtime moves, so a watch build that
@@ -558,6 +575,9 @@ export default createUnplugin((opts: ParsecraftPluginOptions = {}, meta) => {
558
575
  // Module id → its carried lists, under a bundler that writes them beside the chunk.
559
576
  // Any other bundler gets them inline, as before.
560
577
  const sinks = SIDECAR_FRAMEWORKS.has(meta.framework) ? new Map<string, CarriedSink>() : undefined
578
+ // Module id → whether its grammars ran the corpus. A module without the entry rule is
579
+ // fine on its own, because one plugin sees many modules, so only the build can tell.
580
+ const profiled = new Map<string, NonNullable<TransformMacroResult['assemblyProfile']>>()
561
581
  // `order: 'post'`: the sidecar records a digest of each chunk's code, so it is
562
582
  // taken after every other plugin's `generateBundle` has rewritten that code (Vite
563
583
  // rewrites a dynamic `import()` through its preload helper there).
@@ -579,10 +599,19 @@ export default createUnplugin((opts: ParsecraftPluginOptions = {}, meta) => {
579
599
  return /\.[jt]sx?$/.test(id) && !id.includes('node_modules')
580
600
  },
581
601
 
602
+ buildEnd(this: UnpluginBuildContext & { getModuleIds?: () => Iterable<string> }, error?: unknown) {
603
+ if (error) return
604
+ // esbuild, webpack and rspack can't list a build's modules, so there every module seen counts.
605
+ const ids = [...this.getModuleIds?.() ?? profiled.keys()].filter(id => profiled.has(id))
606
+ if (ids.length === 0 || ids.some(id => profiled.get(id) === 'ran')) return
607
+ throw new Error(`assemblyProfile: no grammar has rule "${opts.assemblyProfile?.entry}", so the corpus never ran (${ids.join(', ')})`)
608
+ },
609
+
582
610
  transform(this: { warn?: (msg: string) => void; addWatchFile?: (file: string) => void }, code: string, id: string) {
583
611
  // What this module recorded on an earlier build of a watch session is stale
584
612
  // from here: it records again below, or it no longer lowers anything.
585
613
  sinks?.delete(id)
614
+ profiled.delete(id)
586
615
  if (!code.includes('parseman')) return null
587
616
  if (!code.includes('macro')) return null
588
617
  // A corpus edit changes which sites a profiled build generates, so a watch
@@ -592,6 +621,7 @@ export default createUnplugin((opts: ParsecraftPluginOptions = {}, meta) => {
592
621
  const sink: CarriedSink | undefined = sinks && new Map()
593
622
  const result = transformMacro(code, id, moduleAliases, opts.warnUnloweredRegex === true, opts.recovery === true, opts.grammarCoverage === true, opts.assemblyProfile, opts.assemblyBudget, sink)
594
623
  if (sinks && sink && sink.size > 0) sinks.set(id, sink)
624
+ if (result?.assemblyProfile !== undefined) profiled.set(id, result.assemblyProfile)
595
625
  if (result?.warnings.length) {
596
626
  for (const w of result.warnings) {
597
627
  if (typeof this?.warn === 'function') this.warn(`[parseman] ${w}`)
@@ -786,6 +816,9 @@ export type TransformMacroResult = {
786
816
  map: ReturnType<MagicString['generateMap']>
787
817
  /** Diagnostics for macro-referencing shapes that fell back to the interpreter. */
788
818
  warnings: string[]
819
+ /** With an `assemblyProfile`: `'ran'` when a grammar here parsed the corpus,
820
+ * `'no-entry'` when the grammars that could have don't have its `entry` rule. */
821
+ assemblyProfile?: 'ran' | 'no-entry'
789
822
  /** The sites the module's `assemblyBudget` bought, by program offset, in rank order. */
790
823
  assemblySites: number[]
791
824
  }
@@ -2403,10 +2436,15 @@ function transformMacroImpl(
2403
2436
  /** Measured site calls over the optional corpus, or `undefined` for the static
2404
2437
  * ranking. A corpus that cannot run fails the build: a static ranking standing in
2405
2438
  * for the measured one configured is a slower parser that nothing reports. */
2439
+ let profileOutcome: TransformMacroResult['assemblyProfile']
2406
2440
  const profileOf = (compiled: CompiledRuleMapTable): Int32Array | undefined => {
2407
2441
  // Only the default AST/no-lines table carries an assembly, so only it is profiled.
2408
- if (assemblyProfile === undefined || compiled.hostMode !== 'ast' || compiled.prog.lines === 1
2409
- || !(assemblyProfile.entry in compiled.prog.rules)) return undefined
2442
+ if (assemblyProfile === undefined || compiled.hostMode !== 'ast' || compiled.prog.lines === 1) return undefined
2443
+ if (!(assemblyProfile.entry in compiled.prog.rules)) {
2444
+ profileOutcome ??= 'no-entry'
2445
+ return undefined
2446
+ }
2447
+ profileOutcome = 'ran'
2410
2448
  const refuse = (why: string): never => { throw new Error(`${id} — assemblyProfile: ${why}`) }
2411
2449
  if (corpusTexts(assemblyProfile).every(text => text.trim() === '')) refuse('`files` has no non-empty file')
2412
2450
  let thrower: unknown
@@ -2421,24 +2459,38 @@ function transformMacroImpl(
2421
2459
  },
2422
2460
  })
2423
2461
  })
2424
- return countSiteVisits(compiled.prog, rules => {
2425
- const entry = rules[assemblyProfile.entry]!
2426
- corpusTexts(assemblyProfile).forEach((text, i) => {
2427
- const file = assemblyProfile.files[i]!
2428
- if (text.trim() === '') return
2429
- let result
2430
- try { result = run(entry, text) } catch (e) {
2431
- const source = String(thrower).replace(/\s+/g, ' ').slice(0, 200)
2432
- throw new Error(`${id} — assemblyProfile: ${thrower === undefined ? 'the parse' : `gate or reducer \`${source}\``} threw on ${file}: ${String(e)}`, { cause: e })
2433
- }
2434
- if (!result.ok || result.unconsumedFrom !== null) {
2435
- refuse(`${file} does not parse from rule "${assemblyProfile.entry}": it stops at offset ${result.ok ? result.unconsumedFrom : result.span.end}`)
2436
- }
2437
- if (result.errors.length > 0) {
2438
- refuse(`${file} parses only by recovering from ${result.errors.length} error(s), first at offset ${result.errors[0]!.span.start}`)
2439
- }
2440
- })
2441
- }, fns)
2462
+ // ponytail: callback source is evaluated in global scope, so the corpus run sees
2463
+ // `scope` as globals for this one synchronous call, and nothing can interleave with
2464
+ // it. Async work a callback starts that outlives the call no longer sees them;
2465
+ // rebind each callback instead if a helper ever needs that.
2466
+ const scope = assemblyProfile.scope ?? {}
2467
+ const names = Object.keys(scope)
2468
+ for (const name of names) {
2469
+ if (name in globalThis) refuse(`\`scope\` name "${name}" is already a global; a callback couldn't tell which one it reads`)
2470
+ }
2471
+ try {
2472
+ for (const name of names) Object.defineProperty(globalThis, name, { value: scope[name], configurable: true })
2473
+ return countSiteVisits(compiled.prog, rules => {
2474
+ const entry = rules[assemblyProfile.entry]!
2475
+ corpusTexts(assemblyProfile).forEach((text, i) => {
2476
+ const file = assemblyProfile.files[i]!
2477
+ if (text.trim() === '') return
2478
+ let result
2479
+ try { result = run(entry, text) } catch (e) {
2480
+ const source = String(thrower).replace(/\s+/g, ' ').slice(0, 200)
2481
+ throw new Error(`${id} — assemblyProfile: ${thrower === undefined ? 'the parse' : `gate or reducer \`${source}\``} threw on ${file}: ${String(e)}`, { cause: e })
2482
+ }
2483
+ if (!result.ok || result.unconsumedFrom !== null) {
2484
+ refuse(`${file} does not parse from rule "${assemblyProfile.entry}": it stops at offset ${result.ok ? result.unconsumedFrom : result.span.end}`)
2485
+ }
2486
+ if (result.errors.length > 0) {
2487
+ refuse(`${file} parses only by recovering from ${result.errors.length} error(s), first at offset ${result.errors[0]!.span.start}`)
2488
+ }
2489
+ })
2490
+ }, fns)
2491
+ } finally {
2492
+ for (const name of names) Reflect.deleteProperty(globalThis, name)
2493
+ }
2442
2494
  }
2443
2495
  /** The fused table's replacement, with its selective default assembly if any. */
2444
2496
  const assembledReplacement = (compiled: CompiledRuleMapTable, metadata: string, select: ReadonlySet<number>): string =>
@@ -3321,5 +3373,6 @@ function transformMacroImpl(
3321
3373
  code: ms.toString(),
3322
3374
  map: ms.generateMap({ hires: true }),
3323
3375
  warnings,
3376
+ ...(profileOutcome === undefined ? {} : { assemblyProfile: profileOutcome }),
3324
3377
  }
3325
3378
  }
package/src/version.ts CHANGED
@@ -13,4 +13,4 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export const PARSEMAN_VERSION = '0.61.3'
16
+ export const PARSEMAN_VERSION = '0.61.4'