Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions src/runtime/compile-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,33 @@ export interface CompiledItem {
* Compile an Item into a RegExp source string + literal value.
* Visitor-free dispatch — adding a new Item kind = adding a case here.
*
* `classCtx` selects the compile context. Ruby/RS resolve aliases in two
* contexts (NodeAdapter#materialize_class vs #materialize, mirrored in
* interscript-rs builder.rs):
*
* - sequence context (default): an alias resolving to a String is a
* literal sequence;
* - class context (constraint positions — before/not_before/after/
* not_after — and the arguments of `any()`): an IMPORTED charset alias
* (a dependency map's alias) resolving to a String is a per-character
* set (`convert_class_alias` expands Item::Str into one entry per
* char; Ruby `Any#to_regexp` builds a char class). A LOCAL string
* alias stays a sequence even in class context, matching RS.
*
* Note: this is intentionally a single switch with exhaustive type
* coverage. New Item kinds force this switch to be updated, which is
* good — it surfaces the change rather than hiding it.
*/
export function compileItem(item: Item, ctx: ExecutionContext): CompiledItem {
export function compileItem(item: Item, ctx: ExecutionContext, classCtx = false): CompiledItem {
switch (item.kind) {
case "string":
return { re: regexpEscape(item.value), literal: item.value }

case "capture_group": {
const inner = compileItem(item.data, ctx)
// RS converts capture innards in sequence context even inside a
// constraint (convert_class_fragment only class-resolves the
// top-level alias).
const inner = compileItem(item.data, ctx, false)
return { re: `(${inner.re})`, literal: inner.literal }
}

Expand All @@ -47,15 +63,27 @@ export function compileItem(item: Item, ctx: ExecutionContext): CompiledItem {
if (stdlib) return stdlib
const resolved = ctx.resolveAlias(item.name)
if (!resolved) return { re: "", literal: "" }
return compileItem(resolved, ctx)
if (classCtx && !ctx.map.aliases.has(item.name) && resolved.kind === "string") {
// Imported charset alias in class position: per-character set.
return stringCharset(resolved.value)
}
return compileItem(resolved, ctx, classCtx)
}

case "any": {
const parts = item.of.map((i) => compileItem(i, ctx).re)
// any() arguments are class-resolved regardless of the surrounding
// context (RS convert_any_arg routes alias entries through
// convert_set_entry -> convert_class_alias). Concatenation/capture
// entries inside a list stay sequence-resolved — only alias
// entries differ per context.
const parts = item.of.map((i) => compileItem(i, ctx, i.kind === "alias").re)
// Mirror Ruby's Any#nth_string: the literal form picks the first
// alternative. Sub rules with `to: any("ie")` use this for the
// canonical replacement.
const firstLit = item.of.length > 0 ? compileItem(item.of[0]!, ctx).literal : ""
const firstLit =
item.of.length > 0
? compileItem(item.of[0]!, ctx, item.of[0]!.kind === "alias").literal
: ""
return { re: `(?:${parts.join("|")})`, literal: firstLit }
}

Expand Down Expand Up @@ -97,6 +125,21 @@ export function compileItem(item: Item, ctx: ExecutionContext): CompiledItem {
}
}

/**
* Compile a String charset as a per-character SET (class context).
* Mirrors RS `convert_class_alias` expanding Item::Str into one entry
* per char, and Ruby `Any#to_regexp` building a char class from String
* data. Compiled as an alternation (not a `[...]` class) so astral
* code points and regexp-escaped chars stay correct under the `u` flag.
*/
function stringCharset(value: string): CompiledItem {
const chars = Array.from(value)
return {
re: `(?:${chars.map((c) => regexpEscape(c)).join("|")})`,
literal: chars[0] ?? "",
}
}

/**
* Compile an Item to a literal string only (no regex semantics).
* Used by the parallel-replace executor which needs exact strings.
Expand Down
20 changes: 12 additions & 8 deletions src/runtime/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,12 @@ const executors: { [K in RuleKind]: RuleExecutorFor<K> } = {

const megaRules: MegaregexpRule[] = sorted.map(({ rule: r }) => {
const from = compileItem(r.from!, ctx)
const before = r.before ? compileItem(r.before, ctx).re : ""
const after = r.after ? compileItem(r.after, ctx).re : ""
const notBefore = r.notBefore ? compileItem(r.notBefore, ctx).re : ""
const notAfter = r.notAfter ? compileItem(r.notAfter, ctx).re : ""
// Constraints compile in CLASS context (charset aliases resolve
// per-character — mirrors RS materialize_class).
const before = r.before ? compileItem(r.before, ctx, true).re : ""
const after = r.after ? compileItem(r.after, ctx, true).re : ""
const notBefore = r.notBefore ? compileItem(r.notBefore, ctx, true).re : ""
const notAfter = r.notAfter ? compileItem(r.notAfter, ctx, true).re : ""

const pattern = [
before ? `(?<=${before})` : "",
Expand Down Expand Up @@ -192,10 +194,12 @@ function executeSubRule(rule: SubRule, ctx: ExecutionContext): void {
// Ruby's interpreter uses Ruby gsub with capture groups, which DOES
// consume the surrounding context and re-inserts it via backreferences.
// We use lookarounds for correctness with multi-byte scripts.
const before = rule.before ? compileItem(rule.before, ctx).re : ""
const after = rule.after ? compileItem(rule.after, ctx).re : ""
const notBefore = rule.notBefore ? compileItem(rule.notBefore, ctx).re : ""
const notAfter = rule.notAfter ? compileItem(rule.notAfter, ctx).re : ""
// Constraints compile in CLASS context (charset aliases resolve
// per-character — mirrors RS materialize_class / convert_class_alias).
const before = rule.before ? compileItem(rule.before, ctx, true).re : ""
const after = rule.after ? compileItem(rule.after, ctx, true).re : ""
const notBefore = rule.notBefore ? compileItem(rule.notBefore, ctx, true).re : ""
const notAfter = rule.notAfter ? compileItem(rule.notAfter, ctx, true).re : ""

const patternParts: string[] = []
if (before) patternParts.push(`(?<=${before})`)
Expand Down
91 changes: 91 additions & 0 deletions test/golden-isc-wf0.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* ISC-WF-0 golden capture additivity — the TypeScript mirror of
* interscript-rs tests/golden_capture.rs.
*
* Replays every row of the RS-captured golden file
* (interscript-rs/tests/golden/isc-wf0.jsonl) through THIS runtime and
* requires byte-identical output on every row. The golden is the
* cross-runtime contract: a delta here is a runtime regression (or a
* newly-fixed divergence being adopted into the golden).
*
* Skips when the sibling interscript-rs / maps checkouts are missing.
*/

import { describe, it, expect } from "vitest"
import { readFileSync, readdirSync, existsSync } from "fs"
import { join } from "path"
import { configure, transliterate } from "../src/index.ts"
import { iscBundledStrategy } from "../src/isc/loader.ts"

const REPO_ROOT = join(__dirname, "..", "..")
// GOLDEN_ROOT overrides the sibling-checkout root — used by scratch
// baselines that run this spec from a worktree outside the monorepo.
const SIBLING_ROOT = process.env.GOLDEN_ROOT ?? REPO_ROOT
const GOLDEN = join(SIBLING_ROOT, "interscript-rs", "tests", "golden", "isc-wf0.jsonl")
const MAPS_DIR = join(SIBLING_ROOT, "maps", "maps")
const LIBS_DIR = join(SIBLING_ROOT, "maps", "libs")

interface GoldenRow {
readonly map: string
readonly src: string
readonly out: string
}

function loadGolden(): GoldenRow[] {
if (!existsSync(GOLDEN) || !existsSync(MAPS_DIR)) return []
return readFileSync(GOLDEN, "utf8")
.split("\n")
.filter((l) => l.trim().length > 0)
.map((l) => {
const v = JSON.parse(l) as GoldenRow
return { map: v.map, src: v.src, out: v.out }
})
}

function configureRuntime(): void {
const sources: Record<string, string> = {}
for (const dir of [MAPS_DIR, LIBS_DIR]) {
if (!existsSync(dir)) continue
for (const f of readdirSync(dir).filter((f) => f.endsWith(".isc"))) {
sources[f.replace(/\.isc$/, "")] = readFileSync(join(dir, f), "utf8")
}
}
configure({ strategies: [iscBundledStrategy(sources)] })
}

describe("golden capture — interscript-rs isc-wf0 additivity", () => {
it("replays every golden row byte-identically", () => {
const rows = loadGolden()
// Sibling checkouts absent (standalone checkout) — nothing to verify.
if (rows.length === 0) {
console.warn("skip: golden capture or sibling maps checkout missing")
return
}
configureRuntime()

const deltas: string[] = []
const failedMaps = new Set<string>()
let checked = 0
for (const row of rows) {
let got: string
try {
got = transliterate(row.map, row.src)
} catch (e) {
got = `\0error:${(e as Error).message}`
}
checked++
if (got === row.out) continue
failedMaps.add(row.map)
if (deltas.length < 10) {
deltas.push(
`${row.map}: ${JSON.stringify(row.src)} -> ${JSON.stringify(got)} (golden ${JSON.stringify(row.out)})`,
)
}
}
expect(
[...failedMaps],
`golden divergence on ${failedMaps.size} map(s); first deltas:\n${deltas.join("\n")}`,
).toEqual([])
expect(checked).toBe(rows.length)
}, 120_000)
})
Loading
Loading