From 24958d4896a226da4dfab5405c8efd438f480951 Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 28 May 2026 02:54:31 -0700 Subject: [PATCH] Syntax highlighting bug fix for external. Also timing for tree-sitter checks --- core/src/interp.rs | 183 +++++++++++++++++++++++++++++--- viewport/src/browser/preview.rs | 1 + viewport/src/editor/content.rs | 8 -- viewport/src/editor/eval.rs | 5 + viewport/src/editor/mod.rs | 2 + viewport/src/editor/state.rs | 6 ++ viewport/src/embed.rs | 50 +-------- viewport/src/minimap.rs | 6 +- viewport/src/syntax.rs | 45 ++++++-- viewport/src/text_block.rs | 1 + 10 files changed, 228 insertions(+), 79 deletions(-) diff --git a/core/src/interp.rs b/core/src/interp.rs index a0c421a..ea87ff6 100644 --- a/core/src/interp.rs +++ b/core/src/interp.rs @@ -2007,18 +2007,30 @@ impl Interpreter { Value::Array(a) => a, _ => return Err("for loop requires an array or range".into()), }; + let prev = self.vars.remove(var); + let prev_type = self.var_types.remove(var); let mut iterations = 0; let mut last = Value::Void; + let mut loop_err: Option = None; for item in &items { iterations += 1; if iterations > MAX_ITERATIONS { - return Err(format!("loop exceeded {} iterations", MAX_ITERATIONS)); + loop_err = Some(format!("loop exceeded {} iterations", MAX_ITERATIONS)); + break; } self.vars.insert(var.clone(), item.clone()); for s in body { - last = self.exec_stmt(s, depth)?; + match self.exec_stmt(s, depth) { + Ok(v) => last = v, + Err(e) => { loop_err = Some(e); break; } + } } + if loop_err.is_some() { break; } } + self.vars.remove(var); + if let Some(v) = prev { self.vars.insert(var.clone(), v); } + if let Some(t) = prev_type { self.var_types.insert(var.clone(), t); } + if let Some(e) = loop_err { return Err(e); } Ok(last) } Stmt::FnDef { name, params, return_type, body } => { @@ -2213,17 +2225,28 @@ impl Interpreter { } _ => None, }; - let type_tag = type_tag.ok_or_else(|| - format!("cannot call .{}() — receiver has no __type", method) - )?; - let fndef = self.methods.get(&(type_tag.clone(), method.clone())) - .cloned() - .ok_or_else(|| format!("no method '{}' on type '{}'", method, type_tag))?; - let mut eval_args = vec![recv_val]; - for a in args { - eval_args.push(self.eval_expr(a, depth)?); + // typed receiver → look up method on the type + if let Some(tag) = type_tag { + if let Some(fndef) = self.methods.get(&(tag.clone(), method.clone())).cloned() { + let mut eval_args = vec![recv_val]; + for a in args { + eval_args.push(self.eval_expr(a, depth)?); + } + return self.call_fndef(&fndef, &eval_args, depth); + } + return Err(format!("no method '{}' on type '{}'", method, tag)); } - self.call_fndef(&fndef, &eval_args, depth) + // untyped receiver → desugar to method(receiver, args...) + let mut call_args: Vec = Vec::with_capacity(args.len() + 1); + let placeholder = format!("__method_recv_{}", depth); + self.vars.insert(placeholder.clone(), recv_val); + call_args.push(Expr::Ident(placeholder.clone())); + for a in args { + call_args.push(a.clone()); + } + let result = self.eval_call(method, &call_args, depth); + self.vars.remove(&placeholder); + result } Expr::StaticCall(type_name_str, method, args) => { let fndef = self.methods.get(&(type_name_str.clone(), method.clone())) @@ -3430,25 +3453,33 @@ fn builtin_constant(name: &str) -> Option { } fn value_is_kind(v: &Value, kind: &str) -> bool { - match (kind, v) { + match (canonical_type(kind), v) { ("int", Value::Number(n)) => *n == n.trunc() && n.is_finite(), ("float", Value::Number(_)) => true, ("number", Value::Number(_)) => true, ("bool", Value::Bool(_)) => true, ("str", Value::Str(_)) => true, ("array", Value::Array(_)) => true, + ("struct", Value::Struct(_)) => true, + ("ring", Value::Ring(_)) => true, + ("void", Value::Void) => true, _ => false, } } fn try_cast(v: &Value, target: &str) -> Option { - match (target, v) { + match (canonical_type(target), v) { ("int", Value::Number(n)) if *n == n.trunc() && n.is_finite() => { Some(Value::Number(*n)) } ("float", Value::Number(_)) => Some(v.clone()), + ("number", Value::Number(_)) => Some(v.clone()), ("bool", Value::Bool(_)) => Some(v.clone()), ("str", Value::Str(_)) => Some(v.clone()), + ("array", Value::Array(_)) => Some(v.clone()), + ("struct", Value::Struct(_)) => Some(v.clone()), + ("ring", Value::Ring(_)) => Some(v.clone()), + ("void", Value::Void) => Some(Value::Void), ("bool", Value::Number(n)) => { if *n == 0.0 { @@ -3476,6 +3507,7 @@ fn try_cast(v: &Value, target: &str) -> Option { .filter(|n| *n == n.trunc() && n.is_finite()) .map(Value::Number), ("float", Value::Str(s)) => s.parse::().ok().map(Value::Number), + ("str", Value::Array(_)) => Some(Value::Str(v.display())), _ => None, } @@ -3486,7 +3518,13 @@ fn coerce_to(val: &Value, target: &str) -> Result { return Err(format!("unknown type annotation: {}", target)); } - let t1 = match try_cast(val, target) { + let canon = canonical_type(target); + + if value_is_kind(val, canon) { + return Ok(val.clone()); + } + + let t1 = match try_cast(val, canon) { Some(v) => v, None => { return Err(format!( @@ -3498,6 +3536,10 @@ fn coerce_to(val: &Value, target: &str) -> Result { } }; + if matches!(canon, "array" | "struct" | "ring" | "void") { + return Ok(t1); + } + if values_equal(&t1, val) { return Ok(t1); } @@ -3536,7 +3578,25 @@ fn coerce_to(val: &Value, target: &str) -> Result { } fn is_known_type(t: &str) -> bool { - matches!(t, "int" | "float" | "bool" | "str") + matches!(canonical_type(t), + "int" | "float" | "bool" | "str" | "number" + | "array" | "struct" | "void" | "ring") +} + +/// folds aliases onto a canonical type name. +fn canonical_type(t: &str) -> &str { + match t { + "arr" | "array" | "vec" | "[]" | "list" => "array", + "int" | "integer" => "int", + "float" | "f64" | "f32" => "float", + "bool" | "boolean" => "bool", + "str" | "string" | "String" => "str", + "number" | "num" => "number", + "void" | "null" | "nil" | "none" | "unit" | "()" => "void", + "struct" | "obj" | "object" | "map" | "dict" => "struct", + "ring" => "ring", + other => other, + } } fn values_equal(a: &Value, b: &Value) -> bool { @@ -5962,6 +6022,97 @@ fn find(arr, target) { assert!(result.unwrap_err().contains("missing required method 'bounds'")); } + #[test] + fn fn_return_type_arr_accepts_array() { + let mut i = Interpreter::new(); + i.exec_line("fn which(degree) -> arr {\n return [0, 0]\n}").unwrap(); + let v = i.eval_expr_str("which(5)").unwrap(); + match v { + Value::Array(a) => assert_eq!(a.len(), 2), + other => panic!("expected array, got {:?}", other), + } + } + + #[test] + fn fn_return_type_array_aliases() { + let mut i = Interpreter::new(); + for ty in ["arr", "array", "vec", "list"] { + i.exec_line(&format!("fn f_{}() -> {} {{ return [1, 2, 3] }}", ty, ty)).unwrap(); + let v = i.eval_expr_str(&format!("f_{}()", ty)).unwrap(); + assert!(matches!(v, Value::Array(_)), "{} return type rejected", ty); + } + } + + #[test] + fn fn_return_type_null_accepts_void() { + let mut i = Interpreter::new(); + i.exec_line("fn noop() -> null { }").unwrap(); + let v = i.eval_expr_str("noop()").unwrap(); + assert!(matches!(v, Value::Void)); + } + + #[test] + fn let_with_array_type_annotation() { + let mut i = Interpreter::new(); + i.exec_line("let pos: arr = [1, 2, 3, 4]").unwrap(); + let v = i.get_var("pos").unwrap(); + assert!(matches!(v, Value::Array(_))); + } + + #[test] + fn let_array_type_aliases() { + for ty in ["arr", "array", "vec", "list"] { + let mut i = Interpreter::new(); + i.exec_line(&format!("let x: {} = [1, 2]", ty)).unwrap(); + assert!(matches!(i.get_var("x").unwrap(), Value::Array(_)), + "alias {} failed", ty); + } + } + + #[test] + fn method_len_on_array() { + let mut i = Interpreter::new(); + i.exec_line("let pos = [1, 2, 3, 4]").unwrap(); + let v = i.eval_expr_str("pos.len()").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 4.0)); + } + + #[test] + fn method_call_on_array_literal() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("[1, 2, 3].len()").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 3.0)); + } + + #[test] + fn for_loop_does_not_leak_var() { + let mut i = Interpreter::new(); + i.exec_line("let x = [1, 2, 3]").unwrap(); + i.exec_line("for x in 0..3 { let _ = x }").unwrap(); + let after = i.get_var("x").unwrap(); + assert!(matches!(after, Value::Array(_)), "for loop leaked: x is now {:?}", after); + } + + #[test] + fn for_loop_in_while_with_shadowing() { + let mut i = Interpreter::new(); + i.exec_line("let pos = [10, 20, 30, 40]").unwrap(); + i.exec_line("let count = 0").unwrap(); + i.exec_line("let iters = 0").unwrap(); + i.exec_line("while iters < 2 {\n for pos in 0..len(pos) {\n count = count + 1\n }\n iters = iters + 1\n}").unwrap(); + let count = i.get_var("count").unwrap(); + assert!(matches!(count, Value::Number(n) if n == 8.0), + "expected 8 inner iterations, got {:?}", count); + } + + #[test] + fn fn_return_type_struct_accepts_struct() { + let mut i = Interpreter::new(); + i.exec_line("fn make() -> struct {\n return {x: 1, y: 2}\n}").unwrap(); + let v = i.eval_expr_str("make()").unwrap(); + assert!(matches!(v, Value::Struct(_))); + } + #[test] fn method_on_untyped_struct_errors() { let mut i = Interpreter::new(); diff --git a/viewport/src/browser/preview.rs b/viewport/src/browser/preview.rs index 637e83b..8395ccd 100644 --- a/viewport/src/browser/preview.rs +++ b/viewport/src/browser/preview.rs @@ -19,6 +19,7 @@ pub fn highlight_preview(source: &str) -> Vec { source: source.to_string(), user_idents: crate::syntax::scan_user_idents_in(source), rules: crate::syntax::SyntaxRules::cordial(), + heavy_token: 0, }; let mut highlighter = SyntaxHighlighter::new(&settings); diff --git a/viewport/src/editor/content.rs b/viewport/src/editor/content.rs index d104e55..6b3c596 100644 --- a/viewport/src/editor/content.rs +++ b/viewport/src/editor/content.rs @@ -398,14 +398,6 @@ impl super::EditorState { !tb.selection.is_empty() || tb.spillover.is_some() } - /// jumps the focused text block's internal scroll to the given fraction (0.0..1.0). - pub(super) fn jump_to_fraction(&mut self, frac: f32) { - let frac = frac.clamp(0.0, 1.0); - let line_count = self.content().line_count().max(1); - let target = ((line_count as f32 * frac) as usize).min(line_count.saturating_sub(1)); - self.content_mut().jump_to_line(target); - } - /// scrolls the viewport and places the cursor at the given line. pub(super) fn jump_to_line(&mut self, line: usize) { let clamped = line.min(self.content().line_count().saturating_sub(1)); diff --git a/viewport/src/editor/eval.rs b/viewport/src/editor/eval.rs index 8abec12..7f37b39 100644 --- a/viewport/src/editor/eval.rs +++ b/viewport/src/editor/eval.rs @@ -40,6 +40,10 @@ impl super::EditorState { } pub fn tick(&mut self) { + if self.heavy_pending && self.last_edit.elapsed().as_millis() >= 300 { + self.heavy_token = self.heavy_token.wrapping_add(1); + self.heavy_pending = false; + } if self.render_mode != RenderMode::Live { return; } if self.eval_dirty && self.last_edit.elapsed().as_millis() >= EVAL_DEBOUNCE_MS { self.eval_dirty = false; @@ -85,6 +89,7 @@ impl super::EditorState { self.parsed = markdown::parse(&text).collect(); self.rebuild_modules(); self.refresh_text_caches(); + self.heavy_pending = true; } pub(super) fn build_block_infos(&self) -> Vec { diff --git a/viewport/src/editor/mod.rs b/viewport/src/editor/mod.rs index c04d627..e3f7d71 100644 --- a/viewport/src/editor/mod.rs +++ b/viewport/src/editor/mod.rs @@ -247,6 +247,7 @@ impl EditorState { source: tb.content.text(), user_idents: self.cached_user_idents.clone(), rules: self.syntax_rules.clone(), + heavy_token: self.heavy_token, }; let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::( @@ -715,6 +716,7 @@ impl EditorState { source: tb.content.text(), user_idents: self.cached_user_idents.clone(), rules: self.syntax_rules.clone(), + heavy_token: self.heavy_token, }; editor .highlight_with::( diff --git a/viewport/src/editor/state.rs b/viewport/src/editor/state.rs index b4fe1b5..84f31d1 100644 --- a/viewport/src/editor/state.rs +++ b/viewport/src/editor/state.rs @@ -111,6 +111,10 @@ pub struct EditorState { pub(super) cached_minimap_lines: Vec, /// custom keyword/builtin/type table layered on top of Cordial. pub syntax_rules: crate::syntax::SyntaxRules, + /// tree-sitter rebuild gate, bumped on idle. + pub(super) heavy_token: u64, + /// deferred-rebuild arm; set on edit, cleared on debounce fire. + pub(super) heavy_pending: bool, } impl EditorState { @@ -178,6 +182,8 @@ impl EditorState { cached_user_idents: HashMap::new(), cached_minimap_lines: Vec::new(), syntax_rules: crate::syntax::SyntaxRules::cordial(), + heavy_token: 0, + heavy_pending: false, } } diff --git a/viewport/src/embed.rs b/viewport/src/embed.rs index abb9396..023c117 100644 --- a/viewport/src/embed.rs +++ b/viewport/src/embed.rs @@ -1,60 +1,21 @@ -//! helpers for embedding EditorState inside an external iced application. -//! -//! the editor produces a fully-functional iced widget via EditorState::view(). -//! callers also need to drive periodic state work (eval debounce, autosave hints) -//! by calling EditorState::tick() once per frame, and drain pending clipboard or -//! shell-action output. these helpers wrap the common patterns. -//! -//! example: -//! -//! ```ignore -//! use acord_viewport::{EditorState, embed}; -//! -//! struct App { editor: EditorState } -//! -//! enum Msg { Acord(acord_viewport::Message), Tick } -//! -//! fn update(&mut self, msg: Msg) { -//! match msg { -//! Msg::Acord(m) => self.editor.update(m), -//! Msg::Tick => { -//! self.editor.tick(); -//! let pending = self.editor.drain_pending(); -//! if let Some(text) = pending.clipboard { -//! // copy to host clipboard -//! } -//! } -//! } -//! } -//! -//! fn view(&self) -> Element<'_, Msg> { -//! self.editor.view().map(Msg::Acord) -//! } -//! -//! fn subscription(&self) -> Subscription { -//! iced::time::every(embed::TICK_INTERVAL).map(|_| Msg::Tick) -//! } -//! ``` +//! embedding helpers for EditorState inside an external iced application. use std::time::Duration; use crate::editor::EditorState; -/// recommended tick interval for embedded use (60 fps cadence). +/// tick cadence for embedded use. pub const TICK_INTERVAL: Duration = Duration::from_millis(16); -/// snapshot of host-handled state the editor produced this frame. +/// host-handled output produced during the current frame. pub struct Pending { - /// text the editor wants written to the host clipboard, if any. pub clipboard: Option, - /// numeric command the host shell should act on, if any. pub shell_action: Option, - /// widget that should receive iced focus this frame, if any. pub focus: Option, } impl EditorState { - /// pulls every host-handled output for this frame and clears it from the editor. + /// pulls clipboard, shell-action, and focus output and clears it. pub fn drain_pending(&mut self) -> Pending { Pending { clipboard: self.pending_clipboard.take(), @@ -63,8 +24,7 @@ impl EditorState { } } - /// records the surface size so the minimap, scrollable math, and free-layer - /// placement can size themselves correctly. + /// records the current surface size. pub fn set_viewport_size(&mut self, width: f32, height: f32) { self.viewport_size = (width, height); } diff --git a/viewport/src/minimap.rs b/viewport/src/minimap.rs index 8adede5..55953f4 100644 --- a/viewport/src/minimap.rs +++ b/viewport/src/minimap.rs @@ -1,6 +1,4 @@ -use iced_wgpu::core::{ - alignment, mouse, Color, Element, Font, Length, Theme, -}; +use iced_wgpu::core::{Color, Element, Font, Length, Theme}; use iced_widget::{column, container, mouse_area, text}; use crate::palette; @@ -143,7 +141,7 @@ fn parse_impl_entry(rest: &str, line: usize) -> MapEntry { } } -/// strips `pub`, `pub(crate)`, `pub(super)`, `pub(in path)` from the front. +/// strips a leading Rust visibility modifier. fn strip_visibility(s: &str) -> &str { if let Some(rest) = s.strip_prefix("pub(") { if let Some(end) = rest.find(')') { diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index 6615c0d..ead1cfb 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -78,6 +78,8 @@ pub struct SyntaxSettings { pub user_idents: HashMap, /// optional extra keywords/builtins layered on top of Cordial. pub rules: SyntaxRules, + /// rebuild gate; tree-sitter re-runs only when this changes. + pub heavy_token: u64, } /// extensible token table for languages built on top of Cordial. @@ -88,6 +90,8 @@ pub struct SyntaxRules { pub extra_types: std::collections::BTreeSet, /// when true, the hardcoded Cordial keywords/builtins remain active. pub include_cordial: bool, + /// routes every non-fenced line through the Cordial scanner, bypassing the line classifier. + pub assume_cordial: bool, } impl SyntaxRules { @@ -98,6 +102,7 @@ impl SyntaxRules { extra_builtins: Default::default(), extra_types: Default::default(), include_cordial: true, + assume_cordial: false, } } @@ -108,9 +113,16 @@ impl SyntaxRules { extra_builtins: Default::default(), extra_types: Default::default(), include_cordial: false, + assume_cordial: false, } } + /// builder for the assume_cordial flag. + pub fn assume_cordial(mut self, on: bool) -> Self { + self.assume_cordial = on; + self + } + pub fn keyword(mut self, w: impl Into) -> Self { self.extra_keywords.insert(w.into()); self @@ -171,11 +183,11 @@ pub struct SyntaxHighlighter { /// per-line tree-sitter spans for fenced code body lines, keyed by absolute line index. code_block_spans: HashMap, SyntaxHighlight)>>, rules: SyntaxRules, + last_heavy_token: Option, } impl SyntaxHighlighter { - fn rebuild(&mut self, source: &str) { - self.spans = highlight_source(source, &self.lang); + fn rebuild(&mut self, source: &str, heavy_token: u64) { self.line_offsets.clear(); let mut offset = 0; for line in source.split('\n') { @@ -212,7 +224,11 @@ impl SyntaxHighlighter { self.in_fenced_code = false; self.current_line = 0; - self.scan_fenced_code_blocks(source); + if self.last_heavy_token != Some(heavy_token) { + self.spans = highlight_source(source, &self.lang); + self.scan_fenced_code_blocks(source); + self.last_heavy_token = Some(heavy_token); + } } /// highlights language-tagged fenced blocks via tree-sitter, stashing per-line spans. @@ -718,7 +734,17 @@ fn is_cordial_builtin(w: &str) -> bool { } fn is_cordial_type_annotation(w: &str) -> bool { - matches!(w, "int" | "float" | "bool" | "str" | "number" | "array" | "vec") + matches!(w, + "int" | "integer" + | "float" | "f64" | "f32" + | "number" | "num" + | "bool" | "boolean" + | "str" | "string" + | "array" | "arr" | "vec" | "list" + | "struct" | "obj" | "object" | "map" | "dict" + | "void" | "null" | "nil" | "none" | "unit" + | "ring" + ) } fn last_token_is_colon(spans: &[(Range, SyntaxHighlight)]) -> bool { @@ -969,8 +995,9 @@ impl highlighter::Highlighter for SyntaxHighlighter { user_idents: settings.user_idents.clone(), code_block_spans: HashMap::new(), rules: settings.rules.clone(), + last_heavy_token: None, }; - h.rebuild(&settings.source); + h.rebuild(&settings.source, settings.heavy_token); h } @@ -978,7 +1005,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { self.lang = new_settings.lang.clone(); self.user_idents = new_settings.user_idents.clone(); self.rules = new_settings.rules.clone(); - self.rebuild(&new_settings.source); + self.rebuild(&new_settings.source, new_settings.heavy_token); } fn change_line(&mut self, line: usize) { @@ -1003,6 +1030,12 @@ impl highlighter::Highlighter for SyntaxHighlighter { // pure-code mode bypasses cordial and markdown classifiers. let is_pure_code = !self.lang.is_empty(); + if !is_pure_code && self.rules.assume_cordial && !self.in_fenced_code { + if !trimmed.starts_with("```") { + return highlight_cordial(line, &self.user_idents, &self.rules).into_iter(); + } + } + if !is_pure_code && ln < self.line_kinds.len() && matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment) diff --git a/viewport/src/text_block.rs b/viewport/src/text_block.rs index b2edf36..33e788c 100644 --- a/viewport/src/text_block.rs +++ b/viewport/src/text_block.rs @@ -94,6 +94,7 @@ impl Block for TextBlock { user_idents: syntax::scan_user_idents_in(&source), rules: syntax::SyntaxRules::cordial(), source, + heavy_token: 0, }; let editor_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::(