diff --git a/src/target-go.ts b/src/target-go.ts index 7bb6989..87c3a7a 100644 --- a/src/target-go.ts +++ b/src/target-go.ts @@ -550,6 +550,21 @@ func parse(t []Tok) int32 { \tnodes = nodes[:0]; kids = kids[:0]; scratch = scratch[:0] \treturn parse${ir.entry}() } + +type Edit struct { Start, End int; Text string } +type Doc struct { text string; root int32 } +func NewDoc(src string) *Doc { +\td := &Doc{text: src} +\td.root = parse(tokenize(src)) +\treturn d +} +func (d *Doc) Text() string { return d.text } +func (d *Doc) Root() int32 { return d.root } +func (d *Doc) Edit(edits []Edit) int32 { +\tfor _, e := range edits { d.text = d.text[:e.Start] + e.Text + d.text[e.End:] } +\td.root = parse(tokenize(d.text)) +\treturn d.root +} `; }, emitRunner(): string { @@ -559,6 +574,7 @@ func parse(t []Tok) int32 { package main import ( + "encoding/json" "fmt" "io" "os" @@ -571,7 +587,7 @@ func main() { \tdata, _ := io.ReadAll(os.Stdin) \tsrc := string(data) \t// Self-bench: a numeric arg N times the lex+parse loop and prints ms/iteration. -\tif len(os.Args) > 1 { +\tif len(os.Args) > 1 && os.Args[1] != "edit-session" { \t\tif iters, err := strconv.Atoi(os.Args[1]); err == nil && iters > 0 { \t\t\tfor i := 0; i < 3; i++ { parse(tokenize(src)) } \t\t\tt0 := time.Now() @@ -580,6 +596,27 @@ func main() { \t\t\treturn \t\t} \t} +\tif len(os.Args) > 1 && os.Args[1] == "edit-session" { +\t\tvar sess struct { +\t\t\tInit string \`json:"init"\` +\t\t\tBatches [][][3]interface{} \`json:"batches"\` +\t\t} +\t\tif json.Unmarshal(data, &sess) != nil { os.Exit(1) } +\t\td := NewDoc(sess.Init) +\t\tfor _, batch := range sess.Batches { +\t\t\tedits := make([]Edit, len(batch)) +\t\t\tfor i, t := range batch { +\t\t\t\tedits[i] = Edit{Start: int(t[0].(float64)), End: int(t[1].(float64)), Text: t[2].(string)} +\t\t\t} +\t\t\td.Edit(edits) +\t\t} +\t\troot := d.Root() +\t\tif root < 0 || pos != len(toks) { fmt.Fprintf(os.Stderr, "parse error (pos %d/%d)\\n", pos, len(toks)); os.Exit(1) } +\t\tvar b strings.Builder +\t\twriteJSON(root, &b) +\t\tos.Stdout.WriteString(b.String()) +\t\treturn +\t} \troot := parse(tokenize(src)) \tif root < 0 || pos != len(toks) { \t\tfmt.Fprintf(os.Stderr, "parse error (pos %d/%d)\\n", pos, len(toks)) diff --git a/src/target-rust.ts b/src/target-rust.ts index c39fd6b..1961568 100644 --- a/src/target-rust.ts +++ b/src/target-rust.ts @@ -546,6 +546,17 @@ fn parse<'a>(tokens: Tokens<'a>) -> Option<(Parser<'a>, i32)> { _ => None, } } + +pub struct Edit { pub start: usize, pub end: usize, pub text: String } +pub struct Doc { text: String } +impl Doc { + pub fn new(text: String) -> Doc { Doc { text } } + pub fn text(&self) -> &str { &self.text } + pub fn edit(&mut self, edits: &[Edit]) { + for e in edits { let s = e.start; let en = e.end; self.text = format!("{}{}{}", &self.text[..s], e.text, &self.text[en..]); } + } + pub fn parse(&self) -> Option<(Parser<'_>, i32)> { parse(tokenize(&self.text)) } +} `; }, emitRunner(): string { @@ -553,12 +564,115 @@ fn parse<'a>(tokens: Tokens<'a>) -> Option<(Parser<'a>, i32)> { // CLI runner (harness only): stdin -> CST JSON + a self-bench mode. Appended to the parser // library by the gate (same file/crate, so it calls \`parse\`/\`write_json\` directly); NOT part // of the parser. +fn skip_ws(s: &[u8], mut i: usize) -> usize { while i < s.len() && (s[i] as char).is_whitespace() { i += 1; } i } +fn parse_str(s: &[u8], mut i: usize) -> Option<(String, usize)> { + if s.get(i)? != &b'"' { return None; } + i += 1; + let mut out = String::new(); + while i < s.len() { + match s[i] { + b'"' => return Some((out, i + 1)), + b'\\\\' => { i += 1; if i >= s.len() { return None; } + out.push(match s[i] { b'n' => '\\n', b'r' => '\\r', b't' => '\\t', b'"' => '"', b'\\\\' => '\\\\', b'/' => '/', c => c as char }); + i += 1; } + c => { out.push(c as char); i += 1; } + } + } + None +} +fn parse_num(s: &[u8], mut i: usize) -> Option<(usize, usize)> { + let start = i; + while i < s.len() && s[i].is_ascii_digit() { i += 1; } + if i == start { return None; } + s[start..i].iter().fold(Some(0usize), |a, &d| a.and_then(|n| n.checked_mul(10).and_then(|m| m.checked_add((d - b'0') as usize)))) + .map(|n| (n, i)) +} +fn parse_triple(s: &[u8], mut i: usize) -> Option<((usize, usize, String), usize)> { + if s.get(i)? != &b'[' { return None; } + i = skip_ws(s, i + 1); + let (a, mut i) = parse_num(s, i)?; + i = skip_ws(s, i); if s.get(i)? != &b',' { return None; } + i = skip_ws(s, i + 1); + let (b, mut i) = parse_num(s, i)?; + i = skip_ws(s, i); if s.get(i)? != &b',' { return None; } + i = skip_ws(s, i + 1); + let (t, mut i) = parse_str(s, i)?; + i = skip_ws(s, i); if s.get(i)? != &b']' { return None; } + Some(((a, b, t), i + 1)) +} +fn parse_batch(s: &[u8], mut i: usize) -> Option<(Vec<(usize, usize, String)>, usize)> { + if s.get(i)? != &b'[' { return None; } + i = skip_ws(s, i + 1); + let mut batch = Vec::new(); + if s.get(i)? == &b']' { return Some((batch, i + 1)); } + loop { + let (t, ni) = parse_triple(s, i)?; + batch.push(t); + i = skip_ws(s, ni); + if s.get(i)? == &b']' { return Some((batch, i + 1)); } + if s.get(i)? != &b',' { return None; } + i = skip_ws(s, i + 1); + } +} +fn parse_edit_session(s: &str) -> Option<(String, Vec>)> { + let b = s.as_bytes(); + let mut i = skip_ws(b, 0); + if b.get(i)? != &b'{' { return None; } + i = skip_ws(b, i + 1); + let mut init = None; + let mut batches = None; + loop { + let (key, ni) = parse_str(b, i)?; + i = skip_ws(b, ni); + if b.get(i)? != &b':' { return None; } + i = skip_ws(b, i + 1); + if key == "init" { + let (v, ni) = parse_str(b, i)?; + init = Some(v); + i = skip_ws(b, ni); + } else if key == "batches" { + if b.get(i)? != &b'[' { return None; } + i = skip_ws(b, i + 1); + let mut bs = Vec::new(); + if b.get(i)? == &b']' { batches = Some(bs); i += 1; } + else { + loop { + let (batch, ni) = parse_batch(b, i)?; + bs.push(batch); + i = skip_ws(b, ni); + if b.get(i)? == &b']' { batches = Some(bs); i += 1; break; } + if b.get(i)? != &b',' { return None; } + i = skip_ws(b, i + 1); + } + } + } else { return None; } + if b.get(i)? == &b'}' { break; } + if b.get(i)? != &b',' { return None; } + i = skip_ws(b, i + 1); + } + Some((init?, batches?)) +} + fn main() { use std::io::Read; let mut src = String::new(); std::io::stdin().read_to_string(&mut src).unwrap(); + let args: Vec = std::env::args().collect(); + if args.len() > 1 && args[1] == "edit-session" { + let (init, batches) = parse_edit_session(&src).unwrap(); + let mut doc = Doc::new(init); + for batch in &batches { + let edits: Vec = batch.iter().map(|&(s, e, ref t)| Edit { start: s, end: e, text: t.clone() }).collect(); + doc.edit(&edits); + } + match doc.parse() { + Some((p, root)) => { let mut out = String::new(); write_json(&p, root, &mut out); print!("{}", out); } + None => { eprintln!("parse error"); std::process::exit(1); } + } + return; + } // Self-bench: a numeric arg N times the lex+parse loop and prints ms/iteration. - if let Some(iters) = std::env::args().nth(1).and_then(|a| a.parse::().ok()) { + if let Some(iters) = args.get(1).and_then(|a| a.parse::().ok()) { for _ in 0..3 { let s = std::hint::black_box(&src); if let Some((p, r)) = parse(tokenize(s)) { std::hint::black_box((&p.nodes[r as usize], p.pos)); } } let t = std::time::Instant::now(); for _ in 0..iters { let s = std::hint::black_box(&src); if let Some((p, r)) = parse(tokenize(s)) { std::hint::black_box((&p.nodes[r as usize], p.pos)); } } diff --git a/src/target-ts.ts b/src/target-ts.ts index ec8f5d6..5cb84a1 100644 --- a/src/target-ts.ts +++ b/src/target-ts.ts @@ -471,15 +471,40 @@ export function parse(tokens: Tok[]): Cst | null { const root = parse${ir.entry}(); return root !== null && pos === toks.length ? root : null; } + +export type Edit = { start: number; end: number; text: string }; +export function createDoc(src: string): { text(): string; root(): Node | null; edit(edits: Edit[]): Node | null } { + let text = src; + let root: Node | null = parse(tokenize(src)); + return { + text(): string { return text; }, + root(): Node | null { return root; }, + edit(edits: Edit[]): Node | null { + for (const e of edits) text = text.slice(0, e.start) + e.text + text.slice(e.end); + root = parse(tokenize(text)); + return root; + }, + }; +} `; }, emitRunner(): string { return `// CLI runner (harness only): stdin → CST JSON. Appended to the parser library by the gate; // NOT part of the emitted parser. The import is hoisted, so it may follow the library code. import { readFileSync } from 'node:fs'; -const _root = parse(tokenize(readFileSync(0, 'utf8'))); -if (_root === null) { process.stderr.write('parse error\\n'); process.exit(1); } -process.stdout.write(JSON.stringify(_root)); +const _raw = readFileSync(0, 'utf8'); +if (process.argv.includes('edit-session')) { + const { init, batches } = JSON.parse(_raw) as { init: string; batches: [number, number, string][][] }; + const doc = createDoc(init); + for (const batch of batches) doc.edit(batch.map(([start, end, text]) => ({ start, end, text }))); + const root = doc.root(); + if (root === null) { process.stderr.write('parse error\\n'); process.exit(1); } + process.stdout.write(JSON.stringify(root)); +} else { + const _root = parse(tokenize(_raw)); + if (_root === null) { process.stderr.write('parse error\\n'); process.exit(1); } + process.stdout.write(JSON.stringify(_root)); +} `; }, }; diff --git a/test/portable-targets.ts b/test/portable-targets.ts index 5d55d73..666ab05 100644 --- a/test/portable-targets.ts +++ b/test/portable-targets.ts @@ -271,6 +271,28 @@ function runProc(cmd: string, args: string[], src: string): Outcome { catch { return { ok: false }; } } +type EditBatch = [number, number, string][]; +type EditScenario = { init: string; batches: EditBatch[] }; +const EDIT_SCENARIOS: Record = { + calc: [ + { init: '1+2*3', batches: [[[3, 3, '4']]] }, + { init: '1+2*3', batches: [[[1, 3, '']]] }, + { init: '1+2*3', batches: [[[2, 5, '(7-8)']]] }, + { init: '1+2*3', batches: [[[0, 0, '9-']], [[7, 8, '']]] }, + ], + javascript: [ + { init: 'let a = 1;\nf(a);', batches: [[[8, 9, '42']]] }, + { init: 'let a = 1;\nf(a);', batches: [[[11, 16, '']]] }, + { init: 'let a = 1;\nf(a);', batches: [[[4, 5, 'b'], [12, 13, 'b']]] }, + { init: 'let a = 1;\nf(a);', batches: [[[16, 16, '\ng(b);']], [[0, 4, 'var']]] }, + ], +}; +const applyEdits = (init: string, batches: EditBatch[]): string => { + let text = init; + for (const batch of batches) for (const [start, end, repl] of batch) text = text.slice(0, start) + repl + text.slice(end); + return text; +}; + let failures = 0; for (const c of CASES) { const grammar: CstGrammar = (await import(c.path)).default; @@ -333,6 +355,27 @@ for (const c of CASES) { console.log(` ${c.grammar}/${r.label}: REJECT mismatch on ${JSON.stringify(src)} (oracle ok=${want.ok}, ${r.label} ok=${got.ok})`); } console.log(` ${c.grammar}/${r.label}: ${acc}/${c.accept.length} accept ≡ oracle · ${rej}/${c.reject.length} reject ≡ oracle${snap ? ` · ${snap} shape drift` : ''}`); + + const editScenarios = EDIT_SCENARIOS[c.grammar]; + if (editScenarios) { + let editOk = 0; + const runEdit = (json: string) => runProc( + r.label === 'typescript' ? 'node' : r.label === 'go' ? `${dir}/go/p` : `${dir}/pr`, + r.label === 'typescript' ? [`${dir}/p.ts`, 'edit-session'] : ['edit-session'], + json, + ); + for (const sc of editScenarios) { + const final = applyEdits(sc.init, sc.batches); + const a = runEdit(JSON.stringify({ init: sc.init, batches: sc.batches })); + const b = r.run(final); + if (a.ok === b.ok && (!a.ok || a.cst === b.cst) && a.ok === oracleOut(final).ok) editOk++; + else { + failures++; + console.log(` ${c.grammar}/${r.label}: edit-session mismatch (final=${JSON.stringify(final)}) A ok=${a.ok} B ok=${b.ok}`); + } + } + console.log(` ${c.grammar}/${r.label}: ${editOk}/${editScenarios.length} edit-sessions ≡ fresh`); + } } }