commit 18ff0a052544cdf9a2f89d71f94bf671cd562c95 Author: jess Date: Wed Apr 15 02:39:18 2026 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b908cff --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +target/ +build/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f3e8ebd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libraries/Cord"] + path = libraries/Cord + url = git@ssh-git.else-if.org:jess/Cord.git diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5817dea --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["core", "viewport"] +resolver = "2" + +[profile.release] +panic = "abort" diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..87174e8 --- /dev/null +++ b/Info.plist @@ -0,0 +1,428 @@ + + + + + CFBundleExecutable + Acord + CFBundleIdentifier + org.else-if.acord + CFBundleName + Acord + CFBundleDisplayName + Acord + CFBundlePackageType + APPL + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.developer-tools + CFBundleIconFile + AppIcon + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + net.daringfireball.markdown + + CFBundleTypeExtensions + + md + markdown + mdown + + + + CFBundleTypeName + CSV + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.comma-separated-values-text + + CFBundleTypeExtensions + + csv + + + + CFBundleTypeName + JSON + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.json + + CFBundleTypeExtensions + + json + + + + CFBundleTypeName + TOML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + toml + + + + CFBundleTypeName + YAML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + yaml + yml + + + + CFBundleTypeName + XML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.xml + public.svg-image + + CFBundleTypeExtensions + + xml + svg + + + + CFBundleTypeName + Rust Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + rs + + + + CFBundleTypeName + C/C++ Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.c-source + public.c-plus-plus-source + public.c-header + + CFBundleTypeExtensions + + c + cpp + cc + cxx + h + hpp + hxx + + + + CFBundleTypeName + JavaScript/TypeScript + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + com.netscape.javascript-source + + CFBundleTypeExtensions + + js + jsx + ts + tsx + + + + CFBundleTypeName + HTML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.html + + CFBundleTypeExtensions + + html + htm + + + + CFBundleTypeName + CSS + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + css + scss + less + + + + CFBundleTypeName + Python Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.python-script + + CFBundleTypeExtensions + + py + + + + CFBundleTypeName + Go Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + go + + + + CFBundleTypeName + Ruby Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.ruby-script + + CFBundleTypeExtensions + + rb + + + + CFBundleTypeName + PHP Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.php-script + + CFBundleTypeExtensions + + php + + + + CFBundleTypeName + Lua Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + lua + + + + CFBundleTypeName + Shell Script + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.shell-script + + CFBundleTypeExtensions + + sh + bash + zsh + fish + + + + CFBundleTypeName + Java/Kotlin Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + java + kt + kts + + + + CFBundleTypeName + Swift Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.swift-source + + CFBundleTypeExtensions + + swift + + + + CFBundleTypeName + Zig Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + zig + + + + CFBundleTypeName + SQL + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + sql + + + + CFBundleTypeName + Makefile + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + mk + + + + CFBundleTypeName + Dockerfile + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + dockerfile + + + + CFBundleTypeName + Configuration + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + ini + cfg + conf + env + + + + CFBundleTypeName + Lock File + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + lock + + + + CFBundleTypeName + Plain Text + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.plain-text + + CFBundleTypeExtensions + + txt + text + log + + + + + diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..a38c8a4 --- /dev/null +++ b/LICENCE @@ -0,0 +1,12 @@ +This is free to use, without conditions. + +There is no licence here on purpose. Individuals, students, hobbyists — take what +you need, make it yours, don't think twice. You'd flatter me. + +The absence of a licence is deliberate. A licence is a legal surface. Words can be +reinterpreted, and corporations employ lawyers whose job is exactly that. Silence is +harder to exploit than language. If a company wants to use this, the lack of explicit +permission makes it just inconvenient enough to matter. + +This won't change the balance of power. But it shifts the weight, even slightly, away +from the system that co-opts open work for closed profit. That's enough for me. diff --git a/assets/Acord.svg b/assets/Acord.svg new file mode 100644 index 0000000..87a17c0 --- /dev/null +++ b/assets/Acord.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..b3f18ff --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "acord-core" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib"] + +[dependencies] +cord-expr = { path = "../../Cord/crates/cord-expr" } +cord-trig = { path = "../../Cord/crates/cord-trig" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } + +tree-sitter = "0.24" +tree-sitter-highlight = "0.24" +tree-sitter-language = "0.1" +tree-sitter-rust = "0.23" +tree-sitter-c = "0.23" +tree-sitter-cpp = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-python = "0.23" +tree-sitter-go = "0.23" +tree-sitter-ruby = "0.23" +tree-sitter-bash = "0.23" +tree-sitter-java = "0.23" +tree-sitter-html = "0.23" +tree-sitter-css = "0.23" +tree-sitter-json = "0.24" +tree-sitter-lua = "0.4" +tree-sitter-php = "0.23" +tree-sitter-toml-ng = "0.7" +tree-sitter-yaml = "0.6" +tree-sitter-swift = "0.6" +tree-sitter-zig = "1" +tree-sitter-sequel = "0.3" +tree-sitter-md = "0.5" +tree-sitter-make = "1" + +[build-dependencies] +cbindgen = "0.29" diff --git a/core/build.rs b/core/build.rs new file mode 100644 index 0000000..eb72532 --- /dev/null +++ b/core/build.rs @@ -0,0 +1,21 @@ +fn main() { + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + + let config = cbindgen::Config::from_file("cbindgen.toml") + .unwrap_or_default(); + + match cbindgen::Builder::new() + .with_crate(&crate_dir) + .with_config(config) + .generate() + { + Ok(bindings) => { + let path = format!("{}/include/acord.h", crate_dir); + bindings.write_to_file(&path); + println!("cargo:warning=cbindgen: wrote {}", path); + } + Err(e) => { + println!("cargo:warning=cbindgen: {}", e); + } + } +} diff --git a/core/cbindgen.toml b/core/cbindgen.toml new file mode 100644 index 0000000..ee86b11 --- /dev/null +++ b/core/cbindgen.toml @@ -0,0 +1,17 @@ +language = "C" +header = "/* Generated by cbindgen — do not edit */" +include_guard = "SWIFTLY_H" +include_version = false +tab_width = 4 +documentation = false +style = "both" + +[export] +include = [] +exclude = [] + +[fn] +prefix = "" + +[parse] +parse_deps = false diff --git a/core/include/acord.h b/core/include/acord.h new file mode 100644 index 0000000..f2645ef --- /dev/null +++ b/core/include/acord.h @@ -0,0 +1,39 @@ +/* Generated by cbindgen — do not edit */ + +#ifndef SWIFTLY_H +#define SWIFTLY_H + +#include +#include +#include +#include + +typedef struct AcordDoc AcordDoc; + + struct AcordDoc *acord_doc_new(void); + + void acord_doc_free(struct AcordDoc *doc); + + void acord_doc_set_text(struct AcordDoc *doc, const char *text); + + char *acord_doc_get_text(const struct AcordDoc *doc); + + char *acord_doc_evaluate(struct AcordDoc *doc); + + char *acord_eval_line(const char *text); + + bool acord_doc_save(const struct AcordDoc *doc, const char *path); + + struct AcordDoc *acord_doc_load(const char *path); + + char *acord_cache_save(const struct AcordDoc *doc); + + struct AcordDoc *acord_cache_load(const char *uuid); + + char *acord_list_notes(void); + + char *acord_highlight(const char *source, const char *lang); + + void acord_free_string(char *s); + +#endif /* SWIFTLY_H */ diff --git a/core/queries/dockerfile-highlights.scm b/core/queries/dockerfile-highlights.scm new file mode 100644 index 0000000..a5d6514 --- /dev/null +++ b/core/queries/dockerfile-highlights.scm @@ -0,0 +1,58 @@ +[ + "FROM" + "AS" + "RUN" + "CMD" + "LABEL" + "EXPOSE" + "ENV" + "ADD" + "COPY" + "ENTRYPOINT" + "VOLUME" + "USER" + "WORKDIR" + "ARG" + "ONBUILD" + "STOPSIGNAL" + "HEALTHCHECK" + "SHELL" + "MAINTAINER" + "CROSS_BUILD" + (heredoc_marker) + (heredoc_end) +] @keyword + +[ + ":" + "@" +] @operator + +(comment) @comment + + +(image_spec + (image_tag + ":" @punctuation.special) + (image_digest + "@" @punctuation.special)) + +[ + (double_quoted_string) + (single_quoted_string) + (json_string) + (heredoc_line) +] @string + +(expansion + [ + "$" + "{" + "}" + ] @punctuation.special +) @none + +((variable) @constant + (#match? @constant "^[A-Z][A-Z_0-9]*$")) + + diff --git a/core/queries/kotlin-highlights.scm b/core/queries/kotlin-highlights.scm new file mode 100644 index 0000000..d2e15a6 --- /dev/null +++ b/core/queries/kotlin-highlights.scm @@ -0,0 +1,380 @@ +;; Based on the nvim-treesitter highlighting, which is under the Apache license. +;; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm +;; +;; The only difference in this file is that queries using #lua-match? +;; have been removed. + +;;; Identifiers + +(simple_identifier) @variable + +; `it` keyword inside lambdas +; FIXME: This will highlight the keyword outside of lambdas since tree-sitter +; does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin +(#eq? @variable.builtin "it")) + +; `field` keyword inside property getter/setter +; FIXME: This will highlight the keyword outside of getters and setters +; since tree-sitter does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin +(#eq? @variable.builtin "field")) + +; `this` this keyword inside classes +(this_expression) @variable.builtin + +; `super` keyword inside classes +(super_expression) @variable.builtin + +(class_parameter + (simple_identifier) @property) + +(class_body + (property_declaration + (variable_declaration + (simple_identifier) @property))) + +; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties +(_ + (navigation_suffix + (simple_identifier) @property)) + +(enum_entry + (simple_identifier) @constant) + +(type_identifier) @type + +((type_identifier) @type.builtin + (#any-of? @type.builtin + "Byte" + "Short" + "Int" + "Long" + "UByte" + "UShort" + "UInt" + "ULong" + "Float" + "Double" + "Boolean" + "Char" + "String" + "Array" + "ByteArray" + "ShortArray" + "IntArray" + "LongArray" + "UByteArray" + "UShortArray" + "UIntArray" + "ULongArray" + "FloatArray" + "DoubleArray" + "BooleanArray" + "CharArray" + "Map" + "Set" + "List" + "EmptyMap" + "EmptySet" + "EmptyList" + "MutableMap" + "MutableSet" + "MutableList" +)) + +(package_header + . (identifier)) @namespace + +(import_header + "import" @include) + + +; TODO: Seperate labeled returns/breaks/continue/super/this +; Must be implemented in the parser first +(label) @label + +;;; Function definitions + +(function_declaration + . (simple_identifier) @function) + +(getter + ("get") @function.builtin) +(setter + ("set") @function.builtin) + +(primary_constructor) @constructor +(secondary_constructor + ("constructor") @constructor) + +(constructor_invocation + (user_type + (type_identifier) @constructor)) + +(anonymous_initializer + ("init") @constructor) + +(parameter + (simple_identifier) @parameter) + +(parameter_with_optional_type + (simple_identifier) @parameter) + +; lambda parameters +(lambda_literal + (lambda_parameters + (variable_declaration + (simple_identifier) @parameter))) + +;;; Function calls + +; function() +(call_expression + . (simple_identifier) @function) + +; object.function() or object.property.function() +(call_expression + (navigation_expression + (navigation_suffix + (simple_identifier) @function) . )) + +(call_expression + . (simple_identifier) @function.builtin + (#any-of? @function.builtin + "arrayOf" + "arrayOfNulls" + "byteArrayOf" + "shortArrayOf" + "intArrayOf" + "longArrayOf" + "ubyteArrayOf" + "ushortArrayOf" + "uintArrayOf" + "ulongArrayOf" + "floatArrayOf" + "doubleArrayOf" + "booleanArrayOf" + "charArrayOf" + "emptyArray" + "mapOf" + "setOf" + "listOf" + "emptyMap" + "emptySet" + "emptyList" + "mutableMapOf" + "mutableSetOf" + "mutableListOf" + "print" + "println" + "error" + "TODO" + "run" + "runCatching" + "repeat" + "lazy" + "lazyOf" + "enumValues" + "enumValueOf" + "assert" + "check" + "checkNotNull" + "require" + "requireNotNull" + "with" + "suspend" + "synchronized" +)) + +;;; Literals + +[ + (line_comment) + (multiline_comment) + (shebang_line) +] @comment + +(real_literal) @float +[ + (integer_literal) + (long_literal) + (hex_literal) + (bin_literal) + (unsigned_literal) +] @number + +[ + "null" ; should be highlighted the same as booleans + (boolean_literal) +] @boolean + +(character_literal) @character + +(string_literal) @string + +(character_escape_seq) @string.escape + +; There are 3 ways to define a regex +; - "[abc]?".toRegex() +(call_expression + (navigation_expression + ((string_literal) @string.regex) + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "toRegex"))))) + +; - Regex("[abc]?") +(call_expression + ((simple_identifier) @_function + (#eq? @_function "Regex")) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +; - Regex.fromLiteral("[abc]?") +(call_expression + (navigation_expression + ((simple_identifier) @_class + (#eq? @_class "Regex")) + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "fromLiteral")))) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +;;; Keywords + +(type_alias "typealias" @keyword) +[ + (class_modifier) + (member_modifier) + (function_modifier) + (property_modifier) + (platform_modifier) + (variance_modifier) + (parameter_modifier) + (visibility_modifier) + (reification_modifier) + (inheritance_modifier) +]@keyword + +[ + "val" + "var" + "enum" + "class" + "object" + "interface" +; "typeof" ; NOTE: It is reserved for future use +] @keyword + +("fun") @keyword.function + +(jump_expression) @keyword.return + +[ + "if" + "else" + "when" +] @conditional + +[ + "for" + "do" + "while" +] @repeat + +[ + "try" + "catch" + "throw" + "finally" +] @exception + + +(annotation + "@" @attribute (use_site_target)? @attribute) +(annotation + (user_type + (type_identifier) @attribute)) +(annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +(file_annotation + "@" @attribute "file" @attribute ":" @attribute) +(file_annotation + (user_type + (type_identifier) @attribute)) +(file_annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +;;; Operators & Punctuation + +[ + "!" + "!=" + "!==" + "=" + "==" + "===" + ">" + ">=" + "<" + "<=" + "||" + "&&" + "+" + "++" + "+=" + "-" + "--" + "-=" + "*" + "*=" + "/" + "/=" + "%" + "%=" + "?." + "?:" + "!!" + "is" + "!is" + "in" + "!in" + "as" + "as?" + ".." + "->" +] @operator + +[ + "(" ")" + "[" "]" + "{" "}" +] @punctuation.bracket + +[ + "." + "," + ";" + ":" + "::" +] @punctuation.delimiter + +; NOTE: `interpolated_identifier`s can be highlighted in any way +(string_literal + "$" @punctuation.special + (interpolated_identifier) @none) +(string_literal + "${" @punctuation.special + (interpolated_expression) @none + "}" @punctuation.special) diff --git a/core/queries/sql-highlights.scm b/core/queries/sql-highlights.scm new file mode 100644 index 0000000..5cc586b --- /dev/null +++ b/core/queries/sql-highlights.scm @@ -0,0 +1,51 @@ +; (identifier) @variable FIXME this overrides function call pattern +(string) @string +(number) @number +(comment) @comment + +(function_call + function: (identifier) @function) + +[ + (NULL) + (TRUE) + (FALSE) +] @constant.builtin + +[ + "<" + "<=" + "<>" + "=" + ">" + ">=" + "::" +] @operator + +[ + "(" + ")" + "[" + "]" +] @punctuation.bracket + +[ + (type) + (array_type) +] @type + + +[ + "CREATE TABLE" + "CREATE TYPE" + "CREATE DOMAIN" + "CREATE" + "INDEX" + "UNIQUE" + "SELECT" + "WHERE" + "FROM" + "AS" + "GROUP BY" + "ORDER BY" +] @keyword diff --git a/core/src/doc.rs b/core/src/doc.rs new file mode 100644 index 0000000..353644d --- /dev/null +++ b/core/src/doc.rs @@ -0,0 +1,380 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum LineKind { + Markdown, + Cordial, + Eval, + Comment, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassifiedLine { + pub index: usize, + pub kind: LineKind, + pub content: String, +} + +pub fn classify_line(index: usize, raw: &str) -> ClassifiedLine { + let trimmed = raw.trim(); + + let kind = if trimmed.starts_with("/=") { + LineKind::Eval + } else if trimmed.starts_with("//") { + LineKind::Comment + } else if is_cordial(trimmed) { + LineKind::Cordial + } else { + LineKind::Markdown + }; + + ClassifiedLine { + index, + kind, + content: raw.to_string(), + } +} + +fn is_cordial(line: &str) -> bool { + if line.starts_with("let ") { + let rest = &line[4..]; + if let Some(colon_pos) = rest.find(':') { + let before_colon = rest[..colon_pos].trim(); + if is_ident(before_colon) { + let after_colon = &rest[colon_pos + 1..]; + if after_colon.contains('=') { + return true; + } + } + } + if let Some(eq_pos) = rest.find('=') { + let after_eq = rest.as_bytes().get(eq_pos + 1); + if after_eq != Some(&b'=') { + let name = rest[..eq_pos].trim(); + // Plain binding: `let x = …`. Covers every RHS — plain + // expressions, struct/macro-looking constructions like + // `let lfreq = solve!(l, f0)`, and the function-inversion + // math form `let f(a, b) = expr where …` (where the LHS + // is a function-def-shaped name+params, same as Cordial's + // existing top-level `f(x) = …` short form). + if is_ident(name) || is_assignment_target(name) { + return true; + } + } + } + return false; + } + + if line.starts_with("while ") || line.starts_with("while(") { return true; } + if line.starts_with("fn ") { return true; } + if line.starts_with("if ") || line.starts_with("if(") { return true; } + if line.starts_with("else ") || line == "else" || line.starts_with("else{") { return true; } + if line.starts_with("for ") { return true; } + if line.starts_with("return ") || line == "return" { return true; } + if line.starts_with("use ") { + let rest = line[4..].trim(); + if is_ident(rest.split("::").next().unwrap_or("")) { + return true; + } + } + if line == "}" || line.starts_with("} ") { return true; } + + if let Some(eq_pos) = line.find('=') { + if eq_pos > 0 { + let before = &line[..eq_pos]; + let after_eq = line.as_bytes().get(eq_pos + 1); + if after_eq != Some(&b'=') && !before.ends_with('!') && !before.ends_with('<') && !before.ends_with('>') { + let candidate = before.trim(); + if is_assignment_target(candidate) { + return true; + } + } + } + } + + false +} + +fn is_assignment_target(s: &str) -> bool { + // simple variable: `x` + if is_ident(s) { + return true; + } + // function def: `f(x)` or `f(x, y)` + if let Some(paren) = s.find('(') { + let name = &s[..paren]; + if is_ident(name) && s.ends_with(')') { + return true; + } + } + // cell-ref target: `@Table:A1`, `@Block::Table:A1`, or even bare + // `@Table` / `@Table:A1:B2`. The interpreter's parser surfaces + // whole-table / range mis-assignments as errors, so the classifier + // only needs to recognize the `@name…` shape here. + if let Some(rest) = s.strip_prefix('@') { + if let Some(first) = rest.chars().next() { + if first.is_alphabetic() || first == '_' { + return true; + } + } + } + false +} + +fn is_ident(s: &str) -> bool { + if s.is_empty() { return false; } + let mut chars = s.chars(); + let first = chars.next().unwrap(); + if !first.is_alphabetic() && first != '_' { return false; } + chars.all(|c| c.is_alphanumeric() || c == '_') +} + +pub fn classify_document(text: &str) -> Vec { + let mut result = Vec::new(); + let mut comment_depth: usize = 0; + let mut brace_depth: i32 = 0; + + for (i, line) in text.lines().enumerate() { + let was_in_comment = comment_depth > 0; + comment_depth = scan_comment_depth(line, comment_depth); + + if was_in_comment || line.trim().starts_with("/*") { + result.push(ClassifiedLine { index: i, kind: LineKind::Comment, content: line.to_string() }); + } else if brace_depth > 0 { + let trimmed = line.trim(); + let opens = trimmed.matches('{').count() as i32; + let closes = trimmed.matches('}').count() as i32; + brace_depth += opens - closes; + if brace_depth < 0 { brace_depth = 0; } + result.push(ClassifiedLine { index: i, kind: LineKind::Cordial, content: line.to_string() }); + } else { + let cl = classify_line(i, line); + if cl.kind == LineKind::Cordial { + let trimmed = line.trim(); + let opens = trimmed.matches('{').count() as i32; + let closes = trimmed.matches('}').count() as i32; + brace_depth += opens - closes; + if brace_depth < 0 { brace_depth = 0; } + } + result.push(cl); + } + } + + result +} + +fn scan_comment_depth(line: &str, mut depth: usize) -> usize { + let bytes = line.as_bytes(); + let len = bytes.len(); + let mut i = 0; + while i < len.saturating_sub(1) { + if bytes[i] == b'/' && bytes[i + 1] == b'*' { + depth += 1; + i += 2; + } else if bytes[i] == b'*' && bytes[i + 1] == b'/' { + depth = depth.saturating_sub(1); + i += 2; + } else { + i += 1; + } + } + depth +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markdown_line() { + let c = classify_line(0, "# Hello World"); + assert_eq!(c.kind, LineKind::Markdown); + } + + #[test] + fn eval_line() { + let c = classify_line(0, "/= 2 + 3"); + assert_eq!(c.kind, LineKind::Eval); + } + + #[test] + fn comment_line() { + let c = classify_line(0, "// this is a comment"); + assert_eq!(c.kind, LineKind::Comment); + } + + #[test] + fn let_binding() { + let c = classify_line(0, "let x = 5"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn variable_assignment() { + let c = classify_line(0, "x = 5"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn function_def() { + let c = classify_line(0, "f(x) = x^2"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn plain_text() { + let c = classify_line(0, "Some notes about the project"); + assert_eq!(c.kind, LineKind::Markdown); + } + + #[test] + fn let_prose_not_cordial() { + let c = classify_line(0, "let us consider something"); + assert_eq!(c.kind, LineKind::Markdown); + } + + #[test] + fn let_without_equals_not_cordial() { + let c = classify_line(0, "let me explain"); + assert_eq!(c.kind, LineKind::Markdown); + } + + #[test] + fn single_line_block_comment() { + let lines = classify_document("/* hello */"); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].kind, LineKind::Comment); + } + + #[test] + fn multiline_block_comment() { + let lines = classify_document("/* start\nmiddle\nend */\nlet x = 5"); + assert_eq!(lines.len(), 4); + assert_eq!(lines[0].kind, LineKind::Comment); + assert_eq!(lines[1].kind, LineKind::Comment); + assert_eq!(lines[2].kind, LineKind::Comment); + assert_eq!(lines[3].kind, LineKind::Cordial); + } + + #[test] + fn block_comment_then_code() { + let lines = classify_document("/* comment */\n/= 2 + 3"); + assert_eq!(lines[0].kind, LineKind::Comment); + assert_eq!(lines[1].kind, LineKind::Eval); + } + + #[test] + fn nested_block_comments() { + let lines = classify_document("/* outer /* inner */ still comment */\nlet x = 5"); + assert_eq!(lines[0].kind, LineKind::Comment); + assert_eq!(lines[1].kind, LineKind::Cordial); + } + + #[test] + fn nested_multiline_block_comments() { + let doc = "/* outer\n/* inner */\nstill in outer\n*/\nlet x = 5"; + let lines = classify_document(doc); + assert_eq!(lines[0].kind, LineKind::Comment); + assert_eq!(lines[1].kind, LineKind::Comment); + assert_eq!(lines[2].kind, LineKind::Comment); + assert_eq!(lines[3].kind, LineKind::Comment); + assert_eq!(lines[4].kind, LineKind::Cordial); + } + + #[test] + fn while_line() { + let c = classify_line(0, "while (i < 10) {"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn fn_line() { + let c = classify_line(0, "fn add(a, b) {"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn closing_brace() { + let c = classify_line(0, "}"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn while_block_body_classified() { + let doc = "while (x > 0) {\n x = x - 1\n}"; + let lines = classify_document(doc); + assert_eq!(lines[0].kind, LineKind::Cordial); + assert_eq!(lines[1].kind, LineKind::Cordial); + assert_eq!(lines[2].kind, LineKind::Cordial); + } + + #[test] + fn fn_block_body_classified() { + let doc = "fn add(a, b) {\n a + b\n}"; + let lines = classify_document(doc); + assert_eq!(lines[0].kind, LineKind::Cordial); + assert_eq!(lines[1].kind, LineKind::Cordial); + assert_eq!(lines[2].kind, LineKind::Cordial); + } + + #[test] + fn let_with_type_annotation() { + let c = classify_line(0, "let x: int = 5"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn let_with_bool_type() { + let c = classify_line(0, "let flag: bool = 1"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn if_line() { + let c = classify_line(0, "if (x > 5) {"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn else_line() { + let c = classify_line(0, "} else {"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn for_line() { + let c = classify_line(0, "for i in arr {"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn return_line() { + let c = classify_line(0, "return x"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn use_line() { + let c = classify_line(0, "use calculations"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn use_with_item() { + let c = classify_line(0, "use budget::ramp"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn use_prose_not_cordial() { + let c = classify_line(0, "use a fork to eat"); + assert_eq!(c.kind, LineKind::Markdown); + } + + #[test] + fn if_block_body_classified() { + let doc = "if (x > 5) {\n x = 1\n} else {\n x = 0\n}"; + let lines = classify_document(doc); + assert!(lines.iter().all(|l| l.kind == LineKind::Cordial)); + } +} diff --git a/core/src/document.rs b/core/src/document.rs new file mode 100644 index 0000000..6c3f616 --- /dev/null +++ b/core/src/document.rs @@ -0,0 +1,39 @@ +use crate::doc::{classify_document, ClassifiedLine}; +use crate::eval::{evaluate_document, DocumentResult}; + +pub struct AcordDoc { + pub text: String, + pub uuid: String, + lines: Vec, +} + +impl AcordDoc { + pub fn new() -> Self { + AcordDoc { + text: String::new(), + uuid: uuid::Uuid::new_v4().to_string(), + lines: Vec::new(), + } + } + + pub fn with_uuid(uuid: String) -> Self { + AcordDoc { + text: String::new(), + uuid, + lines: Vec::new(), + } + } + + pub fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + self.lines = classify_document(text); + } + + pub fn classified_lines(&self) -> &[ClassifiedLine] { + &self.lines + } + + pub fn evaluate(&self) -> DocumentResult { + evaluate_document(&self.text) + } +} diff --git a/core/src/eval.rs b/core/src/eval.rs new file mode 100644 index 0000000..52c1227 --- /dev/null +++ b/core/src/eval.rs @@ -0,0 +1,618 @@ +use serde::Serialize; +use crate::doc::{classify_document, LineKind}; +use crate::interp; + +#[derive(Debug, Clone, Serialize)] +pub struct EvalResult { + pub line: usize, + pub result: String, + pub format: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvalError { + pub line: usize, + pub error: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DocumentResult { + pub results: Vec, + pub errors: Vec, +} + +pub fn evaluate_document(text: &str) -> DocumentResult { + let classified = classify_document(text); + let mut results = Vec::new(); + let mut errors = Vec::new(); + + let mut lines: Vec<(usize, &str, bool)> = Vec::new(); + for cl in &classified { + match cl.kind { + LineKind::Cordial => lines.push((cl.index, &cl.content, false)), + LineKind::Eval => lines.push((cl.index, &cl.content, true)), + LineKind::Comment | LineKind::Markdown => {} + } + } + + let interp_results = interp::interpret_document(&lines); + for ir in interp_results { + let fmt = match ir.format { + interp::EvalFormat::Inline => "inline", + interp::EvalFormat::Table => "table", + interp::EvalFormat::Tree => "tree", + }; + match ir.value { + Some(interp::Value::Error(e)) => { + errors.push(EvalError { line: ir.line, error: e }); + } + Some(v) => { + let s = match ir.format { + interp::EvalFormat::Table => value_to_table_json(&v), + interp::EvalFormat::Tree => value_to_tree_json(&v), + interp::EvalFormat::Inline => v.display(), + }; + if !s.is_empty() { + results.push(EvalResult { line: ir.line, result: s, format: fmt.to_string() }); + } + } + None => {} + } + } + + DocumentResult { results, errors } +} + +fn value_to_table_json(val: &interp::Value) -> String { + match val { + interp::Value::Array(rows) => { + let table: Vec> = rows.iter().map(|row| { + match row { + interp::Value::Array(cols) => cols.iter().map(|c| c.display()).collect(), + other => vec![other.display()], + } + }).collect(); + serde_json::to_string(&table).unwrap_or_else(|_| val.display()) + } + _ => val.display(), + } +} + +fn value_to_tree_json(val: &interp::Value) -> String { + fn to_json(v: &interp::Value) -> serde_json::Value { + match v { + interp::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(|i| to_json(i)).collect()) + } + interp::Value::Number(n) => { + serde_json::Value::Number( + serde_json::Number::from_f64(*n) + .unwrap_or_else(|| serde_json::Number::from(0)) + ) + } + interp::Value::Bool(b) => serde_json::Value::Bool(*b), + interp::Value::Str(s) => serde_json::Value::String(s.clone()), + other => serde_json::Value::String(other.display()), + } + } + serde_json::to_string(&to_json(val)).unwrap_or_else(|_| val.display()) +} + +pub fn evaluate_line(text: &str) -> Result { + let mut interp = interp::Interpreter::new(); + match interp.eval_expr_str(text) { + Ok(v) => Ok(v.display()), + Err(_) => { + // fall back to cord-expr/cord-trig for trig and CORDIC expressions + let graph = cord_expr::parse_expr(text)?; + let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0); + Ok(format_value(val)) + } + } +} + +fn format_value(val: f64) -> String { + if val == val.trunc() && val.abs() < 1e15 { + format!("{}", val as i64) + } else { + let s = format!("{:.10}", val); + let s = s.trim_end_matches('0'); + let s = s.trim_end_matches('.'); + s.to_string() + } +} + +// --- Module evaluation pipeline --- + +/// Source material for a single module (block). +pub struct ModuleSource { + /// Module name (from heading text, normalized). + pub name: String, + /// Raw text content of all text blocks in this module, joined. + pub text: String, + /// True for the root module (H1 section). Its exports are auto-imported + /// into every other module. + pub is_root: bool, +} + +/// Per-module evaluation result. +pub struct ModuleResult { + pub name: String, + pub doc_result: DocumentResult, + pub exports: interp::ModuleExports, +} + +/// Evaluate modules in dependency order. Root module is evaluated first +/// and its exports are auto-imported into every other module. `use` +/// declarations are resolved via topological sort. Failed `use` (module +/// name doesn't match any source) is silently dropped. +pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec { + use std::collections::HashMap; + + // Index modules by name + let name_to_idx: HashMap<&str, usize> = sources.iter().enumerate() + .map(|(i, s)| (s.name.as_str(), i)) + .collect(); + + // Extract use declarations from each module + let use_decls: Vec> = sources.iter() + .map(|s| interp::extract_use_declarations(&s.text)) + .collect(); + + // Build adjacency list for topo sort (dependency edges: module -> modules it depends on) + let n = sources.len(); + let mut in_degree = vec![0usize; n]; + let mut dependents: Vec> = vec![Vec::new(); n]; // dep -> modules that depend on it + + for (i, decls) in use_decls.iter().enumerate() { + for decl in decls { + if let Some(&dep_idx) = name_to_idx.get(decl.module.as_str()) { + if dep_idx != i { + dependents[dep_idx].push(i); + in_degree[i] += 1; + } + } + // Unknown module names are silently ignored (failed use = prose) + } + } + + // Kahn's algorithm for topological sort. Root modules get priority + // (pushed to front of queue). + let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); + for (i, s) in sources.iter().enumerate() { + if in_degree[i] == 0 { + if s.is_root { + queue.push_front(i); + } else { + queue.push_back(i); + } + } + } + + let mut order: Vec = Vec::with_capacity(n); + while let Some(idx) = queue.pop_front() { + order.push(idx); + for &dep in &dependents[idx] { + in_degree[dep] -= 1; + if in_degree[dep] == 0 { + queue.push_back(dep); + } + } + } + + // Any modules not in `order` are part of a cycle. Append them at + // the end — they'll evaluate without their cyclic dependencies + // (which means their `use`d bindings won't be available, producing + // natural "undefined variable" errors downstream). + for i in 0..n { + if !order.contains(&i) { + order.push(i); + } + } + + // Evaluate in topological order + let mut exports_by_name: HashMap = HashMap::new(); + let mut root_exports: Option = None; + let mut results: Vec> = (0..n).map(|_| None).collect(); + + for &idx in &order { + let source = &sources[idx]; + + // Create interpreter with imported scope + let mut interp = interp::Interpreter::new(); + + // Auto-import root module exports (unless this IS the root) + if !source.is_root { + if let Some(ref root_exp) = root_exports { + interp.import_all(root_exp); + } + } + + // Import use'd modules' exports + for decl in &use_decls[idx] { + if let Some(module_exports) = exports_by_name.get(&decl.module) { + match &decl.item { + Some(s) if s == "*" => { + interp.import_all(module_exports); + } + None => { + interp.import_all(module_exports); + } + Some(item) => { + interp.import_item(module_exports, item); + } + } + } + } + + // Evaluate this module's text + let doc_result = evaluate_document_with_interp(&mut interp, &source.text); + let module_exports = interp.exports(); + + if source.is_root { + root_exports = Some(module_exports.clone()); + } + exports_by_name.insert(source.name.clone(), module_exports.clone()); + + results[idx] = Some(ModuleResult { + name: source.name.clone(), + doc_result, + exports: module_exports, + }); + } + + results.into_iter().flatten().collect() +} + +/// Evaluate a document's text using an existing (pre-populated) interpreter. +pub fn evaluate_document_with_interp(interp: &mut interp::Interpreter, text: &str) -> DocumentResult { + let classified = classify_document(text); + let mut results = Vec::new(); + let mut errors = Vec::new(); + + let mut lines: Vec<(usize, &str, bool)> = Vec::new(); + for cl in &classified { + match cl.kind { + LineKind::Cordial => lines.push((cl.index, &cl.content, false)), + LineKind::Eval => lines.push((cl.index, &cl.content, true)), + LineKind::Comment | LineKind::Markdown => {} + } + } + + let interp_results = interp::interpret_document_with(interp, &lines); + for ir in interp_results { + let fmt = match ir.format { + interp::EvalFormat::Inline => "inline", + interp::EvalFormat::Table => "table", + interp::EvalFormat::Tree => "tree", + }; + match ir.value { + Some(interp::Value::Error(e)) => { + errors.push(EvalError { line: ir.line, error: e }); + } + Some(v) => { + let s = match ir.format { + interp::EvalFormat::Table => value_to_table_json(&v), + interp::EvalFormat::Tree => value_to_tree_json(&v), + interp::EvalFormat::Inline => v.display(), + }; + if !s.is_empty() { + results.push(EvalResult { line: ir.line, result: s, format: fmt.to_string() }); + } + } + None => {} + } + } + + DocumentResult { results, errors } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_eval() { + let result = evaluate_line("2 + 3").unwrap(); + assert_eq!(result, "5"); + } + + #[test] + fn eval_with_variables() { + let doc = "let a = 5\nlet b = 3\n/= a + b"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "8"); + assert_eq!(result.results[0].line, 2); + } + + #[test] + fn eval_with_markdown() { + let doc = "# Title\nlet val = 10\nSome text\n/= val * 2"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "20"); + } + + #[test] + fn eval_trig() { + let result = evaluate_line("sin(0)").unwrap(); + assert_eq!(result, "0"); + } + + #[test] + fn eval_function_def() { + let doc = "f(a) = a * a\n/= f(5)"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "25"); + } + + #[test] + fn multiple_evals() { + let doc = "let a = 3\n/= a\nlet b = 7\n/= a + b"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 2); + assert_eq!(result.results[0].result, "3"); + assert_eq!(result.results[1].result, "10"); + } + + #[test] + fn format_integer() { + assert_eq!(format_value(42.0), "42"); + } + + #[test] + fn format_float() { + let s = format_value(3.14); + assert!(s.starts_with("3.14")); + } + + #[test] + fn eval_x_plus_5() { + let doc = "let x = 10\n/= x + 5"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "15"); + } + + #[test] + fn eval_string_concat() { + let doc = "let x = \"hello\"\nlet y = \"world\"\n/= x + \" \" + y"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "hello world"); + } + + #[test] + fn eval_booleans() { + let doc = "let x = true\n/= x\n/= 1 > 0"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 2); + assert_eq!(result.results[0].result, "true"); + assert_eq!(result.results[1].result, "true"); + } + + #[test] + fn eval_while_loop() { + let doc = "let i = 0\nlet sum = 0\nwhile (i < 10) {\n sum = sum + i\n i = i + 1\n}\n/= sum"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "45"); + } + + #[test] + fn eval_fn_block() { + let doc = "fn add(a, b) {\n a + b\n}\n/= add(3, 4)"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "7"); + } + + #[test] + fn eval_type_annotation_int_lossy_rejected() { + // Round-trip rule: lossy coercion is rejected. + let doc = "let x: int = 3.7\n/= x"; + let result = evaluate_document(doc); + assert!(result.errors.len() >= 1, "should error on lossy int"); + } + + #[test] + fn eval_type_annotation_int_exact_accepted() { + let doc = "let x: int = 3.0\n/= x"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "3"); + } + + #[test] + fn eval_type_annotation_bool_error() { + let doc = "let x: bool = 2\n/= x"; + let result = evaluate_document(doc); + assert!(result.errors.len() >= 1); + let msg = &result.errors[0].error; + assert!( + msg.contains("clean conversion") || msg.contains("cannot bind"), + "expected clean-conversion error, got: {}", msg + ); + } + + #[test] + fn eval_array() { + let doc = "let arr = [1, \"two\", true]\n/= arr"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "[1, \"two\", true]"); + } + + #[test] + fn eval_error_recovery() { + let doc = "let x = undefined_var\nlet y = 5\n/= y"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "5"); + assert!(result.errors.len() >= 1); + } + + #[test] + fn eval_mixed_markdown_and_code() { + let doc = "# Notes\nlet x = 10\nSome text here\nwhile (x > 0) {\n x = x - 1\n}\n/= x"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "0"); + } + + #[test] + fn eval_if_else() { + let doc = "let x = 10\nif (x > 5) {\n x = 1\n} else {\n x = 0\n}\n/= x"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "1"); + } + + #[test] + fn eval_for_loop() { + let doc = "let sum = 0\nfor i in [1, 2, 3] {\n sum = sum + i\n}\n/= sum"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "6"); + } + + #[test] + fn eval_array_index() { + let doc = "let arr = [10, 20, 30]\n/= arr[1]"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "20"); + } + + #[test] + fn eval_fn_return() { + let doc = "fn max(a, b) {\n if (a > b) {\n return a\n }\n return b\n}\n/= max(3, 7)"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "7"); + } + + #[test] + fn eval_table_format() { + let doc = "let data = [[\"Name\", \"Age\"], [\"Alice\", 30], [\"Bob\", 25]]\n/=| data"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].format, "table"); + let parsed: Vec> = serde_json::from_str(&result.results[0].result).unwrap(); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0], vec!["Name", "Age"]); + } + + #[test] + fn eval_tree_format() { + let doc = "let tree = [1, [2, 3], [4, [5]]]\n/=\\ tree"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].format, "tree"); + let parsed: serde_json::Value = serde_json::from_str(&result.results[0].result).unwrap(); + assert!(parsed.is_array()); + } + + #[test] + fn eval_inline_format_default() { + let doc = "let x = 42\n/= x"; + let result = evaluate_document(doc); + assert_eq!(result.results[0].format, "inline"); + } + + #[test] + fn eval_table_flat_array() { + let doc = "/=| [1, 2, 3]"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].format, "table"); + } + + #[test] + fn eval_document_json_has_format() { + let doc = "let x = 42\n/= x\n/=| [[1, 2], [3, 4]]"; + let result = evaluate_document(doc); + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"format\":\"inline\"")); + assert!(json.contains("\"format\":\"table\"")); + } + + #[test] + fn module_eval_use_imports_binding() { + let sources = vec![ + ModuleSource { name: "root".into(), text: "let pi = 3.14".into(), is_root: true }, + ModuleSource { name: "math".into(), text: "fn double(x) {\n x * 2\n}".into(), is_root: false }, + ModuleSource { name: "main".into(), text: "use math\n/= double(pi)".into(), is_root: false }, + ]; + let results = evaluate_modules(&sources); + assert_eq!(results.len(), 3); + // "main" should see both root's `pi` (auto-import) and math's `double` (via use) + let main_result = results.iter().find(|r| r.name == "main").unwrap(); + assert_eq!(main_result.doc_result.results.len(), 1); + assert_eq!(main_result.doc_result.results[0].result, "6.28"); + } + + #[test] + fn module_eval_root_auto_imported() { + let sources = vec![ + ModuleSource { name: "root".into(), text: "let x = 5".into(), is_root: true }, + ModuleSource { name: "child".into(), text: "/= x".into(), is_root: false }, + ]; + let results = evaluate_modules(&sources); + let child = results.iter().find(|r| r.name == "child").unwrap(); + assert_eq!(child.doc_result.results.len(), 1); + assert_eq!(child.doc_result.results[0].result, "5"); + } + + #[test] + fn module_eval_without_use_no_access() { + let sources = vec![ + ModuleSource { name: "root".into(), text: "".into(), is_root: true }, + ModuleSource { name: "a".into(), text: "let secret = 42".into(), is_root: false }, + ModuleSource { name: "b".into(), text: "/= secret".into(), is_root: false }, + ]; + let results = evaluate_modules(&sources); + let b = results.iter().find(|r| r.name == "b").unwrap(); + assert_eq!(b.doc_result.errors.len(), 1); + assert!(b.doc_result.errors[0].error.contains("undefined")); + } + + #[test] + fn module_eval_use_specific_item() { + let sources = vec![ + ModuleSource { name: "root".into(), text: "".into(), is_root: true }, + ModuleSource { name: "math".into(), text: "let a = 1\nlet b = 2".into(), is_root: false }, + ModuleSource { name: "main".into(), text: "use math::a\n/= a".into(), is_root: false }, + ]; + let results = evaluate_modules(&sources); + let main = results.iter().find(|r| r.name == "main").unwrap(); + assert_eq!(main.doc_result.results.len(), 1); + assert_eq!(main.doc_result.results[0].result, "1"); + } + + #[test] + fn module_eval_failed_use_no_error() { + let sources = vec![ + ModuleSource { name: "root".into(), text: "".into(), is_root: true }, + ModuleSource { name: "main".into(), text: "use nonexistent\nlet x = 1\n/= x".into(), is_root: false }, + ]; + let results = evaluate_modules(&sources); + let main = results.iter().find(|r| r.name == "main").unwrap(); + assert!(main.doc_result.errors.is_empty()); + assert_eq!(main.doc_result.results[0].result, "1"); + } + + #[test] + fn module_eval_cycle_handled() { + let sources = vec![ + ModuleSource { name: "root".into(), text: "".into(), is_root: true }, + ModuleSource { name: "a".into(), text: "use b\nlet x = 1".into(), is_root: false }, + ModuleSource { name: "b".into(), text: "use a\nlet y = 2".into(), is_root: false }, + ]; + // Shouldn't panic. One of them evaluates without the other's exports. + let results = evaluate_modules(&sources); + assert_eq!(results.len(), 3); + } +} diff --git a/core/src/ffi.rs b/core/src/ffi.rs new file mode 100644 index 0000000..2018f55 --- /dev/null +++ b/core/src/ffi.rs @@ -0,0 +1,159 @@ +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::path::Path; + +use crate::document::AcordDoc; +use crate::eval; +use crate::highlight; +use crate::persist; + +fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> { + if ptr.is_null() { return None; } + unsafe { CStr::from_ptr(ptr).to_str().ok() } +} + +fn str_to_cstr(s: &str) -> *mut c_char { + CString::new(s).unwrap_or_default().into_raw() +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_doc_new() -> *mut AcordDoc { + Box::into_raw(Box::new(AcordDoc::new())) +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_doc_free(doc: *mut AcordDoc) { + if doc.is_null() { return; } + unsafe { drop(Box::from_raw(doc)); } +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_doc_set_text(doc: *mut AcordDoc, text: *const c_char) { + let doc = match unsafe { doc.as_mut() } { + Some(d) => d, + None => return, + }; + let text = match cstr_to_str(text) { + Some(s) => s, + None => return, + }; + doc.set_text(text); +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_doc_get_text(doc: *const AcordDoc) -> *mut c_char { + let doc = match unsafe { doc.as_ref() } { + Some(d) => d, + None => return std::ptr::null_mut(), + }; + str_to_cstr(&doc.text) +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_doc_evaluate(doc: *mut AcordDoc) -> *mut c_char { + let doc = match unsafe { doc.as_mut() } { + Some(d) => d, + None => return str_to_cstr("[]"), + }; + let result = doc.evaluate(); + let json = serde_json::to_string(&result).unwrap_or_else(|_| "[]".into()); + str_to_cstr(&json) +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_eval_line(text: *const c_char) -> *mut c_char { + let text = match cstr_to_str(text) { + Some(s) => s, + None => return str_to_cstr(""), + }; + match eval::evaluate_line(text) { + Ok(result) => str_to_cstr(&result), + Err(e) => str_to_cstr(&format!("error: {}", e)), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_doc_save(doc: *const AcordDoc, path: *const c_char) -> bool { + let doc = match unsafe { doc.as_ref() } { + Some(d) => d, + None => return false, + }; + let path = match cstr_to_str(path) { + Some(s) => s, + None => return false, + }; + persist::save_to_file(&doc.text, Path::new(path)).is_ok() +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_doc_load(path: *const c_char) -> *mut AcordDoc { + let path = match cstr_to_str(path) { + Some(s) => s, + None => return std::ptr::null_mut(), + }; + match persist::load_from_file(Path::new(path)) { + Ok(text) => { + let mut doc = AcordDoc::new(); + doc.set_text(&text); + Box::into_raw(Box::new(doc)) + } + Err(_) => std::ptr::null_mut(), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_cache_save(doc: *const AcordDoc) -> *mut c_char { + let doc = match unsafe { doc.as_ref() } { + Some(d) => d, + None => return std::ptr::null_mut(), + }; + let uuid = doc.uuid.clone(); + match persist::cache_save(&uuid, &doc.text) { + Ok(_) => str_to_cstr(&uuid), + Err(_) => std::ptr::null_mut(), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_cache_load(uuid: *const c_char) -> *mut AcordDoc { + let uuid = match cstr_to_str(uuid) { + Some(s) => s, + None => return std::ptr::null_mut(), + }; + match persist::cache_load(uuid) { + Ok(text) => { + let mut doc = AcordDoc::with_uuid(uuid.to_string()); + doc.set_text(&text); + Box::into_raw(Box::new(doc)) + } + Err(_) => std::ptr::null_mut(), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_list_notes() -> *mut c_char { + let notes = persist::list_notes(); + let json = serde_json::to_string(¬es).unwrap_or_else(|_| "[]".into()); + str_to_cstr(&json) +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_highlight(source: *const c_char, lang: *const c_char) -> *mut c_char { + let source = match cstr_to_str(source) { + Some(s) => s, + None => return str_to_cstr("[]"), + }; + let lang = match cstr_to_str(lang) { + Some(s) => s, + None => return str_to_cstr("[]"), + }; + let spans = highlight::highlight_source(source, lang); + let json = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".into()); + str_to_cstr(&json) +} + +#[unsafe(no_mangle)] +pub extern "C" fn acord_free_string(s: *mut c_char) { + if s.is_null() { return; } + unsafe { drop(CString::from_raw(s)); } +} diff --git a/core/src/highlight.rs b/core/src/highlight.rs new file mode 100644 index 0000000..6aa1574 --- /dev/null +++ b/core/src/highlight.rs @@ -0,0 +1,234 @@ +use tree_sitter::Language; +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; + +const HIGHLIGHT_NAMES: &[&str] = &[ + "keyword", + "function", + "function.builtin", + "type", + "type.builtin", + "constructor", + "constant", + "constant.builtin", + "string", + "number", + "comment", + "variable", + "variable.builtin", + "variable.parameter", + "operator", + "punctuation", + "punctuation.bracket", + "punctuation.delimiter", + "property", + "tag", + "attribute", + "label", + "escape", + "embedded", +]; + +#[derive(serde::Serialize)] +pub struct HighlightSpan { + pub start: usize, + pub end: usize, + pub kind: u8, +} + +struct LangDef { + language: Language, + highlights: &'static str, + injections: &'static str, + locals: &'static str, +} + +fn lang_def(lang_id: &str) -> Option { + let ld = match lang_id { + "rust" => LangDef { + language: tree_sitter_rust::LANGUAGE.into(), + highlights: tree_sitter_rust::HIGHLIGHTS_QUERY, + injections: tree_sitter_rust::INJECTIONS_QUERY, + locals: "", + }, + "c" => LangDef { + language: tree_sitter_c::LANGUAGE.into(), + highlights: tree_sitter_c::HIGHLIGHT_QUERY, + injections: "", + locals: "", + }, + "cpp" => LangDef { + language: tree_sitter_cpp::LANGUAGE.into(), + highlights: tree_sitter_cpp::HIGHLIGHT_QUERY, + injections: "", + locals: "", + }, + "javascript" | "jsx" => LangDef { + language: tree_sitter_javascript::LANGUAGE.into(), + highlights: tree_sitter_javascript::HIGHLIGHT_QUERY, + injections: tree_sitter_javascript::INJECTIONS_QUERY, + locals: tree_sitter_javascript::LOCALS_QUERY, + }, + "typescript" => LangDef { + language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY, + injections: "", + locals: tree_sitter_typescript::LOCALS_QUERY, + }, + "tsx" => LangDef { + language: tree_sitter_typescript::LANGUAGE_TSX.into(), + highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY, + injections: "", + locals: tree_sitter_typescript::LOCALS_QUERY, + }, + "python" => LangDef { + language: tree_sitter_python::LANGUAGE.into(), + highlights: tree_sitter_python::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "go" => LangDef { + language: tree_sitter_go::LANGUAGE.into(), + highlights: tree_sitter_go::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "ruby" => LangDef { + language: tree_sitter_ruby::LANGUAGE.into(), + highlights: tree_sitter_ruby::HIGHLIGHTS_QUERY, + injections: "", + locals: tree_sitter_ruby::LOCALS_QUERY, + }, + "bash" | "shell" => LangDef { + language: tree_sitter_bash::LANGUAGE.into(), + highlights: tree_sitter_bash::HIGHLIGHT_QUERY, + injections: "", + locals: "", + }, + "java" => LangDef { + language: tree_sitter_java::LANGUAGE.into(), + highlights: tree_sitter_java::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "html" => LangDef { + language: tree_sitter_html::LANGUAGE.into(), + highlights: tree_sitter_html::HIGHLIGHTS_QUERY, + injections: tree_sitter_html::INJECTIONS_QUERY, + locals: "", + }, + "css" | "scss" | "less" => LangDef { + language: tree_sitter_css::LANGUAGE.into(), + highlights: tree_sitter_css::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "json" => LangDef { + language: tree_sitter_json::LANGUAGE.into(), + highlights: tree_sitter_json::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "lua" => LangDef { + language: tree_sitter_lua::LANGUAGE.into(), + highlights: tree_sitter_lua::HIGHLIGHTS_QUERY, + injections: tree_sitter_lua::INJECTIONS_QUERY, + locals: tree_sitter_lua::LOCALS_QUERY, + }, + "php" => LangDef { + language: tree_sitter_php::LANGUAGE_PHP.into(), + highlights: tree_sitter_php::HIGHLIGHTS_QUERY, + injections: tree_sitter_php::INJECTIONS_QUERY, + locals: "", + }, + "toml" => LangDef { + language: tree_sitter_toml_ng::LANGUAGE.into(), + highlights: tree_sitter_toml_ng::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "yaml" => LangDef { + language: tree_sitter_yaml::language(), + highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "swift" => LangDef { + language: tree_sitter_swift::LANGUAGE.into(), + highlights: tree_sitter_swift::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "zig" => LangDef { + language: tree_sitter_zig::LANGUAGE.into(), + highlights: tree_sitter_zig::HIGHLIGHTS_QUERY, + injections: tree_sitter_zig::INJECTIONS_QUERY, + locals: "", + }, + "sql" => LangDef { + language: tree_sitter_sequel::LANGUAGE.into(), + highlights: tree_sitter_sequel::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "make" | "makefile" => LangDef { + language: tree_sitter_make::LANGUAGE.into(), + highlights: tree_sitter_make::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + _ => return None, + }; + Some(ld) +} + +fn make_config(def: LangDef, name: &str) -> Option { + let mut config = HighlightConfiguration::new( + def.language, + name, + def.highlights, + def.injections, + def.locals, + ).ok()?; + config.configure(HIGHLIGHT_NAMES); + Some(config) +} + +pub fn highlight_source(source: &str, lang_id: &str) -> Vec { + let def = match lang_def(lang_id) { + Some(d) => d, + None => return Vec::new(), + }; + + let config = match make_config(def, lang_id) { + Some(c) => c, + None => return Vec::new(), + }; + + let mut highlighter = Highlighter::new(); + let events = match highlighter.highlight(&config, source.as_bytes(), None, |_| None) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut spans = Vec::new(); + let mut stack: Vec = Vec::new(); + + for event in events { + match event { + Ok(HighlightEvent::Source { start, end }) => { + if let Some(&kind) = stack.last() { + spans.push(HighlightSpan { start, end, kind }); + } + } + Ok(HighlightEvent::HighlightStart(h)) => { + stack.push(h.0 as u8); + } + Ok(HighlightEvent::HighlightEnd) => { + stack.pop(); + } + Err(_) => break, + } + } + + spans +} diff --git a/core/src/interp.rs b/core/src/interp.rs new file mode 100644 index 0000000..956df42 --- /dev/null +++ b/core/src/interp.rs @@ -0,0 +1,4604 @@ +use std::collections::HashMap; + +// --- Values --- + +#[derive(Clone, Debug)] +pub enum Value { + Number(f64), + Bool(bool), + Str(String), + Array(Vec), + Void, + Error(String), +} + +impl Value { + pub fn display(&self) -> String { + match self { + Value::Number(n) => format_number(*n), + Value::Bool(b) => b.to_string(), + Value::Str(s) => s.clone(), + Value::Array(items) => { + // Spice-shape detection: exactly [Number, Str] → render in + // SPICE notation. Any other 2-array (numbers, strings, etc. + // that happen to have two elements) falls through to the + // generic array display. + if items.len() == 2 { + if let (Value::Number(n), Value::Str(u)) = (&items[0], &items[1]) { + return format_spice(*n, u); + } + } + let inner: Vec = items.iter().map(|v| match v { + Value::Str(s) => format!("\"{}\"", s), + other => other.display(), + }).collect(); + format!("[{}]", inner.join(", ")) + } + Value::Void => String::new(), + Value::Error(e) => format!("error: {}", e), + } + } + + pub fn is_error(&self) -> bool { + matches!(self, Value::Error(_)) + } + + fn truthy(&self) -> bool { + match self { + Value::Bool(b) => *b, + Value::Number(n) => *n != 0.0, + Value::Str(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Void => false, + Value::Error(_) => false, + } + } +} + +fn format_number(n: f64) -> String { + if n == n.trunc() && n.abs() < 1e15 { + format!("{}", n as i64) + } else { + let s = format!("{:.10}", n); + let s = s.trim_end_matches('0'); + let s = s.trim_end_matches('.'); + s.to_string() + } +} + +/// Render a spice-typed value (scalar + unit label) in SPICE notation. +/// Picks the closest SI prefix from {none, m, u, n, p} so the mantissa +/// lands in [1, 1000), formats to 3 sig figs, uppercases the unit. +fn format_spice(n: f64, unit: &str) -> String { + if n == 0.0 { + return format!("0{}", unit); + } + if !n.is_finite() { + return format!("{}{}", n, unit); + } + let abs_n = n.abs(); + let (prefix, scale): (&str, f64) = if abs_n >= 1.0 { + ("", 1.0) + } else if abs_n >= 1e-3 { + ("M", 1e-3) + } else if abs_n >= 1e-6 { + ("U", 1e-6) + } else if abs_n >= 1e-9 { + ("N", 1e-9) + } else { + ("P", 1e-12) + }; + let mantissa = n / scale; + let mag = mantissa.abs().log10().floor() as i32; + let decimals = (2 - mag).max(0) as usize; + // 3 sig figs, but trailing zeros after the decimal are cosmetic — + // trim them so `10` doesn't display as `10.0`, matching the house + // number formatter. + let raw = format!("{:.*}", decimals, mantissa); + let trimmed: &str = if raw.contains('.') { + raw.trim_end_matches('0').trim_end_matches('.') + } else { + raw.as_str() + }; + // Simple units (F, H, HZ) sit flush (`100NF`). Compound labels from the + // unit algebra (`F/H`, `1/F`, `F·H`, `F²`) get a separating space so + // `707M F/H` doesn't read as `707MF/H` — the `/` or `·` would merge + // into the prefix letter otherwise. + let compound = unit.chars().any(|c| !c.is_ascii_alphabetic()); + let sep = if compound && !unit.is_empty() { " " } else { "" }; + format!("{}{}{}{}", trimmed, prefix, sep, unit) +} + +/// Peel a spice-shaped value down to (scalar, unit). Anything that isn't +/// `Array([Number, Str])` returns (value, None) — unit is only added to +/// the result when at least one operand was spice. +fn unwrap_spice(v: &Value) -> (Value, Option) { + if let Value::Array(a) = v { + if a.len() == 2 { + if let (Value::Number(_), Value::Str(u)) = (&a[0], &a[1]) { + return (a[0].clone(), Some(u.clone())); + } + } + } + (v.clone(), None) +} + +/// Re-wrap a numeric result with a unit carried from an operand. Non-number +/// results (Bool from comparison, Str from concatenation) drop the unit — +/// they're no longer a measurable quantity. +fn retag_spice(v: Value, unit: Option) -> Value { + match (&v, unit) { + (Value::Number(_), Some(u)) => Value::Array(vec![v, Value::Str(u)]), + _ => v, + } +} + +/// Combine two unit labels under an operation. Returns `None` when the +/// result shouldn't carry a label — either because addition of distinct +/// labels can't be meaningfully preserved, or because division cancels +/// matching labels to dimensionless. `Some(String::new())` never occurs: +/// an empty label means "drop the spice tag", so we use None for that. +/// +/// All four helpers are pure label algebra. No dimensional analysis, no +/// SI knowledge — just literal rewriting. + +fn combine_unit_mul(a: &str, b: &str) -> Option { + match (a.is_empty(), b.is_empty()) { + (true, true) => None, + (true, false) => Some(b.to_string()), + (false, true) => Some(a.to_string()), + (false, false) if a == b => Some(format!("{}²", a)), + (false, false) => Some(format!("{}·{}", a, b)), + } +} + +fn combine_unit_div(a: &str, b: &str) -> Option { + // Same label on both sides → cancellation → plain number. + if a == b { return None; } + if b.is_empty() { + return if a.is_empty() { None } else { Some(a.to_string()) }; + } + if a.is_empty() { return Some(format!("1/{}", b)); } + Some(format!("{}/{}", a, b)) +} + +fn combine_unit_pow(a: &str, exp: f64) -> Option { + if a.is_empty() { return None; } + if exp == 1.0 { return Some(a.to_string()); } + if exp == 2.0 { return Some(format!("{}²", a)); } + if exp == 3.0 { return Some(format!("{}³", a)); } + if exp == 0.5 { return Some(format!("√{}", a)); } + if exp == exp.trunc() && exp.abs() < 1e9 { + return Some(format!("{}^{}", a, exp as i64)); + } + Some(format!("{}^{}", a, exp)) +} + +fn combine_unit_additive(a: &str, b: &str) -> Option { + // Additive ops need compatible labels to keep the tag. Matching labels + // pass through; one-sided labels absorb the untagged operand (an H + // plus a bare number is still H). Distinct non-empty labels strip — + // `F + H` has no clean algebraic answer, so return a plain number + // rather than pretend the sum is meaningful in either unit. + if a == b { + if a.is_empty() { None } else { Some(a.to_string()) } + } else if a.is_empty() { + Some(b.to_string()) + } else if b.is_empty() { + Some(a.to_string()) + } else { + None + } +} + +/// Parse `"A1"`, `"AA12"`, etc. into 0-based `(col, row)`. Case-insensitive. +/// Letters must precede digits; both must be non-empty. Returns None for +/// anything else (e.g. `"x1"` when x isn't a plain letter sequence, `"1A"`, +/// `"A0"`). +pub fn parse_cell_address(s: &str) -> Option<(u32, u32)> { + let s = s.trim(); + if s.is_empty() { + return None; + } + let mut letters = String::new(); + let mut digits = String::new(); + for c in s.chars() { + if c.is_ascii_alphabetic() && digits.is_empty() { + letters.push(c); + } else if c.is_ascii_digit() { + digits.push(c); + } else { + return None; + } + } + if letters.is_empty() || digits.is_empty() { + return None; + } + let col = col_letters_to_index(&letters)?; + let row_1based: u32 = digits.parse().ok()?; + if row_1based == 0 { + return None; + } + Some((col, row_1based - 1)) +} + +fn col_letters_to_index(s: &str) -> Option { + let mut result: u32 = 0; + for c in s.chars() { + if !c.is_ascii_alphabetic() { + return None; + } + let upper = c.to_ascii_uppercase(); + result = result.checked_mul(26)?.checked_add((upper as u32) - ('A' as u32) + 1)?; + } + if result == 0 { + return None; + } + Some(result - 1) +} + +/// Render a 0-based (col, row) back to spreadsheet notation for error messages. +pub fn display_addr(col: u32, row: u32) -> String { + let mut letters = String::new(); + let mut c = col as i64; + loop { + let rem = (c % 26) as u8; + letters.insert(0, (b'A' + rem) as char); + c = c / 26 - 1; + if c < 0 { + break; + } + } + format!("{}{}", letters, row + 1) +} + +/// Interpret a cell's raw string. Number-parseable strings promote to +/// `Value::Number`; empty stays as empty string; anything else is Str. +fn coerce_cell_value(s: &str) -> Value { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Value::Str(String::new()); + } + if let Ok(n) = trimmed.parse::() { + return Value::Number(n); + } + Value::Str(s.to_string()) +} + +fn rows_to_value(rows: &[Vec]) -> Value { + let outer: Vec = rows.iter().map(|row| { + let inner: Vec = row.iter().map(|c| coerce_cell_value(c)).collect(); + Value::Array(inner) + }).collect(); + Value::Array(outer) +} + +// --- Tokens --- + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Number(f64), + /// Spice-notation literal. Value is already scaled by the prefix + /// (e.g. `100nF` → (1e-7, "F")). Empty unit is valid — `100n` is + /// (1e-7, ""). Only emitted when spice mode is active. + Spice(f64, String), + Str(String), + Bool(bool), + Ident(String), + Plus, + Minus, + Star, + Slash, + Percent, + Caret, + LParen, + RParen, + LBrace, + RBrace, + LBracket, + RBracket, + Comma, + Eq, + EqEq, + BangEq, + Lt, + Gt, + LtEq, + GtEq, + And, + Or, + Bang, + Tilde, + Colon, + DotDot, + Arrow, + At, + Let, + While, + Fn, + If, + Else, + For, + In, + Return, + Is, + Use, + ColonColon, + Newline, + Eof, +} + +/// Pre-scan the source for `use spice` / `use spice::…`. Activates spice +/// notation for the whole tokenize pass — gating postfix parsing on runtime +/// module state would require threading `use` through the tokenizer, which +/// is out of proportion for a documentary import. +fn source_enables_spice(src: &str) -> bool { + src.lines().any(|l| { + let t = l.trim(); + if t == "use spice" { return true; } + if let Some(rest) = t.strip_prefix("use spice::") { + !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '_') + } else { + false + } + }) +} + +/// Known SPICE unit strings, stored uppercase. Matched case-insensitively +/// against the alpha run after a number. `"OHM"` is the long form; `"R"` +/// is the short ASCII alias some users prefer. +const SPICE_UNITS: &[&str] = &["F", "H", "HZ", "V", "A", "W", "R", "OHM", "S", "J"]; + +/// Scaling factor for a one-char SPICE prefix (lowercase). Returns None for +/// anything else. Accepts `µ` (both U+00B5 micro sign and U+03BC Greek mu). +fn spice_prefix_scale(c: char) -> Option { + match c { + 'm' | 'M' => Some(1e-3), + 'u' | 'U' | 'µ' | 'μ' => Some(1e-6), + 'n' | 'N' => Some(1e-9), + 'p' | 'P' => Some(1e-12), + _ => None, + } +} + +/// Parse a post-number alpha run as a SPICE suffix. Returns +/// `(scale, unit_uppercase)`. The run must match exactly one of: +/// - lone prefix (`m`, `u`, `µ`, `n`, `p`) +/// - lone unit (`F`, `H`, `Hz`, …) +/// - prefix + unit (`mF`, `uH`, `nF`, …) +/// A miss returns None so the caller can fall back to implicit-mul. +fn parse_spice_suffix(alpha: &str) -> Option<(f64, String)> { + if alpha.is_empty() { + return None; + } + let normalized: String = alpha.chars().map(|c| match c { + 'µ' | 'μ' => 'U', + c => c.to_ascii_uppercase(), + }).collect(); + + // lone prefix + if normalized.len() == 1 { + let first = alpha.chars().next().unwrap(); + if let Some(scale) = spice_prefix_scale(first) { + return Some((scale, String::new())); + } + } + // lone unit + if SPICE_UNITS.iter().any(|u| *u == normalized) { + return Some((1.0, normalized)); + } + // prefix + unit + let first = alpha.chars().next().unwrap(); + if let Some(scale) = spice_prefix_scale(first) { + let rest: String = normalized.chars().skip(1).collect(); + if SPICE_UNITS.iter().any(|u| *u == rest) { + return Some((scale, rest)); + } + } + None +} + +/// After a number's digits are consumed up through position `i`, consume +/// an optional scientific exponent (`e[+-]?DIGITS`). Returns the final +/// multiplier and advances `i`. A malformed exponent (`e` with no digits) +/// is left untouched. +fn try_consume_exponent(chars: &[char], i: &mut usize) -> f64 { + let len = chars.len(); + if *i >= len { return 1.0; } + if chars[*i] != 'e' && chars[*i] != 'E' { return 1.0; } + let mut j = *i + 1; + if j < len && (chars[j] == '+' || chars[j] == '-') { j += 1; } + if j >= len || !chars[j].is_ascii_digit() { return 1.0; } + while j < len && chars[j].is_ascii_digit() { j += 1; } + let exp: i32 = chars[*i + 1..j].iter().collect::() + .parse().unwrap_or(0); + *i = j; + 10f64.powi(exp) +} + +/// Attach exponent / spice-suffix / implicit-mul tail to a freshly-parsed +/// number. Pushes either `Number` or `Spice`, and may follow it with a +/// `Star` when the next char is ident-like or `(`. +fn finalize_number( + tokens: &mut Vec, + mut value: f64, + chars: &[char], + i: &mut usize, + spice: bool, +) { + value *= try_consume_exponent(chars, i); + let len = chars.len(); + + // Greedy alpha run (including µ) — consumed as a whole or not at all. + let run_start = *i; + let mut run_end = run_start; + while run_end < len && (chars[run_end].is_alphabetic() || chars[run_end] == 'µ' || chars[run_end] == 'μ') { + run_end += 1; + } + + if spice && run_end > run_start { + let run: String = chars[run_start..run_end].iter().collect(); + if let Some((scale, unit)) = parse_spice_suffix(&run) { + tokens.push(Token::Spice(value * scale, unit)); + *i = run_end; + return; + } + } + tokens.push(Token::Number(value)); + // Implicit multiplication: Number directly adjacent to an ident-start + // char or `(`. Whitespace between kills the rule (skipped separately). + if *i < len { + let c = chars[*i]; + if c.is_alphabetic() || c == '_' || c == '(' || c == 'µ' || c == 'μ' { + tokens.push(Token::Star); + } + } +} + +fn tokenize(input: &str, spice: bool) -> Result, String> { + let mut tokens = Vec::new(); + let chars: Vec = input.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + let c = chars[i]; + match c { + ' ' | '\t' | '\r' => { i += 1; } + '\n' => { tokens.push(Token::Newline); i += 1; } + '+' => { tokens.push(Token::Plus); i += 1; } + '-' => { + // `->` is the function-return-type separator; takes precedence + // over negative-number detection since `-` adjacent to `>` is + // never a numeric sign. + if i + 1 < len && chars[i + 1] == '>' { + tokens.push(Token::Arrow); + i += 2; + continue; + } + // negative number literal: only if preceded by operator/open/start/newline + if i + 1 < len && (chars[i + 1].is_ascii_digit() || chars[i + 1] == '.') { + let can_be_neg = if tokens.is_empty() { + true + } else { + matches!(tokens.last(), Some( + Token::Plus | Token::Minus | Token::Star | Token::Slash | + Token::Percent | Token::Caret | Token::LParen | Token::LBracket | + Token::Comma | Token::Eq | Token::EqEq | Token::BangEq | + Token::Lt | Token::Gt | Token::LtEq | Token::GtEq | + Token::And | Token::Or | Token::Bang | Token::Tilde | + Token::Newline | Token::Colon + )) + }; + if can_be_neg { + let start = i; + i += 1; + while i < len && (chars[i].is_ascii_digit() || (chars[i] == '.' && !(i + 1 < len && chars[i + 1] == '.'))) { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + let n: f64 = s.parse().map_err(|_| format!("invalid number: {}", s))?; + finalize_number(&mut tokens, n, &chars, &mut i, spice); + } else { + tokens.push(Token::Minus); + i += 1; + } + } else { + tokens.push(Token::Minus); + i += 1; + } + } + '*' => { tokens.push(Token::Star); i += 1; } + '/' => { + if i + 1 < len && chars[i + 1] == '/' { + while i < len && chars[i] != '\n' { i += 1; } + } else { + tokens.push(Token::Slash); + i += 1; + } + } + '%' => { tokens.push(Token::Percent); i += 1; } + '^' => { tokens.push(Token::Caret); i += 1; } + '(' => { tokens.push(Token::LParen); i += 1; } + ')' => { tokens.push(Token::RParen); i += 1; } + '{' => { tokens.push(Token::LBrace); i += 1; } + '}' => { tokens.push(Token::RBrace); i += 1; } + '[' => { tokens.push(Token::LBracket); i += 1; } + ']' => { tokens.push(Token::RBracket); i += 1; } + ',' => { tokens.push(Token::Comma); i += 1; } + ':' => { + if i + 1 < len && chars[i + 1] == ':' { + tokens.push(Token::ColonColon); i += 2; + } else { + tokens.push(Token::Colon); i += 1; + } + } + '.' if i + 1 < len && chars[i + 1] == '.' => { + tokens.push(Token::DotDot); i += 2; + } + '!' => { + if i + 1 < len && chars[i + 1] == '=' { + tokens.push(Token::BangEq); i += 2; + } else { + tokens.push(Token::Bang); i += 1; + } + } + '~' => { tokens.push(Token::Tilde); i += 1; } + '@' => { tokens.push(Token::At); i += 1; } + '=' => { + if i + 1 < len && chars[i + 1] == '=' { + tokens.push(Token::EqEq); i += 2; + } else { + tokens.push(Token::Eq); i += 1; + } + } + '<' => { + if i + 1 < len && chars[i + 1] == '=' { + tokens.push(Token::LtEq); i += 2; + } else { + tokens.push(Token::Lt); i += 1; + } + } + '>' => { + if i + 1 < len && chars[i + 1] == '=' { + tokens.push(Token::GtEq); i += 2; + } else { + tokens.push(Token::Gt); i += 1; + } + } + '&' => { + if i + 1 < len && chars[i + 1] == '&' { + tokens.push(Token::And); i += 2; + } else { + return Err("unexpected '&', did you mean '&&'?".into()); + } + } + '|' => { + if i + 1 < len && chars[i + 1] == '|' { + tokens.push(Token::Or); i += 2; + } else { + return Err("unexpected '|', did you mean '||'?".into()); + } + } + '"' => { + i += 1; + let mut s = String::new(); + while i < len && chars[i] != '"' { + if chars[i] == '\\' && i + 1 < len { + i += 1; + match chars[i] { + 'n' => s.push('\n'), + 't' => s.push('\t'), + '\\' => s.push('\\'), + '"' => s.push('"'), + other => { s.push('\\'); s.push(other); } + } + } else { + s.push(chars[i]); + } + i += 1; + } + if i >= len { + return Err("unterminated string".into()); + } + i += 1; // closing quote + tokens.push(Token::Str(s)); + } + _ if c.is_ascii_digit() || (c == '.' && i + 1 < len && chars[i + 1].is_ascii_digit()) => { + let start = i; + while i < len && (chars[i].is_ascii_digit() || (chars[i] == '.' && !(i + 1 < len && chars[i + 1] == '.'))) { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + let n: f64 = s.parse().map_err(|_| format!("invalid number: {}", s))?; + finalize_number(&mut tokens, n, &chars, &mut i, spice); + } + _ if c.is_alphabetic() || c == '_' => { + let start = i; + while i < len && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + match word.as_str() { + "let" => tokens.push(Token::Let), + "while" => tokens.push(Token::While), + "fn" => tokens.push(Token::Fn), + "if" => tokens.push(Token::If), + "else" => tokens.push(Token::Else), + "for" => tokens.push(Token::For), + "in" => tokens.push(Token::In), + "return" => tokens.push(Token::Return), + "true" => tokens.push(Token::Bool(true)), + "false" => tokens.push(Token::Bool(false)), + // Keyword forms of the logical operators. Coexist with + // `&&` / `||` / `!` and emit the same tokens, so the + // parser doesn't need to learn anything new. + "and" => tokens.push(Token::And), + "or" => tokens.push(Token::Or), + "not" => tokens.push(Token::Bang), + "is" => tokens.push(Token::Is), + "use" => tokens.push(Token::Use), + _ => tokens.push(Token::Ident(word)), + } + } + _ => { + return Err(format!("unexpected character: '{}'", c)); + } + } + } + tokens.push(Token::Eof); + Ok(tokens) +} + +// --- AST --- + +#[derive(Debug, Clone)] +enum Op { + Add, Sub, Mul, Div, Mod, Pow, + Eq, Neq, Lt, Gt, Lte, Gte, + And, Or, Not, Neg, + /// `~expr` — strip the type from a value, demoting Bool to its numeric + /// representation (false→0, true→1) so structurally-similar values can + /// be compared across declared types. + Strip, +} + +#[derive(Debug, Clone)] +enum Stmt { + Let(String, Option, Expr), + Assign(String, Expr), + While(Expr, Vec), + IfElse(Expr, Vec, Option>), + ForLoop(String, Expr, Vec), + /// `fn name(p [: T], …) [-> R] { body }`. Each param optionally carries + /// a type annotation (value type like `int` or a unit label like `F`); + /// `return_type` similarly may be a value type or a unit label that + /// overrides whatever unit the body's last expression produced. + FnDef { + name: String, + params: Vec<(String, Option)>, + return_type: Option, + body: Vec, + }, + Return(Expr), + /// `use module_name` or `use module_name::item`. + /// (module, optional specific item) + Use(String, Option), + /// `@[block::]table:A1 = expr` — write a cell in a live table. Only + /// valid at text-block scope; cell formulas are expressions and never + /// produce this variant. + CellAssign { + block: Option, + table: String, + cell: (u32, u32), + value: Expr, + }, + /// Math-form function inversion: + /// `let NAME(params…) = target_var where source_fn(args…) = result_var`. + /// Both params and source_args are bare identifiers; result_var must + /// equal params[0]; target_var must be in the source fn's param list. + SolveDef { + name: String, + params: Vec, + target_var: String, + source_fn: String, + source_args: Vec, + result_var: String, + }, + ExprStmt(Expr), +} + +/// Target shape of a cell reference. Cell indices are 0-based (`A1` = (0,0)). +#[derive(Debug, Clone, PartialEq)] +pub enum CellRefTarget { + /// `@Table` — the whole table, coerces to Array>. + Whole, + /// `@Table:A1` — a single cell (col, row). + Cell(u32, u32), + /// `@Table:A1:B4` or `@Table[A1:B4]` — rectangular range (col0, row0, col1, row1). + Range(u32, u32, u32, u32), +} + +#[derive(Debug, Clone)] +enum Expr { + Num(f64), + Str(String), + Bool(bool), + Ident(String), + BinOp(Op, Box, Box), + UnaryOp(Op, Box), + Call(String, Vec), + Array(Vec), + Index(Box, Box), + Range(Box, Box), + /// `expr is type_name`. Right side is a type identifier (literal token), + /// not a regular sub-expression — kept as a string for the evaluator to + /// match against the value's runtime kind. + IsCheck(Box, String), + /// `@[block::]table[:cell[:cell] | [cell:cell]]` or bare `A1`/`A1:B4` + /// inside a cell formula (both names None → resolved against + /// `Interpreter::current_table`). + CellRef { + block: Option, + table: Option, + target: CellRefTarget, + }, + /// `solve!(target_var, source_fn)` / `solve!(target_var from source_fn)`. + /// Only valid as the RHS of a `let`; `exec_stmt` intercepts this shape + /// and registers a `SolvedFnDef`. Evaluating it in any other position + /// is an error — there's no runtime value for the macro itself. + SolveMacro { + var: String, + source_fn: String, + }, +} + +// --- Parser --- + +struct Parser { + tokens: Vec, + pos: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Parser { tokens, pos: 0 } + } + + fn peek(&self) -> &Token { + self.tokens.get(self.pos).unwrap_or(&Token::Eof) + } + + fn advance(&mut self) -> Token { + let tok = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof); + self.pos += 1; + tok + } + + fn expect(&mut self, expected: &Token) -> Result<(), String> { + let tok = self.advance(); + if &tok == expected { + Ok(()) + } else { + Err(format!("expected {:?}, got {:?}", expected, tok)) + } + } + + fn skip_newlines(&mut self) { + while self.peek() == &Token::Newline { + self.advance(); + } + } + + fn parse_program(&mut self) -> Result, String> { + let mut stmts = Vec::new(); + self.skip_newlines(); + while self.peek() != &Token::Eof { + stmts.push(self.parse_stmt()?); + self.skip_newlines(); + } + Ok(stmts) + } + + fn parse_block(&mut self) -> Result, String> { + self.expect(&Token::LBrace)?; + self.skip_newlines(); + let mut stmts = Vec::new(); + while self.peek() != &Token::RBrace && self.peek() != &Token::Eof { + stmts.push(self.parse_stmt()?); + self.skip_newlines(); + } + self.expect(&Token::RBrace)?; + Ok(stmts) + } + + fn parse_stmt(&mut self) -> Result { + self.skip_newlines(); + match self.peek().clone() { + Token::Let => self.parse_let(), + Token::While => self.parse_while(), + Token::If => self.parse_if(), + Token::For => self.parse_for(), + Token::Return => self.parse_return(), + Token::Fn => self.parse_fn_def(), + Token::Use => self.parse_use(), + Token::At => { + let saved = self.pos; + let cref = self.parse_cell_ref()?; + if self.peek() == &Token::Eq { + self.advance(); + let value = self.parse_expr()?; + self.skip_newlines(); + return match cref { + Expr::CellRef { block, table: Some(table), target: CellRefTarget::Cell(col, row) } => { + Ok(Stmt::CellAssign { block, table, cell: (col, row), value }) + } + Expr::CellRef { target: CellRefTarget::Whole, .. } => { + Err("cannot assign to a whole table — use @Table:A1 = ... to write a single cell".into()) + } + Expr::CellRef { target: CellRefTarget::Range(..), .. } => { + Err("cannot assign to a range — use @Table:A1 = ... for a single cell".into()) + } + _ => Err("cell assignment requires @Table:A1 = ... form".into()), + }; + } + self.pos = saved; + let expr = self.parse_expr()?; + self.skip_newlines(); + Ok(Stmt::ExprStmt(expr)) + } + Token::Ident(_) => { + let saved = self.pos; + if let Token::Ident(name) = self.advance() { + // name(params) = expr (legacy cord-expr function syntax) + if self.peek() == &Token::LParen { + let paren_saved = self.pos; + self.advance(); + let mut params = Vec::new(); + let mut valid = true; + if self.peek() != &Token::RParen { + match self.peek() { + Token::Ident(_) => { + if let Token::Ident(p) = self.advance() { params.push(p); } + while self.peek() == &Token::Comma { + self.advance(); + if let Token::Ident(p) = self.advance() { + params.push(p); + } else { + valid = false; + break; + } + } + } + _ => { valid = false; } + } + } + if valid && self.peek() == &Token::RParen { + self.advance(); + if self.peek() == &Token::Eq { + self.advance(); + let body_expr = self.parse_expr()?; + self.skip_newlines(); + let typed_params: Vec<(String, Option)> = + params.into_iter().map(|p| (p, None)).collect(); + return Ok(Stmt::FnDef { + name, + params: typed_params, + return_type: None, + body: vec![Stmt::ExprStmt(body_expr)], + }); + } + } + self.pos = paren_saved; + // fall through: not a function def, might be assignment + } + if self.peek() == &Token::Eq { + self.advance(); + let expr = self.parse_expr()?; + self.skip_newlines(); + return Ok(Stmt::Assign(name, expr)); + } + } + self.pos = saved; + let expr = self.parse_expr()?; + self.skip_newlines(); + Ok(Stmt::ExprStmt(expr)) + } + _ => { + let expr = self.parse_expr()?; + self.skip_newlines(); + Ok(Stmt::ExprStmt(expr)) + } + } + } + + fn parse_let(&mut self) -> Result { + self.expect(&Token::Let)?; + let name = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected identifier after 'let', got {:?}", t)), + }; + // `let NAME(params) = …` — function definition. Body is either a + // single expression (legacy `f(x) = expr` form, lifted under `let`) + // or an inversion clause `target where source(args) = result`. + if self.peek() == &Token::LParen { + return self.parse_let_with_params(name); + } + let type_ann = if self.peek() == &Token::Colon { + self.advance(); + match self.advance() { + Token::Ident(t) => Some(t), + t => return Err(format!("expected type name after ':', got {:?}", t)), + } + } else { + None + }; + self.expect(&Token::Eq)?; + let expr = self.parse_expr()?; + self.skip_newlines(); + Ok(Stmt::Let(name, type_ann, expr)) + } + + /// `let NAME ( PARAMS ) = …` — function def with optional `where` clause. + /// Called after the NAME has been consumed; `(` is the next token. + fn parse_let_with_params(&mut self, name: String) -> Result { + self.expect(&Token::LParen)?; + let mut params = Vec::new(); + if self.peek() != &Token::RParen { + match self.advance() { + Token::Ident(p) => params.push(p), + t => return Err(format!("expected parameter name, got {:?}", t)), + } + while self.peek() == &Token::Comma { + self.advance(); + match self.advance() { + Token::Ident(p) => params.push(p), + t => return Err(format!("expected parameter name, got {:?}", t)), + } + } + } + self.expect(&Token::RParen)?; + self.expect(&Token::Eq)?; + let rhs = self.parse_expr()?; + + // Math-form inversion: RHS is a bare ident followed by `where`. + // `where` isn't a reserved keyword at the token layer, so it arrives + // as `Ident("where")` — check textually. + let is_where = matches!(self.peek(), Token::Ident(w) if w == "where"); + if is_where { + let target_var = match rhs { + Expr::Ident(s) => s, + _ => return Err("expected a single target variable before 'where'".into()), + }; + self.advance(); // consume `where` + let source_fn = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected source function name after 'where', got {:?}", t)), + }; + self.expect(&Token::LParen)?; + let mut source_args = Vec::new(); + if self.peek() != &Token::RParen { + match self.advance() { + Token::Ident(a) => source_args.push(a), + t => return Err(format!("expected argument name, got {:?}", t)), + } + while self.peek() == &Token::Comma { + self.advance(); + match self.advance() { + Token::Ident(a) => source_args.push(a), + t => return Err(format!("expected argument name, got {:?}", t)), + } + } + } + self.expect(&Token::RParen)?; + self.expect(&Token::Eq)?; + let result_var = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected result variable after '=', got {:?}", t)), + }; + self.skip_newlines(); + return Ok(Stmt::SolveDef { + name, + params, + target_var, + source_fn, + source_args, + result_var, + }); + } + + // Plain function def: `let f(x) = expr` ≡ bare `f(x) = expr`. + // No type annotations at this entry point — params come in as + // bare idents from parse_let_with_params. + self.skip_newlines(); + let typed_params: Vec<(String, Option)> = + params.into_iter().map(|p| (p, None)).collect(); + Ok(Stmt::FnDef { + name, + params: typed_params, + return_type: None, + body: vec![Stmt::ExprStmt(rhs)], + }) + } + + fn parse_while(&mut self) -> Result { + self.expect(&Token::While)?; + let has_paren = if self.peek() == &Token::LParen { + self.advance(); + true + } else { + false + }; + let cond = self.parse_expr()?; + if has_paren { + self.expect(&Token::RParen)?; + } + self.skip_newlines(); + let body = self.parse_block()?; + Ok(Stmt::While(cond, body)) + } + + fn parse_if(&mut self) -> Result { + self.expect(&Token::If)?; + let has_paren = if self.peek() == &Token::LParen { + self.advance(); + true + } else { + false + }; + let cond = self.parse_expr()?; + if has_paren { + self.expect(&Token::RParen)?; + } + self.skip_newlines(); + let then_body = self.parse_block()?; + self.skip_newlines(); + let else_body = if self.peek() == &Token::Else { + self.advance(); + self.skip_newlines(); + if self.peek() == &Token::If { + Some(vec![self.parse_if()?]) + } else { + Some(self.parse_block()?) + } + } else { + None + }; + Ok(Stmt::IfElse(cond, then_body, else_body)) + } + + fn parse_for(&mut self) -> Result { + self.expect(&Token::For)?; + let var = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected loop variable, got {:?}", t)), + }; + self.expect(&Token::In)?; + let iter = self.parse_expr()?; + self.skip_newlines(); + let body = self.parse_block()?; + Ok(Stmt::ForLoop(var, iter, body)) + } + + fn parse_return(&mut self) -> Result { + self.expect(&Token::Return)?; + if matches!(self.peek(), Token::Newline | Token::Eof | Token::RBrace) { + return Ok(Stmt::Return(Expr::Bool(false))); + } + let expr = self.parse_expr()?; + self.skip_newlines(); + Ok(Stmt::Return(expr)) + } + + /// `use module_name` or `use module_name::item` + fn parse_use(&mut self) -> Result { + self.expect(&Token::Use)?; + let module = match self.advance() { + Token::Ident(name) => name, + other => return Err(format!("expected module name after 'use', got {:?}", other)), + }; + let item = if self.peek() == &Token::ColonColon { + self.advance(); // consume :: + match self.advance() { + Token::Ident(name) => Some(name), + Token::Star => Some("*".to_string()), + other => return Err(format!("expected item name after '::', got {:?}", other)), + } + } else { + None + }; + self.skip_newlines(); + Ok(Stmt::Use(module, item)) + } + + /// Parse `@[block::]table[:cell[:cell] | [cell:cell]]`. Assumes the + /// leading `@` is the next token. Names are lowercased for + /// case-insensitive lookup. + fn parse_cell_ref(&mut self) -> Result { + self.expect(&Token::At)?; + let first = match self.advance() { + Token::Ident(name) => name.to_lowercase(), + t => return Err(format!("expected identifier after '@', got {:?}", t)), + }; + + let (block, table) = if self.peek() == &Token::ColonColon { + self.advance(); + let tname = match self.advance() { + Token::Ident(name) => name.to_lowercase(), + t => return Err(format!("expected table name after '::', got {:?}", t)), + }; + (Some(first), Some(tname)) + } else { + (None, Some(first)) + }; + + let target = if self.peek() == &Token::Colon { + self.advance(); + let (c1, r1) = self.parse_cell_addr_token()?; + if self.peek() == &Token::Colon { + self.advance(); + let (c2, r2) = self.parse_cell_addr_token()?; + CellRefTarget::Range(c1, r1, c2, r2) + } else { + CellRefTarget::Cell(c1, r1) + } + } else if self.peek() == &Token::LBracket { + self.advance(); + let (c1, r1) = self.parse_cell_addr_token()?; + self.expect(&Token::Colon)?; + let (c2, r2) = self.parse_cell_addr_token()?; + self.expect(&Token::RBracket)?; + CellRefTarget::Range(c1, r1, c2, r2) + } else { + CellRefTarget::Whole + }; + + Ok(Expr::CellRef { block, table, target }) + } + + /// `solve ! ( IDENT [,|from] IDENT )`. Caller has verified the first + /// three tokens match `solve!(` — this just consumes and validates the + /// interior. Either `,` or the bare word `from` separates the target + /// variable from the source function name. + fn parse_solve_macro(&mut self) -> Result { + // `solve` + match self.advance() { + Token::Ident(n) if n == "solve" => {} + t => return Err(format!("expected 'solve', got {:?}", t)), + } + self.expect(&Token::Bang)?; + self.expect(&Token::LParen)?; + let var = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected target variable after 'solve!(', got {:?}", t)), + }; + match self.peek().clone() { + Token::Comma => { self.advance(); } + Token::Ident(ref n) if n == "from" => { self.advance(); } + t => return Err(format!("expected ',' or 'from' in solve!(...), got {:?}", t)), + } + let source_fn = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected source function name in solve!(...), got {:?}", t)), + }; + self.expect(&Token::RParen)?; + Ok(Expr::SolveMacro { var, source_fn }) + } + + fn parse_cell_addr_token(&mut self) -> Result<(u32, u32), String> { + let name = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected cell address, got {:?}", t)), + }; + parse_cell_address(&name).ok_or_else(|| format!("invalid cell address: {}", name)) + } + + fn parse_fn_def(&mut self) -> Result { + self.expect(&Token::Fn)?; + let name = match self.advance() { + Token::Ident(n) => n, + t => return Err(format!("expected function name, got {:?}", t)), + }; + self.expect(&Token::LParen)?; + let mut params: Vec<(String, Option)> = Vec::new(); + if self.peek() != &Token::RParen { + params.push(self.parse_typed_param()?); + while self.peek() == &Token::Comma { + self.advance(); + params.push(self.parse_typed_param()?); + } + } + self.expect(&Token::RParen)?; + // Optional `-> T` return-type annotation. + let return_type = if self.peek() == &Token::Arrow { + self.advance(); + match self.advance() { + Token::Ident(t) => Some(t), + t => return Err(format!("expected return type after '->', got {:?}", t)), + } + } else { + None + }; + self.skip_newlines(); + let body = self.parse_block()?; + Ok(Stmt::FnDef { name, params, return_type, body }) + } + + /// `ident` or `ident : type` — a function parameter's name with an + /// optional type annotation. The type is either a value-type (`int`, + /// `float`, …) or a unit label (`F`, `H`, `Ω`, …). + fn parse_typed_param(&mut self) -> Result<(String, Option), String> { + let name = match self.advance() { + Token::Ident(p) => p, + t => return Err(format!("expected parameter name, got {:?}", t)), + }; + let ty = if self.peek() == &Token::Colon { + self.advance(); + match self.advance() { + Token::Ident(t) => Some(t), + t => return Err(format!("expected type after ':', got {:?}", t)), + } + } else { + None + }; + Ok((name, ty)) + } + + fn parse_expr(&mut self) -> Result { + let left = self.parse_or()?; + if self.peek() == &Token::DotDot { + self.advance(); + let right = self.parse_or()?; + return Ok(Expr::Range(Box::new(left), Box::new(right))); + } + // `A1:B4` bare cell range — only produces a CellRef when both sides + // are plain idents parseable as cell addresses. Any other `:` here + // would be a parse error anyway (Colon has no other use in + // expression position), so rewinding is safe. + if self.peek() == &Token::Colon { + if let Expr::Ident(ref start_name) = left { + if let Some((c0, r0)) = parse_cell_address(start_name) { + let saved = self.pos; + self.advance(); + if let Token::Ident(end_name) = self.peek().clone() { + if let Some((c1, r1)) = parse_cell_address(&end_name) { + self.advance(); + return Ok(Expr::CellRef { + block: None, + table: None, + target: CellRefTarget::Range(c0, r0, c1, r1), + }); + } + } + self.pos = saved; + } + } + } + Ok(left) + } + + fn parse_or(&mut self) -> Result { + let mut left = self.parse_and()?; + while self.peek() == &Token::Or { + self.advance(); + let right = self.parse_and()?; + left = Expr::BinOp(Op::Or, Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_and(&mut self) -> Result { + let mut left = self.parse_comparison()?; + while self.peek() == &Token::And { + self.advance(); + let right = self.parse_comparison()?; + left = Expr::BinOp(Op::And, Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_comparison(&mut self) -> Result { + let mut left = self.parse_additive()?; + loop { + // `is` is a comparison-precedence keyword whose right side is a + // type identifier (literal name), not a sub-expression. + if self.peek() == &Token::Is { + self.advance(); + let type_name = match self.advance() { + Token::Ident(t) => t, + t => { + return Err(format!( + "expected type name after 'is', got {:?}", + t + )); + } + }; + left = Expr::IsCheck(Box::new(left), type_name); + continue; + } + let op = match self.peek() { + Token::EqEq => Op::Eq, + Token::BangEq => Op::Neq, + Token::Lt => Op::Lt, + Token::Gt => Op::Gt, + Token::LtEq => Op::Lte, + Token::GtEq => Op::Gte, + _ => break, + }; + self.advance(); + let right = self.parse_additive()?; + left = Expr::BinOp(op, Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_additive(&mut self) -> Result { + let mut left = self.parse_multiplicative()?; + loop { + let op = match self.peek() { + Token::Plus => Op::Add, + Token::Minus => Op::Sub, + _ => break, + }; + self.advance(); + let right = self.parse_multiplicative()?; + left = Expr::BinOp(op, Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_multiplicative(&mut self) -> Result { + let mut left = self.parse_power()?; + loop { + let op = match self.peek() { + Token::Star => Op::Mul, + Token::Slash => Op::Div, + Token::Percent => Op::Mod, + _ => break, + }; + self.advance(); + let right = self.parse_power()?; + left = Expr::BinOp(op, Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_power(&mut self) -> Result { + let base = self.parse_unary()?; + if self.peek() == &Token::Caret { + self.advance(); + let exp = self.parse_power()?; // right-associative + Ok(Expr::BinOp(Op::Pow, Box::new(base), Box::new(exp))) + } else { + Ok(base) + } + } + + fn parse_unary(&mut self) -> Result { + match self.peek() { + Token::Bang => { + self.advance(); + let expr = self.parse_unary()?; + Ok(Expr::UnaryOp(Op::Not, Box::new(expr))) + } + Token::Minus => { + self.advance(); + let expr = self.parse_unary()?; + Ok(Expr::UnaryOp(Op::Neg, Box::new(expr))) + } + Token::Tilde => { + self.advance(); + let expr = self.parse_unary()?; + Ok(Expr::UnaryOp(Op::Strip, Box::new(expr))) + } + _ => self.parse_call(), + } + } + + fn parse_call(&mut self) -> Result { + let mut expr = self.parse_atom()?; + if let Expr::Ident(ref name) = expr { + if self.peek() == &Token::LParen { + self.advance(); + let mut args = Vec::new(); + if self.peek() != &Token::RParen { + args.push(self.parse_expr()?); + while self.peek() == &Token::Comma { + self.advance(); + args.push(self.parse_expr()?); + } + } + self.expect(&Token::RParen)?; + expr = Expr::Call(name.clone(), args); + } + } + while self.peek() == &Token::LBracket { + self.advance(); + let index = self.parse_expr()?; + self.expect(&Token::RBracket)?; + expr = Expr::Index(Box::new(expr), Box::new(index)); + } + Ok(expr) + } + + fn parse_atom(&mut self) -> Result { + match self.peek().clone() { + Token::Number(n) => { self.advance(); Ok(Expr::Num(n)) } + Token::Spice(n, unit) => { + // Lower to the 2-array the interpreter recognizes as spice: + // [scalar, unit]. No new AST variant — existing arithmetic + // handles arrays and the strip/retag helpers key off shape. + self.advance(); + Ok(Expr::Array(vec![Expr::Num(n), Expr::Str(unit)])) + } + Token::Str(s) => { self.advance(); Ok(Expr::Str(s)) } + Token::Bool(b) => { self.advance(); Ok(Expr::Bool(b)) } + Token::At => self.parse_cell_ref(), + Token::Ident(name) => { + // `solve!(VAR[, |from] SOURCE_FN)` — inversion macro. Detected + // here so any identifier followed by `!` still errors cleanly + // (no other macros exist yet; keep the check narrow). + if name == "solve" + && self.tokens.get(self.pos + 1) == Some(&Token::Bang) + && self.tokens.get(self.pos + 2) == Some(&Token::LParen) + { + return self.parse_solve_macro(); + } + self.advance(); + Ok(Expr::Ident(name)) + } + Token::LParen => { + self.advance(); + let expr = self.parse_expr()?; + self.expect(&Token::RParen)?; + Ok(expr) + } + Token::LBracket => { + self.advance(); + let mut items = Vec::new(); + if self.peek() != &Token::RBracket { + items.push(self.parse_expr()?); + while self.peek() == &Token::Comma { + self.advance(); + items.push(self.parse_expr()?); + } + } + self.expect(&Token::RBracket)?; + Ok(Expr::Array(items)) + } + t => Err(format!("unexpected token: {:?}", t)), + } + } +} + +// --- Interpreter --- + +#[derive(Clone, Debug)] +pub struct FnDef { + params: Vec<(String, Option)>, + return_type: Option, + body: Vec, +} + +/// Lowered form of both `solve!(var, fn)` and the `where` clause. Reached by +/// two parse paths that converge on the same shape: pick a parameter of an +/// existing fn, make the fn's return the new first parameter, numerically +/// invert on call. +#[derive(Clone, Debug)] +pub struct SolvedFnDef { + source_fn: String, + /// Index of the solved-for parameter within `source_fn`'s param list. + solve_param_idx: usize, + /// Parameter names of the new (inverted) function. Slot 0 is the + /// target-value name (what the source fn would have returned). + new_params: Vec, +} + +pub struct Interpreter { + vars: HashMap, + /// Sticky declared types. A `let x: T = ...` records `T` here; subsequent + /// `x = ...` reassignments must coerce to `T` via the round-trip rule. + /// Re-declaring `let x` (with or without a type) replaces the entry. + var_types: HashMap, + fns: HashMap, + /// Inverted functions built from `solve!(…)` / `let … where …`. Queried + /// by `eval_call` between the user-fn lookup and the builtin dispatch. + solved_fns: HashMap, + /// Sticky flag set by `exec_line` when its input includes `use spice`. + /// Gates postfix SPICE notation in the tokenizer. Once on, stays on for + /// the interpreter's lifetime — modules are short-lived enough that + /// per-block granularity isn't worth the plumbing. + spice_enabled: bool, + /// Tables registered from the viewport before eval. Keyed by fully + /// qualified lowercase name: a bare global name (e.g. `"budget"`), a + /// positional `"table_N"`, or a cross-block `"block_N::table_N"` / + /// `"blockname::tablename"`. The same table may be registered under + /// multiple keys (heading name, positional name, cross-block alias). + tables: HashMap>>, + /// Cell formulas' scope anchor. Set to `Some(lowercased_table_name)` + /// while a formula inside that table is being evaluated, so bare `A1` + /// refs resolve against the right table. None in text-block scope. + current_table: Option, + /// Current-block scope for resolving unqualified H4 (block-scoped) + /// table names. Lowercased block name. Set per-module by the eval + /// driver. + current_block: Option, + /// Log of cell writes that happened during this eval pass. Drained by + /// the viewport after eval to apply mutations back to live TableBlocks. + table_writes: Vec, +} + +#[derive(Debug, Clone)] +pub struct TableWrite { + /// Fully-resolved registry key — matches one of the strings the + /// viewport passed to `register_table`. The viewport's own name→id + /// map resolves this back to a `TableBlock`. + pub table_key: String, + pub cell: (u32, u32), + pub value: String, +} + +const MAX_ITERATIONS: usize = 10_000; +const MAX_CALL_DEPTH: u32 = 256; + +impl Interpreter { + pub fn new() -> Self { + Interpreter { + vars: HashMap::new(), + var_types: HashMap::new(), + fns: HashMap::new(), + solved_fns: HashMap::new(), + spice_enabled: false, + tables: HashMap::new(), + current_table: None, + current_block: None, + table_writes: Vec::new(), + } + } + + /// Register a table's current cell contents under `name` (lowercased). + /// Overwrites any prior registration under the same key. Called before + /// eval by the viewport for every table in the focused block's scope. + pub fn register_table(&mut self, name: &str, rows: Vec>) { + self.tables.insert(name.to_lowercase(), rows); + } + + /// Set the current-table anchor for bare cell refs in cell formulas. + /// Passing None restores text-block scope. + pub fn set_current_table(&mut self, name: Option<&str>) { + self.current_table = name.map(|s| s.to_lowercase()); + } + + /// Set the current-block anchor for resolving H4 (block-scoped) table + /// names without an explicit `block::` prefix. + pub fn set_current_block(&mut self, name: Option<&str>) { + self.current_block = name.map(|s| s.to_lowercase()); + } + + /// Consume cell writes accumulated during the last eval. Viewport + /// applies each to the matching TableBlock after eval returns. + pub fn drain_table_writes(&mut self) -> Vec { + std::mem::take(&mut self.table_writes) + } + + /// Overwrite a cell's raw string in the registered table without + /// logging a write. Used by the viewport's cell-formula loop to + /// thread a formula's computed value back into the table registry + /// so downstream reads (from text blocks, other formulas) see the + /// computed result instead of the `/=...` string. `name` is an + /// already-registered key; no-op if the table isn't registered. + pub fn write_cell_raw(&mut self, name: &str, col: u32, row: u32, value: &str) { + let key = name.to_lowercase(); + if let Some(rows) = self.tables.get_mut(&key) { + let r = row as usize; + let c = col as usize; + while rows.len() <= r { rows.push(Vec::new()); } + while rows[r].len() <= c { rows[r].push(String::new()); } + rows[r][c] = value.to_string(); + } + } + + /// Build the HashMap key under which a table was registered. Bare refs + /// (no block qualifier) first try the name directly (H3 global or + /// positional `table_N`), then fall back to `current_block::name` (H4 + /// local to the caller's module). Returns None for refs that don't + /// resolve to any registered table. + fn resolve_table_key(&self, block: Option<&str>, table: Option<&str>) -> Option { + match (block, table) { + (Some(b), Some(t)) => { + let key = format!("{}::{}", b.to_lowercase(), t.to_lowercase()); + if self.tables.contains_key(&key) { Some(key) } else { None } + } + (None, Some(t)) => { + let bare = t.to_lowercase(); + if self.tables.contains_key(&bare) { return Some(bare); } + if let Some(ref b) = self.current_block { + let qualified = format!("{}::{}", b, bare); + if self.tables.contains_key(&qualified) { return Some(qualified); } + } + None + } + (None, None) => { + self.current_table.clone().filter(|k| self.tables.contains_key(k)) + } + (Some(_), None) => None, + } + } + + /// Same as `resolve_table_key` but returns the key even when the table + /// isn't registered — used by the write path so a `@Table:A1 = ...` + /// still logs a write in a predictable location (the canonical bare + /// form) even if the viewport hadn't registered it yet. + fn resolve_table_key_lenient(&self, block: Option<&str>, table: Option<&str>) -> Option { + match (block, table) { + (Some(b), Some(t)) => Some(format!("{}::{}", b.to_lowercase(), t.to_lowercase())), + (None, Some(t)) => { + self.resolve_table_key(block, Some(t)) + .or_else(|| Some(t.to_lowercase())) + } + (None, None) => self.current_table.clone(), + (Some(_), None) => None, + } + } + + fn read_cell(&self, block: Option<&str>, table: Option<&str>, col: u32, row: u32) -> Result { + let key = self.resolve_table_key(block, table) + .ok_or_else(|| "cell ref with no table".to_string())?; + let rows = self.tables.get(&key) + .ok_or_else(|| format!("unknown table '{}'", key))?; + let cell = rows.get(row as usize) + .and_then(|r| r.get(col as usize)) + .ok_or_else(|| format!("cell {} out of bounds in '{}'", display_addr(col, row), key))?; + Ok(coerce_cell_value(cell)) + } + + fn read_whole(&self, block: Option<&str>, table: Option<&str>) -> Result { + let key = self.resolve_table_key(block, table) + .ok_or_else(|| "table ref with no name".to_string())?; + let rows = self.tables.get(&key) + .ok_or_else(|| format!("unknown table '{}'", key))?; + Ok(rows_to_value(rows)) + } + + fn read_range(&self, block: Option<&str>, table: Option<&str>, + c0: u32, r0: u32, c1: u32, r1: u32) -> Result { + let key = self.resolve_table_key(block, table) + .ok_or_else(|| "range ref with no table".to_string())?; + let rows = self.tables.get(&key) + .ok_or_else(|| format!("unknown table '{}'", key))?; + let (cmin, cmax) = if c0 <= c1 { (c0, c1) } else { (c1, c0) }; + let (rmin, rmax) = if r0 <= r1 { (r0, r1) } else { (r1, r0) }; + let mut out_rows = Vec::with_capacity((rmax - rmin + 1) as usize); + for r in rmin..=rmax { + let src_row = rows.get(r as usize); + let mut out_row = Vec::with_capacity((cmax - cmin + 1) as usize); + for c in cmin..=cmax { + let cell = src_row.and_then(|row| row.get(c as usize)) + .map(|s| s.as_str()) + .unwrap_or(""); + out_row.push(coerce_cell_value(cell)); + } + out_rows.push(Value::Array(out_row)); + } + Ok(Value::Array(out_rows)) + } + + pub fn exec_line(&mut self, line: &str) -> Result, String> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + // Pre-scan for `use spice` so the tokenizer sees spice mode even + // when the import is later in the block than the first literal. + if !self.spice_enabled && source_enables_spice(trimmed) { + self.spice_enabled = true; + } + let tokens = tokenize(trimmed, self.spice_enabled)?; + let mut parser = Parser::new(tokens); + let stmts = parser.parse_program()?; + let mut last = Value::Void; + for stmt in stmts { + last = self.exec_stmt(&stmt, 0)?; + } + match last { + Value::Void => Ok(None), + v => Ok(Some(v)), + } + } + + /// Evaluate a pre-parsed cell formula. Caller is responsible for having + /// called `set_current_table(Some(owning_table))` first, so bare + /// `A1`-style refs resolve to the right table. + pub fn eval_formula(&mut self, f: &ParsedFormula) -> Result { + self.eval_expr(&f.ast, 0) + } + + pub fn eval_expr_str(&mut self, expr: &str) -> Result { + let trimmed = expr.trim(); + if trimmed.is_empty() { + return Err("empty expression".into()); + } + let tokens = tokenize(trimmed, self.spice_enabled)?; + let mut parser = Parser::new(tokens); + let e = parser.parse_expr()?; + self.eval_expr(&e, 0) + } + + /// Explicitly enable SPICE notation. Used by the viewport when a + /// sibling block has `use spice` — the formula parser doesn't run + /// through `exec_line`, so the caller has to flip the flag itself. + pub fn set_spice_enabled(&mut self, on: bool) { + self.spice_enabled = on; + } + + /// Query whether SPICE notation is currently active. + pub fn spice_enabled(&self) -> bool { + self.spice_enabled + } + + fn exec_stmt(&mut self, stmt: &Stmt, depth: u32) -> Result { + match stmt { + // `let name = solve!(var, source_fn)` — short-circuit before the + // generic Let path so the macro never has to produce a runtime + // Value. Every other Expr reaches `eval_expr`; SolveMacro is the + // one shape that only makes sense as a binding target. + Stmt::Let(name, _type_ann, Expr::SolveMacro { var, source_fn }) => { + let def = self.build_solved_fn_def(source_fn, var, None)?; + self.fns.remove(name); + self.var_types.remove(name); + self.vars.remove(name); + self.solved_fns.insert(name.clone(), def); + Ok(Value::Void) + } + Stmt::SolveDef { name, params, target_var, source_fn, source_args, result_var } => { + // Math-form validation: result_var must be slot 0 of the new + // fn's params, and the remaining source_args must align with + // the source fn's params modulo target_var. We check the + // params-shape here and delegate the target-in-source check + // to `build_solved_fn_def`. + if params.first().map(|s| s.as_str()) != Some(result_var.as_str()) { + return Err(format!( + "inversion: result variable '{}' must be the first parameter of '{}'", + result_var, name + )); + } + let def = self.build_solved_fn_def( + source_fn, + target_var, + Some((params.as_slice(), source_args.as_slice())), + )?; + self.fns.remove(name); + self.var_types.remove(name); + self.vars.remove(name); + self.solved_fns.insert(name.clone(), def); + Ok(Value::Void) + } + Stmt::Let(name, type_ann, expr) => { + let val = self.eval_expr(expr, depth)?; + let val = match apply_type_annotation(&val, type_ann.as_deref()) { + Ok(v) => v, + Err(_) => { + let t = type_ann.as_deref().unwrap_or("?"); + return Err(format!( + "cannot bind {} to '{}' as {}: not a clean conversion", + val.display(), + name, + t + )); + } + }; + // `let` always overwrites any prior type stickiness — this is + // the only path that can change a binding's type. Untyped + // `let x = ...` removes a previously sticky type as well. + if let Some(t) = type_ann { + self.var_types.insert(name.clone(), t.clone()); + } else { + self.var_types.remove(name); + } + self.vars.insert(name.clone(), val); + Ok(Value::Void) + } + Stmt::Assign(name, expr) => { + let val = self.eval_expr(expr, depth)?; + // Reassignment respects the binding's sticky annotation. + // Value-types enforce lossy-round-trip; unit-types rewrap + // the new value with the declared label (overriding whatever + // unit the RHS algebra produced). On failure the previous + // binding is preserved and the error says so explicitly. + if let Some(t) = self.var_types.get(name).cloned() { + match apply_type_annotation(&val, Some(&t)) { + Ok(v) => { + self.vars.insert(name.clone(), v); + } + Err(_) => { + return Err(format!( + "cannot assign {} to '{}' (declared {}); value left unchanged", + val.display(), + name, + t + )); + } + } + } else { + self.vars.insert(name.clone(), val); + } + Ok(Value::Void) + } + Stmt::While(cond, body) => { + let mut iterations = 0; + loop { + let cv = self.eval_expr(cond, depth)?; + if !cv.truthy() { break; } + iterations += 1; + if iterations > MAX_ITERATIONS { + return Err(format!("loop exceeded {} iterations", MAX_ITERATIONS)); + } + let mut last = Value::Void; + for s in body { + last = self.exec_stmt(s, depth)?; + } + drop(last); + } + Ok(Value::Void) + } + Stmt::IfElse(cond, then_body, else_body) => { + let cv = self.eval_expr(cond, depth)?; + let body = if cv.truthy() { then_body } else { + match else_body { Some(b) => b, None => return Ok(Value::Void) } + }; + let mut last = Value::Void; + for s in body { + last = self.exec_stmt(s, depth)?; + } + Ok(last) + } + Stmt::ForLoop(var, iter_expr, body) => { + let iterable = self.eval_expr(iter_expr, depth)?; + let items = match iterable { + Value::Array(a) => a, + _ => return Err("for loop requires an array or range".into()), + }; + let mut iterations = 0; + let mut last = Value::Void; + for item in &items { + iterations += 1; + if iterations > MAX_ITERATIONS { + return Err(format!("loop exceeded {} iterations", MAX_ITERATIONS)); + } + self.vars.insert(var.clone(), item.clone()); + for s in body { + last = self.exec_stmt(s, depth)?; + } + } + Ok(last) + } + Stmt::FnDef { name, params, return_type, body } => { + self.solved_fns.remove(name); + self.fns.insert(name.clone(), FnDef { + params: params.clone(), + return_type: return_type.clone(), + body: body.clone(), + }); + Ok(Value::Void) + } + Stmt::Return(expr) => { + let val = self.eval_expr(expr, depth)?; + Err(format!("\x00return:{}", encode_return(&val))) + } + Stmt::Use(_, _) => { + // No-op at exec time. Use declarations are resolved + // externally by the module evaluation pipeline. + Ok(Value::Void) + } + Stmt::CellAssign { block, table, cell, value } => { + let v = self.eval_expr(value, depth)?; + let text = v.display(); + let key = self.resolve_table_key_lenient(block.as_deref(), Some(table)) + .ok_or_else(|| format!("cannot assign: no table '{}'", table))?; + if let Some(rows) = self.tables.get_mut(&key) { + let r = cell.1 as usize; + let c = cell.0 as usize; + while rows.len() <= r { rows.push(Vec::new()); } + while rows[r].len() <= c { rows[r].push(String::new()); } + rows[r][c] = text.clone(); + } + self.table_writes.push(TableWrite { + table_key: key, + cell: *cell, + value: text, + }); + Ok(Value::Void) + } + Stmt::ExprStmt(expr) => { + self.eval_expr(expr, depth) + } + } + } + + fn eval_expr(&mut self, expr: &Expr, depth: u32) -> Result { + match expr { + Expr::Num(n) => Ok(Value::Number(*n)), + Expr::Str(s) => Ok(Value::Str(s.clone())), + Expr::Bool(b) => Ok(Value::Bool(*b)), + Expr::Ident(name) => { + // Local bindings shadow built-ins (standard scope rule), so a + // user `let pi = 3` would still hide the constant. Built-ins + // are the fallback when no binding exists. + if let Some(v) = self.vars.get(name) { + return Ok(v.clone()); + } + if let Some(v) = builtin_constant(name) { + return Ok(v); + } + // Cell-formula context fallback: inside a cell, bare `A1` + // resolves to the current table's (col, row). + if self.current_table.is_some() { + if let Some((col, row)) = parse_cell_address(name) { + return self.read_cell(None, None, col, row); + } + } + Err(format!("undefined variable '{}'", name)) + } + Expr::SolveMacro { .. } => { + Err("solve!(…) can only appear on the right-hand side of a 'let' binding".into()) + } + Expr::CellRef { block, table, target } => { + match target { + CellRefTarget::Cell(col, row) => { + self.read_cell(block.as_deref(), table.as_deref(), *col, *row) + } + CellRefTarget::Whole => { + self.read_whole(block.as_deref(), table.as_deref()) + } + CellRefTarget::Range(c0, r0, c1, r1) => { + self.read_range(block.as_deref(), table.as_deref(), *c0, *r0, *c1, *r1) + } + } + } + Expr::Array(items) => { + let mut vals = Vec::new(); + for item in items { + vals.push(self.eval_expr(item, depth)?); + } + Ok(Value::Array(vals)) + } + Expr::UnaryOp(Op::Not, inner) => { + let v = self.eval_expr(inner, depth)?; + Ok(Value::Bool(!v.truthy())) + } + Expr::UnaryOp(Op::Neg, inner) => { + let v = self.eval_expr(inner, depth)?; + match v { + Value::Number(n) => Ok(Value::Number(-n)), + _ => Err("cannot negate non-number".into()), + } + } + Expr::UnaryOp(Op::Strip, inner) => { + // `~expr` demotes a typed value to its raw form for loose + // structural comparison: bool→number (false=0, true=1), + // a spice-shaped [Number, Str] array → its scalar, other + // arrays pass through untouched. + let v = self.eval_expr(inner, depth)?; + Ok(match v { + Value::Bool(b) => Value::Number(if b { 1.0 } else { 0.0 }), + Value::Array(ref a) if a.len() == 2 => { + if let (Value::Number(n), Value::Str(_)) = (&a[0], &a[1]) { + Value::Number(*n) + } else { + v + } + } + other => other, + }) + } + Expr::UnaryOp(op, _) => Err(format!("invalid unary op: {:?}", op)), + Expr::BinOp(op, lhs, rhs) => self.eval_binop(op, lhs, rhs, depth), + Expr::Call(name, args) => self.eval_call(name, args, depth), + Expr::Index(target, index) => { + let target_val = self.eval_expr(target, depth)?; + let index_val = self.eval_expr(index, depth)?; + match (&target_val, &index_val) { + (Value::Array(arr), Value::Number(n)) => { + let i = *n as i64; + let idx = if i < 0 { (arr.len() as i64 + i) as usize } else { i as usize }; + arr.get(idx).cloned().ok_or_else(|| format!("index {} out of bounds (len {})", i, arr.len())) + } + (Value::Str(s), Value::Number(n)) => { + let i = *n as i64; + let chars: Vec = s.chars().collect(); + let idx = if i < 0 { (chars.len() as i64 + i) as usize } else { i as usize }; + chars.get(idx).map(|c| Value::Str(c.to_string())) + .ok_or_else(|| format!("index {} out of bounds (len {})", i, chars.len())) + } + _ => Err(format!("cannot index {} with {}", type_name(&target_val), type_name(&index_val))), + } + } + Expr::IsCheck(inner, target) => { + let v = self.eval_expr(inner, depth)?; + Ok(Value::Bool(value_is_kind(&v, target))) + } + Expr::Range(start, end) => { + let sv = self.eval_expr(start, depth)?; + let ev = self.eval_expr(end, depth)?; + match (&sv, &ev) { + (Value::Number(a), Value::Number(b)) => { + let a = *a as i64; + let b = *b as i64; + let items: Vec = (a..b).map(|n| Value::Number(n as f64)).collect(); + if items.len() > MAX_ITERATIONS { + return Err(format!("range too large ({} elements)", items.len())); + } + Ok(Value::Array(items)) + } + _ => Err("range requires two numbers".into()), + } + } + } + } + + fn eval_binop(&mut self, op: &Op, lhs: &Expr, rhs: &Expr, depth: u32) -> Result { + // short-circuit for logical ops + if matches!(op, Op::And) { + let l = self.eval_expr(lhs, depth)?; + if !l.truthy() { return Ok(Value::Bool(false)); } + let r = self.eval_expr(rhs, depth)?; + return Ok(Value::Bool(r.truthy())); + } + if matches!(op, Op::Or) { + let l = self.eval_expr(lhs, depth)?; + if l.truthy() { return Ok(Value::Bool(true)); } + let r = self.eval_expr(rhs, depth)?; + return Ok(Value::Bool(r.truthy())); + } + + let l_raw = self.eval_expr(lhs, depth)?; + let r_raw = self.eval_expr(rhs, depth)?; + // Peel the index-0 scalar off each spice-tagged operand; the index-1 + // unit label is carried through algebraically below. Plain (non- + // spice) values return unit = None. + let (l, l_unit) = unwrap_spice(&l_raw); + let (r, r_unit) = unwrap_spice(&r_raw); + let had_unit = l_unit.is_some() || r_unit.is_some(); + let la = l_unit.unwrap_or_default(); + let ra = r_unit.unwrap_or_default(); + // Operation-specific label algebra. Non-arithmetic ops (&&, ||, + // comparisons, equality) drop the unit since the result isn't a + // measurable quantity anyway. + let unit_after: Option = if !had_unit { + None + } else { + match op { + Op::Add | Op::Sub | Op::Mod => combine_unit_additive(&la, &ra), + Op::Mul => combine_unit_mul(&la, &ra), + Op::Div => combine_unit_div(&la, &ra), + Op::Pow => { + // The exponent is conventionally unitless (`F^2`, not + // `F^(second)`), so only the base's unit propagates. + if let Value::Number(e) = r { + combine_unit_pow(&la, e) + } else if la.is_empty() { + None + } else { + Some(la.clone()) + } + } + _ => None, + } + }; + + let result = match (op, &l, &r) { + // number arithmetic + (Op::Add, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a + b)), + (Op::Sub, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a - b)), + (Op::Mul, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a * b)), + (Op::Div, Value::Number(_, ), Value::Number(b)) if *b == 0.0 => Err("division by zero".into()), + (Op::Div, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a / b)), + (Op::Mod, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a % b)), + (Op::Pow, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a.powf(*b))), + + // string concatenation + (Op::Add, Value::Str(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", a, b))), + (Op::Add, Value::Str(a), Value::Number(b)) => Ok(Value::Str(format!("{}{}", a, format_number(*b)))), + (Op::Add, Value::Number(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", format_number(*a), b))), + (Op::Add, Value::Str(a), Value::Bool(b)) => Ok(Value::Str(format!("{}{}", a, b))), + (Op::Add, Value::Bool(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", a, b))), + + // number comparisons + (Op::Lt, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a < b)), + (Op::Gt, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a > b)), + (Op::Lte, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a <= b)), + (Op::Gte, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a >= b)), + + // equality + (Op::Eq, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a == b)), + (Op::Eq, Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a == b)), + (Op::Eq, Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(a == b)), + (Op::Eq, _, _) => Ok(Value::Bool(false)), + + (Op::Neq, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a != b)), + (Op::Neq, Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a != b)), + (Op::Neq, Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(a != b)), + (Op::Neq, _, _) => Ok(Value::Bool(true)), + + _ => Err(format!("type error: cannot apply {:?} to {:?} and {:?}", op, type_name(&l), type_name(&r))), + }; + Ok(retag_spice(result?, unit_after)) + } + + fn eval_call(&mut self, name: &str, args: &[Expr], depth: u32) -> Result { + if depth >= MAX_CALL_DEPTH { + return Err("maximum call depth exceeded".into()); + } + + // User-defined functions win over built-ins — same shadow rule as + // variables shadowing builtin constants (`let pi = 3` overrides + // the `pi` constant). A note can define `fn max(a, b) { ... }` + // without being blocked by the aggregate `max` builtin below. + if self.fns.contains_key(name) { + return self.call_user_fn(name, args, depth); + } + if self.solved_fns.contains_key(name) { + return self.call_solved_fn(name, args, depth); + } + + // Math builtins are unit-transparent: unwrap the index-0 scalar, + // compute, retag with the SAME unit label. Unit is notation, not + // physics — sqrt(F) stays F, sin(V) stays V, log(A) stays A. The + // user has `~` if they need a scalar. + match name { + "sin" | "cos" | "tan" | "asin" | "acos" | "atan" | + "sqrt" | "abs" | "ln" | "log" => { + if args.len() != 1 { + return Err(format!("{}() expects 1 argument", name)); + } + let v = self.eval_expr(&args[0], depth)?; + let (raw, unit) = unwrap_spice(&v); + let n = match raw { + Value::Number(n) => n, + _ => return Err(format!("{}() expects a number", name)), + }; + let result = match name { + "sin" => n.sin(), + "cos" => n.cos(), + "tan" => n.tan(), + "asin" => n.asin(), + "acos" => n.acos(), + "atan" => n.atan(), + "sqrt" => n.sqrt(), + "abs" => n.abs(), + "ln" => n.ln(), + "log" => n.log10(), + _ => unreachable!(), + }; + return Ok(retag_spice(Value::Number(result), unit)); + } + "floor" | "ceil" | "round" => { + if args.is_empty() || args.len() > 2 { + return Err(format!("{}() expects 1 or 2 arguments", name)); + } + let v = self.eval_expr(&args[0], depth)?; + let (raw, unit) = unwrap_spice(&v); + let n = match raw { + Value::Number(n) => n, + _ => return Err(format!("{}() expects a number", name)), + }; + let digits: i32 = if args.len() == 2 { + let d_v = self.eval_expr(&args[1], depth)?; + let (d_raw, _) = unwrap_spice(&d_v); + match d_raw { + Value::Number(d) if d.is_finite() && d == d.trunc() => d as i32, + _ => return Err(format!("{}() second argument must be an integer", name)), + } + } else { + 0 + }; + let factor = 10f64.powi(digits); + let scaled = n * factor; + let result = match name { + "floor" => scaled.floor() / factor, + "ceil" => scaled.ceil() / factor, + "round" => scaled.round() / factor, + _ => unreachable!(), + }; + return Ok(retag_spice(Value::Number(result), unit)); + } + // Aggregates over a flattened numeric view of the argument. + // Accept anything — cell ranges arrive as 2D arrays, literal + // arrays as 1D, a bare number as a length-1 sequence. Non- + // numeric cells (strings, bools, voids) are skipped so mixed + // tables Just Work. + "sum" | "avg" | "min" | "max" | "count" | "std_devp" | "std_devs" => { + if args.len() != 1 { + return Err(format!("{}() expects 1 argument", name)); + } + let v = self.eval_expr(&args[0], depth)?; + let nums = flatten_numbers(&v); + return aggregate(name, &nums); + } + "len" => { + if args.len() != 1 { + return Err("len() expects 1 argument".into()); + } + let v = self.eval_expr(&args[0], depth)?; + return match v { + Value::Str(s) => Ok(Value::Number(s.len() as f64)), + Value::Array(a) => Ok(Value::Number(a.len() as f64)), + _ => Err("len() expects a string or array".into()), + }; + } + "range" => { + if args.len() != 2 { + return Err("range() expects 2 arguments".into()); + } + let start = match self.eval_expr(&args[0], depth)? { + Value::Number(n) => n as i64, + _ => return Err("range() expects numbers".into()), + }; + let end = match self.eval_expr(&args[1], depth)? { + Value::Number(n) => n as i64, + _ => return Err("range() expects numbers".into()), + }; + let items: Vec = (start..end).map(|n| Value::Number(n as f64)).collect(); + if items.len() > MAX_ITERATIONS { + return Err(format!("range too large ({} elements)", items.len())); + } + return Ok(Value::Array(items)); + } + "push" => { + if args.len() != 2 { + return Err("push() expects 2 arguments (array, value)".into()); + } + let arr = self.eval_expr(&args[0], depth)?; + let val = self.eval_expr(&args[1], depth)?; + return match arr { + Value::Array(mut a) => { a.push(val); Ok(Value::Array(a)) } + _ => Err("push() expects an array as first argument".into()), + }; + } + _ => {} + } + + Err(format!("undefined function '{}'", name)) + } + + fn call_user_fn(&mut self, name: &str, args: &[Expr], depth: u32) -> Result { + let fdef = self.fns.get(name).cloned() + .ok_or_else(|| format!("undefined function '{}'", name))?; + + if args.len() != fdef.params.len() { + return Err(format!("{}() expects {} arguments, got {}", name, fdef.params.len(), args.len())); + } + + let mut arg_vals = Vec::new(); + for arg in args { + arg_vals.push(self.eval_expr(arg, depth)?); + } + + // save current scope (vars + sticky types), set up function scope. + // Function-local `let x: T = ...` must NOT leak its type stickiness + // back to the caller's `x`, so we restore var_types alongside vars. + let saved_vars = self.vars.clone(); + let saved_types = self.var_types.clone(); + // Bind each arg to its param name. If the param carries a type + // annotation, apply it first — value-types coerce, unit-labels + // rewrap the arg as spice with the declared label. Also stash the + // type in var_types so reassignments in the body respect it. + for ((pname, pty), val) in fdef.params.iter().zip(arg_vals) { + let bound = match pty { + Some(t) => apply_type_annotation(&val, Some(t)) + .map_err(|e| format!("{}(): parameter '{}': {}", name, pname, e))?, + None => val, + }; + if let Some(t) = pty { + self.var_types.insert(pname.clone(), t.clone()); + } else { + self.var_types.remove(pname); + } + self.vars.insert(pname.clone(), bound); + } + + let mut result = Value::Void; + for stmt in &fdef.body { + match self.exec_stmt(stmt, depth + 1) { + Ok(v) => result = v, + Err(e) if e.starts_with('\x00') => { + self.vars = saved_vars; + self.var_types = saved_types; + let raw = decode_return(&e); + return Ok(apply_fn_return_type(&fdef.return_type, raw, name)?); + } + Err(e) => { + self.vars = saved_vars; + self.var_types = saved_types; + return Err(e); + } + } + } + + self.vars = saved_vars; + self.var_types = saved_types; + apply_fn_return_type(&fdef.return_type, result, name) + } + + /// Validate the pieces of an inversion (from either the macro or the math + /// form) and assemble the lowered `SolvedFnDef`. For the macro form, + /// `math_form` is None and `new_params` is derived from the source fn. + /// For the math form, `math_form = Some((new_params, source_args))` and + /// we cross-check that source_args line up with the source fn's params, + /// and that new_params[1..] matches source_args minus the target. + fn build_solved_fn_def( + &self, + source_fn: &str, + target_var: &str, + math_form: Option<(&[String], &[String])>, + ) -> Result { + let fdef = self.fns.get(source_fn) + .ok_or_else(|| format!( + "solve: source function '{}' is not defined", source_fn + ))?; + + let (solve_idx, new_params) = match math_form { + None => { + let idx = fdef.params.iter() + .position(|(p, _)| p == target_var) + .ok_or_else(|| format!( + "solve: '{}' is not a parameter of '{}'", + target_var, source_fn + ))?; + let mut np = Vec::with_capacity(fdef.params.len()); + np.push("target".to_string()); + for (i, (p, _)) in fdef.params.iter().enumerate() { + if i != idx { np.push(p.clone()); } + } + (idx, np) + } + Some((new_params, source_args)) => { + if source_args.len() != fdef.params.len() { + return Err(format!( + "solve: '{}' takes {} argument(s), got {} in where clause", + source_fn, fdef.params.len(), source_args.len() + )); + } + let idx = source_args.iter() + .position(|a| a == target_var) + .ok_or_else(|| format!( + "solve: target '{}' does not appear in where-clause arguments", + target_var + ))?; + if source_args.iter().filter(|a| *a == target_var).count() > 1 { + return Err(format!( + "solve: target '{}' appears more than once in where-clause arguments", + target_var + )); + } + let expected_rest: Vec<&String> = source_args.iter() + .filter(|a| *a != target_var).collect(); + let got_rest: Vec<&String> = new_params.iter().skip(1).collect(); + if expected_rest != got_rest { + let expected: Vec = expected_rest.iter().map(|s| (*s).clone()).collect(); + return Err(format!( + "solve: function parameters after the result must be [{}] to match the where clause", + expected.join(", ") + )); + } + (idx, new_params.to_vec()) + } + }; + + Ok(SolvedFnDef { + source_fn: source_fn.to_string(), + solve_param_idx: solve_idx, + new_params, + }) + } + + /// Dispatch a call to an inverted function. Arg 0 is the target value + /// (what the source fn would have returned); args 1..n are passed + /// through as the source fn's non-solved parameters. + fn call_solved_fn(&mut self, name: &str, args: &[Expr], depth: u32) -> Result { + let def = self.solved_fns.get(name).cloned() + .ok_or_else(|| format!("undefined function '{}'", name))?; + if args.len() != def.new_params.len() { + return Err(format!( + "{}() expects {} arguments, got {}", + name, def.new_params.len(), args.len() + )); + } + let mut arg_vals = Vec::with_capacity(args.len()); + for a in args { + arg_vals.push(self.eval_expr(a, depth)?); + } + // Peel spice wrappers off both the target and the fixed args. The + // solver only cares about the scalar; units are a display concern. + let (target_val, _) = unwrap_spice(&arg_vals[0]); + let target = match target_val { + Value::Number(n) => n, + other => return Err(format!( + "{}() target must be a number, got {}", + name, other.display() + )), + }; + let fixed: Vec = arg_vals.iter().skip(1) + .map(|v| { + let (raw, _) = unwrap_spice(v); + match raw { + Value::Number(n) => Ok(n), + other => Err(format!( + "{}() fixed arguments must be numbers, got {}", + name, other.display() + )), + } + }) + .collect::>()?; + let result = self.numerical_solve(&def, target, &fixed, depth)?; + Ok(Value::Number(result)) + } + + /// Damped Newton's method with a secant-approximated derivative. The + /// damping (step halving when a probe yields NaN/Inf/error) keeps Newton + /// from shooting out of a domain boundary — e.g. a target requiring + /// sqrt(negative) because the initial guess was on the wrong side of + /// the well. + fn numerical_solve( + &mut self, + def: &SolvedFnDef, + target: f64, + fixed: &[f64], + depth: u32, + ) -> Result { + const MAX_ITERS: u32 = 100; + const EPSILON: f64 = 1e-10; + const DERIV_EPSILON: f64 = 1e-14; + const MIN_DAMP: f64 = 1e-10; + + let mut x: f64 = 1.0; + for iter in 0..MAX_ITERS { + let fx = match self.eval_source_at(def, x, fixed, depth) { + Ok(v) if v.is_finite() => v - target, + Ok(_) => { + return Err(format!( + "solve: '{}' produced a non-finite value at iteration {}", + def.source_fn, iter + )); + } + Err(e) => { + // Preserve the source fn's error — it usually says + // exactly what's wrong (wrong arity, bad unit, etc.). + return Err(format!( + "solve: '{}' at iteration {}: {}", + def.source_fn, iter, e + )); + } + }; + if fx.abs() < EPSILON { + return Ok(x); + } + // Secant-probe for the derivative. Step size scales with x so + // the probe stays meaningful across orders of magnitude. + let h = (x.abs() * 1e-6).max(1e-9); + let fx_h = self.probe_finite(def, x + h, fixed, depth) + .or_else(|_| self.probe_finite(def, x - h, fixed, depth).map(|v| { + // If forward probe failed but backward works, flip the + // sign so the derivative still makes sense. + 2.0 * fx - v // produces same slope as (v - fx) / -h via (fx - v)/h + })) + .map_err(|_| format!( + "solve: could not probe derivative of '{}' near x={}", + def.source_fn, x + ))? - target; + let dfx = (fx_h - fx) / h; + if dfx.abs() < DERIV_EPSILON { + return Err(format!( + "solve: '{}' has a flat derivative near x={} — nothing to invert", + def.source_fn, x + )); + } + let step = fx / dfx; + // Line search with step halving. Accept the first alpha where + // the source fn is finite at x - alpha * step. + let mut alpha: f64 = 1.0; + loop { + let candidate = x - alpha * step; + match self.eval_source_at(def, candidate, fixed, depth) { + Ok(v) if v.is_finite() => { + x = candidate; + break; + } + _ => { + alpha *= 0.5; + if alpha < MIN_DAMP { + return Err(format!( + "solve: '{}' — cannot find a finite step from x={}", + def.source_fn, x + )); + } + } + } + } + } + Err(format!( + "solve: did not converge in {} iterations (source '{}')", + MAX_ITERS, def.source_fn + )) + } + + /// Evaluate the source fn at `x`, returning an error if the result isn't + /// a finite number. Separate from `eval_source_at` for use in the probe + /// path where we want to recover by trying a different direction. + fn probe_finite( + &mut self, + def: &SolvedFnDef, + x: f64, + fixed: &[f64], + depth: u32, + ) -> Result { + let v = self.eval_source_at(def, x, fixed, depth)?; + if v.is_finite() { Ok(v) } else { Err("non-finite".into()) } + } + + /// Invoke the source fn with `x` spliced into the solved-for slot. + /// If the source fn is annotated (typed params or a return type), its + /// result comes back spice-tagged — unwrap that before the solver sees + /// it, since Newton only cares about the scalar. + fn eval_source_at( + &mut self, + def: &SolvedFnDef, + x: f64, + fixed: &[f64], + depth: u32, + ) -> Result { + let arity = fixed.len() + 1; + let mut call_args: Vec = Vec::with_capacity(arity); + let mut fixed_iter = fixed.iter(); + for i in 0..arity { + if i == def.solve_param_idx { + call_args.push(Expr::Num(x)); + } else { + let v = fixed_iter.next() + .ok_or_else(|| "solve: arity mismatch between solved fn and fixed args".to_string())?; + call_args.push(Expr::Num(*v)); + } + } + let v = self.call_user_fn(&def.source_fn, &call_args, depth)?; + let (raw, _unit) = unwrap_spice(&v); + match raw { + Value::Number(n) => Ok(n), + other => Err(format!( + "solve: '{}' must return a number, got {}", + def.source_fn, other.display() + )), + } + } +} + +const RETURN_PREFIX: &str = "\x00return:"; + +fn encode_return(val: &Value) -> String { + match val { + Value::Number(n) => format!("n:{}", n), + Value::Bool(b) => format!("b:{}", b), + Value::Str(s) => format!("s:{}", s), + Value::Void => "v:".into(), + // Spice-shaped array preserves its structure through the return + // sentinel. Units are always uppercase ASCII so `|` is a safe + // separator. + Value::Array(a) if a.len() == 2 => { + if let (Value::Number(n), Value::Str(u)) = (&a[0], &a[1]) { + return format!("q:{}|{}", n, u); + } + format!("s:{}", val.display()) + } + _ => format!("s:{}", val.display()), + } +} + +fn decode_return(encoded: &str) -> Value { + let payload = &encoded[RETURN_PREFIX.len()..]; + if let Some(rest) = payload.strip_prefix("n:") { + rest.parse::().map(Value::Number).unwrap_or(Value::Void) + } else if let Some(rest) = payload.strip_prefix("b:") { + Value::Bool(rest == "true") + } else if let Some(rest) = payload.strip_prefix("s:") { + Value::Str(rest.to_string()) + } else if let Some(rest) = payload.strip_prefix("q:") { + let (n_str, u) = rest.split_once('|').unwrap_or((rest, "")); + match n_str.parse::() { + Ok(n) => Value::Array(vec![Value::Number(n), Value::Str(u.to_string())]), + Err(_) => Value::Void, + } + } else { + Value::Void + } +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Number(_) => "number", + Value::Bool(_) => "bool", + Value::Str(_) => "str", + Value::Array(_) => "array", + Value::Void => "void", + Value::Error(_) => "error", + } +} + +/// Built-in mathematical constants. Looked up after local bindings, so a +/// user-defined `pi` shadows the built-in (standard scope rule). +fn builtin_constant(name: &str) -> Option { + match name { + "pi" => Some(Value::Number(std::f64::consts::PI)), + _ => None, + } +} + +/// Runtime type-of test for the `is` keyword. Numbers match both `float` +/// (always) and `int` (only when integer-valued and finite) — int is treated +/// as a subset of float so a literal `1.0` is `is int` true and `is float` +/// true. Bool, str, and array match their own kind name only. +fn value_is_kind(v: &Value, kind: &str) -> bool { + match (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, + _ => false, + } +} + +/// Cast `v` into the named target type, returning `Some(converted)` if the +/// conversion is exact for that single direction. Used as the primitive in +/// `coerce_to`'s round-trip rule. +fn try_cast(v: &Value, target: &str) -> Option { + match (target, v) { + // Identity (already the right type). + ("int", Value::Number(n)) if *n == n.trunc() && n.is_finite() => { + Some(Value::Number(*n)) + } + ("float", Value::Number(_)) => Some(v.clone()), + ("bool", Value::Bool(_)) => Some(v.clone()), + ("str", Value::Str(_)) => Some(v.clone()), + + // bool <-> number: 0 and 1 only. + ("bool", Value::Number(n)) => { + if *n == 0.0 { + Some(Value::Bool(false)) + } else if *n == 1.0 { + Some(Value::Bool(true)) + } else { + None + } + } + ("int", Value::Bool(b)) => Some(Value::Number(if *b { 1.0 } else { 0.0 })), + ("float", Value::Bool(b)) => Some(Value::Number(if *b { 1.0 } else { 0.0 })), + + // bool <-> str: only the literals. + ("str", Value::Bool(b)) => Some(Value::Str(b.to_string())), + ("bool", Value::Str(s)) => match s.as_str() { + "true" => Some(Value::Bool(true)), + "false" => Some(Value::Bool(false)), + _ => None, + }, + + // number <-> str: parseable, exact representation. + ("str", Value::Number(n)) => Some(Value::Str(format_number(*n))), + ("int", Value::Str(s)) => s + .parse::() + .ok() + .filter(|n| *n == n.trunc() && n.is_finite()) + .map(Value::Number), + ("float", Value::Str(s)) => s.parse::().ok().map(Value::Number), + + _ => None, + } +} + +/// Round-trip clean coercion: cast to target, cast back into the value's +/// original variant, then forward to target again. Accepts iff both forward +/// casts agree AND the round-tripped value equals the original. Lossy +/// conversions (3.7 → int, 2.1 → bool, -1 → bool) fail. +fn coerce_to(val: &Value, target: &str) -> Result { + if !is_known_type(target) { + return Err(format!("unknown type annotation: {}", target)); + } + + let t1 = match try_cast(val, target) { + Some(v) => v, + None => { + return Err(format!( + "cannot coerce {} {} to {}", + type_name(val), + val.display(), + target + )); + } + }; + + // Identity (already-the-target) shortcut: if the forward cast is a + // structural no-op, no round-trip is needed. + if values_equal(&t1, val) { + return Ok(t1); + } + + // The "source type" for the back-cast is the broadest target try_cast + // knows for the value's discriminant. For Number we use "float" since + // that's an unconstrained f64; for Bool/Str their own name. + let back_target = match val { + Value::Number(_) => "float", + Value::Bool(_) => "bool", + Value::Str(_) => "str", + _ => { + return Err(format!( + "cannot coerce {} to {}", + type_name(val), + target + )); + } + }; + + let lossy = |_| { + format!( + "cannot coerce {} {} to {}: lossy round-trip", + type_name(val), + val.display(), + target + ) + }; + + let back = try_cast(&t1, back_target).ok_or_else(|| lossy(()))?; + if !values_equal(val, &back) { + return Err(lossy(())); + } + let t2 = try_cast(&back, target).ok_or_else(|| lossy(()))?; + if !values_equal(&t1, &t2) { + return Err(lossy(())); + } + Ok(t1) +} + +fn is_known_type(t: &str) -> bool { + matches!(t, "int" | "float" | "bool" | "str") +} + +fn values_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x == y, + (Value::Bool(x), Value::Bool(y)) => x == y, + (Value::Str(x), Value::Str(y)) => x == y, + _ => false, + } +} + +/// Apply the declared type to a value being bound. Value-types (int, float, +/// bool, str) enforce round-trip coercion; any other identifier is treated +/// as a unit label and spice-wraps the value with that label. `let x: F = +/// 22n` discards whatever unit `22n` already had and tags x as F — the +/// declared output wins, as the user spec'd. +fn apply_type_annotation(val: &Value, ann: Option<&str>) -> Result { + match ann { + Some(a) if is_known_type(a) => coerce_to(val, a), + Some(a) => apply_unit_annotation(val, a), + None => Ok(val.clone()), + } +} + +fn apply_unit_annotation(val: &Value, unit: &str) -> Result { + let (raw, _existing_unit) = unwrap_spice(val); + match raw { + Value::Number(_) => Ok(Value::Array(vec![raw, Value::Str(unit.to_string())])), + _ => Err(format!( + "cannot apply unit '{}' to {} {}", + unit, + type_name(val), + val.display() + )), + } +} + +/// Apply a function's declared return type to its result. Void returns +/// pass through untouched — a declared return type only makes sense when +/// the body actually produced a value. +fn apply_fn_return_type(ret_ty: &Option, val: Value, fn_name: &str) -> Result { + match ret_ty { + Some(t) if !matches!(val, Value::Void) => apply_type_annotation(&val, Some(t)) + .map_err(|e| format!("{}() return: {}", fn_name, e)), + _ => Ok(val), + } +} + +// --- Public API for eval.rs integration --- + +pub struct InterpResult { + pub line: usize, + pub value: Option, + pub format: EvalFormat, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum EvalFormat { + Inline, + Table, + Tree, +} + +// --- Module support --- + +/// Collected top-level bindings from a module after evaluation. +#[derive(Debug, Clone, Default)] +pub struct ModuleExports { + pub vars: HashMap, + pub fns: HashMap, + pub solved_fns: HashMap, +} + +/// A parsed `use` declaration: module name and optional specific item. +#[derive(Debug, Clone, PartialEq)] +pub struct UseDecl { + pub module: String, + pub item: Option, +} + +/// A direct cell reference surfaced by a formula — used by the viewport's +/// dependency graph. Bare refs inside a cell formula are resolved against +/// the owning table before the ref is emitted. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FormulaRef { + pub block: Option, + pub table: String, + pub cell: (u32, u32), +} + +/// Opaque pre-parsed cell formula. Viewport parses each `/=...` cell once, +/// harvests `refs()` to build a dep graph, then evaluates in topo order via +/// `Interpreter::eval_formula`. +pub struct ParsedFormula { + ast: Expr, +} + +/// Parse a cell formula body (the text AFTER `/=`). Produces a ParsedFormula +/// that can be evaluated later against any interpreter with the owning +/// table set as current_table. SPICE notation is off by default — call +/// `parse_formula_with_spice` from a context that knows the block state. +pub fn parse_formula(text: &str) -> Result { + parse_formula_with_spice(text, false) +} + +/// Parse a cell formula with explicit SPICE-mode gating. The viewport +/// passes `true` when the formula's owning block imports `spice`. +pub fn parse_formula_with_spice(text: &str, spice: bool) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err("empty formula".into()); + } + let tokens = tokenize(trimmed, spice)?; + let mut parser = Parser::new(tokens); + let ast = parser.parse_expr()?; + Ok(ParsedFormula { ast }) +} + +impl ParsedFormula { + /// Cells this formula directly references, with bare refs resolved + /// against `current_table` (the formula's owning table) and bare H4 + /// tables (no block qualifier) left as `block: None` — the viewport + /// looks up such refs in its module-scoped index. + pub fn refs(&self, current_table: &str) -> Vec { + let mut out = Vec::new(); + collect_formula_refs(&self.ast, current_table, &mut out); + out + } +} + +fn collect_formula_refs(expr: &Expr, current_table: &str, out: &mut Vec) { + match expr { + Expr::CellRef { block, table, target } => { + let tname = match table { + Some(t) => t.clone(), + None => current_table.to_string(), + }; + match target { + CellRefTarget::Cell(c, r) => { + out.push(FormulaRef { block: block.clone(), table: tname, cell: (*c, *r) }); + } + CellRefTarget::Range(c0, r0, c1, r1) => { + let (cmin, cmax) = if c0 <= c1 { (*c0, *c1) } else { (*c1, *c0) }; + let (rmin, rmax) = if r0 <= r1 { (*r0, *r1) } else { (*r1, *r0) }; + for r in rmin..=rmax { + for c in cmin..=cmax { + out.push(FormulaRef { + block: block.clone(), + table: tname.clone(), + cell: (c, r), + }); + } + } + } + CellRefTarget::Whole => { + out.push(FormulaRef { block: block.clone(), table: tname, cell: (0, 0) }); + } + } + } + Expr::Ident(name) => { + if !current_table.is_empty() { + if let Some((c, r)) = parse_cell_address(name) { + out.push(FormulaRef { + block: None, + table: current_table.to_string(), + cell: (c, r), + }); + } + } + } + Expr::BinOp(_, l, r) => { + collect_formula_refs(l, current_table, out); + collect_formula_refs(r, current_table, out); + } + Expr::UnaryOp(_, inner) => collect_formula_refs(inner, current_table, out), + Expr::Call(_, args) => { + for a in args { + collect_formula_refs(a, current_table, out); + } + } + Expr::Array(items) => { + for i in items { + collect_formula_refs(i, current_table, out); + } + } + Expr::Index(target, idx) => { + collect_formula_refs(target, current_table, out); + collect_formula_refs(idx, current_table, out); + } + Expr::Range(s, e) => { + collect_formula_refs(s, current_table, out); + collect_formula_refs(e, current_table, out); + } + Expr::IsCheck(inner, _) => collect_formula_refs(inner, current_table, out), + Expr::Num(_) | Expr::Str(_) | Expr::Bool(_) | Expr::SolveMacro { .. } => {} + } +} + +impl Interpreter { + /// Snapshot the interpreter's top-level bindings as exports. + pub fn exports(&self) -> ModuleExports { + ModuleExports { + vars: self.vars.clone(), + fns: self.fns.clone(), + solved_fns: self.solved_fns.clone(), + } + } + + /// Pre-populate scope with another module's exports. All bindings + /// are imported flat (as if written locally). Existing bindings + /// with the same name are overwritten. + pub fn import_all(&mut self, exports: &ModuleExports) { + for (name, val) in &exports.vars { + self.vars.insert(name.clone(), val.clone()); + } + for (name, fndef) in &exports.fns { + self.fns.insert(name.clone(), fndef.clone()); + } + for (name, def) in &exports.solved_fns { + self.solved_fns.insert(name.clone(), def.clone()); + } + } + + /// Import a single named binding from exports. Returns false if + /// the name doesn't exist in the exports. + pub fn import_item(&mut self, exports: &ModuleExports, item: &str) -> bool { + let mut found = false; + if let Some(val) = exports.vars.get(item) { + self.vars.insert(item.to_string(), val.clone()); + found = true; + } + if let Some(fndef) = exports.fns.get(item) { + self.fns.insert(item.to_string(), fndef.clone()); + found = true; + } + if let Some(def) = exports.solved_fns.get(item) { + self.solved_fns.insert(item.to_string(), def.clone()); + found = true; + } + found + } +} + +/// Scan text for `use` declarations without executing anything. +/// Returns the list of UseDecls found. Lines that fail to parse +/// are silently skipped (they'll be treated as prose). +pub fn extract_use_declarations(text: &str) -> Vec { + let mut decls = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("use ") { + continue; + } + let Ok(tokens) = tokenize(trimmed, false) else { continue }; + let mut parser = Parser::new(tokens); + if let Ok(Stmt::Use(module, item)) = parser.parse_use() { + decls.push(UseDecl { module, item }); + } + } + decls +} + +pub fn interpret_document(lines: &[(usize, &str, bool)]) -> Vec { + let mut interp = Interpreter::new(); + interpret_document_with(&mut interp, lines) +} + +/// Like `interpret_document`, but uses an existing interpreter (with +/// pre-populated scope from module imports). +pub fn interpret_document_with(interp: &mut Interpreter, lines: &[(usize, &str, bool)]) -> Vec { + let mut results = Vec::new(); + + // First pass: collect the entire program from cordial lines + // and evaluate line by line, recording results for eval lines + let mut brace_depth: i32 = 0; + let mut block_acc: Vec = Vec::new(); + + for &(idx, content, is_eval) in lines { + if is_eval { + if !block_acc.is_empty() { + let block_text = block_acc.join("\n"); + block_acc.clear(); + brace_depth = 0; + match interp.exec_line(&block_text) { + Ok(_) => {} + Err(e) => { + results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format: EvalFormat::Inline }); + continue; + } + } + } + + let trimmed = content.trim(); + let (format, expr) = if let Some(rest) = trimmed.strip_prefix("/=|") { + (EvalFormat::Table, rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix("/=\\") { + (EvalFormat::Tree, rest.trim()) + } else { + (EvalFormat::Inline, trimmed.strip_prefix("/=").unwrap_or("").trim()) + }; + if expr.is_empty() { + results.push(InterpResult { line: idx, value: Some(Value::Error("empty expression".into())), format }); + continue; + } + match interp.eval_expr_str(expr) { + Ok(val) => results.push(InterpResult { line: idx, value: Some(val), format }), + Err(e) => results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format }), + } + } else { + let trimmed = content.trim(); + // track brace depth for multi-line blocks + let opens = trimmed.matches('{').count() as i32; + let closes = trimmed.matches('}').count() as i32; + + if brace_depth > 0 || !block_acc.is_empty() { + block_acc.push(trimmed.to_string()); + brace_depth += opens - closes; + if brace_depth <= 0 { + let block_text = block_acc.join("\n"); + block_acc.clear(); + brace_depth = 0; + if let Err(e) = interp.exec_line(&block_text) { + results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format: EvalFormat::Inline }); + } + } + } else if opens > closes { + block_acc.push(trimmed.to_string()); + brace_depth = opens - closes; + } else { + if let Err(e) = interp.exec_line(trimmed) { + results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format: EvalFormat::Inline }); + } + } + } + } + + results +} + +/// Flatten a value into the sequence of numbers an aggregate sees. Cell +/// ranges arrive as nested `Value::Array`s (rows of cells); literal +/// arrays may also be 1D. Strings that happen to be number-parseable DO +/// count — matching how cell reads auto-promote. Non-numeric strings, +/// booleans, voids, and errors are skipped silently so a `sum` over a +/// column with a header row still does the right thing. +fn flatten_numbers(v: &Value) -> Vec { + let mut out = Vec::new(); + walk(v, &mut out); + return out; + + fn walk(v: &Value, out: &mut Vec) { + match v { + Value::Number(n) => out.push(*n), + Value::Array(items) => { + for item in items { + walk(item, out); + } + } + Value::Str(s) => { + if let Ok(n) = s.trim().parse::() { + out.push(n); + } + } + _ => {} + } + } +} + +/// Dispatch the numeric aggregation after the argument has been flattened. +/// Kept separate from the call-site so the same core is used from any +/// future aggregate (median, mode, variance, …) without rewriting the +/// unpacking. +fn aggregate(name: &str, nums: &[f64]) -> Result { + match name { + "sum" => Ok(Value::Number(nums.iter().sum())), + "count" => Ok(Value::Number(nums.len() as f64)), + "avg" => { + if nums.is_empty() { + return Err("avg() of empty range".into()); + } + Ok(Value::Number(nums.iter().sum::() / nums.len() as f64)) + } + "min" => nums.iter().copied().fold(None::, |acc, n| { + Some(match acc { Some(a) => a.min(n), None => n }) + }).map(Value::Number).ok_or_else(|| "min() of empty range".into()), + "max" => nums.iter().copied().fold(None::, |acc, n| { + Some(match acc { Some(a) => a.max(n), None => n }) + }).map(Value::Number).ok_or_else(|| "max() of empty range".into()), + "std_devp" | "std_devs" => { + let n = nums.len(); + if n == 0 { + return Err(format!("{}() of empty range", name)); + } + if name == "std_devs" && n < 2 { + return Err("std_devs() needs at least 2 values".into()); + } + let mean = nums.iter().sum::() / n as f64; + let ss: f64 = nums.iter().map(|v| (v - mean).powi(2)).sum(); + let divisor = if name == "std_devp" { n as f64 } else { (n - 1) as f64 }; + Ok(Value::Number((ss / divisor).sqrt())) + } + _ => Err(format!("unknown aggregate '{}'", name)), + } +} + +// --- Display helpers for type-annotated int --- + +pub fn display_value_with_type(val: &Value, type_ann: Option<&str>) -> String { + match (val, type_ann) { + (Value::Number(n), Some("int")) => format!("{}", *n as i64), + _ => val.display(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + let mut tagged: Vec<(usize, &str, bool)> = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let is_eval = line.trim().starts_with("/="); + let is_comment = line.trim().starts_with("//"); + if !is_eval && !is_comment && !line.trim().is_empty() && !line.trim().starts_with('#') { + tagged.push((i, line, false)); + } else if is_eval { + tagged.push((i, line, true)); + } + } + let results = interpret_document(&tagged); + results.iter() + .filter_map(|r| r.value.as_ref().map(|v| v.display())) + .collect::>() + .join(", ") + } + + fn eval_one(input: &str) -> String { + let mut interp = Interpreter::new(); + match interp.eval_expr_str(input) { + Ok(v) => v.display(), + Err(e) => format!("error: {}", e), + } + } + + #[test] + fn basic_arithmetic() { + assert_eq!(eval_one("2 + 3"), "5"); + assert_eq!(eval_one("10 - 4"), "6"); + assert_eq!(eval_one("3 * 7"), "21"); + assert_eq!(eval_one("15 / 3"), "5"); + assert_eq!(eval_one("2 ^ 10"), "1024"); + assert_eq!(eval_one("10 % 3"), "1"); + } + + #[test] + fn string_literals() { + assert_eq!(eval_one("\"hello\""), "hello"); + assert_eq!(eval_one("\"hello\" + \" \" + \"world\""), "hello world"); + } + + #[test] + fn string_concatenation_mixed() { + assert_eq!(eval_one("\"val: \" + 42"), "val: 42"); + assert_eq!(eval_one("100 + \" items\""), "100 items"); + } + + #[test] + fn boolean_literals() { + assert_eq!(eval_one("true"), "true"); + assert_eq!(eval_one("false"), "false"); + } + + #[test] + fn comparison_operators() { + assert_eq!(eval_one("1 < 2"), "true"); + assert_eq!(eval_one("2 > 3"), "false"); + assert_eq!(eval_one("5 == 5"), "true"); + assert_eq!(eval_one("5 != 3"), "true"); + assert_eq!(eval_one("3 <= 3"), "true"); + assert_eq!(eval_one("4 >= 5"), "false"); + } + + #[test] + fn logical_operators() { + assert_eq!(eval_one("true && false"), "false"); + assert_eq!(eval_one("true || false"), "true"); + assert_eq!(eval_one("!true"), "false"); + assert_eq!(eval_one("!false"), "true"); + } + + #[test] + fn logical_operators_keyword_forms() { + // and/or/not are interchangeable with &&/||/!. + assert_eq!(eval_one("true and false"), "false"); + assert_eq!(eval_one("true or false"), "true"); + assert_eq!(eval_one("not true"), "false"); + assert_eq!(eval_one("not false"), "true"); + } + + #[test] + fn pi_constant() { + // pi resolves to π without parens. + let r = eval_one("pi"); + assert!(r.starts_with("3.14159"), "expected pi, got: {}", r); + // Usable in expressions. + let r2 = eval_one("pi * 2"); + assert!(r2.starts_with("6.283185"), "expected 2π, got: {}", r2); + } + + #[test] + fn pi_can_be_shadowed_by_let() { + // Standard scope rule: a local binding hides the built-in. + let input = "let pi = 3\n/= pi"; + assert_eq!(eval(input), "3"); + } + + #[test] + fn strip_operator_bool_to_number() { + // ~true and ~false demote to their numeric form. + assert_eq!(eval_one("~true"), "1"); + assert_eq!(eval_one("~false"), "0"); + } + + #[test] + fn strip_operator_bridges_bool_and_number() { + // The motivating use case: comparing a typed bool to a typed int. + let input = "let this: bool = 0\nlet that: int = 0\n/= ~this == ~that"; + assert_eq!(eval(input), "true"); + } + + #[test] + fn strip_operator_number_is_noop() { + assert_eq!(eval_one("~5"), "5"); + assert_eq!(eval_one("~3.14"), "3.14"); + assert_eq!(eval_one("~-5"), "-5"); + } + + #[test] + fn strip_operator_str_is_noop() { + assert_eq!(eval_one("~\"hello\""), "hello"); + } + + #[test] + fn is_keyword_basic() { + assert_eq!(eval_one("true is bool"), "true"); + assert_eq!(eval_one("false is bool"), "true"); + assert_eq!(eval_one("\"hello\" is str"), "true"); + assert_eq!(eval_one("[1, 2, 3] is array"), "true"); + // int and float overlap for whole-valued numbers (int ⊂ float). + assert_eq!(eval_one("1 is int"), "true"); + assert_eq!(eval_one("1 is float"), "true"); + assert_eq!(eval_one("1.0 is int"), "true"); + // Non-integer floats are NOT int. + assert_eq!(eval_one("1.5 is int"), "false"); + assert_eq!(eval_one("1.5 is float"), "true"); + // Wrong-kind checks. + assert_eq!(eval_one("1 is bool"), "false"); + assert_eq!(eval_one("true is int"), "false"); + assert_eq!(eval_one("\"42\" is int"), "false"); + } + + #[test] + fn is_keyword_in_if() { + let input = "let x: bool = 0\nlet r = false\nif (x is bool) {\n r = true\n}\n/= r"; + assert_eq!(eval(input), "true"); + } + + #[test] + fn is_keyword_combines_with_logic() { + // is at comparison precedence: parses as `(x is int) and (x > 0)`. + let input = "let x = 5\n/= x is int and x > 0"; + assert_eq!(eval(input), "true"); + let input2 = "let x = 5\n/= x is bool or x is int"; + assert_eq!(eval(input2), "true"); + } + + #[test] + fn logical_operators_mixed_forms() { + // Symbolic and keyword forms in the same expression. + assert_eq!(eval_one("true and not false"), "true"); + assert_eq!(eval_one("(true or false) and not false"), "true"); + // !or composition gives nand semantics: not(a or b) + assert_eq!(eval_one("!(true or true)"), "false"); + assert_eq!(eval_one("not (false or false)"), "true"); + } + + #[test] + fn arrays() { + assert_eq!(eval_one("[1, 2, 3]"), "[1, 2, 3]"); + assert_eq!(eval_one("[1, \"two\", true]"), "[1, \"two\", true]"); + assert_eq!(eval_one("[]"), "[]"); + } + + #[test] + fn variable_binding() { + let input = "let x = 5\n/= x + 10"; + assert_eq!(eval(input), "15"); + } + + #[test] + fn variable_reassignment() { + let input = "let x = 5\nx = 10\n/= x"; + assert_eq!(eval(input), "10"); + } + + #[test] + fn while_loop() { + let input = "let i = 0\nlet sum = 0\nwhile (i < 10) {\n sum = sum + i\n i = i + 1\n}\n/= sum"; + assert_eq!(eval(input), "45"); + } + + #[test] + fn while_loop_guard() { + let input = "let i = 0\nwhile (true) {\n i = i + 1\n}\n/= i"; + let result = eval(input); + assert!(result.contains("error"), "should error on infinite loop: {}", result); + } + + #[test] + fn function_def_and_call() { + let input = "fn add(a, b) {\n a + b\n}\n/= add(3, 4)"; + assert_eq!(eval(input), "7"); + } + + #[test] + fn function_calling_function() { + let input = "fn double(x) {\n x * 2\n}\nfn quad(x) {\n double(double(x))\n}\n/= quad(5)"; + assert_eq!(eval(input), "20"); + } + + #[test] + fn type_annotation_int_lossy_rejected() { + // Round-trip rule: 3.7 -> int -> float != 3.7, so this is lossy and + // must error. + let input = "let x: int = 3.7\n/= x"; + let result = eval(input); + assert!(result.contains("error") || result.contains("lossy"), "should reject lossy: {}", result); + } + + #[test] + fn type_annotation_int_exact_accepted() { + // 3.0 -> int -> float -> int is exact, so this passes. + let input = "let x: int = 3.0\n/= x"; + assert_eq!(eval(input), "3"); + } + + #[test] + fn type_stickiness_reassign_lossy_rejected() { + // boolFlag is bool; assigning 0.1 to it must fail the round-trip + // (0.1 -> bool fails because 0.1 isn't 0 or 1). + let input = "let f: bool = 0\nf = 0.1\n/= f"; + let result = eval(input); + assert!(result.contains("error") || result.contains("lossy") || result.contains("false"), + "should reject lossy reassign: {}", result); + } + + #[test] + fn type_stickiness_reassign_clean_accepted() { + // 1 cleanly coerces to true. + let input = "let f: bool = 0\nf = 1\n/= f"; + assert_eq!(eval(input), "true"); + } + + #[test] + fn type_stickiness_redeclare_changes_type() { + // `let` overrides any prior type stickiness. + let input = "let x: int = 3\nlet x: bool = 1\n/= x"; + assert_eq!(eval(input), "true"); + } + + #[test] + fn type_annotation_bool_valid() { + let input = "let x: bool = 1\n/= x"; + assert_eq!(eval(input), "true"); + } + + #[test] + fn type_annotation_bool_zero() { + let input = "let x: bool = 0\n/= x"; + assert_eq!(eval(input), "false"); + } + + #[test] + fn type_annotation_bool_invalid() { + let input = "let x: bool = 2\n/= x"; + let result = eval(input); + assert!(result.contains("error"), "should error: {}", result); + } + + #[test] + fn type_annotation_str() { + let input = "let x: str = \"hello\"\n/= x"; + assert_eq!(eval(input), "hello"); + } + + #[test] + fn type_annotation_str_from_int_clean() { + // Round-trip: 42 -> "42" -> 42 is exact, so this coerces cleanly. + let input = "let x: str = 42\n/= x"; + assert_eq!(eval(input), "42"); + } + + #[test] + fn type_annotation_str_from_float_clean() { + let input = "let x: str = 3.14\n/= x"; + assert_eq!(eval(input), "3.14"); + } + + #[test] + fn type_annotation_int_from_str_clean() { + let input = "let x: int = \"42\"\n/= x"; + assert_eq!(eval(input), "42"); + } + + #[test] + fn type_annotation_int_from_str_lossy_rejected() { + let input = "let x: int = \"3.7\"\n/= x"; + let result = eval(input); + assert!(result.contains("error") || result.contains("lossy"), + "should reject: {}", result); + } + + #[test] + fn error_undefined_variable() { + let result = eval("/= undefined_var"); + assert!(result.contains("error"), "should error: {}", result); + assert!(result.contains("undefined variable"), "{}", result); + } + + #[test] + fn error_recovery() { + let input = "let x = bad_var\nlet y = 5\n/= y"; + // x assignment fails, but y should still work + let result = eval(input); + assert!(result.contains("5"), "should recover and eval y: {}", result); + } + + #[test] + fn error_undefined_function() { + let result = eval("/= nope(1, 2)"); + assert!(result.contains("error"), "should error: {}", result); + } + + #[test] + fn multiple_evals() { + let input = "let a = 3\n/= a\nlet b = 7\n/= a + b"; + assert_eq!(eval(input), "3, 10"); + } + + #[test] + fn builtin_math_functions() { + assert_eq!(eval_one("abs(-5)"), "5"); + assert_eq!(eval_one("floor(3.7)"), "3"); + assert_eq!(eval_one("ceil(3.2)"), "4"); + assert_eq!(eval_one("sqrt(16)"), "4"); + } + + #[test] + fn nested_expressions() { + assert_eq!(eval_one("(2 + 3) * (4 - 1)"), "15"); + assert_eq!(eval_one("2 * (3 + 4 * 5)"), "46"); + } + + #[test] + fn string_variable() { + let input = "let x = \"hello\"\nlet y = \"world\"\n/= x + \" \" + y"; + assert_eq!(eval(input), "hello world"); + } + + #[test] + fn division_by_zero() { + let result = eval_one("1 / 0"); + assert!(result.contains("error"), "should error on div by zero: {}", result); + } + + #[test] + fn len_function() { + assert_eq!(eval_one("len(\"hello\")"), "5"); + assert_eq!(eval_one("len([1, 2, 3])"), "3"); + } + + #[test] + fn negative_numbers() { + assert_eq!(eval_one("-5"), "-5"); + assert_eq!(eval_one("-3 + 7"), "4"); + assert_eq!(eval_one("10 + -3"), "7"); + } + + #[test] + fn empty_array() { + assert_eq!(eval_one("len([])"), "0"); + } + + #[test] + fn complex_while_with_function() { + let input = "\ +fn fib(n) { + let a = 0 + let b = 1 + let i = 0 + while (i < n) { + let tmp = b + b = a + b + a = tmp + i = i + 1 + } + a +} +/= fib(10)"; + assert_eq!(eval(input), "55"); + } + + #[test] + fn if_true() { + let input = "let x = 10\nif (x > 5) {\n x = 100\n}\n/= x"; + assert_eq!(eval(input), "100"); + } + + #[test] + fn if_false() { + let input = "let x = 3\nif (x > 5) {\n x = 100\n}\n/= x"; + assert_eq!(eval(input), "3"); + } + + #[test] + fn if_else() { + let input = "let x = 3\nif (x > 5) {\n x = 100\n} else {\n x = 0\n}\n/= x"; + assert_eq!(eval(input), "0"); + } + + #[test] + fn if_else_chain() { + let input = "\ +let x = 5 +let r = 0 +if (x > 10) { + r = 3 +} else if (x > 3) { + r = 2 +} else { + r = 1 +} +/= r"; + assert_eq!(eval(input), "2"); + } + + #[test] + fn if_without_parens() { + let input = "let x = 10\nif x > 5 {\n x = 100\n}\n/= x"; + assert_eq!(eval(input), "100"); + } + + #[test] + fn for_loop_array() { + let input = "let sum = 0\nfor x in [1, 2, 3, 4, 5] {\n sum = sum + x\n}\n/= sum"; + assert_eq!(eval(input), "15"); + } + + #[test] + fn for_loop_range() { + let input = "let sum = 0\nfor i in 0..5 {\n sum = sum + i\n}\n/= sum"; + assert_eq!(eval(input), "10"); + } + + #[test] + fn for_loop_range_fn() { + let input = "let sum = 0\nfor i in range(1, 6) {\n sum = sum + i\n}\n/= sum"; + assert_eq!(eval(input), "15"); + } + + #[test] + fn array_index() { + assert_eq!(eval_one("[10, 20, 30][1]"), "20"); + } + + #[test] + fn array_index_variable() { + let input = "let arr = [10, 20, 30]\n/= arr[2]"; + assert_eq!(eval(input), "30"); + } + + #[test] + fn array_negative_index() { + assert_eq!(eval_one("[10, 20, 30][-1]"), "30"); + } + + #[test] + fn string_index() { + assert_eq!(eval_one("\"hello\"[0]"), "h"); + } + + #[test] + fn array_index_out_of_bounds() { + let result = eval_one("[1, 2][5]"); + assert!(result.contains("error"), "should error: {}", result); + } + + #[test] + fn return_from_function() { + let input = "\ +fn first_positive(a, b) { + if (a > 0) { + return a + } + if (b > 0) { + return b + } + return 0 +} +/= first_positive(-1, 5)"; + assert_eq!(eval(input), "5"); + } + + #[test] + fn return_early_from_loop() { + let input = "\ +fn find(arr, target) { + for x in arr { + if (x == target) { + return x + } + } + return -1 +} +/= find([1, 2, 3, 4], 3)"; + assert_eq!(eval(input), "3"); + } + + #[test] + fn push_builtin() { + let input = "let arr = [1, 2]\nlet arr = push(arr, 3)\n/= arr"; + assert_eq!(eval(input), "[1, 2, 3]"); + } + + #[test] + fn range_expression() { + assert_eq!(eval_one("0..5"), "[0, 1, 2, 3, 4]"); + } + + #[test] + fn use_statement_parses() { + // use is a no-op at exec time — just check it doesn't error + let mut interp = Interpreter::new(); + assert!(interp.exec_line("use budget").is_ok()); + assert!(interp.exec_line("use budget::ramp").is_ok()); + } + + #[test] + fn extract_use_decls() { + let text = "let x = 5\nuse calculations\nSome prose\nuse budget::ramp\n/= x"; + let decls = extract_use_declarations(text); + assert_eq!(decls.len(), 2); + assert_eq!(decls[0].module, "calculations"); + assert_eq!(decls[0].item, None); + assert_eq!(decls[1].module, "budget"); + assert_eq!(decls[1].item, Some("ramp".to_string())); + } + + #[test] + fn extract_use_skips_invalid() { + let text = "use\nuse 123\nuse valid_module"; + let decls = extract_use_declarations(text); + assert_eq!(decls.len(), 1); + assert_eq!(decls[0].module, "valid_module"); + } + + #[test] + fn module_exports_and_import() { + let mut module_a = Interpreter::new(); + module_a.exec_line("let x = 42").unwrap(); + module_a.exec_line("fn double(n) {\n n * 2\n}").unwrap(); + let exports = module_a.exports(); + assert!(exports.vars.contains_key("x")); + assert!(exports.fns.contains_key("double")); + + let mut module_b = Interpreter::new(); + module_b.import_all(&exports); + let val = module_b.eval_expr_str("x").unwrap(); + assert!(matches!(val, Value::Number(n) if n == 42.0)); + let val = module_b.eval_expr_str("double(5)").unwrap(); + assert!(matches!(val, Value::Number(n) if n == 10.0)); + } + + #[test] + fn import_specific_item() { + let mut module_a = Interpreter::new(); + module_a.exec_line("let x = 1").unwrap(); + module_a.exec_line("let y = 2").unwrap(); + let exports = module_a.exports(); + + let mut module_b = Interpreter::new(); + assert!(module_b.import_item(&exports, "x")); + assert!(module_b.eval_expr_str("x").is_ok()); + assert!(module_b.eval_expr_str("y").is_err()); + } + + // --- Cell references --- + + #[test] + fn cell_address_parses_A1() { + assert_eq!(parse_cell_address("A1"), Some((0, 0))); + assert_eq!(parse_cell_address("a1"), Some((0, 0))); + assert_eq!(parse_cell_address("B3"), Some((1, 2))); + assert_eq!(parse_cell_address("Z99"), Some((25, 98))); + } + + #[test] + fn cell_address_parses_multi_letter_cols() { + assert_eq!(parse_cell_address("AA1"), Some((26, 0))); + assert_eq!(parse_cell_address("AB1"), Some((27, 0))); + assert_eq!(parse_cell_address("BA1"), Some((52, 0))); + } + + #[test] + fn cell_address_rejects_malformed() { + assert_eq!(parse_cell_address(""), None); + assert_eq!(parse_cell_address("1A"), None); + assert_eq!(parse_cell_address("A"), None); + assert_eq!(parse_cell_address("1"), None); + assert_eq!(parse_cell_address("A0"), None); + assert_eq!(parse_cell_address("A1B"), None); + } + + #[test] + fn display_addr_roundtrip() { + for col in 0..60u32 { + for row in 0..30u32 { + let s = display_addr(col, row); + assert_eq!(parse_cell_address(&s), Some((col, row))); + } + } + } + + #[test] + fn read_cell_number() { + let mut i = Interpreter::new(); + i.register_table("budget", vec![ + vec!["10".into(), "20".into()], + vec!["30".into(), "40".into()], + ]); + let v = i.eval_expr_str("@Budget:A1").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 10.0)); + let v = i.eval_expr_str("@Budget:B2").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 40.0)); + } + + #[test] + fn read_cell_str() { + let mut i = Interpreter::new(); + i.register_table("t", vec![vec!["hello".into(), "world".into()]]); + let v = i.eval_expr_str("@t:A1").unwrap(); + assert!(matches!(v, Value::Str(ref s) if s == "hello")); + } + + #[test] + fn cell_arithmetic() { + let mut i = Interpreter::new(); + i.register_table("b", vec![vec!["10".into(), "20".into()]]); + let v = i.eval_expr_str("@b:A1 + @b:B1").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 30.0)); + } + + #[test] + fn cell_ref_unknown_table_errors() { + let mut i = Interpreter::new(); + assert!(i.eval_expr_str("@Nope:A1").is_err()); + } + + #[test] + fn cell_ref_out_of_bounds_errors() { + let mut i = Interpreter::new(); + i.register_table("t", vec![vec!["1".into()]]); + assert!(i.eval_expr_str("@t:Z99").is_err()); + } + + #[test] + fn whole_table_snapshot() { + let mut i = Interpreter::new(); + i.register_table("b", vec![vec!["1".into(), "2".into()], vec!["3".into(), "4".into()]]); + let v = i.eval_expr_str("@b").unwrap(); + let outer = match v { Value::Array(a) => a, _ => panic!("not array") }; + assert_eq!(outer.len(), 2); + let first = match &outer[0] { Value::Array(a) => a, _ => panic!("not array") }; + assert_eq!(first.len(), 2); + assert!(matches!(first[0], Value::Number(n) if n == 1.0)); + } + + #[test] + fn cross_block_qualified_ref() { + let mut i = Interpreter::new(); + i.register_table("second::local", vec![vec!["7".into()]]); + let v = i.eval_expr_str("@second::local:A1").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 7.0)); + } + + #[test] + fn bare_ref_uses_current_block() { + let mut i = Interpreter::new(); + i.register_table("second::local", vec![vec!["7".into()]]); + i.set_current_block(Some("second")); + let v = i.eval_expr_str("@local:A1").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 7.0)); + } + + #[test] + fn bare_cell_ref_inside_cell_formula() { + let mut i = Interpreter::new(); + i.register_table("budget", vec![vec!["10".into(), "20".into()]]); + i.set_current_table(Some("budget")); + let f = parse_formula("A1 + B1").unwrap(); + let v = i.eval_formula(&f).unwrap(); + assert!(matches!(v, Value::Number(n) if n == 30.0)); + } + + #[test] + fn range_ref_returns_2d_array() { + let mut i = Interpreter::new(); + i.register_table("b", vec![ + vec!["1".into(), "2".into(), "3".into()], + vec!["4".into(), "5".into(), "6".into()], + vec!["7".into(), "8".into(), "9".into()], + ]); + let v = i.eval_expr_str("@b:A1:B2").unwrap(); + let outer = match v { Value::Array(a) => a, _ => panic!() }; + assert_eq!(outer.len(), 2); + let row0 = match &outer[0] { Value::Array(a) => a, _ => panic!() }; + assert_eq!(row0.len(), 2); + assert!(matches!(row0[0], Value::Number(n) if n == 1.0)); + assert!(matches!(row0[1], Value::Number(n) if n == 2.0)); + let row1 = match &outer[1] { Value::Array(a) => a, _ => panic!() }; + assert!(matches!(row1[0], Value::Number(n) if n == 4.0)); + } + + #[test] + fn range_bracket_syntax() { + let mut i = Interpreter::new(); + i.register_table("b", vec![ + vec!["1".into(), "2".into()], + vec!["3".into(), "4".into()], + ]); + let v = i.eval_expr_str("@b[A1:B2]").unwrap(); + let outer = match v { Value::Array(a) => a, _ => panic!() }; + assert_eq!(outer.len(), 2); + } + + #[test] + fn cell_assign_mutates_table() { + let mut i = Interpreter::new(); + i.register_table("b", vec![vec!["0".into(), "0".into()]]); + i.exec_line("@b:A1 = 42").unwrap(); + let v = i.eval_expr_str("@b:A1").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 42.0)); + } + + #[test] + fn cell_assign_logs_write() { + let mut i = Interpreter::new(); + i.register_table("b", vec![vec!["0".into()]]); + i.exec_line("@b:A1 = 99").unwrap(); + let writes = i.drain_table_writes(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].table_key, "b"); + assert_eq!(writes[0].cell, (0, 0)); + assert_eq!(writes[0].value, "99"); + } + + #[test] + fn cell_assign_drain_is_idempotent() { + let mut i = Interpreter::new(); + i.register_table("b", vec![vec!["0".into()]]); + i.exec_line("@b:A1 = 1").unwrap(); + let first = i.drain_table_writes(); + assert_eq!(first.len(), 1); + let second = i.drain_table_writes(); + assert!(second.is_empty()); + } + + #[test] + fn cell_assign_rejects_whole_table_target() { + let mut i = Interpreter::new(); + i.register_table("b", vec![vec!["0".into()]]); + assert!(i.exec_line("@b = 1").is_err()); + } + + #[test] + fn cell_assign_rejects_range_target() { + let mut i = Interpreter::new(); + i.register_table("b", vec![vec!["0".into(), "0".into()]]); + assert!(i.exec_line("@b:A1:B1 = 1").is_err()); + } + + #[test] + fn formula_refs_simple() { + let f = parse_formula("@budget:A1 + @budget:B2").unwrap(); + let refs = f.refs(""); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].table, "budget"); + assert_eq!(refs[0].cell, (0, 0)); + assert_eq!(refs[1].table, "budget"); + assert_eq!(refs[1].cell, (1, 1)); + } + + #[test] + fn formula_refs_bare_with_current_table() { + let f = parse_formula("A1 + B2").unwrap(); + let refs = f.refs("budget"); + assert_eq!(refs.len(), 2); + assert!(refs.iter().all(|r| r.table == "budget" && r.block.is_none())); + } + + #[test] + fn formula_refs_range_expands() { + let f = parse_formula("@t:A1:B2").unwrap(); + let refs = f.refs(""); + assert_eq!(refs.len(), 4); + } + + #[test] + fn formula_refs_cross_block() { + let f = parse_formula("@second::local:A1").unwrap(); + let refs = f.refs(""); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].block.as_deref(), Some("second")); + assert_eq!(refs[0].table, "local"); + } + + #[test] + fn cell_address_case_insensitive_parse() { + // `@BUDGET:a1` should work identically to `@budget:A1`. + let mut i = Interpreter::new(); + i.register_table("budget", vec![vec!["7".into()]]); + let v = i.eval_expr_str("@BUDGET:a1").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 7.0)); + } + + // --- Aggregate fns --- + + fn approx(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } + + #[test] + fn sum_on_literal_array() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("sum([1, 2, 3, 4])").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 10.0)); + } + + #[test] + fn sum_on_range() { + let mut i = Interpreter::new(); + i.register_table("t", vec![ + vec!["1".into(), "2".into()], + vec!["3".into(), "4".into()], + ]); + let v = i.eval_expr_str("sum(@t:A1:B2)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 10.0)); + } + + #[test] + fn sum_skips_non_numeric() { + let mut i = Interpreter::new(); + i.register_table("t", vec![ + vec!["label".into(), "3".into()], + vec!["10".into(), "hello".into()], + ]); + let v = i.eval_expr_str("sum(@t)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 13.0)); + } + + #[test] + fn avg_basic() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("avg([2, 4, 6])").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 4.0)); + } + + #[test] + fn avg_on_empty_errors() { + let mut i = Interpreter::new(); + assert!(i.eval_expr_str("avg([])").is_err()); + } + + #[test] + fn min_and_max() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("min([5, 2, 8, 1, 9])").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 1.0)); + let v = i.eval_expr_str("max([5, 2, 8, 1, 9])").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 9.0)); + } + + #[test] + fn count_on_mixed_range() { + let mut i = Interpreter::new(); + i.register_table("t", vec![ + vec!["1".into(), "2".into(), "hello".into()], + vec!["3".into(), "".into(), "4".into()], + ]); + let v = i.eval_expr_str("count(@t)").unwrap(); + // Four parseable numbers in the flattened view. + assert!(matches!(v, Value::Number(n) if n == 4.0)); + } + + #[test] + fn std_devp_matches_formula() { + // Population std-dev of {2,4,4,4,5,5,7,9}: + // mean = 5, variance = 4, stddev = 2. + let mut i = Interpreter::new(); + let v = i.eval_expr_str("std_devp([2, 4, 4, 4, 5, 5, 7, 9])").unwrap(); + match v { + Value::Number(n) => assert!(approx(n, 2.0), "got {}", n), + _ => panic!("not a number"), + } + } + + #[test] + fn std_devs_differs_from_std_devp() { + // Sample stddev uses (n-1) in the denominator. + let mut i = Interpreter::new(); + let p = match i.eval_expr_str("std_devp([1, 2, 3, 4])").unwrap() { + Value::Number(n) => n, + _ => panic!(), + }; + let s = match i.eval_expr_str("std_devs([1, 2, 3, 4])").unwrap() { + Value::Number(n) => n, + _ => panic!(), + }; + assert!(s > p, "sample ({}) should exceed population ({})", s, p); + } + + #[test] + fn std_devs_needs_two_values() { + let mut i = Interpreter::new(); + assert!(i.eval_expr_str("std_devs([5])").is_err()); + } + + #[test] + fn round_with_digits() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("round(3.14159, 2)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 3.14)), _ => panic!() } + let v = i.eval_expr_str("round(3.14159, 4)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 3.1416)), _ => panic!() } + // Default digits (0) still works. + let v = i.eval_expr_str("round(3.7)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 4.0)); + } + + #[test] + fn ceil_with_digits() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("ceil(1.234, 1)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 1.3)), _ => panic!() } + let v = i.eval_expr_str("ceil(1.01)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 2.0)); + } + + #[test] + fn floor_with_digits() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("floor(1.999, 2)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 1.99)), _ => panic!() } + let v = i.eval_expr_str("floor(1.9)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 1.0)); + } + + #[test] + fn round_digits_must_be_integer() { + let mut i = Interpreter::new(); + assert!(i.eval_expr_str("round(3.14, 1.5)").is_err()); + } + + #[test] + fn aggregate_rejects_zero_or_many_args() { + let mut i = Interpreter::new(); + assert!(i.eval_expr_str("sum()").is_err()); + assert!(i.eval_expr_str("avg(1, 2)").is_err()); + } + + // --- Function inversion (solve! / where) --- + + fn solve_interp() -> Interpreter { + // Reusable setup: square fn on one param. Easy to verify by hand. + let mut i = Interpreter::new(); + i.exec_line("fn square(x) { x * x }").unwrap(); + i + } + + #[test] + fn solve_macro_parses_comma() { + let mut i = solve_interp(); + i.exec_line("let invsq = solve!(x, square)").unwrap(); + assert!(i.solved_fns.contains_key("invsq")); + let def = &i.solved_fns["invsq"]; + assert_eq!(def.source_fn, "square"); + assert_eq!(def.solve_param_idx, 0); + assert_eq!(def.new_params.len(), 1); // just the target slot + } + + #[test] + fn solve_macro_parses_from() { + let mut i = solve_interp(); + i.exec_line("let invsq = solve!(x from square)").unwrap(); + let def = &i.solved_fns["invsq"]; + assert_eq!(def.source_fn, "square"); + assert_eq!(def.solve_param_idx, 0); + } + + #[test] + fn solve_macro_unknown_source_errors() { + let mut i = Interpreter::new(); + let err = i.exec_line("let bad = solve!(x, nonexistent)").unwrap_err(); + assert!(err.contains("not defined"), "error was: {}", err); + } + + #[test] + fn solve_macro_unknown_var_errors() { + let mut i = solve_interp(); + let err = i.exec_line("let bad = solve!(y, square)").unwrap_err(); + assert!(err.contains("not a parameter"), "error was: {}", err); + } + + #[test] + fn math_form_parses() { + let mut i = solve_interp(); + i.exec_line("let invsq(out) = x where square(x) = out").unwrap(); + let def = &i.solved_fns["invsq"]; + assert_eq!(def.source_fn, "square"); + assert_eq!(def.solve_param_idx, 0); + assert_eq!(def.new_params, vec!["out".to_string()]); + } + + #[test] + fn math_form_result_not_first_errors() { + let mut i = solve_interp(); + // `x` is the target but also listed as the result position — the + // first param has to be the result variable, not the target. + let err = i.exec_line("let bad(x) = x where square(x) = out").unwrap_err(); + assert!(err.contains("first parameter"), "error was: {}", err); + } + + #[test] + fn math_form_mismatched_params_errors() { + let mut i = Interpreter::new(); + i.exec_line("fn f(a, b) { a + b }").unwrap(); + // Declared params are [out, c] but source_args minus target are [b]. + let err = i.exec_line("let bad(out, c) = a where f(a, b) = out").unwrap_err(); + assert!(err.contains("parameters"), "error was: {}", err); + } + + #[test] + fn lc_tank_inversion() { + // Define f0(l, c) = 1 / (2π√(lc)), create lfreq via solve!, compare + // the inverted result against the analytical closed-form. + let mut i = Interpreter::new(); + i.exec_line("fn f0(l, c) { 1 / (2 * pi * sqrt(l * c)) }").unwrap(); + i.exec_line("let lfreq = solve!(l, f0)").unwrap(); + let v = i.eval_expr_str("lfreq(1000000, 1 / 1000000000)").unwrap(); + let got = match v { Value::Number(n) => n, _ => panic!("not a number") }; + let pi = std::f64::consts::PI; + let f = 1_000_000.0f64; + let c = 1e-9; + let want = 1.0 / (4.0 * pi * pi * f * f * c); + assert!((got - want).abs() / want < 1e-6, "got {}, want {}", got, want); + } + + #[test] + fn math_form_and_macro_agree() { + let mut i = Interpreter::new(); + i.exec_line("fn f0(l, c) { 1 / (2 * pi * sqrt(l * c)) }").unwrap(); + i.exec_line("let a = solve!(l, f0)").unwrap(); + i.exec_line("let b(freq, c) = l where f0(l, c) = freq").unwrap(); + let av = i.eval_expr_str("a(1000000, 1 / 1000000000)").unwrap(); + let bv = i.eval_expr_str("b(1000000, 1 / 1000000000)").unwrap(); + let (an, bn) = match (av, bv) { + (Value::Number(a), Value::Number(b)) => (a, b), + _ => panic!("not numbers"), + }; + assert!((an - bn).abs() < 1e-9, "macro {} vs math {}", an, bn); + } + + #[test] + fn non_convergent_errors() { + // Constant function has zero derivative everywhere. + let mut i = Interpreter::new(); + i.exec_line("fn flat(x) { 42 }").unwrap(); + i.exec_line("let inv = solve!(x, flat)").unwrap(); + let err = i.eval_expr_str("inv(10)").unwrap_err(); + assert!( + err.contains("flat derivative") || err.contains("did not converge"), + "unexpected error: {}", err + ); + } + + #[test] + fn solve_macro_outside_let_errors() { + let mut i = solve_interp(); + let err = i.eval_expr_str("solve!(x, square)").unwrap_err(); + assert!(err.contains("right-hand side"), "error was: {}", err); + } + + #[test] + fn let_with_params_is_regular_fn_def() { + // `let f(x) = expr` without a `where` clause is equivalent to the + // bare `f(x) = expr` form. Covered here to make sure the parser + // extension didn't break that path. + let mut i = Interpreter::new(); + i.exec_line("let double(x) = x * 2").unwrap(); + assert!(i.fns.contains_key("double")); + let v = i.eval_expr_str("double(21)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 42.0)); + } + + // --- Implicit multiplication (juxtaposition) --- + + #[test] + fn implicit_mul_number_times_ident() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("2pi").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 2.0 * std::f64::consts::PI)), _ => panic!() } + } + + #[test] + fn implicit_mul_number_times_paren() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("2(3 + 4)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 14.0)); + } + + #[test] + fn implicit_mul_with_user_var() { + let mut i = Interpreter::new(); + i.exec_line("let n = 2").unwrap(); + let v = i.eval_expr_str("2n").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 4.0)); + } + + #[test] + fn implicit_mul_only_fires_adjacent() { + // `2pi` inserts the Star; `2 pi` does not — locks in the adjacency + // rule. Whitespace between the number and ident keeps `pi` as a + // leftover token, which the parser drops, so the result is just `2`. + let mut i = Interpreter::new(); + let v_adj = i.eval_expr_str("2pi").unwrap(); + let v_space = i.eval_expr_str("2 pi").unwrap(); + match (v_adj, v_space) { + (Value::Number(a), Value::Number(b)) => { + assert!(approx(a, 2.0 * std::f64::consts::PI)); + assert_eq!(b, 2.0); + } + _ => panic!("unexpected shapes"), + } + } + + #[test] + fn scientific_notation_lowercase() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("1e-9").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 1e-9)), _ => panic!() } + } + + #[test] + fn scientific_notation_uppercase_and_plus() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("2E+3").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 2000.0)); + } + + #[test] + fn scientific_notation_negative_literal() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("-1e3").unwrap(); + assert!(matches!(v, Value::Number(n) if n == -1000.0)); + } + + // --- SPICE notation (gated on `use spice`) --- + + #[test] + fn spice_off_by_default() { + // Without `use spice`, `100n` falls back to implicit mul of 100 and n. + // When n isn't defined, that's an undefined-variable error — which is + // the behavior we want, so a user who hasn't opted in sees a clean + // error instead of a silent reinterpretation. + let mut i = Interpreter::new(); + assert!(i.eval_expr_str("100n").is_err()); + } + + #[test] + fn spice_prefix_only() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("100n").unwrap(); + let (n, u) = match v { + Value::Array(a) if a.len() == 2 => match (&a[0], &a[1]) { + (Value::Number(n), Value::Str(u)) => (*n, u.clone()), + _ => panic!("not spice-shaped"), + }, + _ => panic!("not an array"), + }; + assert!(approx(n, 1e-7)); + assert_eq!(u, ""); + } + + #[test] + fn spice_prefix_with_unit() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("100nF").unwrap(); + assert_eq!(v.display(), "100NF"); + } + + #[test] + fn spice_unit_only_no_prefix() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("80Hz").unwrap(); + assert_eq!(v.display(), "80HZ"); + } + + #[test] + fn spice_micro_sign() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("10µF").unwrap(); + assert_eq!(v.display(), "10UF"); + } + + #[test] + fn spice_arithmetic_preserves_unit() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("100nF + 1nF").unwrap(); + // 101e-9 → renormalized 101NF. + assert_eq!(v.display(), "101NF"); + } + + #[test] + fn spice_cross_magnitude_renormalization() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + // 2500nF = 2.5uF; rendered with closest prefix. + let v = i.eval_expr_str("2500nF").unwrap(); + assert_eq!(v.display(), "2.5UF"); + } + + #[test] + fn spice_scalar_op_preserves_unit() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("100nF * 2").unwrap(); + assert_eq!(v.display(), "200NF"); + } + + #[test] + fn spice_unrecognized_suffix_falls_back_to_implicit_mul() { + // `2pi` under spice mode: `pi` isn't a valid suffix, so we fall + // back to implicit multiplication. This keeps math-style input + // working even after `use spice`. + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("2pi").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 2.0 * std::f64::consts::PI)), _ => panic!() } + } + + #[test] + fn spice_display_small_value() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + // 0.5nF = 500pF. + let v = i.eval_expr_str("0.5nF").unwrap(); + assert_eq!(v.display(), "500PF"); + } + + #[test] + fn spice_negative_literal() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("-100nF").unwrap(); + assert_eq!(v.display(), "-100NF"); + } + + #[test] + fn spice_plain_number_display_unchanged() { + // A pure float result (no unit) should still use the plain + // number formatter, not the SPICE path. + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("1 + 1").unwrap(); + assert_eq!(v.display(), "2"); + } + + // Unit-label algebra (mul · , div /, cancellation, additive strip on + // mismatch) plus the declaration-overrides-algebra rules. + + #[test] + fn unit_mul_different_labels_concatenates() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + i.exec_line("let a = 2F").unwrap(); + i.exec_line("let b = 3H").unwrap(); + let v = i.eval_expr_str("a * b").unwrap(); + assert_eq!(v.display(), "6 F·H"); + } + + #[test] + fn unit_div_cancels_to_plain_number() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("6F / 3F").unwrap(); + // Same label on both sides → dimensionless → plain number. + assert!(matches!(v, Value::Number(n) if n == 2.0)); + } + + #[test] + fn unit_div_different_labels() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("6F / 2H").unwrap(); + assert_eq!(v.display(), "3 F/H"); + } + + #[test] + fn unit_add_mismatched_strips() { + // F + H has no clean algebraic answer, so the result drops the + // spice wrapper entirely rather than picking one side. + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("1F + 2H").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 3.0)); + } + + #[test] + fn unit_add_same_label_preserves() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + let v = i.eval_expr_str("1F + 2F").unwrap(); + assert_eq!(v.display(), "3F"); + } + + #[test] + fn unit_annotation_on_let() { + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + i.exec_line("let x: F = 22n").unwrap(); + let v = i.eval_expr_str("x").unwrap(); + // 22n = 22e-9 → tagged F → display as nanofarads. + assert_eq!(v.display(), "22NF"); + } + + #[test] + fn unit_annotation_overrides_rhs_unit() { + // `let x: F = 22nH` — declared F wins, the H label is dropped. + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + i.exec_line("let x: F = 22nH").unwrap(); + assert_eq!(i.eval_expr_str("x").unwrap().display(), "22NF"); + } + + #[test] + fn unit_annotation_wraps_plain_number() { + let mut i = Interpreter::new(); + i.exec_line("let x: H = 5").unwrap(); + assert_eq!(i.eval_expr_str("x").unwrap().display(), "5H"); + } + + #[test] + fn fn_param_type_wraps_arg_on_entry() { + // f receives a raw number; the param's `: F` annotation tags it + // inside the body. + let mut i = Interpreter::new(); + i.exec_line("fn f(c: F) { return c }").unwrap(); + let v = i.eval_expr_str("f(5)").unwrap(); + assert_eq!(v.display(), "5F"); + } + + #[test] + fn fn_return_type_overrides_algebra() { + // The algebra inside would produce `F·H`, but the declared return + // type replaces whatever label comes out. + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + i.exec_line("fn ry(c: F, l: H) -> ohm { return l * c }").unwrap(); + let v = i.eval_expr_str("ry(2, 3)").unwrap(); + assert_eq!(v.display(), "6ohm"); + } + + #[test] + fn fn_return_type_tags_raw_result() { + let mut i = Interpreter::new(); + i.exec_line("fn square(x) -> V { x * x }").unwrap(); + let v = i.eval_expr_str("square(4)").unwrap(); + assert_eq!(v.display(), "16V"); + } + + #[test] + fn solve_through_typed_source_fn() { + // When the source fn has typed params and return type, its result + // comes back spice-tagged. The solver unwraps that layer before + // doing Newton steps — otherwise it'd reject every iteration. + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + i.exec_line("fn f0(l: H, c: F) -> Hz {\n return 1 / ((2 * pi) * (sqrt(l * c)))\n}").unwrap(); + i.exec_line("let L_solved = solve!(l, f0)").unwrap(); + let v = i.eval_expr_str("L_solved(2600, 1nF)").unwrap(); + let n = match v { + Value::Number(n) => n, + Value::Array(ref a) if a.len() == 2 => match &a[0] { + Value::Number(n) => *n, + _ => panic!(), + }, + _ => panic!("unexpected shape"), + }; + let pi = std::f64::consts::PI; + let want = 1.0 / (4.0 * pi * pi * 2600.0 * 2600.0 * 1e-9); + assert!((n - want).abs() / want < 1e-6, "got {}, want {}", n, want); + } + + #[test] + fn spice_lc_tank_use_case() { + // End-to-end reproduction of the Freq note. + let mut i = Interpreter::new(); + i.exec_line("use spice").unwrap(); + i.exec_line("fn L(f, c) {\n let b = (2 * pi * f)\n return 1 / ((b*b) * c)\n}").unwrap(); + let v = i.eval_expr_str("L(2600, 1nF)").unwrap(); + // Closed-form: 1 / (4π²·2600²·1e-9) ≈ 3.747e-3 H. The body does its + // arithmetic in F (the unit that propagates from `c`), so the result + // comes back as a spice-tagged value with label `1/F`. Numerically + // it's still the right henry value — only the label is symbolic. + let n = match v { + Value::Number(n) => n, + Value::Array(ref a) if a.len() == 2 => match &a[0] { + Value::Number(n) => *n, + _ => panic!("not numeric"), + }, + _ => panic!("unexpected shape"), + }; + let pi = std::f64::consts::PI; + let want = 1.0 / (4.0 * pi * pi * 2600.0 * 2600.0 * 1e-9); + assert!((n - want).abs() / want < 1e-6, "got {}, want {}", n, want); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..dc076b0 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,7 @@ +pub mod doc; +pub mod document; +pub mod eval; +pub mod highlight; +pub mod interp; +pub mod persist; +pub mod ffi; diff --git a/core/src/persist.rs b/core/src/persist.rs new file mode 100644 index 0000000..0e82cab --- /dev/null +++ b/core/src/persist.rs @@ -0,0 +1,168 @@ +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteMeta { + pub uuid: String, + pub title: String, + pub path: String, + pub modified: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateIndex { + pub notes: HashMap, +} + +impl StateIndex { + pub fn new() -> Self { + StateIndex { + notes: HashMap::new(), + } + } + + pub fn load() -> io::Result { + let path = state_path(); + if !path.exists() { + return Ok(Self::new()); + } + let data = fs::read_to_string(&path)?; + serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } + + pub fn save(&self) -> io::Result<()> { + let path = state_path(); + ensure_dir(path.parent().unwrap())?; + let data = serde_json::to_string_pretty(self)?; + fs::write(&path, data) + } + + pub fn upsert(&mut self, meta: NoteMeta) { + self.notes.insert(meta.uuid.clone(), meta); + } + + pub fn remove(&mut self, uuid: &str) { + self.notes.remove(uuid); + } + + pub fn list(&self) -> Vec<&NoteMeta> { + let mut notes: Vec<&NoteMeta> = self.notes.values().collect(); + notes.sort_by(|a, b| b.modified.cmp(&a.modified)); + notes + } +} + +fn acord_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".acord") +} + +fn cache_dir() -> PathBuf { + acord_dir().join("cache") +} + +fn state_path() -> PathBuf { + acord_dir().join("state.json") +} + +fn ensure_dir(dir: &Path) -> io::Result<()> { + if !dir.exists() { + fs::create_dir_all(dir)?; + } + Ok(()) +} + +fn now_epoch() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn title_from_text(text: &str) -> String { + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { continue; } + let title = trimmed.trim_start_matches('#').trim(); + if !title.is_empty() { + return title.chars().take(80).collect(); + } + } + "Untitled".into() +} + +pub fn save_to_file(text: &str, path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + ensure_dir(parent)?; + } + fs::write(path, text) +} + +pub fn load_from_file(path: &Path) -> io::Result { + fs::read_to_string(path) +} + +pub fn cache_save(uuid: &str, text: &str) -> io::Result { + let dir = cache_dir(); + ensure_dir(&dir)?; + + let filename = format!("{}.sw", uuid); + let path = dir.join(&filename); + fs::write(&path, text)?; + + let mut index = StateIndex::load().unwrap_or_else(|_| StateIndex::new()); + index.upsert(NoteMeta { + uuid: uuid.to_string(), + title: title_from_text(text), + path: path.to_string_lossy().into_owned(), + modified: now_epoch(), + }); + index.save()?; + + Ok(path) +} + +pub fn cache_load(uuid: &str) -> io::Result { + let filename = format!("{}.sw", uuid); + let path = cache_dir().join(filename); + fs::read_to_string(path) +} + +pub fn list_notes() -> Vec { + StateIndex::load() + .unwrap_or_else(|_| StateIndex::new()) + .list() + .into_iter() + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn title_extraction() { + assert_eq!(title_from_text("# My Note\nSome content"), "My Note"); + assert_eq!(title_from_text("Hello world"), "Hello world"); + assert_eq!(title_from_text(""), "Untitled"); + assert_eq!(title_from_text("\n\n## Section\nstuff"), "Section"); + } + + #[test] + fn state_index_round_trip() { + let mut idx = StateIndex::new(); + idx.upsert(NoteMeta { + uuid: "abc".into(), + title: "Test".into(), + path: "/tmp/test.sw".into(), + modified: 1000, + }); + assert_eq!(idx.list().len(), 1); + idx.remove("abc"); + assert_eq!(idx.list().len(), 0); + } +} diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift new file mode 100644 index 0000000..ae46d85 --- /dev/null +++ b/src/AppDelegate.swift @@ -0,0 +1,733 @@ +import Cocoa +import Combine +import SwiftUI +import UniformTypeIdentifiers + +extension Notification.Name { + static let focusEditor = Notification.Name("focusEditor") + static let focusTitle = Notification.Name("focusTitle") +} + +class WindowController { + let window: NSWindow + let appState: AppState + init(window: NSWindow, appState: AppState) { + self.window = window + self.appState = appState + } +} + +class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { + var window: NSWindow! + var appState: AppState! + private var titleCancellable: AnyCancellable? + private var textCancellable: AnyCancellable? + private var titleBarView: TitleBarView? + private var focusTitleObserver: NSObjectProtocol? + private var windowControllers: [WindowController] = [] + /// Writes the viewport's current text to the notes directory on a + /// tight interval. Deliberately bypasses `appState.documentText` — the + /// Combine sink on that property pushes text back into the viewport + /// via `vp.setText`, which rebuilds viewport state and clears the + /// eval overlay. By writing straight to disk, autosave can't disturb + /// what the user sees. + private var autosaveTimer: Timer? + + private var viewport: IcedViewportView? { + window?.contentView as? IcedViewportView + } + + func applicationDidFinishLaunching(_ notification: Notification) { + _ = ConfigManager.shared + appState = AppState() + + let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) + viewport.autoresizingMask = [.width, .height] + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.backgroundColor = Theme.current.base + window.title = "Acord" + window.contentView = viewport + window.center() + window.setFrameAutosaveName("AcordMainWindow") + window.makeKeyAndOrderFront(nil) + + applyThemeAppearance() + setupTitleBar() + setupMenuBar() + observeDocumentTitle() + + observeDocumentText() + syncThemeToViewport() + startAutosaveTimer() + + DocumentBrowserController.shared = DocumentBrowserController(appState: appState) + + NotificationCenter.default.addObserver( + self, selector: #selector(settingsDidChange), + name: .settingsChanged, object: nil + ) + + if let url = pendingOpenURLs.first { + pendingOpenURLs = [] + appState.loadNoteFromFile(url) + } + } + + private var pendingOpenURLs: [URL] = [] + + func application(_ application: NSApplication, open urls: [URL]) { + guard let url = urls.first else { return } + if appState != nil { + appState.loadNoteFromFile(url) + } else { + pendingOpenURLs = [url] + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + // Runs before AppKit tears the window down. We must front-run the window + // teardown so the Rust-backed viewport releases its wgpu/Metal resources + // while the NSView + CAMetalLayer it holds raw pointers to are still + // alive. `applicationWillTerminate` is too late: by the time that fires, + // AppKit has already started deallocating the window/contentView graph + // and the delegate can no longer safely read `self.window`. + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + // Pull out any unsaved text before tearing down. `getText` refreshes + // the viewport's own `cachedText`, so later reads during teardown + // can fall back to it if the handle is already gone. + syncTextFromViewport() + appState.saveNote() + + // Explicit, ordered teardown of every viewport we own, while the + // views + window graph are still fully alive. + if let vp = viewport { + vp.teardown() + } + for controller in windowControllers { + if let vp = controller.window.contentView as? IcedViewportView { + vp.teardown() + } + } + + // Drop strong refs so AppKit doesn't try to replay anything through + // the delegate during its own terminate phases. + titleCancellable = nil + textCancellable = nil + if let observer = focusTitleObserver { + NotificationCenter.default.removeObserver(observer) + focusTitleObserver = nil + } + NotificationCenter.default.removeObserver(self) + + return .terminateNow + } + + // MARK: - Menu bar + + private func setupMenuBar() { + let mainMenu = NSMenu() + + mainMenu.addItem(buildAppMenu()) + mainMenu.addItem(buildFileMenu()) + mainMenu.addItem(buildEditMenu()) + mainMenu.addItem(buildRenderMenu()) + mainMenu.addItem(buildViewMenu()) + mainMenu.addItem(buildWindowMenu()) + + NSApp.mainMenu = mainMenu + } + + private func buildAppMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu() + menu.addItem(withTitle: "About Acord", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + menu.addItem(.separator()) + let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",") + settingsItem.target = self + menu.addItem(settingsItem) + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit Acord", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + item.submenu = menu + return item + } + + private func buildFileMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "File") + + let newWindowItem = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "n") + newWindowItem.target = self + menu.addItem(newWindowItem) + + let newNoteItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "N") + newNoteItem.keyEquivalentModifierMask = [.command, .shift] + newNoteItem.target = self + menu.addItem(newNoteItem) + + let openItem = NSMenuItem(title: "Open...", action: #selector(openNote), keyEquivalent: "o") + openItem.target = self + menu.addItem(openItem) + + menu.addItem(.separator()) + + let saveItem = NSMenuItem(title: "Save", action: #selector(saveNote), keyEquivalent: "s") + saveItem.target = self + menu.addItem(saveItem) + + let saveAsItem = NSMenuItem(title: "Save As...", action: #selector(saveNoteAs), keyEquivalent: "S") + saveAsItem.target = self + menu.addItem(saveAsItem) + + menu.addItem(.separator()) + + let exportCrateItem = NSMenuItem(title: "Export as Rust Library...", action: #selector(exportCrate), keyEquivalent: "E") + exportCrateItem.keyEquivalentModifierMask = [.command, .shift] + exportCrateItem.target = self + menu.addItem(exportCrateItem) + + menu.addItem(.separator()) + + let openStorageItem = NSMenuItem(title: "Open Storage Directory", action: #selector(openStorageDirectory), keyEquivalent: "") + openStorageItem.target = self + menu.addItem(openStorageItem) + + item.submenu = menu + return item + } + + private func buildEditMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Edit") + menu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + menu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + menu.addItem(.separator()) + menu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + menu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + menu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + menu.addItem(.separator()) + + let boldItem = NSMenuItem(title: "Bold", action: #selector(boldSelection), keyEquivalent: "b") + boldItem.target = self + menu.addItem(boldItem) + + let italicItem = NSMenuItem(title: "Italic", action: #selector(italicizeSelection), keyEquivalent: "i") + italicItem.target = self + menu.addItem(italicItem) + + menu.addItem(.separator()) + + let tableItem = NSMenuItem(title: "Insert Table", action: #selector(insertTable), keyEquivalent: "t") + tableItem.target = self + menu.addItem(tableItem) + + let evalItem = NSMenuItem(title: "Smart Eval", action: #selector(smartEval), keyEquivalent: "e") + evalItem.target = self + menu.addItem(evalItem) + + menu.addItem(.separator()) + + let findItem = NSMenuItem(title: "Find...", action: #selector(NSTextView.performFindPanelAction(_:)), keyEquivalent: "f") + findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue) + menu.addItem(findItem) + + menu.addItem(.separator()) + + let formatItem = NSMenuItem(title: "Format Document", action: #selector(formatDocument), keyEquivalent: "F") + formatItem.keyEquivalentModifierMask = [.command, .shift] + formatItem.target = self + menu.addItem(formatItem) + + item.submenu = menu + return item + } + + private func buildRenderMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Render") + + let modesHeader = NSMenuItem(title: "Modes", action: nil, keyEquivalent: "") + modesHeader.isEnabled = false + menu.addItem(modesHeader) + + let liveItem = NSMenuItem(title: "Live", action: #selector(setLiveMode), keyEquivalent: "") + liveItem.target = self + menu.addItem(liveItem) + + let editorItem = NSMenuItem(title: "Editor", action: #selector(setEditorMode), keyEquivalent: "") + editorItem.target = self + menu.addItem(editorItem) + + let viewItem = NSMenuItem(title: "View", action: #selector(setViewMode), keyEquivalent: "") + viewItem.target = self + menu.addItem(viewItem) + + item.submenu = menu + return item + } + + private func buildViewMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "View") + let toggleItem = NSMenuItem(title: "Document Browser", action: #selector(toggleBrowser), keyEquivalent: "b") + toggleItem.keyEquivalentModifierMask = .control + toggleItem.target = self + menu.addItem(toggleItem) + + menu.addItem(.separator()) + + let zoomInItem = NSMenuItem(title: "Zoom In", action: #selector(zoomIn), keyEquivalent: "=") + zoomInItem.target = self + menu.addItem(zoomInItem) + + let zoomOutItem = NSMenuItem(title: "Zoom Out", action: #selector(zoomOut), keyEquivalent: "-") + zoomOutItem.target = self + menu.addItem(zoomOutItem) + + let actualSizeItem = NSMenuItem(title: "Actual Size", action: #selector(zoomReset), keyEquivalent: "0") + actualSizeItem.target = self + menu.addItem(actualSizeItem) + + item.submenu = menu + return item + } + + private func buildWindowMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Window") + menu.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m") + menu.addItem(withTitle: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "") + item.submenu = menu + NSApp.windowsMenu = menu + return item + } + + // MARK: - Actions + + @objc private func newNote() { + appState.newNote() + } + + @objc private func newWindow() { + let state = AppState() + let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) + viewport.autoresizingMask = [.width, .height] + + let win = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + win.isReleasedWhenClosed = false + win.titlebarAppearsTransparent = true + win.titleVisibility = .hidden + win.backgroundColor = Theme.current.base + win.title = "Acord" + win.contentView = viewport + win.center() + win.makeKeyAndOrderFront(nil) + + let controller = WindowController(window: win, appState: state) + windowControllers.append(controller) + } + + @objc private func openStorageDirectory() { + let dir = ConfigManager.shared.autoSaveDirectory + let url = URL(fileURLWithPath: dir, isDirectory: true) + NSWorkspace.shared.open(url) + } + + @objc private func boldSelection() { + viewport?.sendCommand(1) + } + + @objc private func italicizeSelection() { + viewport?.sendCommand(2) + } + + @objc private func insertTable() { + viewport?.sendCommand(3) + } + + @objc private func smartEval() { + viewport?.sendCommand(4) + } + + @objc private func openNote() { + let panel = NSOpenPanel() + panel.allowedContentTypes = Self.supportedContentTypes + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.appState.loadNoteFromFile(url) + } + } + + @objc private func saveNote() { + syncTextFromViewport() + if appState.currentFileURL != nil { + appState.saveNote() + } else { + saveNoteAs() + } + } + + @objc private func saveNoteAs() { + syncTextFromViewport() + let panel = NSSavePanel() + panel.allowedContentTypes = Self.supportedContentTypes + panel.nameFieldStringValue = defaultFilename() + if let url = appState.currentFileURL { + panel.directoryURL = url.deletingLastPathComponent() + panel.nameFieldStringValue = url.lastPathComponent + } + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.appState.saveNoteToFile(url) + } + } + + @objc private func exportCrate() { + syncTextFromViewport() + guard let w = window, let vp = w.contentView as? IcedViewportView, + let handle = vp.viewportHandle else { return } + + let panel = NSSavePanel() + panel.title = "Export as Rust Library" + panel.message = "Choose a location and name for your exported crate" + panel.prompt = "Export" + panel.nameFieldLabel = "Crate name:" + panel.nameFieldStringValue = defaultCrateName() + panel.canCreateDirectories = true + + panel.beginSheetModal(for: w) { response in + guard response == .OK, let url = panel.url else { return } + let parentDir = url.deletingLastPathComponent().path + let name = url.lastPathComponent + parentDir.withCString { pd in + name.withCString { n in + if let cstr = viewport_export_crate(handle, pd, n) { + let resultPath = String(cString: cstr) + viewport_free_string(cstr) + self.notifyExportComplete(at: resultPath) + } else { + self.notifyExportFailed() + } + } + } + } + } + + private func defaultCrateName() -> String { + let firstLine = appState.documentText + .components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + let stripped = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + let words = stripped.split(separator: " ").prefix(2).joined(separator: "-") + let sanitized = words.lowercased() + .map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined() + return sanitized.isEmpty ? "my-note" : sanitized + } + + private func notifyExportComplete(at path: String) { + let alert = NSAlert() + alert.messageText = "Export complete" + alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions." + alert.addButton(withTitle: "Reveal in Finder") + alert.addButton(withTitle: "OK") + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + } + + private func notifyExportFailed() { + let alert = NSAlert() + alert.messageText = "Export failed" + alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder." + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func defaultFilename() -> String { + if let url = appState.currentFileURL { + return url.lastPathComponent + } + let firstLine = appState.documentText + .components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + let stripped = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + let trimmed = stripped.trimmingCharacters(in: .whitespaces) + let ext = extensionForFormat(appState.currentFileFormat) + guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" } + let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined() + return sanitized.prefix(80) + ".\(ext)" + } + + private func extensionForFormat(_ format: FileFormat) -> String { + switch format { + case .markdown: return "md" + case .csv: return "csv" + case .json: return "json" + case .toml: return "toml" + case .yaml: return "yaml" + case .xml: return "xml" + case .svg: return "svg" + case .rust: return "rs" + case .c: return "c" + case .cpp: return "cpp" + case .objc: return "m" + case .javascript: return "js" + case .typescript: return "ts" + case .jsx: return "jsx" + case .tsx: return "tsx" + case .html: return "html" + case .css: return "css" + case .scss: return "scss" + case .less: return "less" + case .python: return "py" + case .go: return "go" + case .ruby: return "rb" + case .php: return "php" + case .lua: return "lua" + case .shell: return "sh" + case .java: return "java" + case .kotlin: return "kt" + case .swift: return "swift" + case .zig: return "zig" + case .sql: return "sql" + case .makefile: return "mk" + case .dockerfile: return "Dockerfile" + case .config: return "conf" + case .lock: return "lock" + case .plainText, .unknown: return "txt" + } + } + + private static let supportedContentTypes: [UTType] = { + let extensions = [ + "md", "markdown", "mdown", + "csv", "json", "toml", "yaml", "yml", "xml", "svg", + "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", + "js", "jsx", "ts", "tsx", + "html", "htm", "css", "scss", "less", + "py", "go", "rb", "php", "lua", + "sh", "bash", "zsh", "fish", + "java", "kt", "kts", "swift", "zig", "sql", + "mk", "ini", "cfg", "conf", "env", + "lock", "txt", "text", "log" + ] + var types: [UTType] = [.plainText] + for ext in extensions { + if let t = UTType(filenameExtension: ext) { + types.append(t) + } + } + return Array(Set(types)) + }() + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + let mode = viewport?.renderMode() ?? 0 + switch menuItem.action { + case #selector(setLiveMode): + menuItem.state = mode == 0 ? .on : .off + case #selector(setEditorMode): + menuItem.state = mode == 1 ? .on : .off + case #selector(setViewMode): + menuItem.state = mode == 2 ? .on : .off + default: + break + } + return true + } + + @objc private func setLiveMode() { + viewport?.sendCommand(11) + } + + @objc private func setEditorMode() { + viewport?.sendCommand(12) + } + + @objc private func setViewMode() { + viewport?.sendCommand(13) + } + + @objc private func formatDocument() { + viewport?.sendCommand(10) + } + + @objc private func openSettings() { + SettingsWindowController.show() + } + + @objc private func settingsDidChange() { + window.backgroundColor = Theme.current.base + syncThemeToViewport() + window.contentView?.needsDisplay = true + } + + private func syncThemeToViewport() { + let mode = ConfigManager.shared.themeMode + let name: String + switch mode { + case "dark": name = "mocha" + case "light": name = "latte" + default: + let appearance = NSApp.effectiveAppearance + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + name = isDark ? "mocha" : "latte" + } + viewport?.setTheme(name) + } + + @objc private func toggleBrowser() { + DocumentBrowserController.shared?.toggle() + } + + @objc private func zoomIn() { + if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { + browser.browserState.scaleUp() + return + } + ConfigManager.shared.zoomLevel += 1 + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + + @objc private func zoomOut() { + if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { + browser.browserState.scaleDown() + return + } + let current = ConfigManager.shared.zoomLevel + if 11 + current > 8 { + ConfigManager.shared.zoomLevel -= 1 + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + } + + @objc private func zoomReset() { + ConfigManager.shared.zoomLevel = 0 + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + + private func setupTitleBar() { + let accessory = TitleBarAccessoryController() + window.addTitlebarAccessoryViewController(accessory) + + let tbv = accessory.titleView + tbv.onCommit = { [weak self] rawTitle in + guard let self = self else { return } + // Only drop the document's first line if it actually IS a title + // (starts with `#`). Normalize whatever the user typed in the + // title bar to a `# ` prefix so the saved markdown is valid. + let trimmed = rawTitle.trimmingCharacters(in: .whitespaces) + let normalizedTitle: String + if trimmed.isEmpty { + normalizedTitle = "" + } else if trimmed.hasPrefix("#") { + normalizedTitle = trimmed + } else { + normalizedTitle = "# " + trimmed + } + + let lines = self.appState.documentText.components(separatedBy: "\n") + let firstIsTitle = lines.first + .map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") } + ?? false + let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines + + let newLines: [String] + if normalizedTitle.isEmpty { + newLines = body + } else { + newLines = [normalizedTitle] + body + } + self.appState.documentText = newLines.joined(separator: "\n") + } + + titleBarView = tbv + + focusTitleObserver = NotificationCenter.default.addObserver( + forName: .focusTitle, object: nil, queue: .main + ) { [weak self] _ in + self?.titleBarView?.beginEditing() + } + } + + private func observeDocumentText() { + textCancellable = appState.$documentText + .receive(on: RunLoop.main) + .sink { [weak self] text in + guard let self = self, let vp = self.viewport else { return } + // Idempotent: when the sync timer pulls text FROM the + // viewport and assigns it to `documentText`, this sink + // fires again and would push the identical text back in — + // and `vp.setText` rebuilds viewport state, clearing eval + // results. Skip the round-trip when vp already has it. + if vp.getText() == text { return } + vp.setText(text) + } + } + + private func syncTextFromViewport() { + guard let w = window, let vp = w.contentView as? IcedViewportView else { return } + let text = vp.getText() + if !text.isEmpty || appState.documentText.isEmpty { + appState.documentText = text + } + } + + /// 100ms autosave loop. Reads straight from the viewport and writes a + /// file in the notes directory — no Combine publishers, no `setText`, + /// no viewport-state rebuilds. The existing explicit flows (Cmd+S, + /// note switch, quit) still route through `syncTextFromViewport` so + /// `appState.documentText` stays current when Swift actually needs it. + private func startAutosaveTimer() { + autosaveTimer?.invalidate() + autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + self?.persistViewportToNotesDir() + } + } + + private func persistViewportToNotesDir() { + guard let w = window, let vp = w.contentView as? IcedViewportView else { return } + let text = vp.getText() + guard !AppState.isEffectivelyBlank(text) else { return } + appState.writeAutosavedCopy(text: text) + } + + private func observeDocumentTitle() { + titleCancellable = appState.$documentText + .receive(on: RunLoop.main) + .sink { [weak self] text in + guard let self = self else { return } + let firstLine = text.components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + let clean = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + let displayTitle = clean.isEmpty ? "Acord" : String(clean.prefix(60)) + self.window.title = displayTitle + self.titleBarView?.title = firstLine + } + } +} diff --git a/src/AppState.swift b/src/AppState.swift new file mode 100644 index 0000000..82c6a0a --- /dev/null +++ b/src/AppState.swift @@ -0,0 +1,537 @@ +import Foundation +import Combine + +enum FileFormat: String, CaseIterable { + case markdown, csv, json, toml, yaml, xml, svg + case rust, c, cpp, objc + case javascript, typescript, jsx, tsx + case html, css, scss, less + case python, go, ruby, php, lua + case shell, java, kotlin, swift, zig, sql + case makefile, dockerfile + case config, lock, plainText + case unknown + + static func from(extension ext: String) -> FileFormat { + switch ext.lowercased() { + case "md", "markdown", "mdown": return .markdown + case "csv": return .csv + case "json": return .json + case "toml": return .toml + case "yaml", "yml": return .yaml + case "xml": return .xml + case "svg": return .svg + case "rs": return .rust + case "c": return .c + case "cpp", "cc", "cxx": return .cpp + case "h", "hpp", "hxx": return .cpp + case "m": return .objc + case "js": return .javascript + case "jsx": return .jsx + case "ts": return .typescript + case "tsx": return .tsx + case "html", "htm": return .html + case "css": return .css + case "scss": return .scss + case "less": return .less + case "py": return .python + case "go": return .go + case "rb": return .ruby + case "php": return .php + case "lua": return .lua + case "sh", "bash", "zsh", "fish": return .shell + case "java": return .java + case "kt", "kts": return .kotlin + case "swift": return .swift + case "zig": return .zig + case "sql": return .sql + case "mk": return .makefile + case "ini", "cfg", "conf", "env": return .config + case "lock": return .lock + case "txt", "text", "log": return .plainText + default: return .unknown + } + } + + static func from(filename: String) -> FileFormat { + let lower = filename.lowercased() + if lower == "makefile" { return .makefile } + if lower == "dockerfile" { return .dockerfile } + let ext = (filename as NSString).pathExtension + if ext.isEmpty { return .unknown } + return from(extension: ext) + } + + var isCode: Bool { + switch self { + case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx, + .html, .css, .scss, .less, .python, .go, .ruby, .php, .lua, + .shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile, + .json, .toml, .yaml, .xml, .svg: + return true + default: + return false + } + } + + var isMarkdown: Bool { self == .markdown } + var isCSV: Bool { self == .csv } + + var treeSitterLang: String? { + switch self { + case .rust: return "rust" + case .c: return "c" + case .cpp: return "cpp" + case .javascript: return "javascript" + case .jsx: return "jsx" + case .typescript: return "typescript" + case .tsx: return "tsx" + case .python: return "python" + case .go: return "go" + case .ruby: return "ruby" + case .php: return "php" + case .lua: return "lua" + case .shell: return "bash" + case .java: return "java" + case .kotlin: return "kotlin" + case .swift: return "swift" + case .zig: return "zig" + case .sql: return "sql" + case .html: return "html" + case .css, .scss, .less: return "css" + case .json: return "json" + case .toml: return "toml" + case .yaml: return "yaml" + case .makefile: return "make" + case .dockerfile: return "dockerfile" + default: return nil + } + } +} + +class AppState: ObservableObject { + @Published var documentText: String = "" { + didSet { + if documentText != oldValue { + modified = true + bridge.setText(currentNoteID, text: documentText) + scheduleAutoSave() + } + } + } + @Published var evalResults: [Int: EvalEntry] = [:] + @Published var noteList: [NoteInfo] = [] + @Published var currentNoteID: UUID + @Published var selectedNoteIDs: Set = [] + @Published var modified: Bool = false + @Published var currentFileURL: URL? = nil + @Published var currentFileFormat: FileFormat = .markdown + + private let bridge = RustBridge.shared + private var autoSaveTimer: DispatchSourceTimer? + private var autoSaveDirty = false + private var autoSaveCoolingDown = false + private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave") + + init() { + let id = bridge.newDocument() + self.currentNoteID = id + self.selectedNoteIDs = [id] + refreshNoteList() + } + + // MARK: - Auto-save + + private func scheduleAutoSave() { + if autoSaveCoolingDown { + autoSaveDirty = true + return + } + performAutoSave() + } + + private func performAutoSave() { + guard shouldAutoSave() else { return } + + autoSaveCoolingDown = true + autoSaveDirty = false + + let text = documentText + let noteID = currentNoteID + let title = extractTitle(from: text) + + autoSaveQueue.async { [weak self] in + self?.writeAutoSaveFile(noteID: noteID, title: title, text: text) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + self.autoSaveCoolingDown = false + if self.autoSaveDirty { + self.autoSaveDirty = false + self.performAutoSave() + } + } + } + + bridge.setText(currentNoteID, text: documentText) + let _ = bridge.cacheSave(currentNoteID) + modified = false + refreshNoteList() + } + + private func shouldAutoSave() -> Bool { + // Autosave only when the note has real user content. A freshly- + // created doc that picked up the default `Header 1 | Header 2 | + // Header 3` table from Cmd+T without the user typing anything + // still reads as "blank" by this check — that's what stops the + // ~/.acord/notes directory from accumulating `{uuid}.md` phantoms. + // + // Explicit saves (Cmd+S → `saveNote`) skip this gate, so a user + // who genuinely wants to keep a note with only an empty table + // can still force it. + !AppState.isEffectivelyBlank(documentText) + } + + /// Shared blank-detection used by both the autosave gate and (via its + /// `static` form) the browser's `(empty note)` preview label. A note + /// is "blank" when, after the `` sidecar is + /// stripped, nothing remains except whitespace or default empty-table + /// scaffolding (all-empty cells or the `Header N` placeholder row). + static func isEffectivelyBlank(_ text: String) -> Bool { + let body = stripSidecarArchive(text) + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + let meaningful = trimmed.components(separatedBy: "\n").filter { line in + let t = line.trimmingCharacters(in: .whitespaces) + if t.isEmpty { return false } + if !t.hasPrefix("|") { return true } + let cells = t + .trimmingCharacters(in: CharacterSet(charactersIn: "|")) + .components(separatedBy: "|") + .map { $0.trimmingCharacters(in: .whitespaces) } + if cells.allSatisfy({ !$0.isEmpty && $0.allSatisfy { "-:".contains($0) } }) { + return false + } + let isDefaultHeader = cells.enumerated().allSatisfy { (i, cell) in + cell == "Header \(i + 1)" + } + if cells.allSatisfy({ $0.isEmpty }) || isDefaultHeader { + return false + } + return true + } + return meaningful.isEmpty + } + + private static func stripSidecarArchive(_ text: String) -> String { + guard let marker = text.range(of: "` base64 sidecar comment before + /// previewing. Without this, phantom notes that were saved with only + /// an empty default table render their archive blob as tile text. + private static func stripSidecarArchive(_ text: String) -> String { + guard let marker = text.range(of: " +//! ``` +//! +//! Why this shape: +//! - HTML comments are valid markdown — every renderer (GitHub, Bear, Obsidian) +//! treats them as invisible. Vim shows them as a single comment block, not +//! as binary garbage. +//! - Base64 stays text-clean — no `\0` bytes, vim won't flag the file as +//! binary, `git diff` is still legible (modulo a wide line at the bottom). +//! - The zip's central directory makes it trivial to add more entries later +//! (per-block scratch state, formula caches, embedded images) without +//! changing the framing. +//! +//! Per-table linking is positional: the Nth non-eval table in document layout +//! order is sidecar key "N". No proprietary tags appear in the markdown body. +//! Identity is runtime state derived from the document, never written to disk. +//! +//! The archive is structured like a Rust crate — each block is a submodule +//! file under `src/`, and `config.toml` holds display-only metadata (col +//! widths, row heights, cell styles). Save direction only: the markdown is +//! always the source of truth; the archive is regenerated fresh on every save. +//! On load, only `config.toml` is read for display metadata. If missing or +//! malformed, start fresh — next save overwrites. +//! +//! Eval result tables are explicitly NOT persisted. Only the source `/= expr` +//! line goes into markdown; the result table re-renders fresh on load. + +use std::collections::HashMap; +use std::io::{Cursor, Read, Write}; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +/// Sentinel that opens the embedded archive comment. Anything from this string +/// to the matching `-->` is the archive payload (base64-encoded zip). +const ARCHIVE_OPEN: &str = ""; + +/// Root-level display metadata file inside the zip. Holds col widths, row +/// heights, cell styles, formulas — things that don't affect evaluation. +const CONFIG_ENTRY: &str = "config.toml"; +/// Directory inside the zip holding one `.cord` file per block. Each file +/// contains TOML front-matter + source, structured like a crate submodule. +const SRC_DIR: &str = "src/"; + +/// Top-level schema of a `.acord.toml` companion. Versioned so we can +/// migrate later as the Numbers-class table feature set grows. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Sidecar { + /// Schema version. Bump on incompatible changes. + #[serde(default = "default_version")] + pub version: u32, + /// Table metadata indexed by `[#id]` markers in the markdown. + #[serde(default)] + pub tables: HashMap, +} + +fn default_version() -> u32 { + 1 +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TableSidecar { + /// Per-column widths in pixels. Same length as the table's column count + /// (or shorter; missing entries fall back to the editor's default width). + #[serde(default)] + pub col_widths: Vec, + /// Sparse per-row explicit heights. Keys are row indices serialized as + /// strings (TOML's native key type); convert with `parse::()` at + /// the boundary. A table with a few resized rows doesn't carry the + /// default for every other row. + #[serde(default)] + pub row_heights: HashMap, + /// Per-cell metadata indexed by spreadsheet-style address ("A1", "D2", ...). + #[serde(default)] + pub cells: HashMap, + /// Cell formulas indexed by spreadsheet address. + #[serde(default)] + pub formulas: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CellSidecar { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_weight: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub align: Option, +} + +/// Reads sidecar TOML. Returns `Default` on parse error so a corrupt sidecar +/// never blocks opening a markdown file — the user just loses the rich metadata +/// until they re-save. +pub struct SidecarReader { + inner: Sidecar, +} + +impl SidecarReader { + pub fn from_toml(text: &str) -> Self { + let inner: Sidecar = toml::from_str(text).unwrap_or_default(); + Self { inner } + } + + pub fn empty() -> Self { + Self { inner: Sidecar::default() } + } + + pub fn table(&self, id: &str) -> Option<&TableSidecar> { + self.inner.tables.get(id) + } +} + +/// Accumulates sidecar entries during a save pass. Each block's `to_md` writes +/// its side-channel state into the writer; after the pass, `flush` produces the +/// TOML text to write to disk (or `None` if there's nothing to write — empty +/// sidecars should be deleted from disk to avoid littering). +pub struct SidecarWriter { + inner: Sidecar, +} + +impl SidecarWriter { + pub fn new() -> Self { + Self { + inner: Sidecar { + version: 1, + tables: HashMap::new(), + }, + } + } + + pub fn put_table(&mut self, id: String, data: TableSidecar) { + self.inner.tables.insert(id, data); + } + + /// Returns the serialized TOML, or `None` if the sidecar has no entries. + pub fn flush(self) -> Option { + if self.inner.tables.is_empty() { + return None; + } + toml::to_string_pretty(&self.inner).ok() + } +} + +impl Default for SidecarWriter { + fn default() -> Self { + Self::new() + } +} + +// ---------------------------------------------------------------------------- +// Embedded archive: split markdown text into (body, optional sidecar) +// ---------------------------------------------------------------------------- + +/// Result of pulling an archive out of an `.md` file. `markdown` is the user +/// content with the archive comment stripped; `sidecar` is the parsed config +/// (or `None` if the file had no archive). +pub struct LoadedDoc { + pub markdown: String, + pub sidecar: Option, +} + +/// Pull an embedded archive out of a markdown file. If the file has no +/// `` comment, returns the text unchanged with +/// `sidecar = None`. Failure modes (truncated comment, bad base64, malformed +/// zip, malformed TOML) all degrade gracefully to "no sidecar" — the user +/// never loses access to their markdown content because of corrupted metadata. +pub fn extract_archive(text: &str) -> LoadedDoc { + let Some(open_idx) = text.rfind(ARCHIVE_OPEN) else { + return LoadedDoc { + markdown: text.to_string(), + sidecar: None, + }; + }; + // The closing `-->` must come AFTER the opener. + let after_open = open_idx + ARCHIVE_OPEN.len(); + let Some(rel_close) = text[after_open..].find(ARCHIVE_CLOSE) else { + return LoadedDoc { + markdown: text.to_string(), + sidecar: None, + }; + }; + let close_idx = after_open + rel_close; + let payload = text[after_open..close_idx].trim(); + + let body = strip_trailing_blank_lines(text[..open_idx].trim_end_matches('\n')); + + let parsed = decode_archive_payload(payload); + LoadedDoc { + markdown: body, + sidecar: parsed, + } +} + +/// A single block's source file for the archive. Written to `src/` +/// inside the zip. Content is TOML front-matter + `---` separator + raw source. +pub struct BlockFile { + pub filename: String, + pub content: String, +} + +/// Append an archive comment to the markdown body. If there's nothing to store +/// (no block files AND no sidecar entries), returns the body unchanged. +pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String { + if sidecar.tables.is_empty() && block_files.is_empty() { + return markdown.to_string(); + } + + let toml_text = match toml::to_string_pretty(sidecar) { + Ok(t) => t, + Err(_) => return markdown.to_string(), + }; + + let zip_bytes = match write_zip(&toml_text, block_files) { + Ok(b) => b, + Err(_) => return markdown.to_string(), + }; + + let encoded = B64.encode(&zip_bytes); + + // Wrap base64 to ~76 cols so the comment doesn't blow out git diffs and + // terminal viewers. The decoder ignores whitespace. + let wrapped = wrap_base64(&encoded, 76); + + let mut out = markdown.trim_end_matches('\n').to_string(); + out.push_str("\n\n"); + out.push_str(ARCHIVE_OPEN); + out.push('\n'); + out.push_str(&wrapped); + out.push('\n'); + out.push_str(ARCHIVE_CLOSE); + out.push('\n'); + out +} + +fn strip_trailing_blank_lines(s: &str) -> String { + // Walk back over consecutive trailing newlines / whitespace lines so that + // round-tripping a doc with an archive doesn't accumulate blank lines. + let mut end = s.len(); + let bytes = s.as_bytes(); + while end > 0 { + let line_end = end; + let mut line_start = end; + while line_start > 0 && bytes[line_start - 1] != b'\n' { + line_start -= 1; + } + let line = &s[line_start..line_end]; + if line.trim().is_empty() { + end = if line_start == 0 { 0 } else { line_start - 1 }; + } else { + break; + } + } + s[..end].to_string() +} + +fn decode_archive_payload(payload: &str) -> Option { + // Strip whitespace inside the comment so the wrapping is invisible to the + // decoder. + let cleaned: String = payload.chars().filter(|c| !c.is_whitespace()).collect(); + let zip_bytes = B64.decode(cleaned.as_bytes()).ok()?; + let toml_text = read_zip(&zip_bytes)?; + toml::from_str::(&toml_text).ok() +} + +fn write_zip(toml_text: &str, block_files: &[BlockFile]) -> Result, String> { + let total_bytes = toml_text.len() + + block_files.iter().map(|f| f.filename.len() + f.content.len()).sum::(); + let mut buf: Vec = Vec::with_capacity(total_bytes + 512); + { + let cursor = Cursor::new(&mut buf); + let mut zip = ZipWriter::new(cursor); + let opts: SimpleFileOptions = + SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + + if !toml_text.is_empty() { + zip.start_file(CONFIG_ENTRY, opts) + .map_err(|e| format!("zip start_file config: {}", e))?; + zip.write_all(toml_text.as_bytes()) + .map_err(|e| format!("zip write config: {}", e))?; + } + + for file in block_files { + let path = format!("{}{}", SRC_DIR, file.filename); + zip.start_file(path, opts) + .map_err(|e| format!("zip start_file {}: {}", file.filename, e))?; + zip.write_all(file.content.as_bytes()) + .map_err(|e| format!("zip write {}: {}", file.filename, e))?; + } + + zip.finish() + .map_err(|e| format!("zip finish: {}", e))?; + } + Ok(buf) +} + +fn read_zip(bytes: &[u8]) -> Option { + let cursor = Cursor::new(bytes); + let mut zip = ZipArchive::new(cursor).ok()?; + let mut entry = zip.by_name(CONFIG_ENTRY).ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).ok()?; + Some(text) +} + +fn wrap_base64(s: &str, width: usize) -> String { + if width == 0 || s.len() <= width { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + s.len() / width); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let end = (i + width).min(bytes.len()); + // Base64 is ASCII, slicing by byte == slicing by char. + out.push_str(&s[i..end]); + if end < bytes.len() { + out.push('\n'); + } + i = end; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_sidecar() -> Sidecar { + let mut tables = HashMap::new(); + tables.insert( + "t1".to_string(), + TableSidecar { + col_widths: vec![100.0, 200.0, 150.0], + row_heights: HashMap::new(), + cells: HashMap::new(), + formulas: HashMap::new(), + }, + ); + Sidecar { version: 1, tables } + } + + #[test] + fn round_trip_embed_extract() { + let body = "# Hello\n\nSome text.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + let sidecar = sample_sidecar(); + let with_archive = embed_archive(body, &sidecar, &[]); + assert!(with_archive.contains(ARCHIVE_OPEN)); + assert!(with_archive.contains(ARCHIVE_CLOSE)); + + let loaded = extract_archive(&with_archive); + assert_eq!(loaded.markdown.trim_end(), body.trim_end()); + let parsed = loaded.sidecar.expect("sidecar should round-trip"); + assert_eq!(parsed.tables.len(), 1); + let t1 = &parsed.tables["t1"]; + assert_eq!(t1.col_widths, vec![100.0, 200.0, 150.0]); + } + + #[test] + fn empty_sidecar_skips_embed() { + let body = "Just some markdown.\n"; + let empty = Sidecar::default(); + let out = embed_archive(body, &empty, &[]); + assert_eq!(out, body); + assert!(!out.contains("acord-archive")); + } + + #[test] + fn extract_with_no_archive() { + let body = "# Plain doc\n\nNo archive here."; + let loaded = extract_archive(body); + assert_eq!(loaded.markdown, body); + assert!(loaded.sidecar.is_none()); + } + + #[test] + fn extract_with_corrupt_payload_recovers_markdown() { + // Garbage in the comment body must NOT eat the user's markdown — they + // get the body back, sidecar None. + let doc = "# Body\n\nstuff\n\n\n"; + let loaded = extract_archive(doc); + assert!(loaded.markdown.contains("# Body")); + assert!(loaded.markdown.contains("stuff")); + assert!(loaded.sidecar.is_none()); + } + + #[test] + fn round_trip_preserves_complex_metadata() { + let mut tables = HashMap::new(); + let mut cells = HashMap::new(); + cells.insert( + "A1".to_string(), + CellSidecar { + background: Some("#ff0000".into()), + foreground: Some("#ffffff".into()), + font_weight: Some("bold".into()), + align: Some("center".into()), + }, + ); + let mut row_heights = HashMap::new(); + row_heights.insert("2".to_string(), 48.0); + let mut formulas = HashMap::new(); + formulas.insert("B3".to_string(), "=SUM(A1:A10)".to_string()); + tables.insert( + "t1".to_string(), + TableSidecar { + col_widths: vec![80.0, 120.0], + row_heights, + cells, + formulas, + }, + ); + let sc = Sidecar { version: 1, tables }; + + let body = "# Doc\n"; + let embedded = embed_archive(body, &sc, &[]); + let loaded = extract_archive(&embedded); + let parsed = loaded.sidecar.unwrap(); + + let t = &parsed.tables["t1"]; + assert_eq!(t.col_widths, vec![80.0, 120.0]); + assert_eq!(t.row_heights["2"], 48.0); + assert_eq!(t.cells["A1"].background.as_deref(), Some("#ff0000")); + assert_eq!(t.formulas["B3"], "=SUM(A1:A10)"); + } + + #[test] + fn embed_does_not_double_blank_line() { + // Body that already ends with newlines should round-trip cleanly. + let body = "Line\n\n\n"; + let sc = sample_sidecar(); + let embedded = embed_archive(body, &sc, &[]); + let loaded = extract_archive(&embedded); + // Trailing blank lines around the archive should not accumulate. + assert_eq!(loaded.markdown.trim_end(), "Line"); + } +} diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs new file mode 100644 index 0000000..47ebe07 --- /dev/null +++ b/viewport/src/syntax.rs @@ -0,0 +1,891 @@ +use std::ops::Range; + +use iced_wgpu::core::text::highlighter; +use iced_wgpu::core::{Color, Font}; +use iced_wgpu::core::font::{Weight, Style as FontStyle}; +use acord_core::highlight::{highlight_source, HighlightSpan}; +use acord_core::doc::{classify_document, LineKind}; +use crate::editor::{RESULT_PREFIX, ERROR_PREFIX}; +use crate::palette; + +pub const EVAL_RESULT_KIND: u8 = 24; +pub const EVAL_ERROR_KIND: u8 = 25; + +// --- Cordial (eval-line) tokens. Start at 50 to leave room above the +// markdown range. A single hand-rolled scanner (`highlight_cordial`) dispatches +// on these so every Cordial visual element — the `/=` sigil, the `@` ref +// prefix, `::`, table/block names, cell addresses, keywords, builtins, +// numbers, strings, comments — gets its own color. +const COR_EVAL_SIGIL: u8 = 50; +const COR_AT_SIGIL: u8 = 51; +const COR_COLON_COLON: u8 = 52; +const COR_REF_COLON: u8 = 53; +const COR_TABLE_NAME: u8 = 54; +const COR_BLOCK_NAME: u8 = 55; +const COR_CELL_ADDR: u8 = 56; +const COR_KEYWORD: u8 = 57; +const COR_BUILTIN_FN: u8 = 58; +const COR_NUMBER: u8 = 59; +const COR_STRING: u8 = 60; +const COR_COMMENT: u8 = 61; +const COR_OPERATOR: u8 = 62; +const COR_BRACKET: u8 = 63; +const COR_TYPE_ANN: u8 = 64; + +const MD_HEADING_MARKER: u8 = 26; +const MD_H1: u8 = 27; +const MD_H2: u8 = 28; +const MD_H3: u8 = 29; +const MD_BOLD: u8 = 30; +const MD_ITALIC: u8 = 31; +const MD_INLINE_CODE: u8 = 32; +const MD_FORMAT_MARKER: u8 = 33; +const MD_LINK_TEXT: u8 = 34; +const MD_LINK_URL: u8 = 35; +const MD_BLOCKQUOTE_MARKER: u8 = 36; +const MD_BLOCKQUOTE: u8 = 37; +const MD_LIST_MARKER: u8 = 38; +const MD_FENCE_MARKER: u8 = 39; +const MD_CODE_BLOCK: u8 = 40; +const MD_HR: u8 = 41; +const MD_TASK_OPEN: u8 = 42; +const MD_TASK_DONE: u8 = 43; +const MD_BOLD_ITALIC: u8 = 44; + +/// The monospace family used for the editor body and every inline highlight +/// span. Naming the family explicitly (rather than `Family::Monospace`) forces +/// cosmic-text / fontdb to resolve real Bold, Italic and BoldItalic faces, +/// which the generic monospace fallback does not reliably do on macOS because +/// cosmic-text hardcodes its default monospace family to "Noto Sans Mono". +#[cfg(target_os = "macos")] +pub const EDITOR_FONT: Font = Font::with_name("Menlo"); +#[cfg(target_os = "windows")] +pub const EDITOR_FONT: Font = Font::with_name("Consolas"); +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub const EDITOR_FONT: Font = Font::with_name("DejaVu Sans Mono"); + +#[derive(Clone, PartialEq)] +pub struct SyntaxSettings { + pub lang: String, + pub source: String, +} + +#[derive(Clone, Copy, Debug)] +pub struct SyntaxHighlight { + pub kind: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LineDecor { + None, + CodeBlock, + Blockquote, + HorizontalRule, + FenceMarker, +} + +pub struct SyntaxHighlighter { + lang: String, + spans: Vec, + line_offsets: Vec, + line_kinds: Vec, + in_fenced_code: bool, + current_line: usize, + line_decors: Vec, +} + +impl SyntaxHighlighter { + fn rebuild(&mut self, source: &str) { + self.spans = highlight_source(source, &self.lang); + self.line_offsets.clear(); + let mut offset = 0; + for line in source.split('\n') { + self.line_offsets.push(offset); + offset += line.len() + 1; + } + let classified = classify_document(source); + self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect(); + + self.line_decors.clear(); + let mut in_fence = false; + for (i, raw_line) in source.split('\n').enumerate() { + let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown; + if is_md { + let trimmed = raw_line.trim_start(); + if trimmed.starts_with("```") { + in_fence = !in_fence; + self.line_decors.push(LineDecor::FenceMarker); + } else if in_fence { + self.line_decors.push(LineDecor::CodeBlock); + } else if is_horizontal_rule(trimmed) { + self.line_decors.push(LineDecor::HorizontalRule); + } else if trimmed.starts_with("> ") || trimmed == ">" { + self.line_decors.push(LineDecor::Blockquote); + } else { + self.line_decors.push(LineDecor::None); + } + } else { + if in_fence { in_fence = false; } + self.line_decors.push(LineDecor::None); + } + } + + self.in_fenced_code = false; + self.current_line = 0; + } + + fn highlight_markdown(&self, line: &str) -> Vec<(Range, SyntaxHighlight)> { + let trimmed = line.trim_start(); + let leading = line.len() - trimmed.len(); + + if is_horizontal_rule(trimmed) { + return vec![(0..line.len(), SyntaxHighlight { kind: MD_HR })]; + } + + if let Some(level) = heading_level(trimmed) { + let marker_end = leading + level + 1; + let kind = match level { + 1 => MD_H1, + 2 => MD_H2, + _ => MD_H3, + }; + let mut spans = vec![ + (0..marker_end, SyntaxHighlight { kind: MD_HEADING_MARKER }), + ]; + if marker_end < line.len() { + spans.push((marker_end..line.len(), SyntaxHighlight { kind })); + } + return spans; + } + + if trimmed.starts_with("> ") || trimmed == ">" { + let marker_end = leading + if trimmed.len() > 1 { 2 } else { 1 }; + let mut spans = vec![ + (0..marker_end, SyntaxHighlight { kind: MD_BLOCKQUOTE_MARKER }), + ]; + if marker_end < line.len() { + let content = &line[marker_end..]; + let inner = parse_inline(content, marker_end); + if inner.is_empty() { + spans.push((marker_end..line.len(), SyntaxHighlight { kind: MD_BLOCKQUOTE })); + } else { + spans.extend(inner); + } + } + return spans; + } + + if let Some(list_info) = list_marker_info(trimmed) { + let (marker_len, marker_kind) = match list_info { + ListKind::TaskOpen(n) => (n, MD_TASK_OPEN), + ListKind::TaskDone(n) => (n, MD_TASK_DONE), + ListKind::Plain(n) => (n, MD_LIST_MARKER), + }; + let marker_end = leading + marker_len; + let mut spans = vec![ + (0..marker_end, SyntaxHighlight { kind: marker_kind }), + ]; + if marker_end < line.len() { + let content = &line[marker_end..]; + let inner = parse_inline(content, marker_end); + if inner.is_empty() { + return spans; + } + spans.extend(inner); + } + return spans; + } + + parse_inline(line, 0) + } +} + +/// Scan a Cordial line (or an eval line) and emit per-token highlight +/// spans. Idempotent, single-pass; each branch either consumes a whole +/// token or advances one byte. Unknown bytes get no highlight (they fall +/// through to the editor's default text color). +fn highlight_cordial(line: &str) -> Vec<(Range, SyntaxHighlight)> { + let bytes = line.as_bytes(); + let len = bytes.len(); + let mut spans: Vec<(Range, SyntaxHighlight)> = Vec::new(); + let mut i = 0; + + // Opening `/=`, `/=|`, `/=\` sigil (with optional leading whitespace). + let leading = line.len() - line.trim_start().len(); + if leading + 2 <= len && &bytes[leading..leading + 2] == b"/=" { + let sigil_end = if leading + 3 <= len + && (bytes[leading + 2] == b'|' || bytes[leading + 2] == b'\\') + { + leading + 3 + } else { + leading + 2 + }; + spans.push((leading..sigil_end, SyntaxHighlight { kind: COR_EVAL_SIGIL })); + i = sigil_end; + } + + while i < len { + let c = bytes[i]; + + // Whitespace: skip. + if c == b' ' || c == b'\t' || c == b'\r' { + i += 1; + continue; + } + + // Line comment: `// …` — rest of line. + if c == b'/' && i + 1 < len && bytes[i + 1] == b'/' { + spans.push((i..len, SyntaxHighlight { kind: COR_COMMENT })); + break; + } + + // String literal. + if c == b'"' { + let start = i; + i += 1; + while i < len && bytes[i] != b'"' { + if bytes[i] == b'\\' && i + 1 < len { i += 2; } else { i += 1; } + } + if i < len { i += 1; } + spans.push((start..i, SyntaxHighlight { kind: COR_STRING })); + continue; + } + + // `@` cell reference: @[Block::]Table[:A1[:B4]] or @T[A1:B4]. + if c == b'@' { + spans.push((i..i + 1, SyntaxHighlight { kind: COR_AT_SIGIL })); + i += 1; + // First ident. + let n1_start = i; + while i < len && is_ident_byte(bytes[i]) { i += 1; } + let n1_end = i; + // Is it a block qualifier? Look for `::` after. + if i + 1 < len && bytes[i] == b':' && bytes[i + 1] == b':' { + if n1_end > n1_start { + spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_BLOCK_NAME })); + } + spans.push((i..i + 2, SyntaxHighlight { kind: COR_COLON_COLON })); + i += 2; + let t_start = i; + while i < len && is_ident_byte(bytes[i]) { i += 1; } + if i > t_start { + spans.push((t_start..i, SyntaxHighlight { kind: COR_TABLE_NAME })); + } + } else if n1_end > n1_start { + spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_TABLE_NAME })); + } + // Optional `:A1` or `:A1:B2` cell/range target. + if i < len && bytes[i] == b':' { + spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON })); + i += 1; + i = consume_cell_addr(bytes, i, &mut spans); + if i < len && bytes[i] == b':' { + spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON })); + i += 1; + i = consume_cell_addr(bytes, i, &mut spans); + } + } else if i < len && bytes[i] == b'[' { + // Bracket range: `[A1:B2]`. + spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET })); + i += 1; + i = consume_cell_addr(bytes, i, &mut spans); + if i < len && bytes[i] == b':' { + spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON })); + i += 1; + i = consume_cell_addr(bytes, i, &mut spans); + } + if i < len && bytes[i] == b']' { + spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET })); + i += 1; + } + } + continue; + } + + // Numeric literal (integer or decimal, with optional leading `-` + // in operator-valid position — keep it simple: only recognise as + // a number when we're right after an operator or at the start of + // whitespace, otherwise leave `-` to the operator scanner). + if c.is_ascii_digit() + || (c == b'.' && i + 1 < len && bytes[i + 1].is_ascii_digit()) + { + let start = i; + while i < len && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + i += 1; + } + spans.push((start..i, SyntaxHighlight { kind: COR_NUMBER })); + continue; + } + + // Identifier → keyword / builtin / plain. Plain idents get no + // highlight so user-defined names stay in the default editor + // color — keeps the document from looking like confetti. + if is_ident_byte(c) && !c.is_ascii_digit() { + let start = i; + while i < len && is_ident_byte(bytes[i]) { i += 1; } + let word = &line[start..i]; + if is_cordial_keyword(word) { + spans.push((start..i, SyntaxHighlight { kind: COR_KEYWORD })); + } else if is_cordial_builtin(word) { + spans.push((start..i, SyntaxHighlight { kind: COR_BUILTIN_FN })); + } else if is_cordial_type_annotation(word) && last_token_is_colon(&spans) { + // Type annotation immediately after `:` in a `let x: T = …` + // reads the type name; give it the yellow/type color. + spans.push((start..i, SyntaxHighlight { kind: COR_TYPE_ANN })); + } + continue; + } + + // `::` as a namespace separator outside of a ref (e.g. `use mod::item`). + if c == b':' && i + 1 < len && bytes[i + 1] == b':' { + spans.push((i..i + 2, SyntaxHighlight { kind: COR_COLON_COLON })); + i += 2; + continue; + } + + // Plain `:` — likely a type annotation colon in `let x: T = …`. + if c == b':' { + spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON })); + i += 1; + continue; + } + + // Bracket / brace / paren — separate color from operators. + if matches!(c, b'(' | b')' | b'{' | b'}' | b'[' | b']' | b',') { + spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET })); + i += 1; + continue; + } + + // Operator run: consume a contiguous block of operator bytes. + if is_operator_byte(c) { + let start = i; + while i < len && is_operator_byte(bytes[i]) { i += 1; } + spans.push((start..i, SyntaxHighlight { kind: COR_OPERATOR })); + continue; + } + + i += 1; + } + + spans +} + +fn consume_cell_addr( + bytes: &[u8], + start: usize, + spans: &mut Vec<(Range, SyntaxHighlight)>, +) -> usize { + let mut i = start; + while i < bytes.len() && bytes[i].is_ascii_alphabetic() { i += 1; } + let letters_end = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } + // Only tag as a cell address when we matched BOTH letters AND digits — + // otherwise we're looking at a bare identifier or a digit run that + // some other branch should have handled. + if i > start && letters_end > start && i > letters_end { + spans.push((start..i, SyntaxHighlight { kind: COR_CELL_ADDR })); + } + i +} + +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +fn is_operator_byte(b: u8) -> bool { + matches!(b, b'+' | b'-' | b'*' | b'/' | b'%' | b'^' + | b'=' | b'<' | b'>' | b'!' | b'~' | b'&' | b'|' | b'.') +} + +fn is_cordial_keyword(w: &str) -> bool { + matches!(w, "let" | "fn" | "if" | "else" | "while" | "for" | "in" + | "return" | "use" | "is" | "true" | "false" | "and" | "or" | "not" + // Function-inversion DSL — two forms: + // programmer: let lfreq = solve!(l, f0) // or `solve!(l from f0)` + // math: let lfreq(freq, c) = l where f0(l, c) = freq + | "solve" | "where" | "from") +} + +fn is_cordial_builtin(w: &str) -> bool { + matches!(w, + // math + "sin" | "cos" | "tan" | "asin" | "acos" | "atan" + | "sqrt" | "abs" | "floor" | "ceil" | "round" | "ln" | "log" + // collections + | "len" | "range" | "push" + // aggregates + | "sum" | "avg" | "min" | "max" | "count" | "std_devp" | "std_devs" + // constants + | "pi" + ) +} + +fn is_cordial_type_annotation(w: &str) -> bool { + matches!(w, "int" | "float" | "bool" | "str" | "number" | "array" | "vec") +} + +/// Did the scanner just emit a `:` span? Used so a type name following a +/// `:` picks up the type-annotation color only in the `let x: T = …` shape, +/// never when it happens to sit elsewhere on the line. +fn last_token_is_colon(spans: &[(Range, SyntaxHighlight)]) -> bool { + matches!(spans.last(), Some((_, h)) if h.kind == COR_REF_COLON) +} + +fn heading_level(trimmed: &str) -> Option { + let bytes = trimmed.as_bytes(); + if bytes.is_empty() || bytes[0] != b'#' { return None; } + let mut level = 0; + while level < bytes.len() && bytes[level] == b'#' { level += 1; } + if level > 3 { return None; } + if level < bytes.len() && bytes[level] == b' ' { + Some(level) + } else { + None + } +} + +fn is_horizontal_rule(trimmed: &str) -> bool { + if trimmed.len() < 3 { return false; } + let first = trimmed.as_bytes()[0]; + if !matches!(first, b'-' | b'*' | b'_') { return false; } + trimmed.bytes().all(|b| b == first || b == b' ') +} + +#[derive(Clone, Copy, PartialEq)] +enum ListKind { + Plain(usize), + TaskOpen(usize), + TaskDone(usize), +} + +fn list_marker_info(trimmed: &str) -> Option { + let bytes = trimmed.as_bytes(); + if bytes.is_empty() { return None; } + + if matches!(bytes[0], b'-' | b'*' | b'+') && bytes.get(1) == Some(&b' ') { + if trimmed.starts_with("- [ ] ") { + return Some(ListKind::TaskOpen(6)); + } + if trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") { + return Some(ListKind::TaskDone(6)); + } + return Some(ListKind::Plain(2)); + } + + let mut i = 0; + while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } + if i > 0 && i < bytes.len() && matches!(bytes[i], b'.' | b')') { + if bytes.get(i + 1) == Some(&b' ') { + return Some(ListKind::Plain(i + 2)); + } + } + None +} + +fn parse_inline(text: &str, base: usize) -> Vec<(Range, SyntaxHighlight)> { + let bytes = text.as_bytes(); + let len = bytes.len(); + let mut spans = Vec::new(); + let mut i = 0; + + while i < len { + if bytes[i] == b'\\' && i + 1 < len && is_md_punctuation(bytes[i + 1]) { + i += 2; + continue; + } + + // cosmic-text's partial reshape (called by iced's text_editor after + // add_span) drops the new run's attrs on the FIRST glyph of the new + // attribute run, so `*hello*` would render with "h" plain and "ello" + // italic. Workaround: emit the bold/italic span covering the opening + // marker bytes too — the marker becomes the "lost first glyph" and + // the first letter of the inner text gets the style. The marker span + // pushed first is overridden by the bold/italic span that follows + // because cosmic-text uses the LAST add_span to win on overlap. + // Markers (`*`, `**`, `***`) end up italic/bold themselves, which is + // imperceptible at typical font sizes. + + if i + 2 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' { + if let Some(end) = find_triple_star(bytes, i + 3) { + spans.push((base + i..base + i + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + spans.push((base + end..base + end + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 3 < end { + spans.push((base + i..base + end + 3, SyntaxHighlight { kind: MD_BOLD_ITALIC })); + } + i = end + 3; + continue; + } + } + + if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' { + if let Some(end) = find_closing(bytes, i + 2, b'*', b'*') { + spans.push((base + i..base + i + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 2 < end { + let inner = parse_inline(&text[i + 2..end], base + i + 2); + if inner.is_empty() { + spans.push((base + i..base + end + 2, SyntaxHighlight { kind: MD_BOLD })); + } else { + spans.push((base + i..base + i + 2, SyntaxHighlight { kind: MD_BOLD })); + for (r, h) in inner { + let kind = if h.kind == MD_ITALIC { MD_BOLD_ITALIC } else { h.kind }; + spans.push((r, SyntaxHighlight { kind })); + } + // Extend bold over closing marker for visual consistency. + spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_BOLD })); + } + } + i = end + 2; + continue; + } + } + + if bytes[i] == b'*' && (i + 1 >= len || bytes[i + 1] != b'*') { + if let Some(end) = find_single_closing(bytes, i + 1, b'*') { + if end > i + 1 && bytes[end - 1] != b'*' { + spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + spans.push((base + end..base + end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 1 < end { + spans.push((base + i..base + end + 1, SyntaxHighlight { kind: MD_ITALIC })); + } + i = end + 1; + continue; + } + } + } + + if bytes[i] == b'`' { + let tick_count = count_backticks(bytes, i); + if let Some(end) = find_backtick_close(bytes, i + tick_count, tick_count) { + spans.push((base + i..base + i + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + tick_count < end { + spans.push((base + i + tick_count..base + end, SyntaxHighlight { kind: MD_INLINE_CODE })); + } + spans.push((base + end..base + end + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = end + tick_count; + continue; + } + } + + if bytes[i] == b'[' { + if let Some((text_end, url_end)) = find_link(bytes, i) { + spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 1 < text_end { + spans.push((base + i + 1..base + text_end, SyntaxHighlight { kind: MD_LINK_TEXT })); + } + spans.push((base + text_end..base + text_end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if text_end + 2 < url_end { + spans.push((base + text_end + 2..base + url_end, SyntaxHighlight { kind: MD_LINK_URL })); + } + spans.push((base + url_end..base + url_end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = url_end + 1; + continue; + } + } + + i += 1; + } + + spans +} + +fn is_md_punctuation(b: u8) -> bool { + matches!(b, b'\\' | b'`' | b'*' | b'_' | b'{' | b'}' | b'[' | b']' + | b'(' | b')' | b'#' | b'+' | b'-' | b'.' | b'!' | b'|') +} + +fn find_triple_star(bytes: &[u8], start: usize) -> Option { + let mut i = start; + while i + 2 < bytes.len() { + if bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' { + return Some(i); + } + i += 1; + } + None +} + +fn count_backticks(bytes: &[u8], start: usize) -> usize { + let mut n = 0; + while start + n < bytes.len() && bytes[start + n] == b'`' { n += 1; } + n +} + +fn find_backtick_close(bytes: &[u8], start: usize, count: usize) -> Option { + if count == 0 { return None; } + let mut i = start; + while i + count <= bytes.len() { + if count_backticks(bytes, i) == count { + return Some(i); + } + i += 1; + } + None +} + +fn find_closing(bytes: &[u8], start: usize, c1: u8, c2: u8) -> Option { + let mut i = start; + while i + 1 < bytes.len() { + if bytes[i] == c1 && bytes[i + 1] == c2 { + return Some(i); + } + i += 1; + } + None +} + +fn find_single_closing(bytes: &[u8], start: usize, ch: u8) -> Option { + let mut i = start; + while i < bytes.len() { + if bytes[i] == ch { + return Some(i); + } + i += 1; + } + None +} + +fn find_link(bytes: &[u8], open: usize) -> Option<(usize, usize)> { + let mut i = open + 1; + while i < bytes.len() { + if bytes[i] == b']' { + if i + 1 < bytes.len() && bytes[i + 1] == b'(' { + let text_end = i; + let mut j = i + 2; + while j < bytes.len() { + if bytes[j] == b')' { + return Some((text_end, j)); + } + j += 1; + } + } + return None; + } + if bytes[i] == b'\n' { return None; } + i += 1; + } + None +} + +impl highlighter::Highlighter for SyntaxHighlighter { + type Settings = SyntaxSettings; + type Highlight = SyntaxHighlight; + type Iterator<'a> = std::vec::IntoIter<(Range, SyntaxHighlight)>; + + fn new(settings: &Self::Settings) -> Self { + let mut h = SyntaxHighlighter { + lang: settings.lang.clone(), + spans: Vec::new(), + line_offsets: Vec::new(), + line_kinds: Vec::new(), + in_fenced_code: false, + current_line: 0, + line_decors: Vec::new(), + }; + h.rebuild(&settings.source); + h + } + + fn update(&mut self, new_settings: &Self::Settings) { + self.lang = new_settings.lang.clone(); + self.rebuild(&new_settings.source); + } + + fn change_line(&mut self, line: usize) { + self.current_line = self.current_line.min(line); + if line == 0 { + self.in_fenced_code = false; + } + } + + fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { + let ln = self.current_line; + self.current_line += 1; + + let trimmed = line.trim_start(); + if trimmed.starts_with(RESULT_PREFIX) { + return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_RESULT_KIND })].into_iter(); + } + if trimmed.starts_with(ERROR_PREFIX) { + return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter(); + } + + let is_markdown = ln < self.line_kinds.len() + && self.line_kinds[ln] == LineKind::Markdown; + + if is_markdown { + if trimmed.starts_with("```") { + self.in_fenced_code = !self.in_fenced_code; + return vec![(0..line.len(), SyntaxHighlight { kind: MD_FENCE_MARKER })].into_iter(); + } + + if self.in_fenced_code { + return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter(); + } + + // Markdown lines always return md_spans, even when empty — + // falling through to the code path would let plain prose pick up + // Rust keyword highlighting on words like "let", "type", "return". + return self.highlight_markdown(line).into_iter(); + } else if self.in_fenced_code { + self.in_fenced_code = false; + } + + // Non-markdown lines are Cordial / Eval / Comment — hand-rolled + // Cordial scanner, not the generic tree-sitter path (which uses + // the configured `lang`, wrong for Cordial). Each token gets its + // own color: `/=`, `@`, `::`, table / block names, cell addresses, + // keywords, builtins, numbers, strings, comments. + if ln < self.line_kinds.len() + && matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment) + { + return highlight_cordial(line).into_iter(); + } + + if ln >= self.line_offsets.len() { + return Vec::new().into_iter(); + } + + let line_start = self.line_offsets[ln]; + let line_end = if ln + 1 < self.line_offsets.len() { + self.line_offsets[ln + 1] - 1 + } else { + line_start + line.len() + }; + + let mut result = Vec::new(); + for span in &self.spans { + if span.end <= line_start || span.start >= line_end { + continue; + } + let start = span.start.max(line_start) - line_start; + let end = span.end.min(line_end) - line_start; + if start < end { + result.push((start..end, SyntaxHighlight { kind: span.kind })); + } + } + result.into_iter() + } + + fn current_line(&self) -> usize { + self.current_line + } +} + +pub fn highlight_color(kind: u8) -> Color { + let p = palette::current(); + match kind { + 0 => p.mauve, + 1 => p.blue, + 2 => p.teal, + 3 => p.yellow, + 4 => p.yellow, + 5 => p.teal, + 6 => p.peach, + 7 => p.peach, + 8 => p.green, + 9 => p.peach, + 10 => p.overlay0, + 11 => p.text, + 12 => p.red, + 13 => p.flamingo, + 14 => p.sky, + 15 => p.overlay2, + 16 => p.overlay2, + 17 => p.overlay2, + 18 => p.blue, + 19 => p.mauve, + 20 => p.yellow, + 21 => p.teal, + 22 => p.red, + 23 => p.text, + 24 => p.green, + 25 => p.maroon, + COR_EVAL_SIGIL => p.teal, + COR_AT_SIGIL => p.mauve, + COR_COLON_COLON => p.flamingo, + COR_REF_COLON => p.flamingo, + COR_TABLE_NAME => p.blue, + COR_BLOCK_NAME => p.lavender, + COR_CELL_ADDR => p.yellow, + COR_KEYWORD => p.mauve, + COR_BUILTIN_FN => p.sky, + COR_NUMBER => p.peach, + COR_STRING => p.green, + COR_COMMENT => p.overlay1, + COR_OPERATOR => p.overlay2, + COR_BRACKET => p.overlay2, + COR_TYPE_ANN => p.yellow, + MD_HEADING_MARKER => p.overlay0, + MD_H1 => p.rosewater, + MD_H2 => p.peach, + MD_H3 => p.yellow, + MD_BOLD => p.text, + MD_ITALIC => p.text, + MD_INLINE_CODE => p.green, + MD_FORMAT_MARKER => p.overlay0, + MD_LINK_TEXT => p.blue, + MD_LINK_URL => p.overlay1, + MD_BLOCKQUOTE_MARKER => p.overlay0, + MD_BLOCKQUOTE => p.sky, + MD_LIST_MARKER => p.sky, + MD_FENCE_MARKER => p.overlay0, + MD_CODE_BLOCK => p.text, + MD_HR => p.overlay1, + MD_TASK_OPEN => p.overlay2, + MD_TASK_DONE => p.green, + MD_BOLD_ITALIC => p.text, + _ => p.text, + } +} + +pub fn highlight_font(kind: u8) -> Option { + // Spans inherit the named family from EDITOR_FONT so fontdb can pick up + // the real Bold, Italic and BoldItalic faces of the system monospace. + match kind { + MD_HEADING_MARKER => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }), + MD_H1 => Some(Font { weight: Weight::Black, ..EDITOR_FONT }), + MD_H2 => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }), + MD_H3 => Some(Font { weight: Weight::Semibold, ..EDITOR_FONT }), + MD_BOLD => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }), + MD_ITALIC => Some(Font { style: FontStyle::Italic, ..EDITOR_FONT }), + MD_BOLD_ITALIC => Some(Font { weight: Weight::Bold, style: FontStyle::Italic, ..EDITOR_FONT }), + MD_INLINE_CODE => Some(EDITOR_FONT), + MD_FORMAT_MARKER => Some(EDITOR_FONT), + MD_BLOCKQUOTE => Some(Font { style: FontStyle::Italic, ..EDITOR_FONT }), + MD_FENCE_MARKER => Some(EDITOR_FONT), + MD_CODE_BLOCK => Some(EDITOR_FONT), + MD_TASK_DONE => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }), + _ => None, + } +} + +pub fn compute_line_decors(source: &str) -> Vec { + let classified = classify_document(source); + let line_kinds: Vec = classified.into_iter().map(|cl| cl.kind).collect(); + let mut decors = Vec::new(); + let mut in_fence = false; + for (i, raw_line) in source.split('\n').enumerate() { + let is_md = i < line_kinds.len() && line_kinds[i] == LineKind::Markdown; + if is_md { + let trimmed = raw_line.trim_start(); + if trimmed.starts_with("```") { + in_fence = !in_fence; + decors.push(LineDecor::FenceMarker); + } else if in_fence { + decors.push(LineDecor::CodeBlock); + } else if is_horizontal_rule(trimmed) { + decors.push(LineDecor::HorizontalRule); + } else if trimmed.starts_with("> ") || trimmed == ">" { + decors.push(LineDecor::Blockquote); + } else { + decors.push(LineDecor::None); + } + } else { + if in_fence { in_fence = false; } + decors.push(LineDecor::None); + } + } + decors +} diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs new file mode 100644 index 0000000..fa31738 --- /dev/null +++ b/viewport/src/table_block.rs @@ -0,0 +1,1363 @@ +use iced_wgpu::core::widget::Id as WidgetId; +use iced_wgpu::core::{ + Background, Border, Color, Element, Font, Length, Padding, Point, Shadow, Theme, +}; +use iced_widget::button; +use iced_widget::container; +use iced_widget::text; +use iced_widget::text_input; +use iced_widget::MouseArea; +use iced_wgpu::core::mouse::Interaction; + +use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +use crate::palette; +use crate::selection::{BlockId, InnerPath}; +use crate::syntax::EDITOR_FONT; + +const MIN_COL_WIDTH: f32 = 60.0; +const DEFAULT_COL_WIDTH: f32 = 120.0; +const CELL_PADDING: Padding = Padding { + top: 2.0, + right: 8.0, + bottom: 2.0, + left: 8.0, +}; +const ROW_NUMBER_WIDTH: f32 = 26.0; +const PLUS_BUTTON_THICKNESS: f32 = 14.0; +/// Default per-row height. Calibrated to match the natural height of an iced +/// text_input at size 13 with CELL_PADDING — 13pt font + ~1.3 line height + +/// 4px vertical padding + 2px border ≈ 23. +const ROW_HEIGHT_ESTIMATE: f32 = 23.0; +const MIN_ROW_HEIGHT: f32 = 18.0; +#[allow(dead_code)] +const ROW_RESIZE_HANDLE_HEIGHT: f32 = 3.0; +/// Vertical gap between rows. Slightly tighter than RESIZE_HANDLE_WIDTH — +/// the horizontal gap stays at 4 so the resize handle has enough hit area. +const CELL_GAP_Y: f32 = 2.0; + +#[derive(Debug, Clone, Copy)] +pub enum ReorderDrag { + Column { from: usize, target: usize, start_x: f32 }, + Row { from: usize, target: usize, start_y: f32 }, +} + +/// Modifier state at the moment of a selection click or drag. Computed by +/// editor.rs from its tracked modifier state, then passed in via the +/// table-state mutation methods. The modifier determines the OPERATION on +/// the existing selection — single-cell click vs rectangular drag is +/// orthogonal to the modifier and determined by gesture (click = 1 cell, +/// drag = rectangle). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionMode { + /// No modifier — selection becomes only the clicked cell (or drag rect). + Replace, + /// Cmd — toggle (XOR): each touched cell is added if absent, removed if + /// present. + Toggle, + /// Shift — add (union): each touched cell is added; nothing is removed. + Extend, + /// Cmd+Shift — remove (subtract): each touched cell is removed; nothing + /// is added. + Subtract, +} + +#[derive(Debug, Clone)] +pub enum TableMessage { + CellChanged(usize, usize, String), + FocusCell(usize, usize), + /// Single click on a cell — select it but stay out of edit mode. Cell + /// renders as static text with a tinted background. The Numbers/Excel + /// "I might do something to this cell" gesture. + SelectCell(usize, usize), + /// Double click on a cell — enter edit mode. Cell renders as a text_input + /// and gets iced focus. Same target as `SelectCell`, but the editor's + /// `editing` field is also set to this cell's `NodePath`. + EditCell(usize, usize), + /// Click on the corner-cell delete affordance. The editor's TableMsg arm + /// promotes this to a top-level `DeleteCurrentTable` so undo + block + /// removal go through the same path as the keyboard shortcut. + DeleteTable, + /// Click on the corner-cell drag handle. Selects the whole table — + /// every cell renders highlighted, plain Backspace clears all cells, + /// Cmd+Backspace deletes the table outright. Single-cell click clears + /// this back to single-cell selection. + SelectAll, + /// Plain Backspace/Delete with `table_selected == true` — empty every + /// cell's content but leave the row/column structure intact. + ClearAll, + /// Right-click on a cell. Promoted in editor.rs to a top-level + /// `ShowContextMenu` carrying the cursor anchor position. + ContextMenu(usize, usize), + /// Mouse entered a cell. Used by the drag-select system: when a drag + /// is armed (post-click, pre-release), each `CellEnter` extends the + /// selection rectangle to that cell. No-op when no drag is active. + CellEnter(usize, usize), + AddRow, + AddColumn, + InsertRowAbove, + InsertRowBelow, + DeleteRow, + InsertColLeft, + InsertColRight, + DeleteCol, + CursorMove(f32, f32), + BeginColResize(usize), + BeginRowResize(usize), + BeginColReorder(usize), + BeginRowReorder(usize), + EndDrag, +} + +/// Trait-implementing block for tables. Owns all the per-table mutable state +/// directly (rows, widths, focus, drags, selection) — no separate `TableState` +/// HashMap. Lives in `EditorState::blocks` as a `Box`. +pub struct TableBlock { + pub id: BlockId, + pub start_line: usize, + /// User-assigned name from a ### or #### heading directly above this table. + /// H3 = global scope, H4 = block-scoped. None for unnamed tables. + pub table_name: Option, + pub rows: Vec>, + pub col_widths: Vec, + /// Per-row explicit height override. None means use ROW_HEIGHT_ESTIMATE. + pub row_heights: Vec>, + /// Last cell that had focus. PRESERVED across blur so keyboard shortcuts + /// (Cmd+Opt+Arrow, Cmd+Shift+T, Tab/Enter) keep targeting the right cell + /// even when the user has clicked elsewhere in the document. + pub focused_cell: Option<(usize, usize)>, + /// True only on frames where iced's focus is currently inside one of this + /// table's cells. Used for "active editing chrome" (ABCD/123 headers) which + /// must DISAPPEAR when the user clicks out, even though focused_cell is kept. + pub is_active: bool, + /// Whole-table selection mode. Set by clicking the corner select-all + /// affordance. All cells render highlighted; plain Backspace/Delete + /// clears every cell's content; Cmd+Backspace deletes the whole table. + /// Cleared the moment a single cell is clicked. + pub table_selected: bool, + /// Eval-result tables set this so the widget disables cell editing while + /// keeping selection and Cmd-C intact. Markdown tables keep it false. + pub read_only: bool, + /// True for eval-result tables (regenerated on every eval). Skipped during + /// markdown serialization. + pub is_eval_result: bool, + /// Active column-resize drag: (col index, original width at drag start, drag-start x). + pub resize_drag: Option<(usize, f32, f32)>, + /// Active row-resize drag: (row index, original height at drag start, drag-start y). + pub row_resize_drag: Option<(usize, f32, f32)>, + pub reorder_drag: Option, + pub selection: std::collections::HashSet<(usize, usize)>, + pub selection_anchor: Option<(usize, usize)>, + /// Drag-rectangle origin. Set on cell click; cleared on mouse release. + /// When `Some`, every cell-enter event extends the rectangle. + pub drag_select_start: Option<(usize, usize)>, + /// SelectionMode captured at the moment the drag started — keeps the + /// modifier semantics constant for the duration of the drag, even if + /// the user releases the modifier mid-drag. + pub drag_select_mode: Option, + /// Selection state at the moment the drag started. Each `cell_enter` + /// during the drag recomputes the selection by re-applying the mode + /// against the baseline + the current rectangle. Without a baseline, + /// repeated rectangle redraws would compound (e.g. Toggle mode would + /// flip cells multiple times as the rectangle grows and shrinks). + pub drag_select_baseline: std::collections::HashSet<(usize, usize)>, + pub last_cursor_x: f32, + pub last_cursor_y: f32, +} + +impl TableBlock { + pub fn new(id: BlockId, rows: Vec>, start_line: usize) -> Self { + Self::build(id, rows, start_line, false, None) + } + + pub fn new_eval(id: BlockId, rows: Vec>, start_line: usize) -> Self { + Self::build(id, rows, start_line, true, None) + } + + fn build( + id: BlockId, + rows: Vec>, + start_line: usize, + is_eval_result: bool, + col_widths_override: Option>, + ) -> Self { + let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0); + let col_widths = col_widths_override.unwrap_or_else(|| { + // For eval result tables, size columns to fit content; for markdown + // tables, use a uniform default width. + if is_eval_result { + (0..col_count) + .map(|ci| { + let max_len = rows + .iter() + .map(|r| r.get(ci).map(|s| s.len()).unwrap_or(0)) + .max() + .unwrap_or(0); + ((max_len as f32) * 9.0).max(MIN_COL_WIDTH).min(300.0) + }) + .collect() + } else { + vec![DEFAULT_COL_WIDTH; col_count] + } + }); + let row_count = rows.len(); + Self { + id, + start_line, + table_name: None, + rows, + col_widths, + row_heights: vec![None; row_count], + focused_cell: None, + is_active: false, + table_selected: false, + read_only: is_eval_result, + is_eval_result, + resize_drag: None, + row_resize_drag: None, + reorder_drag: None, + selection: std::collections::HashSet::new(), + selection_anchor: None, + drag_select_start: None, + drag_select_mode: None, + drag_select_baseline: std::collections::HashSet::new(), + last_cursor_x: 0.0, + last_cursor_y: 0.0, + } + } + + pub fn col_count(&self) -> usize { + self.col_widths.len() + } + + pub fn row_count(&self) -> usize { + self.rows.len() + } + + /// Apply a modifier-aware single-cell click to the multi-cell selection + /// set. Called by `EditorState::update`'s TableMsg arm after it reads + /// `self.mods` and derives the `SelectionMode`. Mutates `selection`, + /// `selection_anchor`, and `focused_cell` in place. + /// + /// Also arms a drag-select: snapshots the selection BEFORE this click as + /// the baseline so that if the user drags to other cells, the rectangle + /// can be re-applied against the baseline (without compounding repeated + /// applications of the modifier op). + pub fn apply_click_selection(&mut self, row: usize, col: usize, mode: SelectionMode) { + // Baseline = selection state before the click. The drag handler + // recomputes from this so the rectangle can shrink/grow without + // compounding (e.g. toggling the same cell twice). + let baseline = self.selection.clone(); + + match mode { + SelectionMode::Replace => { + self.selection.clear(); + self.selection.insert((row, col)); + self.selection_anchor = Some((row, col)); + } + SelectionMode::Toggle => { + // Cmd = invert this cell. + if self.selection.contains(&(row, col)) { + self.selection.remove(&(row, col)); + if self.selection.is_empty() { + self.selection_anchor = None; + } + } else { + self.selection.insert((row, col)); + if self.selection_anchor.is_none() { + self.selection_anchor = Some((row, col)); + } + } + } + SelectionMode::Extend => { + // Shift = add this cell (no removal). Drag extends with a + // rectangle (also additive). + self.selection.insert((row, col)); + if self.selection_anchor.is_none() { + self.selection_anchor = Some((row, col)); + } + } + SelectionMode::Subtract => { + // Cmd+Shift = remove this cell (no addition). Drag removes + // a rectangle. + self.selection.remove(&(row, col)); + if self.selection.is_empty() { + self.selection_anchor = None; + } + } + } + self.focused_cell = Some((row, col)); + self.is_active = true; + self.table_selected = false; + + // Arm the drag. Subsequent CellEnter events extend the rectangle. + self.drag_select_start = Some((row, col)); + self.drag_select_mode = Some(mode); + self.drag_select_baseline = baseline; + } + + /// Extend the active drag-rectangle to (row, col). Recomputes the + /// selection from the baseline + drag mode + current rectangle. No-op + /// if no drag is active. + pub fn apply_drag_to(&mut self, row: usize, col: usize) { + let Some(start) = self.drag_select_start else { return }; + let Some(mode) = self.drag_select_mode else { return }; + + let (r0, r1) = (start.0.min(row), start.0.max(row)); + let (c0, c1) = (start.1.min(col), start.1.max(col)); + let mut rect: std::collections::HashSet<(usize, usize)> = + std::collections::HashSet::new(); + for ri in r0..=r1 { + for ci in c0..=c1 { + if ri < self.rows.len() && ci < self.col_widths.len() { + rect.insert((ri, ci)); + } + } + } + + self.selection = match mode { + SelectionMode::Replace => rect, + SelectionMode::Toggle => { + // baseline XOR rect + let mut s = self.drag_select_baseline.clone(); + for cell in rect { + if s.contains(&cell) { + s.remove(&cell); + } else { + s.insert(cell); + } + } + s + } + SelectionMode::Extend => { + // baseline UNION rect + let mut s = self.drag_select_baseline.clone(); + s.extend(rect); + s + } + SelectionMode::Subtract => { + // baseline DIFFERENCE rect + let mut s = self.drag_select_baseline.clone(); + for cell in rect { + s.remove(&cell); + } + s + } + }; + + self.focused_cell = Some((row, col)); + } + + /// Clear the drag-select state. Called from EndDrag. + pub fn end_drag_select(&mut self) { + self.drag_select_start = None; + self.drag_select_mode = None; + self.drag_select_baseline.clear(); + } + + pub fn handle(&mut self, msg: TableMessage) { + match msg { + TableMessage::CellChanged(row, col, val) => { + if self.read_only { + return; + } + if row < self.rows.len() && col < self.rows[row].len() { + self.rows[row][col] = val; + } + self.focused_cell = Some((row, col)); + } + TableMessage::FocusCell(row, col) => { + self.focused_cell = Some((row, col)); + } + TableMessage::SelectCell(row, col) => { + // Single click — selected, not editing. The editor's + // `editing` field is cleared by the editor-level dispatch + // in `EditorState::update`'s `TableMsg` arm. The actual + // multi-cell selection update happens via `apply_click_selection`, + // called from the editor's TableMsg arm AFTER it has read + // `self.mods` to derive the SelectionMode. Here we only mark + // the cell as the focus point and clear table-level selection. + self.focused_cell = Some((row, col)); + self.is_active = true; + self.table_selected = false; + } + TableMessage::EditCell(row, col) => { + // Double click — selected AND editing. The editor's + // `editing` field is set, and `pending_focus` is queued so + // iced moves keyboard focus to the cell's text_input on the + // next frame. + self.focused_cell = Some((row, col)); + self.is_active = true; + } + TableMessage::DeleteTable => { + // Handled at the editor level — the TableMsg arm in + // editor.rs promotes this to DeleteCurrentTable. The block + // itself does nothing here, but we still need to ensure the + // table is registered as focused so the editor's + // focused_table_index() finds it. + if self.focused_cell.is_none() { + self.focused_cell = Some((0, 0)); + } + } + TableMessage::SelectAll => { + // Whole-table selection. Mark every cell as selected via the + // table_selected flag — cell rendering keys off this for the + // highlighted look. focused_cell stays where it was so + // arrow keys can drop back into single-cell mode naturally. + self.table_selected = true; + self.is_active = true; + if self.focused_cell.is_none() { + self.focused_cell = Some((0, 0)); + } + } + TableMessage::ClearAll => { + if self.read_only { + return; + } + for row in &mut self.rows { + for cell in row.iter_mut() { + cell.clear(); + } + } + } + TableMessage::ContextMenu(_row, _col) => { + // Right-click is purely a menu trigger — it does NOT modify + // selection state. The context menu operates on whatever was + // already selected. The editor.rs TableMsg arm handles the + // overlay anchor; this branch is intentionally a no-op. + } + TableMessage::CellEnter(row, col) => { + // Drag-select extension: only acts when a drag is armed. + // Without an active drag, hovering over cells is a no-op + // (every cell still fires CellEnter on every mouse-over, + // which is a tiny per-frame cost). + if self.drag_select_start.is_some() { + self.apply_drag_to(row, col); + } + } + TableMessage::AddRow => { + if self.read_only { + return; + } + let cols = self.col_count(); + self.rows.push(vec![String::new(); cols]); + self.row_heights.push(None); + } + TableMessage::AddColumn => { + if self.read_only { + return; + } + self.col_widths.push(DEFAULT_COL_WIDTH); + for row in &mut self.rows { + row.push(String::new()); + } + } + TableMessage::InsertRowAbove => { + if self.read_only { + return; + } + let Some((fr, _)) = self.focused_cell else { return }; + // Never insert above the header row — treat as insert-below-header. + let insert_at = fr.max(1).min(self.rows.len()); + let cols = self.col_count(); + self.rows.insert(insert_at, vec![String::new(); cols]); + self.row_heights.insert(insert_at, None); + self.focused_cell = Some((insert_at, 0)); + } + TableMessage::InsertRowBelow => { + if self.read_only { + return; + } + let Some((fr, _)) = self.focused_cell else { return }; + let insert_at = (fr + 1).min(self.rows.len()); + let cols = self.col_count(); + self.rows.insert(insert_at, vec![String::new(); cols]); + self.row_heights.insert(insert_at, None); + self.focused_cell = Some((insert_at, 0)); + } + TableMessage::DeleteRow => { + if self.read_only { + return; + } + let Some((fr, fc)) = self.focused_cell else { return }; + if fr == 0 || self.rows.len() <= 1 { + return; + } + self.rows.remove(fr); + if fr < self.row_heights.len() { + self.row_heights.remove(fr); + } + let new_row_count = self.rows.len(); + let new_row = if new_row_count == 1 { + 0 + } else { + fr.min(new_row_count - 1).max(1) + }; + self.focused_cell = Some((new_row, fc)); + } + TableMessage::InsertColLeft => { + if self.read_only { + return; + } + let Some((fr, fc)) = self.focused_cell else { return }; + self.insert_column_at(fc); + self.focused_cell = Some((fr, fc)); + } + TableMessage::InsertColRight => { + if self.read_only { + return; + } + let Some((fr, fc)) = self.focused_cell else { return }; + let at = (fc + 1).min(self.col_count()); + self.insert_column_at(at); + self.focused_cell = Some((fr, at)); + } + TableMessage::DeleteCol => { + if self.read_only { + return; + } + let Some((fr, fc)) = self.focused_cell else { return }; + if self.col_count() <= 1 { + return; + } + for row in &mut self.rows { + if fc < row.len() { + row.remove(fc); + } + } + if fc < self.col_widths.len() { + self.col_widths.remove(fc); + } + let new_col = fc.min(self.col_count().saturating_sub(1)); + self.focused_cell = Some((fr, new_col)); + } + TableMessage::CursorMove(x, y) => { + self.last_cursor_x = x; + self.last_cursor_y = y; + if let Some((col, start_w, start_x)) = self.resize_drag { + let delta = x - start_x; + let new_w = (start_w + delta).max(MIN_COL_WIDTH); + if col < self.col_widths.len() { + self.col_widths[col] = new_w; + } + } + if let Some((row, start_h, start_y)) = self.row_resize_drag { + let delta = y - start_y; + let new_h = (start_h + delta).max(MIN_ROW_HEIGHT); + if row < self.rows.len() { + if self.row_heights.len() <= row { + self.row_heights.resize(row + 1, None); + } + self.row_heights[row] = Some(new_h); + } + } + if let Some(drag) = self.reorder_drag { + match drag { + ReorderDrag::Column { from, start_x, .. } => { + let target = self.target_column_for_drag(from, x - start_x); + self.reorder_drag = Some(ReorderDrag::Column { from, target, start_x }); + } + ReorderDrag::Row { from, start_y, .. } => { + let target = self.target_row_for_drag(from, y - start_y); + self.reorder_drag = Some(ReorderDrag::Row { from, target, start_y }); + } + } + } + } + TableMessage::BeginColResize(col) => { + if self.read_only { + return; + } + if let Some(w) = self.col_widths.get(col).copied() { + self.resize_drag = Some((col, w, self.last_cursor_x)); + } + } + TableMessage::BeginRowResize(row) => { + if self.read_only || row >= self.rows.len() { + return; + } + let current_h = self.row_heights.get(row).copied().flatten().unwrap_or(ROW_HEIGHT_ESTIMATE); + self.row_resize_drag = Some((row, current_h, self.last_cursor_y)); + } + TableMessage::BeginColReorder(col) => { + if self.read_only || col >= self.col_widths.len() { + return; + } + self.reorder_drag = Some(ReorderDrag::Column { + from: col, + target: col, + start_x: self.last_cursor_x, + }); + } + TableMessage::BeginRowReorder(row) => { + if self.read_only || row == 0 || row >= self.rows.len() || self.rows.len() <= 2 { + return; + } + self.reorder_drag = Some(ReorderDrag::Row { + from: row, + target: row, + start_y: self.last_cursor_y, + }); + } + TableMessage::EndDrag => { + self.resize_drag = None; + self.row_resize_drag = None; + if let Some(drag) = self.reorder_drag.take() { + match drag { + ReorderDrag::Column { from, target, .. } => { + if from != target { + self.move_column(from, target); + } + } + ReorderDrag::Row { from, target, .. } => { + if from != target { + self.move_row(from, target); + } + } + } + } + // Also tear down the cell drag-select. The selection state + // has already been committed by the last `apply_drag_to`, + // so we just clear the bookkeeping. + self.end_drag_select(); + } + } + } + + fn target_column_for_drag(&self, from: usize, delta_x: f32) -> usize { + let n = self.col_widths.len(); + if n == 0 || from >= n { + return from; + } + if delta_x > 0.0 { + let mut accumulated = 0.0; + let mut target = from; + let mut i = from + 1; + while i < n { + let w = self.col_widths[i]; + if delta_x > accumulated + w / 2.0 { + target = i; + accumulated += w; + i += 1; + } else { + break; + } + } + target + } else if delta_x < 0.0 { + let mut accumulated = 0.0; + let mut target = from; + let mut i = from; + let abs_d = -delta_x; + while i > 0 { + i -= 1; + let w = self.col_widths[i]; + if abs_d > accumulated + w / 2.0 { + target = i; + accumulated += w; + } else { + break; + } + } + target + } else { + from + } + } + + fn target_row_for_drag(&self, from: usize, delta_y: f32) -> usize { + let n = self.rows.len(); + if n <= 2 || from == 0 { + return from; + } + let row_h = ROW_HEIGHT_ESTIMATE; + let raw_steps = (delta_y / row_h).round() as i32; + let target_signed = (from as i32) + raw_steps; + target_signed.max(1).min(n as i32 - 1) as usize + } + + pub fn move_column(&mut self, from: usize, to: usize) { + let n = self.col_widths.len(); + if n == 0 || from >= n || from == to { + return; + } + let to = to.min(n - 1); + let w = self.col_widths.remove(from); + self.col_widths.insert(to, w); + for row in &mut self.rows { + if from < row.len() { + let cell = row.remove(from); + let to_in_row = to.min(row.len()); + row.insert(to_in_row, cell); + } + } + if let Some((r, c)) = self.focused_cell { + let new_c = if c == from { + to + } else if from < to && c > from && c <= to { + c - 1 + } else if from > to && c >= to && c < from { + c + 1 + } else { + c + }; + self.focused_cell = Some((r, new_c)); + } + } + + pub fn move_row(&mut self, from: usize, to: usize) { + let n = self.rows.len(); + if n <= 2 || from == 0 || to == 0 || from >= n || from == to { + return; + } + let to = to.min(n - 1).max(1); + let row = self.rows.remove(from); + self.rows.insert(to, row); + if from < self.row_heights.len() { + let h = self.row_heights.remove(from); + let to_h = to.min(self.row_heights.len()); + self.row_heights.insert(to_h, h); + } + if let Some((r, c)) = self.focused_cell { + let new_r = if r == from { + to + } else if from < to && r > from && r <= to { + r - 1 + } else if from > to && r >= to && r < from { + r + 1 + } else { + r + }; + self.focused_cell = Some((new_r, c)); + } + } + + fn insert_column_at(&mut self, at: usize) { + let at = at.min(self.col_count()); + for row in &mut self.rows { + if at <= row.len() { + row.insert(at, String::new()); + } else { + row.push(String::new()); + } + } + if at <= self.col_widths.len() { + self.col_widths.insert(at, DEFAULT_COL_WIDTH); + } else { + self.col_widths.push(DEFAULT_COL_WIDTH); + } + } + + pub fn next_cell(&self, row: usize, col: usize) -> Option<(usize, usize)> { + let cols = self.col_count(); + let rows = self.row_count(); + if col + 1 < cols { + Some((row, col + 1)) + } else if row + 1 < rows { + Some((row + 1, 0)) + } else { + None + } + } + + pub fn prev_cell(&self, row: usize, col: usize) -> Option<(usize, usize)> { + let cols = self.col_count(); + if col > 0 { + Some((row, col - 1)) + } else if row > 0 { + Some((row - 1, cols.saturating_sub(1))) + } else { + None + } + } + + pub fn cell_below(&self, row: usize, col: usize) -> Option<(usize, usize)> { + if row + 1 < self.row_count() { + Some((row + 1, col)) + } else { + None + } + } + + pub fn pending_focus_id(&self) -> Option { + self.focused_cell.map(|(r, c)| cell_id(self.id, r, c)) + } + + /// True if this table carries metadata that markdown alone can't + /// represent — non-default column widths, explicit row heights, or + /// cell formulas (markdown would otherwise serialize the raw `/=...` + /// text into the cell, but round-tripping via the sidecar keeps the + /// formula label separate from the computed display). + pub fn has_persistent_metadata(&self) -> bool { + if self.col_widths.iter().any(|w| (*w - DEFAULT_COL_WIDTH).abs() > f32::EPSILON) { + return true; + } + if self.row_heights.iter().any(|h| h.is_some()) { + return true; + } + if self.rows.iter().any(|row| row.iter().any(|c| c.trim_start().starts_with("/="))) { + return true; + } + false + } +} + +impl Block for TableBlock { + fn id(&self) -> BlockId { + self.id + } + + fn kind_tag(&self) -> &'static str { + "table" + } + + fn start_line(&self) -> usize { + self.start_line + } + + fn set_start_line(&mut self, line: usize) { + self.start_line = line; + } + + fn line_count(&self) -> usize { + // Header + separator + (rows-1) data lines = rows.len() + 1. + if self.rows.is_empty() { + 0 + } else { + self.rows.len() + 1 + } + } + + fn is_eval_result(&self) -> bool { + self.is_eval_result + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message> { + let block_idx = ctx.block_index; + let on_table = ctx.on_table_msg; + let on_msg = move |tmsg: TableMessage| on_table(block_idx, tmsg); + + // Pull the (row, col) currently in edit mode out of the central + // `editing` path, but only if it points at THIS table block. + let editing_cell: Option<(usize, usize)> = match ctx.editing { + Some(path) if path.block_id == self.id => match &path.inner { + InnerPath::Cell { row, col } => Some((*row, *col)), + _ => None, + }, + _ => None, + }; + + let element = table_view(self, editing_cell, ctx.font_size, ctx.computed_cells, on_msg); + LayeredView::just(element) + } + + fn to_md(&self) -> String { + if self.is_eval_result || self.rows.is_empty() { + return String::new(); + } + let mut lines: Vec = Vec::new(); + if let Some(header) = self.rows.first() { + let cells: Vec<&str> = header.iter().map(|s| s.as_str()).collect(); + lines.push(format!("| {} |", cells.join(" | "))); + let sep = header + .iter() + .map(|c| "-".repeat(c.len().max(3))) + .collect::>() + .join(" | "); + lines.push(format!("| {} |", sep)); + for row in self.rows.iter().skip(1) { + let cells: Vec<&str> = row.iter().map(|s| s.as_str()).collect(); + lines.push(format!("| {} |", cells.join(" | "))); + } + } + lines.join("\n") + } + + fn hit_test(&self, _point: Point) -> Option { + Some(InnerPath::Whole) + } + + fn apply(&mut self, cmd: BlockCommand) { + match cmd { + BlockCommand::InsertRowAbove(_row) => { + self.handle(TableMessage::InsertRowAbove); + } + BlockCommand::InsertRowBelow(_row) => { + self.handle(TableMessage::InsertRowBelow); + } + BlockCommand::DeleteRow(_row) => { + self.handle(TableMessage::DeleteRow); + } + BlockCommand::InsertColLeft(_col) => { + self.handle(TableMessage::InsertColLeft); + } + BlockCommand::InsertColRight(_col) => { + self.handle(TableMessage::InsertColRight); + } + BlockCommand::DeleteCol(_col) => { + self.handle(TableMessage::DeleteCol); + } + BlockCommand::SetCellValue { row, col, value } => { + self.handle(TableMessage::CellChanged(row, col, value)); + } + BlockCommand::ResizeCol { col, width } => { + if col < self.col_widths.len() { + self.col_widths[col] = width; + } + } + BlockCommand::ResizeRow { row, height } => { + if row < self.row_heights.len() { + self.row_heights[row] = Some(height); + } + } + BlockCommand::MoveCol { from, to } => { + self.move_column(from, to); + } + BlockCommand::MoveRow { from, to } => { + self.move_row(from, to); + } + _ => {} + } + } + + fn selectable_paths(&self) -> Box + '_> { + let rows = self.rows.len(); + let cols = self.col_count(); + Box::new((0..rows).flat_map(move |r| { + (0..cols).map(move |c| InnerPath::Cell { row: r, col: c }) + })) + } +} + +pub fn cell_id(block_id: u64, row: usize, col: usize) -> WidgetId { + WidgetId::from(format!("table_cell_{}_{}_{}", block_id, row, col)) +} + +fn cell_border() -> Border { + let ws = palette::widget_surface(); + Border { + color: ws.border, + width: 1.0, + radius: 0.0.into(), + } +} + +fn cell_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { + let p = palette::current(); + let ws = palette::widget_surface(); + text_input::Style { + background: Background::Color(ws.fill), + border: cell_border(), + icon: p.overlay2, + placeholder: p.overlay0, + value: ws.body_text, + selection: Color { a: 0.4, ..p.blue }, + } +} + +fn header_cell_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { + let p = palette::current(); + let ws = palette::widget_surface(); + text_input::Style { + background: Background::Color(ws.fill), + border: cell_border(), + icon: p.overlay2, + placeholder: p.overlay0, + value: ws.header_accent, + selection: Color { a: 0.4, ..p.blue }, + } +} + +const RESIZE_HANDLE_WIDTH: f32 = 4.0; + +pub fn table_view<'a, Message, F>( + block: &'a TableBlock, + editing_cell: Option<(usize, usize)>, + font_size: f32, + computed_cells: &std::collections::HashMap<(BlockId, u32, u32), acord_core::interp::Value>, + on_msg: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + Message: Clone + 'a, + F: Fn(TableMessage) -> Message + 'a + Copy, +{ + let block_id = block.id; + let mut col_elements: Vec> = Vec::new(); + let read_only = block.read_only; + let reserve_chrome = !read_only; + // Derived sizes that scale with the editor's zoom level. + let chrome_font = font_size * 0.77; + let corner_font = font_size * 0.69; + let plus_font = font_size * 0.85; + let row_h = font_size * 1.3 + CELL_PADDING.top + CELL_PADDING.bottom + 2.0; + let header_h = chrome_font * 1.3; + // Chrome (ABCD column letters, 123 row numbers) appears whenever the table + // has a selected cell, not just when iced widget focus is in a cell. Without + // the focused_cell branch the chrome would vanish the moment selection mode + // takes over from edit mode, hiding the visual cue that the table is yours + // to manipulate. + let chrome_active = !read_only + && (block.focused_cell.is_some() + || block.is_active + || block.reorder_drag.is_some()); + let drag = block.reorder_drag; + + if reserve_chrome { + let mut header_row_cells: Vec> = Vec::new(); + // Corner cell at (row-numbers ⨉ column-letters). The "select-all" + // affordance — click to mark the whole table as selected. With the + // table selected, plain Backspace clears every cell, Cmd+Backspace + // deletes the table outright. Eventually this same handle will also + // be the drag origin for moving the table around the document, in + // line with the broader plan to make every chunk-level node draggable. + // Visible whenever the chrome is active so the user always has a + // reachable affordance once they've touched the table once. + let corner: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active { + iced_widget::button( + text("\u{25A0}") + .size(corner_font) + .font(EDITOR_FONT) + ) + .width(Length::Fixed(ROW_NUMBER_WIDTH)) + .height(Length::Fixed(header_h)) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }) + .style(plus_button_style) + .on_press(on_msg(TableMessage::SelectAll)) + .into() + } else { + container(text("")) + .width(Length::Fixed(ROW_NUMBER_WIDTH)) + .height(Length::Fixed(header_h)) + .into() + }; + header_row_cells.push(corner); + let p = palette::current(); + for (ci, w) in block.col_widths.iter().enumerate() { + let letter = if chrome_active { column_letter(ci) } else { String::new() }; + let bg_color: Option = if let Some(ReorderDrag::Column { from, target, .. }) = drag { + if from == ci { + Some(p.surface1) + } else if target == ci && ci != from { + Some(Color { a: 0.4, ..p.blue }) + } else { + None + } + } else { + None + }; + let letter_container = container( + text(letter) + .size(chrome_font) + .font(EDITOR_FONT) + .color(p.overlay0) + ) + .width(Length::Fixed(*w)) + .height(Length::Fixed(header_h)) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 6.0 }) + .style(move |_theme: &Theme| container::Style { + background: bg_color.map(Background::Color), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }); + let letter_cell: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active { + MouseArea::new(letter_container) + .on_press(on_msg(TableMessage::BeginColReorder(ci))) + .into() + } else { + letter_container.into() + }; + header_row_cells.push(letter_cell); + header_row_cells.push( + container(text("")) + .width(Length::Fixed(RESIZE_HANDLE_WIDTH)) + .height(Length::Fixed(header_h)) + .into() + ); + } + col_elements.push(iced_widget::row(header_row_cells).spacing(0.0).into()); + } + + for (ri, row) in block.rows.iter().enumerate() { + let is_header = ri == 0; + let mut row_cells: Vec> = Vec::new(); + + if reserve_chrome { + let p = palette::current(); + let label = if chrome_active { format!("{}", ri + 1) } else { String::new() }; + let bg_color: Option = if let Some(ReorderDrag::Row { from, target, .. }) = drag { + if from == ri { + Some(p.surface1) + } else if target == ri && ri != from { + Some(Color { a: 0.4, ..p.blue }) + } else { + None + } + } else { + None + }; + let row_num_container = container( + text(label) + .size(chrome_font) + .font(EDITOR_FONT) + .color(p.overlay0) + ) + .width(Length::Fixed(ROW_NUMBER_WIDTH)) + .padding(Padding { top: 4.0, right: 6.0, bottom: 0.0, left: 0.0 }) + .style(move |_theme: &Theme| container::Style { + background: bg_color.map(Background::Color), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }); + let row_num_cell: Element<'a, Message, Theme, iced_wgpu::Renderer> = + if chrome_active && ri > 0 { + MouseArea::new(row_num_container) + .on_press(on_msg(TableMessage::BeginRowReorder(ri))) + .into() + } else { + row_num_container.into() + }; + row_cells.push(row_num_cell); + } + + for (ci, cell) in row.iter().enumerate() { + let width = block.col_widths.get(ci).copied().unwrap_or(DEFAULT_COL_WIDTH); + let r = ri; + let c = ci; + + let is_editing_this = editing_cell == Some((ri, ci)); + // A cell renders selected ONLY because it's in the selection set + // (or the table-wide select-all mode is on). The HashSet is the + // sole source of truth — `focused_cell` is preserved across blur + // for keyboard targeting, so it's not a valid selection signal. + let is_focused_this = block.selection.contains(&(ri, ci)) + || block.table_selected; + + let font = if is_header { + Font { weight: iced_wgpu::core::font::Weight::Bold, ..EDITOR_FONT } + } else { + EDITOR_FONT + }; + + let cell_element: Element<'a, Message, Theme, iced_wgpu::Renderer> = if is_editing_this + || read_only + { + // Edit mode (or eval-result table that the user can still + // copy from) — use the real text_input. + let style_fn: fn(&Theme, text_input::Status) -> text_input::Style = if is_header { + header_cell_style + } else { + cell_input_style + }; + let mut input = text_input::TextInput::new("", cell) + .id(cell_id(block_id, ri, ci)) + .font(font) + .size(font_size) + .padding(CELL_PADDING) + .width(Length::Fixed(width)) + .style(style_fn); + if !read_only { + input = input.on_input(move |val| on_msg(TableMessage::CellChanged(r, c, val))); + } + input.into() + } else { + // Selected-but-not-editing or fully unfocused cell. Renders + // as a static text widget inside a container styled to match + // the text_input's bounds — visually identical to the edit + // form modulo a tinted background when this cell is the + // current selection. + let label_color = if is_header { + palette::widget_surface().header_accent + } else { + palette::widget_surface().body_text + }; + // Show the computed formula value when this cell is a + // `/=...` formula and the eval loop produced a result. + // Any cell without a computed entry (plain values, eval + // errors during parse/topo pre-pass) falls back to raw. + let display_text: String = if cell.trim_start().starts_with("/=") { + match computed_cells.get(&(block_id, ci as u32, ri as u32)) { + Some(v) => v.display(), + None => cell.clone(), + } + } else { + cell.clone() + }; + let display = text(display_text) + .size(font_size) + .font(font) + .color(label_color); + + let container_style = move |_theme: &Theme| { + let ws = palette::widget_surface(); + let p = palette::current(); + let background = if is_focused_this { + // Tinted blue background — Excel/Numbers selection look. + // Heavier alpha than the default tint so selection is + // unmistakably visible against the cell fill. + Some(Background::Color(Color { a: 0.45, ..p.blue })) + } else { + Some(Background::Color(ws.fill)) + }; + container::Style { + background, + border: cell_border(), + text_color: Some(label_color), + shadow: Shadow::default(), + snap: false, + } + }; + let cell_container = container(display) + .width(Length::Fixed(width)) + .height(Length::Fixed(row_h)) + .padding(CELL_PADDING) + .style(container_style); + + MouseArea::new(cell_container) + .on_press(on_msg(TableMessage::SelectCell(r, c))) + .on_double_click(on_msg(TableMessage::EditCell(r, c))) + .on_right_press(on_msg(TableMessage::ContextMenu(r, c))) + .on_enter(on_msg(TableMessage::CellEnter(r, c))) + .into() + }; + + row_cells.push(cell_element); + + if is_header && !read_only { + let handle_col = ci; + let handle: Element<'a, Message, Theme, iced_wgpu::Renderer> = + MouseArea::new( + container(text(" ")) + .width(Length::Fixed(RESIZE_HANDLE_WIDTH)) + .height(Length::Shrink) + ) + .interaction(Interaction::ResizingHorizontally) + .on_press(on_msg(TableMessage::BeginColResize(handle_col))) + .into(); + row_cells.push(handle); + } else { + let spacer: Element<'a, Message, Theme, iced_wgpu::Renderer> = + container(text(" ")) + .width(Length::Fixed(RESIZE_HANDLE_WIDTH)) + .height(Length::Shrink) + .into(); + row_cells.push(spacer); + } + } + + let row_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = + iced_widget::row(row_cells).spacing(0.0).into(); + col_elements.push(row_el); + } + + let table: Element<'a, Message, Theme, iced_wgpu::Renderer> = + iced_widget::column(col_elements).spacing(CELL_GAP_Y).into(); + + let with_plus: Element<'a, Message, Theme, iced_wgpu::Renderer> = if read_only { + table + } else { + let right_plus = iced_widget::button( + text("+") + .size(plus_font) + .font(EDITOR_FONT) + ) + .width(Length::Fixed(PLUS_BUTTON_THICKNESS)) + .height(Length::Fill) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }) + .style(plus_button_style) + .on_press(on_msg(TableMessage::AddColumn)); + + let table_with_right: Element<'a, Message, Theme, iced_wgpu::Renderer> = + iced_widget::row(vec![table, right_plus.into()]) + .spacing(CELL_GAP_Y) + .into(); + + let bottom_plus = iced_widget::button( + text("+") + .size(plus_font) + .font(EDITOR_FONT) + ) + .width(Length::Fill) + .height(Length::Fixed(PLUS_BUTTON_THICKNESS)) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }) + .style(plus_button_style) + .on_press(on_msg(TableMessage::AddRow)); + + iced_widget::column(vec![table_with_right, bottom_plus.into()]) + .spacing(CELL_GAP_Y) + .into() + }; + + let outer: Element<'a, Message, Theme, iced_wgpu::Renderer> = if read_only { + iced_widget::container(with_plus) + .padding(Padding { top: 2.0, right: 0.0, bottom: 2.0, left: 8.0 }) + .width(Length::Shrink) + .style(|_theme: &Theme| container::Style { + background: None, + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }) + .into() + } else { + let wrapper = iced_widget::container(with_plus) + .padding(Padding { top: 2.0, right: 0.0, bottom: 2.0, left: 8.0 }) + .width(Length::Shrink) + .style(|_theme: &Theme| container::Style { + background: None, + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }); + + MouseArea::new(wrapper) + .on_move(move |pt| on_msg(TableMessage::CursorMove(pt.x, pt.y))) + .on_release(on_msg(TableMessage::EndDrag)) + .into() + }; + + outer +} + +fn column_letter(mut idx: usize) -> String { + let mut s = String::new(); + loop { + s.insert(0, (b'A' + (idx % 26) as u8) as char); + if idx < 26 { break; } + idx = idx / 26 - 1; + } + s +} + +fn plus_button_style(_theme: &Theme, status: button::Status) -> button::Style { + let p = palette::current(); + let ws = palette::widget_surface(); + let (bg, text_color) = match status { + button::Status::Hovered => (Some(Background::Color(ws.fill)), p.text), + button::Status::Pressed => (Some(Background::Color(ws.border)), p.text), + _ => (None, p.overlay0), + }; + button::Style { + background: bg, + text_color, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + shadow: Shadow::default(), + snap: false, + } +} diff --git a/viewport/src/text_block.rs b/viewport/src/text_block.rs new file mode 100644 index 0000000..9caf28f --- /dev/null +++ b/viewport/src/text_block.rs @@ -0,0 +1,128 @@ +//! `TextBlock` — the trait-implementing wrapper around `text_widget::Content`. +//! +//! Owns the editor content and language hint for syntax highlighting. Lives in +//! `EditorState::blocks` as a `Box`. + +use iced_wgpu::core::text::Wrapping; +use iced_wgpu::core::text::highlighter::Format; +use iced_wgpu::core::{ + Background, Border, Color, Element, Length, Padding, Point, Theme, +}; +use crate::text_widget::{self, Style}; + +use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +use crate::palette; +use crate::selection::{BlockId, InnerPath}; +use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings}; + +pub struct TextBlock { + pub id: BlockId, + pub content: text_widget::Content, + /// Document-relative starting line. Maintained by `recount_lines`. + pub start_line: usize, + /// Language hint for syntax highlighting. + pub lang: String, +} + +impl TextBlock { + pub fn new(id: BlockId, text: &str, start_line: usize, lang: String) -> Self { + Self { + id, + content: text_widget::Content::with_text(text), + start_line, + lang, + } + } +} + +impl Block for TextBlock { + fn id(&self) -> BlockId { + self.id + } + + fn kind_tag(&self) -> &'static str { + "text" + } + + fn start_line(&self) -> usize { + self.start_line + } + + fn set_start_line(&mut self, line: usize) { + self.start_line = line; + } + + fn line_count(&self) -> usize { + self.content.line_count() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message> { + let block_idx = ctx.block_index; + let on_action = ctx.on_text_action; + + let editor = text_widget::TextEditor::new(&self.content) + .on_action(move |action| on_action(block_idx, action)) + .font(syntax::EDITOR_FONT) + .size(ctx.font_size) + .height(Length::Fill) + .padding(Padding { + top: 8.0, + right: 8.0, + bottom: 8.0, + left: 8.0, + }) + .wrapping(Wrapping::Word) + .style(|_theme: &Theme, _status: text_widget::Status| { + let p = palette::current(); + Style { + background: Background::Color(p.base), + border: Border::default(), + placeholder: p.overlay0, + value: p.text, + selection: Color { a: 0.4, ..p.blue }, + } + }); + + let settings = SyntaxSettings { + lang: self.lang.clone(), + source: self.content.text(), + }; + let editor_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = editor + .highlight_with::( + settings, + |highlight, _theme| Format { + color: Some(syntax::highlight_color(highlight.kind)), + font: syntax::highlight_font(highlight.kind), + }, + ) + .into(); + + LayeredView::just(editor_el) + } + + fn to_md(&self) -> String { + self.content.text() + } + + fn hit_test(&self, _point: Point) -> Option { + Some(InnerPath::Whole) + } + + fn apply(&mut self, _cmd: BlockCommand) { + // Text mutations go through `text_editor::Action` routed via + // `Message::BlockAction` in the editor's update loop. BlockCommand + // on a text block is a no-op. + } + + fn selectable_paths(&self) -> Box + '_> { + Box::new(std::iter::once(InnerPath::Whole)) + } +} diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs new file mode 100644 index 0000000..a091ae4 --- /dev/null +++ b/viewport/src/text_widget.rs @@ -0,0 +1,1803 @@ +//! Text editors display a multi-line text input for text editing. +//! +//! # Example +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! # +//! use iced::widget::text_editor; +//! +//! struct State { +//! content: text_editor::Content, +//! } +//! +//! #[derive(Debug, Clone)] +//! enum Message { +//! Edit(text_editor::Action) +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! text_editor(&state.content) +//! .placeholder("Type something here...") +//! .on_action(Message::Edit) +//! .into() +//! } +//! +//! fn update(state: &mut State, message: Message) { +//! match message { +//! Message::Edit(action) => { +//! state.content.perform(action); +//! } +//! } +//! } +//! ``` +use iced_wgpu::core::alignment; +use iced_wgpu::core::clipboard::{self, Clipboard}; +use iced_wgpu::core::input_method; +use iced_wgpu::core::keyboard; +use iced_wgpu::core::keyboard::key; +use iced_wgpu::core::layout::{self, Layout}; +use iced_wgpu::core::mouse; +use iced_wgpu::core::renderer::{self, Renderer as _}; +use iced_wgpu::core::text::editor::Editor as _; +use iced_wgpu::core::text::Renderer as _; +use iced_wgpu::core::text::highlighter::{self, Highlighter}; +use iced_wgpu::core::text::paragraph::Paragraph as _; +use iced_wgpu::core::text::{self, LineHeight, Span, Text, Wrapping}; +use iced_wgpu::core::theme; +use iced_wgpu::core::time::{Duration, Instant}; +use iced_wgpu::core::widget::operation; +use iced_wgpu::core::widget::{self, Widget}; +use iced_wgpu::core::window; +use iced_wgpu::core::{ + Background, Border, Color, Element, Event, Font, InputMethod, Length, + Padding, Pixels, Point, Rectangle, Shell, Size, SmolStr, Theme, Vector, +}; + +use std::borrow::Cow; +use std::cell::RefCell; +use std::fmt; +use std::ops; +use std::ops::DerefMut; +use std::sync::Arc; + + +pub use text::editor::{ + Action, Cursor, Edit, Line, LineEnding, Motion, Position, Selection, +}; + +/// An anchored child element rendered at a line boundary within the text widget. +/// The caller builds these using existing rendering code; the widget just draws them in order. +pub struct AnchoredItem<'a, Message, Theme = iced_wgpu::core::Theme> { + pub after_line: usize, + pub height: f32, + pub element: Element<'a, Message, Theme, iced_wgpu::Renderer>, +} + +/// Walk the content stream (text lines + anchored items) and map widget-space y to text-space y. +fn stream_y_to_text_y(y: f32, items: &[AnchoredItem<'_, M, T>], line_h: f32, line_count: usize) -> f32 { + let mut text_y = 0.0f32; + let mut widget_y = 0.0f32; + let mut item_idx = 0; + + for line in 0..line_count { + if y < widget_y + line_h { + return text_y + (y - widget_y); + } + text_y += line_h; + widget_y += line_h; + + while item_idx < items.len() && items[item_idx].after_line == line { + let ih = items[item_idx].height; + if y < widget_y + ih { + return text_y; + } + widget_y += ih; + item_idx += 1; + } + } + text_y + (y - widget_y).max(0.0) +} + +/// Cumulative height of anchored items before a given text line. +fn items_height_before_line(items: &[AnchoredItem<'_, M, T>], line: usize) -> f32 { + items.iter() + .filter(|it| it.after_line < line) + .map(|it| it.height) + .sum() +} + +/// Total height of all anchored items. +fn total_items_height(items: &[AnchoredItem<'_, M, T>]) -> f32 { + items.iter().map(|it| it.height).sum() +} + +/// Build iced Spans from a LayoutRun's glyphs, grouping consecutive glyphs by color. +fn build_color_spans<'a>( + text: &'a str, + glyphs: &[cosmic_text::LayoutGlyph], +) -> Vec> { + fn cosmic_to_iced(c: cosmic_text::Color) -> Color { + Color::from_rgba8(c.r(), c.g(), c.b(), c.a() as f32 / 255.0) + } + + if glyphs.is_empty() { + return vec![Span::new(text)]; + } + + let mut spans = Vec::new(); + let mut seg_start = 0usize; + let mut cur_color: Option = glyphs.first().and_then(|g| g.color_opt); + + for glyph in glyphs { + if glyph.color_opt != cur_color { + let end = glyph.start.min(text.len()); + if end > seg_start { + let mut span = Span::new(&text[seg_start..end]); + if let Some(c) = cur_color { + span = span.color(cosmic_to_iced(c)); + } + spans.push(span); + } + seg_start = end; + cur_color = glyph.color_opt; + } + } + + if seg_start < text.len() { + let mut span = Span::new(&text[seg_start..]); + if let Some(c) = cur_color { + span = span.color(cosmic_to_iced(c)); + } + spans.push(span); + } + + if spans.is_empty() { + spans.push(Span::new(text)); + } + + spans +} + +/// A multi-line text input. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// # +/// use iced::widget::text_editor; +/// +/// struct State { +/// content: text_editor::Content, +/// } +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// Edit(text_editor::Action) +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// text_editor(&state.content) +/// .placeholder("Type something here...") +/// .on_action(Message::Edit) +/// .into() +/// } +/// +/// fn update(state: &mut State, message: Message) { +/// match message { +/// Message::Edit(action) => { +/// state.content.perform(action); +/// } +/// } +/// } +/// ``` +pub struct TextEditor< + 'a, + Highlighter, + Message, + Theme = iced_wgpu::core::Theme, +> where + Highlighter: text::Highlighter, + Theme: Catalog, +{ + id: Option, + content: &'a Content, + placeholder: Option>, + font: Option, + text_size: Option, + line_height: LineHeight, + width: Length, + height: Length, + min_height: f32, + max_height: f32, + padding: Padding, + wrapping: Wrapping, + class: Theme::Class<'a>, + key_binding: Option Option> + 'a>>, + on_edit: Option Message + 'a>>, + highlighter_settings: Highlighter::Settings, + highlighter_format: fn( + &Highlighter::Highlight, + &Theme, + ) -> highlighter::Format, + last_status: Option, + // Acord extensions + anchored_children: Vec>, + gutter_offset: usize, + is_focused_block: bool, +} + +impl<'a, Message, Theme> + TextEditor<'a, highlighter::PlainText, Message, Theme> +where + Theme: Catalog, +{ + /// Creates new [`TextEditor`] with the given [`Content`]. + pub fn new(content: &'a Content) -> Self { + Self { + id: None, + content, + placeholder: None, + font: None, + text_size: None, + line_height: LineHeight::default(), + width: Length::Fill, + height: Length::Shrink, + min_height: 0.0, + max_height: f32::INFINITY, + padding: Padding::new(5.0), + wrapping: Wrapping::default(), + class: ::default(), + key_binding: None, + on_edit: None, + highlighter_settings: (), + highlighter_format: |_highlight, _theme| { + highlighter::Format::default() + }, + last_status: None, + anchored_children: Vec::new(), + gutter_offset: 0, + is_focused_block: false, + } + } + + /// Sets the [`Id`](widget::Id) of the [`TextEditor`]. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } +} + +impl<'a, Highlighter, Message, Theme> + TextEditor<'a, Highlighter, Message, Theme> +where + Highlighter: text::Highlighter, + Theme: Catalog, +{ + /// Sets the placeholder of the [`TextEditor`]. + pub fn placeholder( + mut self, + placeholder: impl text::IntoFragment<'a>, + ) -> Self { + self.placeholder = Some(placeholder.into_fragment()); + self + } + + /// Sets the width of the [`TextEditor`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = Length::from(width.into()); + self + } + + /// Sets the height of the [`TextEditor`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the minimum height of the [`TextEditor`]. + pub fn min_height(mut self, min_height: impl Into) -> Self { + self.min_height = min_height.into().0; + self + } + + /// Sets the maximum height of the [`TextEditor`]. + pub fn max_height(mut self, max_height: impl Into) -> Self { + self.max_height = max_height.into().0; + self + } + + /// Sets the message that should be produced when some action is performed in + /// the [`TextEditor`]. + /// + /// If this method is not called, the [`TextEditor`] will be disabled. + pub fn on_action( + mut self, + on_edit: impl Fn(Action) -> Message + 'a, + ) -> Self { + self.on_edit = Some(Box::new(on_edit)); + self + } + + /// Sets the [`Font`] of the [`TextEditor`]. + /// + pub fn font(mut self, font: impl Into) -> Self { + self.font = Some(font.into()); + self + } + + /// Sets the text size of the [`TextEditor`]. + pub fn size(mut self, size: impl Into) -> Self { + self.text_size = Some(size.into()); + self + } + + /// Sets the [`text::LineHeight`] of the [`TextEditor`]. + pub fn line_height( + mut self, + line_height: impl Into, + ) -> Self { + self.line_height = line_height.into(); + self + } + + /// Sets the [`Padding`] of the [`TextEditor`]. + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the [`Wrapping`] strategy of the [`TextEditor`]. + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.wrapping = wrapping; + self + } + + /// Highlights the [`TextEditor`] with the given [`Highlighter`] and + /// a strategy to turn its highlights into some text format. + pub fn highlight_with( + self, + settings: H::Settings, + to_format: fn( + &H::Highlight, + &Theme, + ) -> highlighter::Format, + ) -> TextEditor<'a, H, Message, Theme> { + TextEditor { + id: self.id, + content: self.content, + placeholder: self.placeholder, + font: self.font, + text_size: self.text_size, + line_height: self.line_height, + width: self.width, + height: self.height, + min_height: self.min_height, + max_height: self.max_height, + padding: self.padding, + wrapping: self.wrapping, + class: self.class, + key_binding: self.key_binding, + on_edit: self.on_edit, + highlighter_settings: settings, + highlighter_format: to_format, + last_status: self.last_status, + anchored_children: self.anchored_children, + gutter_offset: self.gutter_offset, + is_focused_block: self.is_focused_block, + } + } + + /// Sets the closure to produce key bindings on key presses. + /// + /// See [`Binding`] for the list of available bindings. + pub fn key_binding( + mut self, + key_binding: impl Fn(KeyPress) -> Option> + 'a, + ) -> Self { + self.key_binding = Some(Box::new(key_binding)); + self + } + + /// Sets the style of the [`TextEditor`]. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self + where + Theme::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the [`TextEditor`]. + #[must_use] + pub fn class(mut self, class: impl Into>) -> Self { + self.class = class.into(); + self + } + + /// Sets the anchored child elements to draw at line boundaries. + /// Items must be sorted by after_line. + pub fn anchored(mut self, items: Vec>) -> Self { + self.anchored_children = items; + self + } + + /// Sets the global line offset for gutter numbering. + pub fn gutter_offset(mut self, offset: usize) -> Self { + self.gutter_offset = offset; + self + } + + /// Marks this widget as the focused editing block. + pub fn focused(mut self, focused: bool) -> Self { + self.is_focused_block = focused; + self + } + + fn input_method<'b>( + &self, + state: &'b State, + renderer: &iced_wgpu::Renderer, + layout: Layout<'_>, + ) -> InputMethod<&'b str> { + let Some(Focus { + is_window_focused: true, + .. + }) = &state.focus + else { + return InputMethod::Disabled; + }; + + let bounds = layout.bounds(); + let internal = self.content.0.borrow_mut(); + + let text_bounds = bounds.shrink(self.padding); + let translation = text_bounds.position() - Point::ORIGIN; + + let cursor = match internal.editor.selection() { + Selection::Caret(position) => position, + Selection::Range(ranges) => { + ranges.first().cloned().unwrap_or_default().position() + } + }; + + let line_height = self.line_height.to_absolute( + self.text_size.unwrap_or_else(|| renderer.default_size()), + ); + + let adjusted = if self.anchored_children.is_empty() { + cursor + } else { + let line_h: f32 = line_height.into(); + let line = (cursor.y / line_h).round() as usize; + let offset = items_height_before_line(&self.anchored_children, line); + Point::new(cursor.x, cursor.y + offset) + }; + + let position = adjusted + translation; + + InputMethod::Enabled { + cursor: Rectangle::new( + position, + Size::new(1.0, f32::from(line_height)), + ), + purpose: input_method::Purpose::Normal, + preedit: state.preedit.as_ref().map(input_method::Preedit::as_ref), + } + } +} + +/// The content of a [`TextEditor`]. +pub struct Content(RefCell); + +struct Internal { + editor: iced_graphics::text::Editor, +} + +impl Content { + /// Creates an empty [`Content`]. + pub fn new() -> Self { + Self::with_text("") + } + + /// Creates a [`Content`] with the given text. + pub fn with_text(text: &str) -> Self { + Self(RefCell::new(Internal { + editor: ::with_text(text), + })) + } + + /// Performs an [`Action`] on the [`Content`]. + pub fn perform(&mut self, action: Action) { + let internal = self.0.get_mut(); + + internal.editor.perform(action); + } + + /// Moves the current cursor to reflect the given one. + pub fn move_to(&mut self, cursor: Cursor) { + let internal = self.0.get_mut(); + + internal.editor.move_to(cursor); + } + + /// Returns the current cursor position of the [`Content`]. + pub fn cursor(&self) -> Cursor { + self.0.borrow().editor.cursor() + } + + /// Returns the amount of lines of the [`Content`]. + pub fn line_count(&self) -> usize { + self.0.borrow().editor.line_count() + } + + /// Returns the text of the line at the given index, if it exists. + pub fn line(&self, index: usize) -> Option> { + let internal = self.0.borrow(); + let line = internal.editor.line(index)?; + + Some(Line { + text: Cow::Owned(line.text.into_owned()), + ending: line.ending, + }) + } + + /// Returns an iterator of the text of the lines in the [`Content`]. + pub fn lines(&self) -> impl Iterator> { + (0..) + .map(|i| self.line(i)) + .take_while(Option::is_some) + .flatten() + } + + /// Returns the text of the [`Content`]. + pub fn text(&self) -> String { + let mut contents = String::new(); + let mut lines = self.lines().peekable(); + + while let Some(line) = lines.next() { + contents.push_str(&line.text); + + if lines.peek().is_some() { + contents.push_str(if line.ending == LineEnding::None { + LineEnding::default().as_str() + } else { + line.ending.as_str() + }); + } + } + + contents + } + + /// Returns the selected text of the [`Content`]. + pub fn selection(&self) -> Option { + self.0.borrow().editor.copy() + } + + /// Returns the kind of [`LineEnding`] used for separating lines in the [`Content`]. + pub fn line_ending(&self) -> Option { + Some(self.line(0)?.ending) + } + + /// Returns whether or not the the [`Content`] is empty. + pub fn is_empty(&self) -> bool { + self.0.borrow().editor.is_empty() + } +} + +impl Clone for Content { + fn clone(&self) -> Self { + Self::with_text(&self.text()) + } +} + +impl Default for Content { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for Content +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let internal = self.0.borrow(); + + f.debug_struct("Content") + .field("editor", &internal.editor) + .finish() + } +} + +/// The state of a [`TextEditor`]. +#[derive(Debug)] +pub struct State { + focus: Option, + preedit: Option, + last_click: Option, + drag_click: Option, + partial_scroll: f32, + last_theme: RefCell>, + highlighter: RefCell, + highlighter_settings: Highlighter::Settings, + highlighter_format_address: usize, + /// Paragraphs built during draw() — kept alive so the renderer's Weak refs + /// survive until the prepare() phase processes them. + retained_paragraphs: RefCell>, +} + +#[derive(Debug, Clone)] +struct Focus { + updated_at: Instant, + now: Instant, + is_window_focused: bool, +} + +impl Focus { + const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500; + + fn now() -> Self { + let now = Instant::now(); + + Self { + updated_at: now, + now, + is_window_focused: true, + } + } + + fn is_cursor_visible(&self) -> bool { + self.is_window_focused + && ((self.now - self.updated_at).as_millis() + / Self::CURSOR_BLINK_INTERVAL_MILLIS) + .is_multiple_of(2) + } +} + +impl State { + /// Returns whether the [`TextEditor`] is currently focused or not. + pub fn is_focused(&self) -> bool { + self.focus.is_some() + } +} + +impl operation::Focusable + for State +{ + fn is_focused(&self) -> bool { + self.focus.is_some() + } + + fn focus(&mut self) { + self.focus = Some(Focus::now()); + } + + fn unfocus(&mut self) { + self.focus = None; + } +} + +impl Widget + for TextEditor<'_, Highlighter, Message, Theme> +where + Highlighter: text::Highlighter, + Theme: Catalog, +{ + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::>() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State { + focus: None, + preedit: None, + last_click: None, + drag_click: None, + partial_scroll: 0.0, + last_theme: RefCell::default(), + highlighter: RefCell::new(Highlighter::new( + &self.highlighter_settings, + )), + highlighter_settings: self.highlighter_settings.clone(), + highlighter_format_address: self.highlighter_format as usize, + retained_paragraphs: RefCell::new(Vec::new()), + }) + } + + fn children(&self) -> Vec { + self.anchored_children.iter().map(|item| widget::Tree::new(&item.element)).collect() + } + + fn diff(&self, tree: &mut widget::Tree) { + tree.diff_children(&self.anchored_children.iter().map(|item| &item.element).collect::>()); + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &iced_wgpu::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let mut internal = self.content.0.borrow_mut(); + let state = tree.state.downcast_mut::>(); + + if state.highlighter_format_address != self.highlighter_format as usize + { + state.highlighter.borrow_mut().change_line(0); + + state.highlighter_format_address = self.highlighter_format as usize; + } + + if state.highlighter_settings != self.highlighter_settings { + state + .highlighter + .borrow_mut() + .update(&self.highlighter_settings); + + state.highlighter_settings = self.highlighter_settings.clone(); + } + + let limits = limits + .width(self.width) + .height(self.height) + .min_height(self.min_height) + .max_height(self.max_height); + + internal.editor.update( + limits.shrink(self.padding).max(), + self.font.unwrap_or_else(|| renderer.default_font()), + self.text_size.unwrap_or_else(|| renderer.default_size()), + self.line_height, + self.wrapping, + state.highlighter.borrow_mut().deref_mut(), + ); + + let line_h: f32 = self.line_height.to_absolute( + self.text_size.unwrap_or_else(|| renderer.default_size()), + ).into(); + let extra = total_items_height(&self.anchored_children); + + // Compute child layouts at their stream positions + let mut child_nodes = Vec::with_capacity(self.anchored_children.len()); + let child_limits = layout::Limits::new( + Size::ZERO, + Size::new(limits.shrink(self.padding).max().width, f32::INFINITY), + ); + let mut stream_y = 0.0f32; + let mut next_child = 0; + let line_count = internal.editor.line_count(); + for line in 0..line_count { + stream_y += line_h; + while next_child < self.anchored_children.len() + && self.anchored_children[next_child].after_line == line + { + let child = &mut self.anchored_children[next_child]; + let mut node = child.element.as_widget_mut().layout( + &mut tree.children[next_child], + renderer, + &child_limits, + ); + node = node.move_to(Point::new(self.padding.left, self.padding.top + stream_y)); + child.height = node.bounds().height; + stream_y += child.height; + child_nodes.push(node); + next_child += 1; + } + } + // Remaining children after last line + while next_child < self.anchored_children.len() { + let child = &mut self.anchored_children[next_child]; + let mut node = child.element.as_widget_mut().layout( + &mut tree.children[next_child], + renderer, + &child_limits, + ); + node = node.move_to(Point::new(self.padding.left, self.padding.top + stream_y)); + child.height = node.bounds().height; + stream_y += child.height; + child_nodes.push(node); + next_child += 1; + } + + match self.height { + Length::Fill | Length::FillPortion(_) | Length::Fixed(_) => { + let mut size = limits.max(); + size.height += extra; + layout::Node::with_children(size, child_nodes) + } + Length::Shrink => { + let min_bounds = internal.editor.min_bounds(); + + layout::Node::with_children( + limits + .height(min_bounds.height + extra) + .max() + .expand(Size::new(0.0, self.padding.y())), + child_nodes, + ) + } + } + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &iced_wgpu::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) { + // Forward events to anchored children first + if !self.anchored_children.is_empty() { + let children_layouts: Vec<_> = layout.children().collect(); + for (i, child) in self.anchored_children.iter_mut().enumerate() { + if i < children_layouts.len() && i < tree.children.len() { + child.element.as_widget_mut().update( + &mut tree.children[i], + event, + children_layouts[i], + cursor, + renderer, + clipboard, + shell, + _viewport, + ); + if shell.is_event_captured() { + return; + } + } + } + } + + let Some(on_edit) = self.on_edit.as_ref() else { + return; + }; + + let state = tree.state.downcast_mut::>(); + let is_redraw = matches!( + event, + Event::Window(window::Event::RedrawRequested(_now)), + ); + + match event { + Event::Window(window::Event::Unfocused) => { + if let Some(focus) = &mut state.focus { + focus.is_window_focused = false; + } + } + Event::Window(window::Event::Focused) => { + if let Some(focus) = &mut state.focus { + focus.is_window_focused = true; + focus.updated_at = Instant::now(); + + shell.request_redraw(); + } + } + Event::Window(window::Event::RedrawRequested(now)) => { + if let Some(focus) = &mut state.focus + && focus.is_window_focused + { + focus.now = *now; + + let millis_until_redraw = + Focus::CURSOR_BLINK_INTERVAL_MILLIS + - (focus.now - focus.updated_at).as_millis() + % Focus::CURSOR_BLINK_INTERVAL_MILLIS; + + shell.request_redraw_at( + focus.now + + Duration::from_millis(millis_until_redraw as u64), + ); + } + } + _ => {} + } + + if let Some(update) = Update::from_event( + event, + state, + layout.bounds(), + self.padding, + cursor, + self.key_binding.as_deref(), + ) { + let line_h: f32 = self.line_height.to_absolute( + self.text_size.unwrap_or_else(|| renderer.default_size()), + ).into(); + + match update { + Update::Click(click) => { + let action = match click.kind() { + mouse::click::Kind::Single => { + let mut pos = click.position(); + if !self.anchored_children.is_empty() { + let lc = self.content.0.borrow().editor.line_count(); + pos.y = stream_y_to_text_y(pos.y, &self.anchored_children, line_h, lc); + } + Action::Click(pos) + } + mouse::click::Kind::Double => Action::SelectWord, + mouse::click::Kind::Triple => Action::SelectLine, + }; + + state.focus = Some(Focus::now()); + state.last_click = Some(click); + state.drag_click = Some(click.kind()); + + shell.publish(on_edit(action)); + shell.capture_event(); + } + Update::Drag(position) => { + let mut pos = position; + if !self.anchored_children.is_empty() { + let lc = self.content.0.borrow().editor.line_count(); + pos.y = stream_y_to_text_y(pos.y, &self.anchored_children, line_h, lc); + } + shell.publish(on_edit(Action::Drag(pos))); + } + Update::Release => { + state.drag_click = None; + } + Update::Scroll(lines) => { + let bounds = self.content.0.borrow().editor.bounds(); + + if bounds.height >= i32::MAX as f32 { + return; + } + + let lines = lines + state.partial_scroll; + state.partial_scroll = lines.fract(); + + shell.publish(on_edit(Action::Scroll { + lines: lines as i32, + })); + shell.capture_event(); + } + Update::InputMethod(update) => match update { + Ime::Toggle(is_open) => { + state.preedit = + is_open.then(input_method::Preedit::new); + + shell.request_redraw(); + } + Ime::Preedit { content, selection } => { + state.preedit = Some(input_method::Preedit { + content, + selection, + text_size: self.text_size, + }); + + shell.request_redraw(); + } + Ime::Commit(text) => { + shell.publish(on_edit(Action::Edit(Edit::Paste( + Arc::new(text), + )))); + } + }, + Update::Binding(binding) => { + fn apply_binding< + H: text::Highlighter, + Message, + >( + binding: Binding, + content: &Content, + state: &mut State, + on_edit: &dyn Fn(Action) -> Message, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + let mut publish = + |action| shell.publish(on_edit(action)); + + match binding { + Binding::Unfocus => { + state.focus = None; + state.drag_click = None; + } + Binding::Copy => { + if let Some(selection) = content.selection() { + clipboard.write( + clipboard::Kind::Standard, + selection, + ); + } + } + Binding::Cut => { + if let Some(selection) = content.selection() { + clipboard.write( + clipboard::Kind::Standard, + selection, + ); + + publish(Action::Edit(Edit::Delete)); + } + } + Binding::Paste => { + if let Some(contents) = + clipboard.read(clipboard::Kind::Standard) + { + publish(Action::Edit(Edit::Paste( + Arc::new(contents), + ))); + } + } + Binding::Move(motion) => { + publish(Action::Move(motion)); + } + Binding::Select(motion) => { + publish(Action::Select(motion)); + } + Binding::SelectWord => { + publish(Action::SelectWord); + } + Binding::SelectLine => { + publish(Action::SelectLine); + } + Binding::SelectAll => { + publish(Action::SelectAll); + } + Binding::Insert(c) => { + publish(Action::Edit(Edit::Insert(c))); + } + Binding::Enter => { + publish(Action::Edit(Edit::Enter)); + } + Binding::Backspace => { + publish(Action::Edit(Edit::Backspace)); + } + Binding::Delete => { + publish(Action::Edit(Edit::Delete)); + } + Binding::Sequence(sequence) => { + for binding in sequence { + apply_binding( + binding, content, state, on_edit, + clipboard, shell, + ); + } + } + Binding::Custom(message) => { + shell.publish(message); + } + } + } + + if !matches!(binding, Binding::Unfocus) { + shell.capture_event(); + } + + apply_binding( + binding, + self.content, + state, + on_edit, + clipboard, + shell, + ); + + if let Some(focus) = &mut state.focus { + focus.updated_at = Instant::now(); + } + } + } + } + + let status = { + let is_disabled = self.on_edit.is_none(); + let is_hovered = cursor.is_over(layout.bounds()); + + if is_disabled { + Status::Disabled + } else if state.focus.is_some() { + Status::Focused { is_hovered } + } else if is_hovered { + Status::Hovered + } else { + Status::Active + } + }; + + if is_redraw { + self.last_status = Some(status); + + shell.request_input_method( + &self.input_method(state, renderer, layout), + ); + } else if self + .last_status + .is_some_and(|last_status| status != last_status) + { + shell.request_redraw(); + } + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut iced_wgpu::Renderer, + theme: &Theme, + _defaults: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + let mut internal = self.content.0.borrow_mut(); + let state = tree.state.downcast_ref::>(); + + let font = self.font.unwrap_or_else(|| renderer.default_font()); + + let theme_name = theme.name(); + + if state + .last_theme + .borrow() + .as_ref() + .is_none_or(|last_theme| last_theme != theme_name) + { + state.highlighter.borrow_mut().change_line(0); + let _ = + state.last_theme.borrow_mut().replace(theme_name.to_owned()); + } + + internal.editor.highlight( + font, + state.highlighter.borrow_mut().deref_mut(), + |highlight| (self.highlighter_format)(highlight, theme), + ); + + let style = theme + .style(&self.class, self.last_status.unwrap_or(Status::Active)); + + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + ..renderer::Quad::default() + }, + style.background, + ); + + let text_bounds = bounds.shrink(self.padding); + + let text_size = self.text_size.unwrap_or_else(|| renderer.default_size()); + let line_h: f32 = self.line_height.to_absolute(text_size).into(); + + if internal.editor.is_empty() { + if let Some(placeholder) = self.placeholder.clone() { + renderer.fill_text( + Text { + content: placeholder.into_owned(), + bounds: text_bounds.size(), + size: text_size, + line_height: self.line_height, + font, + align_x: text::Alignment::Default, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: self.wrapping, + }, + text_bounds.position(), + style.placeholder, + text_bounds, + ); + } + } else if self.anchored_children.is_empty() { + renderer.fill_editor( + &internal.editor, + text_bounds.position(), + style.value, + text_bounds, + ); + } else { + // Sequential stream: text lines (layer 0) interleaved with + // anchored children (layer 1) in one continuous pass. + let buffer = internal.editor.buffer(); + let line_count = buffer.lines.len(); + let mut stream_y = 0.0f32; + let mut child_idx = 0; + let children_layouts: Vec<_> = layout.children().collect(); + + // Build paragraphs and retain in widget State. fill_paragraph + // stores Weak refs — the Paragraphs must survive until the + // renderer's prepare() phase. State lives in the widget tree. + { + let mut paras = state.retained_paragraphs.borrow_mut(); + paras.clear(); + for i in 0..line_count { + let line_text = buffer.lines[i].text(); + let glyphs: Vec = + buffer.lines[i].layout_opt() + .map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect()) + .unwrap_or_default(); + let spans = build_color_spans(line_text, &glyphs); + paras.push(iced_graphics::text::Paragraph::with_spans(Text { + content: spans.as_slice(), + bounds: Size::new(text_bounds.width, line_h), + size: text_size, + line_height: self.line_height, + font, + align_x: text::Alignment::Default, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: self.wrapping, + })); + } + } + + let paras = state.retained_paragraphs.borrow(); + for line_i in 0..line_count { + let y = text_bounds.y + line_i as f32 * line_h + stream_y; + renderer.fill_paragraph( + ¶s[line_i], + Point::new(text_bounds.x, y), + style.value, + text_bounds, + ); + + // After this line, draw any anchored children + while child_idx < self.anchored_children.len() + && self.anchored_children[child_idx].after_line == line_i + { + if child_idx < children_layouts.len() { + self.anchored_children[child_idx].element.as_widget().draw( + &tree.children[child_idx], + renderer, + theme, + _defaults, + children_layouts[child_idx], + _cursor, + _viewport, + ); + } + stream_y += self.anchored_children[child_idx].height; + child_idx += 1; + } + } + + // Draw remaining children after last text line + while child_idx < self.anchored_children.len() { + if child_idx < children_layouts.len() { + self.anchored_children[child_idx].element.as_widget().draw( + &tree.children[child_idx], + renderer, + theme, + _defaults, + children_layouts[child_idx], + _cursor, + _viewport, + ); + } + child_idx += 1; + } + } + + let translation = text_bounds.position() - Point::ORIGIN; + + if let Some(focus) = state.focus.as_ref() { + let adjust_y = |pos: Point| -> Point { + if self.anchored_children.is_empty() { + pos + } else { + let line = (pos.y / line_h).round() as usize; + let offset = items_height_before_line(&self.anchored_children, line); + Point::new(pos.x, pos.y + offset) + } + }; + + match internal.editor.selection() { + Selection::Caret(position) if focus.is_cursor_visible() => { + let position = adjust_y(position); + let cursor = + Rectangle::new( + position + translation, + Size::new(1.0, line_h), + ); + + if let Some(clipped_cursor) = + text_bounds.intersection(&cursor) + { + renderer.fill_quad( + renderer::Quad { + bounds: clipped_cursor, + ..renderer::Quad::default() + }, + style.value, + ); + } + } + Selection::Range(ranges) => { + for range in ranges.into_iter().map(|r| { + let adjusted = Rectangle::new( + adjust_y(r.position()), + r.size(), + ); + adjusted + translation + }).filter_map(|r| text_bounds.intersection(&r)) { + renderer.fill_quad( + renderer::Quad { + bounds: range, + ..renderer::Quad::default() + }, + style.selection, + ); + } + } + Selection::Caret(_) => {} + } + } + } + + fn mouse_interaction( + &self, + _tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &iced_wgpu::Renderer, + ) -> mouse::Interaction { + let is_disabled = self.on_edit.is_none(); + + if cursor.is_over(layout.bounds()) { + if is_disabled { + mouse::Interaction::NotAllowed + } else { + mouse::Interaction::Text + } + } else { + mouse::Interaction::default() + } + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + _renderer: &iced_wgpu::Renderer, + operation: &mut dyn widget::Operation, + ) { + let state = tree.state.downcast_mut::>(); + + operation.focusable(self.id.as_ref(), layout.bounds(), state); + } +} + +impl<'a, Highlighter, Message, Theme> + From> + for Element<'a, Message, Theme, iced_wgpu::Renderer> +where + Highlighter: text::Highlighter, + Message: 'a, + Theme: Catalog + 'a, +{ + fn from( + text_editor: TextEditor<'a, Highlighter, Message, Theme>, + ) -> Self { + Self::new(text_editor) + } +} + +/// A binding to an action in the [`TextEditor`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Binding { + /// Unfocus the [`TextEditor`]. + Unfocus, + /// Copy the selection of the [`TextEditor`]. + Copy, + /// Cut the selection of the [`TextEditor`]. + Cut, + /// Paste the clipboard contents in the [`TextEditor`]. + Paste, + /// Apply a [`Motion`]. + Move(Motion), + /// Select text with a given [`Motion`]. + Select(Motion), + /// Select the word at the current cursor. + SelectWord, + /// Select the line at the current cursor. + SelectLine, + /// Select the entire buffer. + SelectAll, + /// Insert the given character. + Insert(char), + /// Break the current line. + Enter, + /// Delete the previous character. + Backspace, + /// Delete the next character. + Delete, + /// A sequence of bindings to execute. + Sequence(Vec), + /// Produce the given message. + Custom(Message), +} + +/// A key press. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyPress { + /// The original key pressed without modifiers applied to it. + /// + /// You should use this key for combinations (e.g. Ctrl+C). + pub key: keyboard::Key, + /// The key pressed with modifiers applied to it. + /// + /// You should use this key for any single key bindings (e.g. motions). + pub modified_key: keyboard::Key, + /// The physical key pressed. + /// + /// You should use this key for layout-independent bindings. + pub physical_key: keyboard::key::Physical, + /// The state of the keyboard modifiers. + pub modifiers: keyboard::Modifiers, + /// The text produced by the key press. + pub text: Option, + /// The current [`Status`] of the [`TextEditor`]. + pub status: Status, +} + +impl Binding { + /// Returns the default [`Binding`] for the given key press. + pub fn from_key_press(event: KeyPress) -> Option { + let KeyPress { + key, + modified_key, + physical_key, + modifiers, + text, + status, + } = event; + + if !matches!(status, Status::Focused { .. }) { + return None; + } + + let combination = match key.to_latin(physical_key) { + Some('c') if modifiers.command() => Some(Self::Copy), + Some('x') if modifiers.command() => Some(Self::Cut), + Some('v') if modifiers.command() && !modifiers.alt() => { + Some(Self::Paste) + } + Some('a') if modifiers.command() => Some(Self::SelectAll), + _ => None, + }; + + if let Some(binding) = combination { + return Some(binding); + } + + #[cfg(target_os = "macos")] + let modified_key = + convert_macos_shortcut(&key, modifiers).unwrap_or(modified_key); + + match modified_key.as_ref() { + keyboard::Key::Named(key::Named::Enter) => Some(Self::Enter), + keyboard::Key::Named(key::Named::Backspace) => { + Some(Self::Backspace) + } + keyboard::Key::Named(key::Named::Delete) + if text.is_none() || text.as_deref() == Some("\u{7f}") => + { + Some(Self::Delete) + } + keyboard::Key::Named(key::Named::Escape) => Some(Self::Unfocus), + _ => { + if let Some(text) = text { + let c = text.chars().find(|c| !c.is_control())?; + + Some(Self::Insert(c)) + } else if let keyboard::Key::Named(named_key) = key.as_ref() { + let motion = motion(named_key)?; + + let motion = if modifiers.macos_command() { + match motion { + Motion::Left => Motion::Home, + Motion::Right => Motion::End, + _ => motion, + } + } else { + motion + }; + + let motion = if modifiers.jump() { + motion.widen() + } else { + motion + }; + + Some(if modifiers.shift() { + Self::Select(motion) + } else { + Self::Move(motion) + }) + } else { + None + } + } + } + } +} + +enum Update { + Click(mouse::Click), + Drag(Point), + Release, + Scroll(f32), + InputMethod(Ime), + Binding(Binding), +} + +enum Ime { + Toggle(bool), + Preedit { + content: String, + selection: Option>, + }, + Commit(String), +} + +impl Update { + fn from_event( + event: &Event, + state: &State, + bounds: Rectangle, + padding: Padding, + cursor: mouse::Cursor, + key_binding: Option<&dyn Fn(KeyPress) -> Option>>, + ) -> Option { + let binding = |binding| Some(Update::Binding(binding)); + + match event { + Event::Mouse(event) => match event { + mouse::Event::ButtonPressed(mouse::Button::Left) => { + if let Some(cursor_position) = cursor.position_in(bounds) { + let cursor_position = cursor_position + - Vector::new(padding.left, padding.top); + + let click = mouse::Click::new( + cursor_position, + mouse::Button::Left, + state.last_click, + ); + + Some(Update::Click(click)) + } else if state.focus.is_some() { + binding(Binding::Unfocus) + } else { + None + } + } + mouse::Event::ButtonReleased(mouse::Button::Left) => { + Some(Update::Release) + } + mouse::Event::CursorMoved { .. } => match state.drag_click { + Some(mouse::click::Kind::Single) => { + let cursor_position = cursor.position_in(bounds)? + - Vector::new(padding.left, padding.top); + + Some(Update::Drag(cursor_position)) + } + _ => None, + }, + mouse::Event::WheelScrolled { delta } + if cursor.is_over(bounds) => + { + Some(Update::Scroll(match delta { + mouse::ScrollDelta::Lines { y, .. } => { + if y.abs() > 0.0 { + y.signum() * -(y.abs() * 4.0).max(1.0) + } else { + 0.0 + } + } + mouse::ScrollDelta::Pixels { y, .. } => -y / 4.0, + })) + } + _ => None, + }, + Event::InputMethod(event) => match event { + input_method::Event::Opened | input_method::Event::Closed => { + Some(Update::InputMethod(Ime::Toggle(matches!( + event, + input_method::Event::Opened + )))) + } + input_method::Event::Preedit(content, selection) + if state.focus.is_some() => + { + Some(Update::InputMethod(Ime::Preedit { + content: content.clone(), + selection: selection.clone(), + })) + } + input_method::Event::Commit(content) + if state.focus.is_some() => + { + Some(Update::InputMethod(Ime::Commit(content.clone()))) + } + _ => None, + }, + Event::Keyboard(keyboard::Event::KeyPressed { + key, + modified_key, + physical_key, + modifiers, + text, + .. + }) => { + let status = if state.focus.is_some() { + Status::Focused { + is_hovered: cursor.is_over(bounds), + } + } else { + Status::Active + }; + + let key_press = KeyPress { + key: key.clone(), + modified_key: modified_key.clone(), + physical_key: *physical_key, + modifiers: *modifiers, + text: text.clone(), + status, + }; + + if let Some(key_binding) = key_binding { + key_binding(key_press) + } else { + Binding::from_key_press(key_press) + } + .map(Self::Binding) + } + _ => None, + } + } +} + +fn motion(key: key::Named) -> Option { + match key { + key::Named::ArrowLeft => Some(Motion::Left), + key::Named::ArrowRight => Some(Motion::Right), + key::Named::ArrowUp => Some(Motion::Up), + key::Named::ArrowDown => Some(Motion::Down), + key::Named::Home => Some(Motion::Home), + key::Named::End => Some(Motion::End), + key::Named::PageUp => Some(Motion::PageUp), + key::Named::PageDown => Some(Motion::PageDown), + _ => None, + } +} + +/// The possible status of a [`TextEditor`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// The [`TextEditor`] can be interacted with. + Active, + /// The [`TextEditor`] is being hovered. + Hovered, + /// The [`TextEditor`] is focused. + Focused { + /// Whether the [`TextEditor`] is hovered, while focused. + is_hovered: bool, + }, + /// The [`TextEditor`] cannot be interacted with. + Disabled, +} + +/// The appearance of a text input. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Style { + /// The [`Background`] of the text input. + pub background: Background, + /// The [`Border`] of the text input. + pub border: Border, + /// The [`Color`] of the placeholder of the text input. + pub placeholder: Color, + /// The [`Color`] of the value of the text input. + pub value: Color, + /// The [`Color`] of the selection of the text input. + pub selection: Color, +} + +/// The theme catalog of a [`TextEditor`]. +pub trait Catalog: theme::Base { + /// The item class of the [`Catalog`]. + type Class<'a>; + + /// The default class produced by the [`Catalog`]. + fn default<'a>() -> Self::Class<'a>; + + /// The [`Style`] of a class with the given status. + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style; +} + +/// A styling function for a [`TextEditor`]. +pub type StyleFn<'a, Theme> = Box Style + 'a>; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +/// The default style of a [`TextEditor`]. +pub fn default(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + + let active = Style { + background: Background::Color(palette.background.base.color), + border: Border { + radius: 2.0.into(), + width: 1.0, + color: palette.background.strong.color, + }, + placeholder: palette.secondary.base.color, + value: palette.background.base.text, + selection: palette.primary.weak.color, + }; + + match status { + Status::Active => active, + Status::Hovered => Style { + border: Border { + color: palette.background.base.text, + ..active.border + }, + ..active + }, + Status::Focused { .. } => Style { + border: Border { + color: palette.primary.strong.color, + ..active.border + }, + ..active + }, + Status::Disabled => Style { + background: Background::Color(palette.background.weak.color), + value: active.placeholder, + placeholder: palette.background.strongest.color, + ..active + }, + } +} + +#[cfg(target_os = "macos")] +pub fn convert_macos_shortcut( + key: &keyboard::Key, + modifiers: keyboard::Modifiers, +) -> Option { + if modifiers != keyboard::Modifiers::CTRL { + return None; + } + + let key = match key.as_ref() { + keyboard::Key::Character("b") => key::Named::ArrowLeft, + keyboard::Key::Character("f") => key::Named::ArrowRight, + keyboard::Key::Character("a") => key::Named::Home, + keyboard::Key::Character("e") => key::Named::End, + keyboard::Key::Character("h") => key::Named::Backspace, + keyboard::Key::Character("d") => key::Named::Delete, + _ => return None, + }; + + Some(keyboard::Key::Named(key)) +} diff --git a/viewport/src/tree_block.rs b/viewport/src/tree_block.rs new file mode 100644 index 0000000..f6e5a28 --- /dev/null +++ b/viewport/src/tree_block.rs @@ -0,0 +1,345 @@ +use iced_wgpu::core::text::LineHeight; +use iced_wgpu::core::{ + alignment, Background, Border, Element, Font, Length, Padding, Pixels, + Point, Rectangle, Shadow, Theme, +}; +use iced_widget::canvas; +use iced_widget::container; + +use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +use crate::palette; +use crate::selection::{BlockId, InnerPath}; + +const BASE_FONT: f32 = 13.0; + +fn node_height(font_size: f32) -> f32 { font_size * (20.0 / BASE_FONT) } +fn indent_px(font_size: f32) -> f32 { font_size * (20.0 / BASE_FONT) } +fn branch_inset(font_size: f32) -> f32 { font_size * (12.0 / BASE_FONT) } +fn glyph_width(font_size: f32) -> f32 { font_size * (7.2 / BASE_FONT) } +const WIDGET_INNER_PADDING: Padding = Padding { + top: 4.0, + right: 8.0, + bottom: 4.0, + left: 8.0, +}; +const WIDGET_OUTER_PADDING: Padding = Padding { + top: 2.0, + right: 0.0, + bottom: 2.0, + left: 8.0, +}; + +#[derive(Debug, Clone)] +pub enum TreeMessage {} + +struct TreeNode { + label: String, + depth: usize, + is_last: bool, +} + +fn flatten_tree(val: &serde_json::Value, depth: usize, is_last: bool, out: &mut Vec) { + match val { + serde_json::Value::Array(items) => { + if depth > 0 { + out.push(TreeNode { + label: "[array]".into(), + depth, + is_last, + }); + } + let len = items.len(); + for (i, item) in items.iter().enumerate() { + flatten_tree(item, depth + 1, i == len - 1, out); + } + } + serde_json::Value::Object(_) => { + out.push(TreeNode { + label: "{object}".into(), + depth, + is_last, + }); + } + serde_json::Value::String(s) => { + out.push(TreeNode { + label: format!("\"{}\"", s), + depth, + is_last, + }); + } + serde_json::Value::Number(n) => { + out.push(TreeNode { + label: n.to_string(), + depth, + is_last, + }); + } + serde_json::Value::Bool(b) => { + out.push(TreeNode { + label: b.to_string(), + depth, + is_last, + }); + } + serde_json::Value::Null => { + out.push(TreeNode { + label: "null".into(), + depth, + is_last, + }); + } + } +} + +pub struct TreeProgram { + nodes: Vec, + total_height: f32, + content_width: f32, + font_size: f32, +} + +impl TreeProgram { + pub fn from_json_scaled(val: &serde_json::Value, font_size: f32) -> Self { + let mut nodes = Vec::new(); + match val { + serde_json::Value::Array(items) => { + let len = items.len(); + for (i, item) in items.iter().enumerate() { + flatten_tree(item, 0, i == len - 1, &mut nodes); + } + } + _ => { + flatten_tree(val, 0, true, &mut nodes); + } + } + let nh = node_height(font_size); + let ind = indent_px(font_size); + let gw = glyph_width(font_size); + let total_height = (nodes.len() as f32 * nh).max(nh); + let content_width = nodes.iter() + .map(|n| { + let depth_px = n.depth as f32 * ind + 16.0; + let label_px = (n.label.chars().count() as f32 + 3.0) * gw; + depth_px + label_px + }) + .fold(60.0_f32, f32::max); + Self { nodes, total_height, content_width, font_size } + } + + pub fn height(&self) -> f32 { + self.total_height + } + + pub fn width(&self) -> f32 { + self.content_width + } +} + +impl canvas::Program for TreeProgram { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced_wgpu::Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: iced_wgpu::core::mouse::Cursor, + ) -> Vec> { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let p = palette::current(); + let ws = palette::widget_surface(); + let connector_color = p.overlay0; + let label_color = ws.body_text; + let array_color = p.overlay1; + + let nh = node_height(self.font_size); + let ind = indent_px(self.font_size); + let bi = branch_inset(self.font_size); + + for (i, node) in self.nodes.iter().enumerate() { + let y = i as f32 * nh; + let indent_x = node.depth as f32 * ind + 8.0; + + if node.depth > 0 { + let parent_x = (node.depth - 1) as f32 * ind + 8.0; + let connector = canvas::Path::new(|b| { + b.move_to(Point::new(parent_x, y)); + b.line_to(Point::new(parent_x, y + nh / 2.0)); + b.line_to(Point::new(parent_x + bi, y + nh / 2.0)); + }); + frame.stroke( + &connector, + canvas::Stroke::default() + .with_width(1.0) + .with_color(connector_color), + ); + + if !node.is_last { + let vert = canvas::Path::line( + Point::new(indent_x - ind, y + nh / 2.0), + Point::new(indent_x - ind, y + nh), + ); + frame.stroke( + &vert, + canvas::Stroke::default() + .with_width(1.0) + .with_color(connector_color), + ); + } + } + + let text_color = if node.label.starts_with('[') || node.label.starts_with('{') { + array_color + } else { + label_color + }; + + let branch_char = if node.depth == 0 { + String::new() + } else if node.is_last { + "\u{2514}\u{2500} ".into() // └─ + } else { + "\u{251C}\u{2500} ".into() // ├─ + }; + + let display = format!("{}{}", branch_char, node.label); + + frame.fill_text(canvas::Text { + content: display, + position: Point::new(indent_x, y + 2.0), + max_width: bounds.width - indent_x, + color: text_color, + size: Pixels(self.font_size), + line_height: LineHeight::Relative(1.3), + font: Font::MONOSPACE, + align_x: alignment::Horizontal::Left.into(), + align_y: alignment::Vertical::Top, + shaping: iced_wgpu::core::text::Shaping::Basic, + }); + } + + vec![frame.into_geometry()] + } +} + +/// Builds the framed canvas Element for a tree block. Returns `'static` +/// because `TreeProgram::from_json` clones the labels into an owned `Vec` — +/// nothing in the returned widget tree borrows from `data`. +/// Total rendered height of a tree element including padding and border. +pub fn element_height(data: &serde_json::Value, font_size: f32) -> f32 { + let program = TreeProgram::from_json_scaled(data, font_size); + program.height() + + WIDGET_INNER_PADDING.top + WIDGET_INNER_PADDING.bottom + + WIDGET_OUTER_PADDING.top + WIDGET_OUTER_PADDING.bottom +} + +pub fn build( + data: &serde_json::Value, + font_size: f32, +) -> Element<'static, Message, Theme, iced_wgpu::Renderer> { + let program = TreeProgram::from_json_scaled(data, font_size); + let h = program.height(); + let w = program.width(); + let canvas_el: Element<'static, Message, Theme, iced_wgpu::Renderer> = + canvas::Canvas::new(program) + .width(Length::Fixed(w)) + .height(Length::Fixed(h)) + .into(); + + let framed = container(canvas_el) + .padding(WIDGET_INNER_PADDING) + .style(|_theme: &Theme| { + let ws = palette::widget_surface(); + container::Style { + background: Some(Background::Color(ws.fill)), + border: Border { + color: ws.border, + width: 1.0, + radius: 0.0.into(), + }, + text_color: Some(ws.body_text), + shadow: Shadow::default(), + snap: false, + } + }); + + container(framed) + .padding(WIDGET_OUTER_PADDING) + .width(Length::Shrink) + .style(|_theme: &Theme| container::Style { + background: None, + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }) + .into() +} + +/// Trait-implementing struct for a tree block. Owns the JSON value; the +/// canvas program is rebuilt fresh on each `view` call (cheap — flatten_tree +/// is O(nodes) and the JSON is already parsed). +pub struct TreeBlock { + pub id: BlockId, + pub data: serde_json::Value, + pub start_line: usize, +} + +impl TreeBlock { + pub fn new(id: BlockId, data: serde_json::Value, start_line: usize) -> Self { + Self { id, data, start_line } + } +} + +impl Block for TreeBlock { + fn id(&self) -> BlockId { + self.id + } + + fn kind_tag(&self) -> &'static str { + "tree" + } + + fn start_line(&self) -> usize { + self.start_line + } + + fn set_start_line(&mut self, line: usize) { + self.start_line = line; + } + + fn line_count(&self) -> usize { + 1 + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message> { + LayeredView::just(build(&self.data, ctx.font_size)) + } + + fn to_md(&self) -> String { + // Trees aren't currently round-tripped through markdown — they only + // appear as eval results. + String::new() + } + + fn hit_test(&self, _point: Point) -> Option { + Some(InnerPath::Whole) + } + + fn apply(&mut self, _cmd: BlockCommand) { + // Trees are read-only. + } + + fn selectable_paths(&self) -> Box + '_> { + Box::new(std::iter::once(InnerPath::Whole)) + } +}