From 47e00b1868bdf4fd3418a257c1e4b4ca6717130b Mon Sep 17 00:00:00 2001 From: Jan Klaas Kollhof Date: Sat, 28 Mar 2026 15:31:32 +0000 Subject: [PATCH 1/2] feat: upgrade fink to v0.4.0, fix CPS/WAT sourcemap sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade fink crate from v0.3.0 to v0.4.0: - Replace closure_lifting::lift_all with lifting::lift - Switch from lower_expr to lower_module (correct entry for module-level CPS) - Add partial::apply before scopes in all pipelines (required by v0.4.0) - Add scopes::analyse for ScopeResult required by lower_module - Add SynthIdent arm to node_kind_label, serialize_children, collect_tokens - WAT panel: strip inline sourceMappingURL comment from displayed WAT text and parse it for source↔WAT cursor sync (highlight line on src cursor move) - Fix VLQ sourcemap decoder: unmapped stop-marker segments (1-field VLQ) must still advance the column accumulator — skipping them caused col positions to be 4 units low (e.g. cpsCol 30/32 instead of 34/41) - Fix CPS panel: clear source→CPS highlight decoration when clicking in CPS (was persisting after source-driven sync until next source cursor move) - Fix CPS tokenizer timing: call tokenizer.update() before model.setValue() so Monaco's re-tokenize pass sees the new tokens immediately - Add WAT syntax highlighting via hand-written Monarch tokenizer --- crate/Cargo.lock | 19 +++--- crate/Cargo.toml | 3 +- crate/src/lib.rs | 94 +++++++++++++++++++----------- package-lock.json | 22 +++---- src/cps-panel.ts | 46 +++++++++------ src/main.ts | 6 +- src/wat-panel.ts | 143 +++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 256 insertions(+), 77 deletions(-) diff --git a/crate/Cargo.lock b/crate/Cargo.lock index fa1f097..f87065f 100644 --- a/crate/Cargo.lock +++ b/crate/Cargo.lock @@ -35,7 +35,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "fink" version = "0.0.0" -source = "git+https://github.com/fink-lang/fink.git?tag=v0.3.0#f7ba307c4d1a1f05688decb36c7d8404cfc86cfd" +source = "git+https://github.com/fink-lang/fink.git?tag=v0.4.0#f66f348a08096da410405ba19fd284189ba43850" dependencies = [ "wasm-encoder", "wasmparser", @@ -48,7 +48,6 @@ version = "0.1.0" dependencies = [ "fink", "wasm-bindgen", - "wasmprinter", ] [[package]] @@ -179,9 +178,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -192,9 +191,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -202,9 +201,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -215,9 +214,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] diff --git a/crate/Cargo.toml b/crate/Cargo.toml index 12956c4..16d743d 100644 --- a/crate/Cargo.toml +++ b/crate/Cargo.toml @@ -8,8 +8,7 @@ crate-type = ["cdylib", "rlib"] path = "src/lib.rs" [dependencies] -fink = { git = "https://github.com/fink-lang/fink.git", tag = "v0.3.0", default-features = false, features = ["compiler", "wat"] } -wasmprinter = "0.245" +fink = { git = "https://github.com/fink-lang/fink.git", tag = "v0.4.0", default-features = false, features = ["compiler", "wat"] } wasm-bindgen = "0.2" [profile.release] diff --git a/crate/src/lib.rs b/crate/src/lib.rs index a6f6af1..9c29e66 100644 --- a/crate/src/lib.rs +++ b/crate/src/lib.rs @@ -4,11 +4,13 @@ use wasm_bindgen::prelude::*; use fink::ast::{self, Node, NodeKind}; use fink::lexer::{self, TokenKind}; use fink::parser; -use fink::passes::closure_lifting::lift_all; use fink::passes::cps::fmt as cps_fmt; use fink::passes::cps::ir::CpsId; -use fink::passes::cps::transform::lower_expr; +use fink::passes::cps::transform::lower_module; +use fink::passes::lifting::lift; use fink::passes::name_res::{self, Resolution}; +use fink::passes::partial; +use fink::passes::scopes; // --------------------------------------------------------------------------- // String literal sub-parsing: split StrText into text + escape segments @@ -477,6 +479,7 @@ fn collect_tokens<'src>(node: &'src Node<'src>, tokens: &mut Vec) { | NodeKind::LitStr { .. } | NodeKind::Partial | NodeKind::Wildcard + | NodeKind::SynthIdent(_) | NodeKind::Spread { inner: None, .. } => {} } } @@ -600,6 +603,7 @@ fn node_kind_label<'a>(node: &'a ast::Node<'a>) -> (&'static str, String) { Yield(_) => ("Yield", String::new()), Block { sep, .. } => ("Block", sep.src.to_string()), Module(_) => ("Module", String::new()), + SynthIdent(n) => ("SynthIdent", format!("·$_{n}")), } } @@ -610,7 +614,7 @@ fn serialize_children(node: &ast::Node) -> String { match &node.kind { LitBool(_) | LitInt(_) | LitFloat(_) | LitDecimal(_) | LitStr { .. } - | Ident(_) | Partial | Wildcard => {} + | Ident(_) | Partial | Wildcard | SynthIdent(_) => {} LitSeq { items, .. } | LitRec { items, .. } | Pipe(items) | Patterns(items) | Module(items) => { @@ -669,40 +673,36 @@ fn serialize_children(node: &ast::Node) -> String { // --------------------------------------------------------------------------- /// Compile Fink source to a WASM binary. -/// Returns the raw bytes on success, or throws a JS error on failure. +/// Currently unimplemented — WASM codegen is being reworked in fink v0.4.0. #[wasm_bindgen] -pub fn compile(src: &str) -> Result, JsValue> { - use fink::ast::build_index; - use fink::parser::parse; - use fink::passes::closure_lifting::lift_all; - use fink::passes::cps::transform::lower_expr; - use fink::passes::wasm::codegen::codegen; - - let r = parse(src).map_err(|e| JsValue::from_str(&e.message))?; - let ast_index = build_index(&r); - let cps = lower_expr(&r.root); - let (lifted, resolved) = lift_all(cps, &ast_index); - let result = codegen(&lifted, &resolved, &ast_index); - Ok(result.wasm) +pub fn compile(_src: &str) -> Result, JsValue> { + Err(JsValue::from_str("WASM compilation not yet supported in this version")) } -/// Compile Fink source and return the WAT text disassembly. +/// Compile Fink source and return the WAT text with inline source map. /// Returns the WAT string on success, or throws a JS error on failure. #[wasm_bindgen] pub fn compile_wat(src: &str) -> Result { use fink::ast::build_index; + use fink::ast::NodeKind; use fink::parser::parse; - use fink::passes::closure_lifting::lift_all; - use fink::passes::cps::transform::lower_expr; - use fink::passes::wasm::codegen::codegen; + use fink::passes::wat::writer; let r = parse(src).map_err(|e| JsValue::from_str(&e.message))?; + let (root, node_count) = partial::apply(r.root, r.node_count) + .map_err(|e| JsValue::from_str(&format!("{e:?}")))?; + let r = fink::ast::ParseResult { root, node_count }; let ast_index = build_index(&r); - let cps = lower_expr(&r.root); - let (lifted, resolved) = lift_all(cps, &ast_index); - let result = codegen(&lifted, &resolved, &ast_index); - wasmprinter::print_bytes(&result.wasm) - .map_err(|e| JsValue::from_str(&format!("{e}"))) + let scope = scopes::analyse(&r.root, r.node_count as usize, &[]); + let exprs = match &r.root.kind { + NodeKind::Module(items) => &items.items, + _ => return Err(JsValue::from_str("expected module")), + }; + let cps = lower_module(exprs, &scope); + let lifted = lift(cps, &ast_index); + let (wat, srcmap) = writer::emit_mapped_with_content(&lifted, &ast_index, "input.fnk", src); + let srcmap_b64 = fink::sourcemap::base64_encode(srcmap.to_json().as_bytes()); + Ok(format!("{}\n//# sourceMappingURL=data:application/json;base64,{srcmap_b64}", wat.trim())) } // --------------------------------------------------------------------------- @@ -867,8 +867,18 @@ impl ParsedDocument { Err(_) => return, }; + let (root, node_count) = match partial::apply(parse_result.root, parse_result.node_count) { + Ok(r) => r, + Err(_) => return, + }; + let parse_result = ast::ParseResult { root, node_count }; let ast_index = ast::build_index(&parse_result); - let cps = lower_expr(&parse_result.root); + let scope = scopes::analyse(&parse_result.root, parse_result.node_count as usize, &[]); + let exprs = match &parse_result.root.kind { + NodeKind::Module(items) => &items.items, + _ => return, + }; + let cps = lower_module(exprs, &scope); let node_count = cps.origin.len(); let resolved = name_res::resolve(&cps.root, &cps.origin, &ast_index, node_count, &cps.synth_alias); @@ -1084,8 +1094,18 @@ impl ParsedDocument { Ok(r) => r, Err(_) => return r#"{"code":"","map":""}"#.to_string(), }; + let (root, node_count) = match partial::apply(r.root, r.node_count) { + Ok(r) => r, + Err(_) => return r#"{"code":"","map":""}"#.to_string(), + }; + let r = ast::ParseResult { root, node_count }; let ast_index = ast::build_index(&r); - let cps = lower_expr(&r.root); + let scope = scopes::analyse(&r.root, r.node_count as usize, &[]); + let exprs = match &r.root.kind { + NodeKind::Module(items) => &items.items, + _ => return r#"{"code":"","map":""}"#.to_string(), + }; + let cps = lower_module(exprs, &scope); let ctx = cps_fmt::Ctx { origin: &cps.origin, ast_index: &ast_index, captures: None }; let (code, map) = cps_fmt::fmt_with_mapped(&cps.root, &ctx, "input.fnk"); let map_json = map.to_json(); @@ -1095,17 +1115,26 @@ impl ParsedDocument { } /// Return lifted CPS output as JSON: `{"code": "...", "map": "..."}`. - /// Runs the full pipeline: parse → CPS → cont_lifting + closure_lifting (lift_all) - /// → format with sourcemap. + /// Runs the full pipeline: parse → scopes → CPS → lift → format with sourcemap. /// Returns `{"code":"","map":""}` if the source fails to parse. pub fn get_cps_lifted(&self) -> String { let r = match parser::parse(&self.src) { Ok(r) => r, Err(_) => return r#"{"code":"","map":""}"#.to_string(), }; + let (root, node_count) = match partial::apply(r.root, r.node_count) { + Ok(r) => r, + Err(_) => return r#"{"code":"","map":""}"#.to_string(), + }; + let r = ast::ParseResult { root, node_count }; let ast_index = ast::build_index(&r); - let cps = lower_expr(&r.root); - let (lifted, _) = lift_all(cps, &ast_index); + let scope = scopes::analyse(&r.root, r.node_count as usize, &[]); + let exprs = match &r.root.kind { + NodeKind::Module(items) => &items.items, + _ => return r#"{"code":"","map":""}"#.to_string(), + }; + let cps = lower_module(exprs, &scope); + let lifted = lift(cps, &ast_index); let ctx = cps_fmt::Ctx { origin: &lifted.origin, ast_index: &ast_index, captures: None }; let (code, map) = cps_fmt::fmt_with_mapped(&lifted.root, &ctx, "input.fnk"); let map_json = map.to_json(); @@ -1133,6 +1162,7 @@ impl ParsedDocument { mod tests { use super::*; + #[test] fn no_escapes() { let segs = split_str_escapes("hello world"); diff --git a/package-lock.json b/package-lock.json index f0cbb6b..b9e82fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2150,9 +2150,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2864,9 +2864,9 @@ } }, "node_modules/npm": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.0.tgz", - "integrity": "sha512-xPhOap4ZbJWyd7DAOukP564WFwNSGu/2FeTRFHhiiKthcauxhH/NpkJAQm24xD+cAn8av5tQ00phi98DqtfLsg==", + "version": "11.12.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.1.tgz", + "integrity": "sha512-zcoUuF1kezGSAo0CqtvoLXX3mkRqzuqYdL6Y5tdo8g69NVV3CkjQ6ZBhBgB4d7vGkPcV6TcvLi3GRKPDFX+xTA==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -2946,7 +2946,7 @@ "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^9.4.2", - "@npmcli/config": "^10.8.0", + "@npmcli/config": "^10.8.1", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -3140,7 +3140,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.8.0", + "version": "10.8.1", "dev": true, "inBundle": true, "license": "ISC", @@ -5989,9 +5989,9 @@ } }, "node_modules/undici": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", "dev": true, "license": "MIT", "engines": { diff --git a/src/cps-panel.ts b/src/cps-panel.ts index afd1959..c878292 100644 --- a/src/cps-panel.ts +++ b/src/cps-panel.ts @@ -53,8 +53,10 @@ function decodeMappings(mappingsStr: string): Mapping[] { for (const seg of line.split(',')) { if (!seg) continue const fields = vlqDecode(seg) - if (fields.length < 4) continue + if (fields.length === 0) continue prevCpsCol += fields[0] + // 1-field segment = unmapped stop marker — advance col but emit no mapping + if (fields.length < 4) continue // fields[1] = source index delta (always 0) prevSrcLine += fields[2] prevSrcCol += fields[3] @@ -95,8 +97,27 @@ function lookupCpsToSrc(lookup: PosMap, cpsLine: number, cpsCol: number): Mappin } // Find the first CPS position that maps from (srcLine, srcCol). +// Falls back to the last mapping on the same line with srcCol <= cursor, +// then the nearest mapped line. function lookupSrcToCps(lookup: PosMap, srcLine: number, srcCol: number): Mapping | null { - return lookup.srcToFirst.get(srcLine * 100000 + srcCol) ?? null + const exact = lookup.srcToFirst.get(srcLine * 100000 + srcCol) + if (exact) return exact + + // Closest col on the same line + let best: Mapping | null = null + for (const m of lookup.cpsToSrc) { + if (m.srcLine > srcLine) break + if (m.srcLine === srcLine && m.srcCol <= srcCol) { + if (!best || m.srcCol > best.srcCol) best = m + } + } + if (best) return best + + // Nearest line — last mapping before srcLine + for (let i = lookup.cpsToSrc.length - 1; i >= 0; i--) { + if (lookup.cpsToSrc[i].srcLine <= srcLine) return lookup.cpsToSrc[i] + } + return null } // Find the token that contains (line, col) — cursor can be anywhere inside it. @@ -129,7 +150,6 @@ export class CpsPanel { private srcHighlightDeco: monaco.editor.IEditorDecorationsCollection onWillHighlightSrc: (() => void) | null = null onActivate: (() => void) | null = null - private pendingTokens: { tokens: LexToken[]; code: string } | null = null private lastSemanticTokens: Uint32Array = new Uint32Array(0) private cpsTokens: LexToken[] = [] private srcTokens: LexToken[] = [] @@ -172,19 +192,10 @@ export class CpsPanel { this.highlightDeco = this.cpsEditor.createDecorationsCollection([]) this.srcHighlightDeco = this.srcEditor.createDecorationsCollection([]) - // After setValue() fires, push pending tokens into the tokenizer. - // Pass -1 to force unconditional update; the version change in the state - // already causes Monaco to re-tokenize all lines from scratch. - this.cpsEditor.getModel()!.onDidChangeContent(() => { - if (!this.pendingTokens) return - const model = this.cpsEditor.getModel()! - this.tokenizer.update(this.pendingTokens.tokens, -1, this.pendingTokens.code) - this.pendingTokens = null - }) - // CPS cursor → highlight source this.cpsEditor.onDidChangeCursorPosition(e => { if (this.syncingFromSrc) return + this.highlightDeco.set([]) this.onActivate?.() if (!this.lookup) return const cpsLine = e.position.lineNumber - 1 @@ -246,9 +257,6 @@ export class CpsPanel { const model = this.cpsEditor.getModel() if (model) { - // Lex the CPS code and stash the tokens — the model's onDidChangeContent - // handler (set up in the constructor) will push them into the tokenizer - // with the correct post-setValue version number. if (ParsedDocument && code) { const cpsDoc = new ParsedDocument(code) const highlightJson = cpsDoc.get_highlight_tokens() @@ -256,11 +264,13 @@ export class CpsPanel { cpsDoc.free() const tokens: LexToken[] = JSON.parse(highlightJson) this.cpsTokens = tokens - this.pendingTokens = { tokens, code } + // Update the tokenizer cache before setValue so Monaco's re-tokenize + // pass sees the new tokens immediately, not on the next edit. + this.tokenizer.update(tokens, -1, code) } else { - this.pendingTokens = null this.lastSemanticTokens = new Uint32Array(0) this.cpsTokens = [] + this.tokenizer.update([], -1, '') } model.setValue(code) } diff --git a/src/main.ts b/src/main.ts index d990399..2d8c662 100644 --- a/src/main.ts +++ b/src/main.ts @@ -397,7 +397,7 @@ editor.onDidChangeModelContent(() => { // --------------------------------------------------------------------------- // Tabs that participate in bidirectional cursor sync. -const SYNC_TABS = new Set(['fink-tokens', 'fink-ast', 'fink-cps', 'fink-cps-lifted']) +const SYNC_TABS = new Set(['fink-tokens', 'fink-ast', 'fink-cps', 'fink-cps-lifted', 'fink-wat']) // Currently active tab id — used to gate cursor sync. let activeTab = 'fink-run-panel' @@ -407,6 +407,7 @@ function clearAllDecorations(): void { astPanel?.clearEditorHighlight() cpsPanel?.clearAll() cpsPanelLifted?.clearAll() + watPanel?.clearHighlight() } // Tab switching @@ -434,6 +435,7 @@ for (const tab of document.querySelectorAll('.fink-tab')) { if (activeTab === 'fink-ast') astPanel?.highlightAtPosition(line, col) if (activeTab === 'fink-cps') cpsPanel?.syncFromSource(line, col) if (activeTab === 'fink-cps-lifted') cpsPanelLifted?.syncFromSource(line, col) + if (activeTab === 'fink-wat') watPanel?.syncFromSource(line, col) } } }) @@ -483,6 +485,7 @@ editor.onDidFocusEditorText(() => { if (activeTab === 'fink-ast') astPanel?.highlightAtPosition(line, col) if (activeTab === 'fink-cps') cpsPanel?.syncFromSource(line, col) if (activeTab === 'fink-cps-lifted') cpsPanelLifted?.syncFromSource(line, col) + if (activeTab === 'fink-wat') watPanel?.syncFromSource(line, col) }) editor.onDidChangeCursorPosition(e => { @@ -494,6 +497,7 @@ editor.onDidChangeCursorPosition(e => { if (activeTab === 'fink-ast') astPanel?.highlightAtPosition(line, col) if (activeTab === 'fink-cps') cpsPanel?.syncFromSource(line, col) if (activeTab === 'fink-cps-lifted') cpsPanelLifted?.syncFromSource(line, col) + if (activeTab === 'fink-wat') watPanel?.syncFromSource(line, col) }) // --------------------------------------------------------------------------- diff --git a/src/wat-panel.ts b/src/wat-panel.ts index a95b9fd..784151d 100644 --- a/src/wat-panel.ts +++ b/src/wat-panel.ts @@ -1,13 +1,123 @@ // WAT panel — read-only Monaco editor showing the WAT disassembly of compiled Fink source. // -// No cursor sync for now — sourcemap integration is deferred. -// Updated live on every reparse via update(wat). +// Cursor sync via the inline sourceMappingURL comment appended by compile_wat(): +// source editor position → sourcemap lookup → WAT editor position // +// The sourcemap is stripped from the displayed WAT text and parsed for sync. // Syntax highlighting: hand-written Monarch tokenizer (no onigasm / TextMate deps). // Covers the WAT subset emitted by fink codegen plus the full WAT MVP spec. import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' +// --------------------------------------------------------------------------- +// VLQ / Source Map v3 decoder (same as cps-panel.ts) +// --------------------------------------------------------------------------- + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +function vlqDecode(str: string): number[] { + const values: number[] = [] + let i = 0 + while (i < str.length) { + let shift = 0, value = 0, cont = true + while (cont) { + const digit = B64.indexOf(str[i++]) + cont = (digit & 0x20) !== 0 + value |= (digit & 0x1f) << shift + shift += 5 + } + values.push(value & 1 ? -(value >> 1) : value >> 1) + } + return values +} + +interface Mapping { watLine: number; watCol: number; srcLine: number; srcCol: number } + +function decodeMappings(mappingsStr: string): Mapping[] { + const result: Mapping[] = [] + let prevSrcLine = 0, prevSrcCol = 0 + const lines = mappingsStr.split(';') + for (let watLine = 0; watLine < lines.length; watLine++) { + const line = lines[watLine] + if (!line) continue + let prevWatCol = 0 + for (const seg of line.split(',')) { + if (!seg) continue + const fields = vlqDecode(seg) + if (fields.length === 0) continue + prevWatCol += fields[0] + // 1-field segment = unmapped stop marker — advance col but emit no mapping + if (fields.length < 4) continue + prevSrcLine += fields[2] + prevSrcCol += fields[3] + result.push({ watLine, watCol: prevWatCol, srcLine: prevSrcLine, srcCol: prevSrcCol }) + } + } + return result +} + +interface Lookup { + srcToFirst: Map // exact key: srcLine * 100000 + srcCol + byLine: Map // srcLine → all mappings on that line, sorted by srcCol +} + +function buildLookup(mappings: Mapping[]): Lookup { + const srcToFirst = new Map() + const byLine = new Map() + for (const m of mappings) { + const key = m.srcLine * 100000 + m.srcCol + if (!srcToFirst.has(key)) srcToFirst.set(key, m) + if (!byLine.has(m.srcLine)) byLine.set(m.srcLine, []) + byLine.get(m.srcLine)!.push(m) + } + for (const arr of byLine.values()) arr.sort((a, b) => a.srcCol - b.srcCol) + return { srcToFirst, byLine } +} + +// Find the best mapping for (srcLine, srcCol): exact match first, then +// closest col on the same line, then nearest line. +function lookupSrcToWat(lookup: Lookup, srcLine: number, srcCol: number): Mapping | null { + const exact = lookup.srcToFirst.get(srcLine * 100000 + srcCol) + if (exact) return exact + + // Closest col on the same line (last mapping with srcCol <= cursor) + const lineArr = lookup.byLine.get(srcLine) + if (lineArr && lineArr.length > 0) { + let best = lineArr[0] + for (const m of lineArr) { + if (m.srcCol <= srcCol) best = m + else break + } + return best + } + + // Nearest line — find closest srcLine + let bestLine: number | null = null + for (const line of lookup.byLine.keys()) { + if (bestLine === null || Math.abs(line - srcLine) < Math.abs(bestLine - srcLine)) bestLine = line + } + if (bestLine === null) return null + return lookup.byLine.get(bestLine)![0] +} + +// --------------------------------------------------------------------------- +// Inline sourceMappingURL parser +// --------------------------------------------------------------------------- + +function parseInlineSourcemap(wat: string): { code: string; mappings: string } | null { + const marker = '//# sourceMappingURL=data:application/json;base64,' + const idx = wat.lastIndexOf(marker) + if (idx === -1) return null + const code = wat.slice(0, idx).trimEnd() + const b64 = wat.slice(idx + marker.length).trim() + try { + const json = atob(b64) + const obj = JSON.parse(json) as { mappings?: string } + if (typeof obj.mappings !== 'string') return null + return { code, mappings: obj.mappings } + } catch { return null } +} + // Register language + tokenizer once. monaco.languages.register({ id: 'wat' }) @@ -125,6 +235,8 @@ monaco.languages.setLanguageConfiguration('wat', { export class WatPanel { private editor: monaco.editor.IStandaloneCodeEditor + private lookup: Lookup | null = null + private highlightDeco: monaco.editor.IEditorDecorationsCollection constructor(container: HTMLElement) { this.editor = monaco.editor.create(container, { @@ -141,10 +253,35 @@ export class WatPanel { accessibilitySupport: 'off', automaticLayout: true, }) + this.highlightDeco = this.editor.createDecorationsCollection([]) } update(wat: string): void { - this.editor.getModel()?.setValue(wat) + const parsed = parseInlineSourcemap(wat) + if (parsed) { + this.editor.getModel()?.setValue(parsed.code) + this.lookup = buildLookup(decodeMappings(parsed.mappings)) + } else { + this.editor.getModel()?.setValue(wat) + this.lookup = null + } + this.highlightDeco.set([]) + } + + // Highlight the WAT position corresponding to the given source (line, col) — 0-based. + syncFromSource(srcLine: number, srcCol: number): void { + if (!this.lookup) return + const m = lookupSrcToWat(this.lookup, srcLine, srcCol) + if (!m) return + this.highlightDeco.set([{ + range: new monaco.Range(m.watLine + 1, m.watCol + 1, m.watLine + 1, m.watCol + 1), + options: { className: 'fink-token-highlight', isWholeLine: true }, + }]) + this.editor.revealPositionInCenterIfOutsideViewport({ lineNumber: m.watLine + 1, column: m.watCol + 1 }) + } + + clearHighlight(): void { + this.highlightDeco.set([]) } layout(): void { From 83b42116e19bf359084cbe7199c73da858bf3f02 Mon Sep 17 00:00:00 2001 From: Jan Klaas Kollhof Date: Sat, 28 Mar 2026 16:11:22 +0000 Subject: [PATCH 2/2] feat: improve WAT and CPS source mapping accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAT panel: - Fix VLQ decoder: unmapped stop-marker segments (1-field) must advance the column accumulator even though they emit no mapping entry — fixes col positions being off when stop markers precede mapped segments - Switch from whole-line to inline highlight decoration - Use s-expression paren matching to tightly bound the highlight span, falling back to next mapped col then end-of-line CPS panel: - Same VLQ decoder fix (1-field segments advance col accumulator) - Clear source→CPS highlight decoration when CPS cursor moves, so source-driven highlights don't persist after clicking into CPS --- src/wat-panel.ts | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/wat-panel.ts b/src/wat-panel.ts index 784151d..69fe674 100644 --- a/src/wat-panel.ts +++ b/src/wat-panel.ts @@ -57,21 +57,51 @@ function decodeMappings(mappingsStr: string): Mapping[] { } interface Lookup { - srcToFirst: Map // exact key: srcLine * 100000 + srcCol - byLine: Map // srcLine → all mappings on that line, sorted by srcCol + srcToFirst: Map // exact key: srcLine * 100000 + srcCol + byLine: Map // srcLine → all mappings on that line, sorted by srcCol + byWatLine: Map // watLine → sorted list of watCol values with mappings } function buildLookup(mappings: Mapping[]): Lookup { const srcToFirst = new Map() const byLine = new Map() + const byWatLine = new Map() for (const m of mappings) { const key = m.srcLine * 100000 + m.srcCol if (!srcToFirst.has(key)) srcToFirst.set(key, m) if (!byLine.has(m.srcLine)) byLine.set(m.srcLine, []) byLine.get(m.srcLine)!.push(m) + if (!byWatLine.has(m.watLine)) byWatLine.set(m.watLine, []) + byWatLine.get(m.watLine)!.push(m.watCol) } for (const arr of byLine.values()) arr.sort((a, b) => a.srcCol - b.srcCol) - return { srcToFirst, byLine } + for (const arr of byWatLine.values()) arr.sort((a, b) => a - b) + return { srcToFirst, byLine, byWatLine } +} + +// Return the start column of the next mapping on the same WAT line after watCol, +// or null if there is none. +function nextWatCol(lookup: Lookup, watLine: number, watCol: number): number | null { + const cols = lookup.byWatLine.get(watLine) + if (!cols) return null + for (const c of cols) { + if (c > watCol) return c + } + return null +} + +// Return the column just past the end of the s-expression starting at (line, col) +// in the model — finds the closing ')' by counting paren depth. +// Falls back to null if the model is unavailable or col doesn't start with '('. +function sExprEnd(model: monaco.editor.ITextModel, line: number, col: number): number | null { + const text = model.getLineContent(line + 1) + if (col >= text.length || text[col] !== '(') return null + let depth = 0 + for (let i = col; i < text.length; i++) { + if (text[i] === '(') depth++ + else if (text[i] === ')') { depth--; if (depth === 0) return i + 1 } + } + return null } // Find the best mapping for (srcLine, srcCol): exact match first, then @@ -273,9 +303,13 @@ export class WatPanel { if (!this.lookup) return const m = lookupSrcToWat(this.lookup, srcLine, srcCol) if (!m) return + const model = this.editor.getModel() + const sExprEndCol = model ? sExprEnd(model, m.watLine, m.watCol) : null + const next = nextWatCol(this.lookup, m.watLine, m.watCol) + const endCol = sExprEndCol ?? next ?? (model ? model.getLineMaxColumn(m.watLine + 1) - 1 : m.watCol + 1) this.highlightDeco.set([{ - range: new monaco.Range(m.watLine + 1, m.watCol + 1, m.watLine + 1, m.watCol + 1), - options: { className: 'fink-token-highlight', isWholeLine: true }, + range: new monaco.Range(m.watLine + 1, m.watCol + 1, m.watLine + 1, endCol + 1), + options: { className: 'fink-token-highlight', isWholeLine: false }, }]) this.editor.revealPositionInCenterIfOutsideViewport({ lineNumber: m.watLine + 1, column: m.watCol + 1 }) }