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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,13 @@ jobs:
run: make build-macos

- name: Upload macOS artifacts
# The .dmg is a single opaque disk image, so upload-artifact's lack of
# permission preservation can't strip the executable bit from the app
# inside it (unlike a raw .app tree).
uses: actions/upload-artifact@v7
with:
name: macos-app
path: build/*
path: build/*.dmg

build-linux:
name: Build Linux Packages
Expand Down Expand Up @@ -195,11 +198,8 @@ jobs:
- name: Collect release assets
run: |
mkdir -p release-assets
# macOS
if [ -d "artifacts/macos-app/Elo.app" ]; then
(cd artifacts/macos-app && zip -r ../../release-assets/Elo-macos.app.zip Elo.app)
fi
cp artifacts/macos-app/*.dmg release-assets/ 2>/dev/null || true
# macOS — ship the universal .dmg. Homebrew casks mount it natively.
cp artifacts/macos-app/*.dmg release-assets/
# Linux
cp artifacts/linux-packages/*.deb release-assets/ 2>/dev/null || true
cp artifacts/linux-packages/*.AppImage release-assets/ 2>/dev/null || true
Expand Down
5 changes: 2 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ build-cli:

build-macos:
mkdir -p build
cd elo-tauri && pnpm install && cd .. && cargo tauri build --bundles app --target universal-apple-darwin
cp -r target/universal-apple-darwin/release/bundle/macos/Elo.app build/
cp target/universal-apple-darwin/release/bundle/dmg/*.dmg build/ 2>/dev/null || true
cd elo-tauri && pnpm install && cd .. && cargo tauri build --bundles dmg --target universal-apple-darwin
cp target/universal-apple-darwin/release/bundle/dmg/*.dmg build/

build-linux:
mkdir -p build
Expand Down
8 changes: 8 additions & 0 deletions crates/elo-core/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ impl Parser {
None
}

/// Whether the parser has consumed every token. A complete formula
/// reaches the end of the token stream; trailing tokens are a strong
/// signal that the line is prose (e.g. "buy 3 apples") rather than a
/// calculation.
pub fn at_end(&self) -> bool {
self.pos >= self.tokens.len()
}

pub fn parse_expr(&mut self) -> Expr {
self.parse_conversion()
}
Expand Down
217 changes: 175 additions & 42 deletions crates/elo-core/src/session.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::sync::Arc;

use crate::ast::{Expr, Line};
use crate::eval::{EvalContext, eval_line};
use crate::eval::{EvalContext, eval_expr};
use crate::formatter::format_value;
use crate::parser::Parser;
use crate::rates::RateStore;
Expand All @@ -13,12 +13,30 @@ pub struct Session {
in_code_fence: bool,
}

/// How a line was interpreted.
///
/// Elo is a notepad first and a calculator second: a document is a mix of
/// prose and the occasional expression. Rather than parsing every line
/// eagerly and surfacing "unknown identifier" errors for ordinary sentences,
/// each line is classified into one of these states.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineKind {
/// The line was recognized as a formula. The accompanying [`LineResult`]
/// `value` holds the result, which may itself be a [`Value::Error`] for a
/// genuinely malformed calculation (e.g. `foo(10)` or `1 / bogus`).
Formula,
/// The line is plain text / markdown prose (or a header, comment, code
/// fence, etc.). No result is displayed and it never reports an error.
Text,
}

/// Result of evaluating a single line
#[derive(Debug, Clone)]
pub struct LineResult {
pub input: String,
pub value: Value,
pub display: String,
pub kind: LineKind,
}

impl Session {
Expand All @@ -38,75 +56,84 @@ impl Session {

/// Evaluate a single line in the context of this session
pub fn eval_line(&mut self, input: &str) -> LineResult {
let empty = || LineResult {
let text = || LineResult {
input: input.to_string(),
value: Value::Empty,
display: String::new(),
kind: LineKind::Text,
};

if input.trim().is_empty() {
self.ctx.new_block();
return empty();
return text();
}

// Code fence toggle: lines starting with ``` produce empty results
// Code fence toggle: lines starting with ``` produce no result.
if input.trim_start().starts_with("```") {
self.in_code_fence = !self.in_code_fence;
return empty();
return text();
}

// Inside a code fence: don't evaluate
// Inside a code fence: never evaluate.
if self.in_code_fence {
return empty();
return text();
}

// List item prefix: strip "- " or "* ", evaluate the rest.
// If the stripped content errors (pure text), return empty instead.
// Markdown list marker ("- " / "* "): strip it and evaluate the rest,
// so "- 2 + 2" works. A marker immediately followed by a digit (e.g.
// "- 5") is left intact so it still parses as a negation, matching how
// a calculator would read it. A bare marker ("- ") is just text.
let trimmed = input.trim_start();
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
let content = if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
let after_marker = trimmed[2..].trim_start();
// Empty list marker (just "- " or "* ")
if after_marker.is_empty() {
return empty();
return text();
}
// Non-digit content: try to evaluate, fall back to empty on error.
// Digits fall through so "- 5" still parses as negative 5.
if after_marker.starts_with(|c: char| !c.is_ascii_digit()) {
let mut parser = Parser::new(after_marker);
let line = parser.parse_line();
// Only swallow errors for bare identifiers (plain text like "groceries").
// Expression-like structures (function calls, operators, etc.) should
// propagate their errors so the user gets useful feedback.
let is_bare_text = matches!(
&line,
Line::Expression {
expr: Expr::Ident(_),
..
}
);
let value = eval_line(&line, &mut self.ctx);
if value.is_error() && is_bare_text {
return empty();
after_marker
} else {
input
}
} else {
input
};

let mut parser = Parser::new(content);
let line = parser.parse_line();
let consumed_all = parser.at_end();

// Headers, comments, and lines that lex to nothing are structural prose.
if matches!(line, Line::Empty | Line::Comment(_) | Line::Header(_)) {
return text();
}

// Evaluate without committing side effects yet — a line that turns out
// to be prose must not pollute `prev`, the running block, or variables.
let value = match &line {
Line::Expression { expr, .. } | Line::Assignment { expr, .. } => {
eval_expr(expr, &self.ctx)
}
// `Empty`/`Comment`/`Header` were handled above.
_ => Value::Empty,
};

match classify(&line, consumed_all, &value) {
LineKind::Text => text(),
LineKind::Formula => {
// Commit side effects only now that we know it's a real formula.
if let Line::Assignment { name, .. } = &line {
self.ctx.variables.insert(name.clone(), value.clone());
}
self.ctx.record_result(&value);
let display = format_value(&value);
return LineResult {
LineResult {
input: input.to_string(),
value,
display,
};
kind: LineKind::Formula,
}
}
}

let mut parser = Parser::new(input);
let line = parser.parse_line();
let value = eval_line(&line, &mut self.ctx);
let display = format_value(&value);

LineResult {
input: input.to_string(),
value,
display,
}
}

/// Evaluate a full document (multiple lines)
Expand All @@ -126,6 +153,44 @@ impl Default for Session {
}
}

/// Decide whether an evaluated line should be surfaced as a formula result or
/// treated as plain prose.
///
/// The parser is deliberately eager (see the markdown/list-item work in commits
/// `090c470` and `7a36ca6`): genuine mistakes like `foo(10)` or `1 / bogus`
/// should still surface "unknown function" / "unknown identifier" errors rather
/// than silently disappearing. So we only fall back to [`LineKind::Text`] when a
/// line *both* errors *and* doesn't look like a deliberate calculation:
///
/// * the parser left tokens unconsumed — trailing prose like "buy 3 apples"
/// or "- `code` text", where only the first word parsed; or
/// * the whole line is a single bare identifier, e.g. "groceries".
///
/// A line that evaluates cleanly is always a formula (so writing a lone variable
/// name like `total` still recalls its value), and a line that parsed fully into
/// a computational structure keeps its error.
fn classify(line: &Line, consumed_all: bool, value: &Value) -> LineKind {
if !value.is_error() {
return LineKind::Formula;
}
if !consumed_all || is_bare_text(line) {
return LineKind::Text;
}
LineKind::Formula
}

/// A line whose expression is a single identifier with nothing else — plain
/// prose such as "groceries" or "TODO", optionally carrying a label.
fn is_bare_text(line: &Line) -> bool {
matches!(
line,
Line::Expression {
expr: Expr::Ident(_),
..
}
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -315,4 +380,72 @@ mod tests {
let result = session.eval_line("- foo `code fence`");
assert!(result.value.is_empty());
}

#[test]
fn test_session_plain_text_is_not_error() {
let mut session = Session::new();
// A bare word and a full sentence are prose, not failed formulas.
for line in ["groceries", "buy some milk", "this is a note"] {
let result = session.eval_line(line);
assert!(!result.value.is_error(), "{line:?} should not error");
assert!(result.value.is_empty(), "{line:?} should be empty");
assert_eq!(result.kind, LineKind::Text, "{line:?} should be text");
}
}

#[test]
fn test_session_prose_with_number_is_text() {
let mut session = Session::new();
// Leading word + trailing tokens: parser stops at "I", prose remains.
let result = session.eval_line("I have 3 cats");
assert!(!result.value.is_error());
assert_eq!(result.kind, LineKind::Text);
}

#[test]
fn test_session_real_formula_error_is_preserved() {
let mut session = Session::new();
// Structured calculations that fail should still surface their error.
for line in ["1 / bogus", "foo(10)", "10 +"] {
let result = session.eval_line(line);
assert!(result.value.is_error(), "{line:?} should error");
assert_eq!(result.kind, LineKind::Formula, "{line:?} is a formula");
}
}

#[test]
fn test_session_bare_variable_recall() {
let mut session = Session::new();
session.eval_line("total = 100");
// A lone known variable name recalls its value (not treated as text).
let result = session.eval_line("total");
assert_eq!(result.display, "100");
assert_eq!(result.kind, LineKind::Formula);
}

#[test]
fn test_session_prose_does_not_pollute_prev() {
let mut session = Session::new();
session.eval_line("42");
session.eval_line("this is a note"); // prose: must not clobber prev
let result = session.eval_line("prev");
assert_eq!(result.display, "42");
}

#[test]
fn test_session_prose_does_not_pollute_sum() {
let mut session = Session::new();
session.eval_line("10");
session.eval_line("shopping list"); // prose between numbers
session.eval_line("20");
let result = session.eval_line("sum");
assert_eq!(result.display, "30");
}

#[test]
fn test_session_text_list_item_kind() {
let mut session = Session::new();
assert_eq!(session.eval_line("- groceries").kind, LineKind::Text);
assert_eq!(session.eval_line("* TODO item").kind, LineKind::Text);
}
}
6 changes: 6 additions & 0 deletions crates/elo-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use elo_core::session::LineKind;
use elo_core::{RateStore, Session, Value};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
Expand All @@ -15,6 +16,9 @@ struct LineResult {
display: String,
is_empty: bool,
is_error: bool,
/// True when the line was treated as plain text / markdown prose rather than
/// a formula. Such lines never carry an error.
is_text: bool,
error: Option<String>,
}

Expand All @@ -37,6 +41,7 @@ fn evaluate_document(text: &str, state: State<AppState>) -> Vec<LineResult> {
display: result.display,
is_empty: result.value.is_empty(),
is_error: result.value.is_error(),
is_text: result.kind == LineKind::Text,
error,
}
})
Expand All @@ -57,6 +62,7 @@ fn evaluate_line(line: &str, state: State<AppState>) -> LineResult {
display: result.display,
is_empty: result.value.is_empty(),
is_error: result.value.is_error(),
is_text: result.kind == LineKind::Text,
error,
}
}
Expand Down
2 changes: 2 additions & 0 deletions elo-tauri/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface LineResult {
display: string;
is_empty: boolean;
is_error: boolean;
// True when the line is plain text / markdown prose rather than a formula.
is_text: boolean;
error: string | null;
}

Expand Down