pmacs/tests/lean_input_acceptance.rs

1021 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Lean 4 Unicode input method acceptance (Arc 8 Stage 4b,
//! docs/lean4-mode-framing.md Q#LN11/Q#LN21/Q#LN22, criteria 3845i).
//!
//! Dispatch-driven throughout: `dispatch_key` is the producer that arms
//! the typed-edit record for a grid frontend. The optimistic CRDT
//! producer is criterion 45f and lives in a `--lib` test, where the gate
//! list's `--features crdt` run reaches it.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use pmacs::protocol::FrontendId;
use pmacs::window::{FrontendView, Layout, Window, WindowId};
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
fn fresh_dir() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"pmacs-leaninput-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn cursor(s: &EditorState) -> i64 {
eval(s, "return pmacs.editor.cursor()")
}
fn type_as(s: &mut EditorState, fid: FrontendId, chars: &str) {
for ch in chars.chars() {
s.dispatch_key(fid, key(KeyCode::Char(ch)));
}
}
fn type_str(s: &mut EditorState, chars: &str) {
type_as(s, FrontendId::LOCAL, chars);
}
/// An editor with an empty `.lean` file open and the point at 0.
/// `pmacs.lsp.config = {}` keeps the real user config from spawning a
/// server; the language still resolves from the extension.
fn lean_editor() -> (EditorState, PathBuf) {
let dir = fresh_dir();
let f = dir.join("a.lean");
std::fs::write(&f, "").unwrap();
let s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
let fd = f.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
assert_eq!(
eval::<Option<String>>(
&s,
"return pmacs.lsp.buffer_language(pmacs.window.buffer())"
)
.as_deref(),
Some("lean4"),
"the fixture must actually be a lean4 buffer, or every \
expansion assertion below is vacuous"
);
(s, f)
}
// ---------------------------------------------------------------------------
// 38 / 41 — the two expansion paths, and what an undo restores
// ---------------------------------------------------------------------------
#[test]
fn the_finish_path_retains_the_terminator_in_one_undo_step() {
// `alp` is not a key; `alpha` is the shortest key extending it. The
// space does not extend anything, so it lands first and the
// expansion replaces the leader and the typed text — the span stops
// BEFORE the terminator, so whatever auto-pairing did with it
// survives. One undo restores the same text either way, because the
// terminator was its own insert to begin with.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alp ");
assert_eq!(text(&s), "α ", "terminator retained, not consumed");
exec(&s, "pmacs.window.buffer():undo()");
assert_eq!(
text(&s),
"\\alp ",
"one undo restores the pre-expansion text WITH its terminator — \
the expansion is a single edit"
);
}
#[test]
fn the_eager_path_takes_no_terminator_and_undoes_separately() {
// `alpha` has no longer key extending it, so it is one of the 1,550
// eager keys: it expands the moment the final `a` lands, and a
// following space is a SEPARATE edit. Rev 8 asserted the finish-path
// undo text for this example, which is the trap (round 9).
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alpha");
assert_eq!(text(&s), "α", "eager expansion, no terminator typed");
type_str(&mut s, " ");
assert_eq!(text(&s), "α ");
exec(&s, "pmacs.window.buffer():undo()");
assert_eq!(text(&s), "α", "the first undo removes the separate space");
exec(&s, "pmacs.window.buffer():undo()");
assert_eq!(text(&s), "\\alpha", "the second undoes the expansion");
}
#[test]
fn to_is_not_eager_because_longer_keys_extend_it() {
// The criterion rev 8 got wrong: `to` looks unique and is not.
// `top`, `to0`, `toa` and others extend it, so it needs a
// terminator. Bites against an eager rule that tests only "is this
// a key" without asking whether anything extends it.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\to");
assert_eq!(text(&s), "\\to", "no expansion without a terminator");
type_str(&mut s, " ");
assert_eq!(text(&s), "", "the finish path then resolves it");
}
// ---------------------------------------------------------------------------
// 39 — $CURSOR
// ---------------------------------------------------------------------------
#[test]
fn the_cursor_placeholder_places_the_point_between_the_symbols() {
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\<>");
assert_eq!(text(&s), "⟨⟩");
// The placeholder is a point position, not a literal: typing lands
// between the brackets.
type_str(&mut s, "x");
assert_eq!(text(&s), "⟨x⟩", "$CURSOR left the point inside");
}
// ---------------------------------------------------------------------------
// 40 — the pair collision
// ---------------------------------------------------------------------------
#[test]
fn a_pending_abbreviation_is_never_corrupted_by_auto_pairing() {
// 64 keys contain a `lean4` pair-set character. Two DISTINCT bugs
// produce the same symptom here, so both are asserted: pairing
// running first, and a consumer that claims only completed
// expansions (which would hand each intermediate `[` to pairing).
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\[");
assert_eq!(
text(&s),
"\\[",
"the intermediate `[` was CLAIMED — pairing inserted no `]`, \
which is what keeps `\\[[]]` reachable"
);
type_str(&mut s, "[]]");
assert_eq!(text(&s), "⟦⟧", "the full key resolves");
}
#[test]
fn a_pair_character_that_terminates_an_abbreviation_still_pairs() {
// The other half of the collision, and the one the first revision
// of this file got wrong. `(` does not extend `alp`, so it
// TERMINATES — and a terminator is an ordinary character that
// pairing is entitled to react to.
//
// Claiming the terminator suppresses pairing entirely (`α(`).
// Expanding before declining is no better: the replace makes
// pairing's copy of the record stale, so pairing declines and the
// closer is silently lost. Only deferring the expansion past the
// chain gives both.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alp(");
assert_eq!(
text(&s),
"α()",
"the abbreviation expanded AND the terminator paired"
);
assert_eq!(
cursor(&s),
3,
"and the point sits between the pair — after α (2 bytes) and \
the opener"
);
}
#[test]
fn a_nested_fan_out_between_the_expander_and_pairing_does_not_expand_early() {
// `buffer.after-edit` fan-outs NEST — the typed-edit contract
// explicitly supports a consumer calling `pmacs.hook.run`, and a
// nested run re-enters every subscriber, including the deferred
// expansion's. If the nested pass performed the expansion, the
// OUTER chain would then resume and hand pairing a record the
// replace had already invalidated: `α(` again, reached through the
// chain's documented re-entrancy seam rather than through claiming.
let (mut s, _f) = lean_editor();
exec(
&s,
r#"
_G.NESTED = 0
pmacs.typed_edit.add_consumer {
name = "nested-fan-out",
priority = 75, -- between the expander (50) and pairing (100)
fn = function()
if _G.NESTED == 0 then
_G.NESTED = 1
pmacs.hook.run("buffer.after-edit")
end
return false
end,
}
"#,
);
type_str(&mut s, "\\alp(");
let nested: i64 = eval(&s, "return _G.NESTED");
assert_eq!(nested, 1, "the nested fan-out must actually have run");
assert_eq!(
text(&s),
"α()",
"the expansion waited for the OUTERMOST pass, so pairing still \
held a valid record when the terminator reached it"
);
}
#[test]
fn a_nested_fan_out_that_never_reaches_the_expander_still_does_not_expand_early() {
// The chain's OTHER exit: a consumer may CLAIM and stop the chain
// before the expander is reached, while the fan-out's
// deferred-expansion subscriber still runs. Counting in the
// expander itself therefore misses that pass — it would look like
// the outermost one and expand early, and outer pairing would
// resume with an invalidated record.
//
// The sequence, exactly: a consumer at 25 declines on the outer
// pass (there is a record) and claims on the nested one (there is
// not); a consumer at 75 runs one nested fan-out from between the
// expander and pairing.
let (mut s, _f) = lean_editor();
exec(
&s,
r#"
_G.NESTED, _G.CLAIMED = 0, 0
pmacs.typed_edit.add_consumer {
name = "claims-only-when-recordless",
priority = 25, -- ahead of the expander at 50
fn = function(rec)
if rec == nil then
_G.CLAIMED = _G.CLAIMED + 1
return true -- stops the chain: the expander never runs
end
return false
end,
}
pmacs.typed_edit.add_consumer {
name = "nested-fan-out",
priority = 75, -- between the expander (50) and pairing (100)
fn = function()
if _G.NESTED == 0 then
_G.NESTED = 1
pmacs.hook.run("buffer.after-edit")
end
return false
end,
}
"#,
);
type_str(&mut s, "\\alp(");
let (nested, claimed): (i64, i64) = eval(&s, "return _G.NESTED, _G.CLAIMED");
assert_eq!(nested, 1, "the nested fan-out must actually have run");
assert!(
claimed >= 1,
"the nested pass must actually have been short-circuited before \
the expander, or this pins the same thing as 45n"
);
assert_eq!(
text(&s),
"α()",
"the nesting count comes from a point that runs before any \
consumer can claim, so the nested pass was still recognised"
);
}
#[test]
fn a_pair_character_outside_a_pending_abbreviation_still_pairs() {
// The other direction: claiming extensions must not disable pairing
// in Lean buffers generally.
let (mut s, _f) = lean_editor();
type_str(&mut s, "[");
assert_eq!(text(&s), "[]", "ordinary auto-pairing is untouched");
}
// ---------------------------------------------------------------------------
// 42 — a prefix that opens nothing
// ---------------------------------------------------------------------------
#[test]
fn a_prefix_that_opens_no_key_is_left_literal_with_no_edit() {
// `W` is one of exactly six printable characters that begin no key
// (`$ % , ; @ W`). Rev 8 used `\zzzz`, which expands — `ze`, `zeta`
// and `zsqrtd` exist (round 9).
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\WWWW ");
assert_eq!(text(&s), "\\WWWW ", "literal text, no expansion");
}
#[test]
fn a_prefix_with_no_complete_match_still_expands_its_best_prefix() {
// The case rev 8 mistook for "no match": `z` DOES open a pending
// abbreviation, and the second `z` finishes it.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\zzzz ");
assert_eq!(text(&s), "ζzzz ", "`z` resolved through `ze`");
}
// ---------------------------------------------------------------------------
// 43 — lazy abandonment
// ---------------------------------------------------------------------------
#[test]
fn moving_the_point_away_abandons_the_pending_abbreviation() {
// There is no cursor-motion hook, so the pending record is
// validated at the NEXT typed edit: the point must still be at the
// end of the pending span.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alp");
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "h");
assert_eq!(text(&s), "h\\alp", "the `h` landed as plain text");
// The keystroke that makes abandonment OBSERVABLE. Asserting only
// the line above proves nothing: claiming an extension makes no
// edit, so a record that wrongly survived would look identical
// here. If `h` had extended the record to `alph`, this `a`
// completes `alpha` and eagerly expands — over a span whose offsets
// are now stale by one.
type_str(&mut s, "a");
assert_eq!(
text(&s),
"ha\\alp",
"`\\alp` is still literal: the record was dropped when the \
point left the end of its span, not carried along"
);
}
#[test]
fn switching_buffers_clears_pending_state_eagerly() {
let (mut s, f) = lean_editor();
let dir = fresh_dir();
let other = dir.join("b.lean");
std::fs::write(&other, "").unwrap();
let od = other.display().to_string();
let fd = f.display().to_string();
// Open the second buffer FIRST, then come back. `find_or_open`
// fires `buffer.after-switch` only on the already-open branch — a
// fresh load fires `buffer.after-load` instead, and its own insert
// fires a record-less `buffer.after-edit`. Without this warm-up the
// test passes through the nil-record path and pins nothing about
// switching: deleting the after-switch subscriber leaves it green.
exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "\\alph");
exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
type_str(&mut s, "a");
assert_eq!(
text(&s),
"\\alpha",
"without the switch this would have eagerly expanded to α; \
`buffer.after-switch` cleared the record"
);
}
#[test]
fn a_self_insert_that_moves_the_point_afterwards_does_not_expand() {
// Buffer and window matching is not enough. A redefined
// `buffer.self-insert` may insert the completing character and THEN
// move the point; expanding over a span the user has left teleports
// them back into it. Pairing makes the same three-part check
// (`ed.cursor() ~= rec.post_cursor`) for the same reason.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alph");
exec(
&s,
r#"
pmacs.command.unregister("buffer.self-insert")
pmacs.command.define {
name = "buffer.self-insert",
description = "test override: insert, then move the point away",
fn = function(cp)
pmacs.editor.insert_char_over_region(cp)
pmacs.editor.goto_byte(0)
end,
}
"#,
);
type_str(&mut s, "a");
assert_eq!(
text(&s),
"\\alpha",
"the record died with the point that left it — no expansion"
);
assert_eq!(cursor(&s), 0, "and the point stayed where it was moved to");
}
#[test]
fn an_intercept_that_switches_buffers_does_not_move_the_other_points() {
// A buffer intercept may switch window or buffer while the replace
// runs. An unguarded `goto_byte` afterwards moves the point of
// whatever it switched TO — a buffer with nothing to do with this
// expansion. Pairing's `repair_cursor` guards the same way.
let (mut s, f) = lean_editor();
let dir = fresh_dir();
let other = dir.join("other.lean");
std::fs::write(&other, "0123456789").unwrap();
let od = other.display().to_string();
let fd = f.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
exec(
&s,
&format!(
r#"
_G.SWITCHED = false
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "replace" and not _G.SWITCHED then
_G.SWITCHED = true
pmacs.buffer.find_or_open({od:?})
end
return nil
end)
"#
),
);
type_str(&mut s, "\\alpha");
let switched: bool = eval(&s, "return _G.SWITCHED");
assert!(switched, "the intercept must actually have fired");
assert_eq!(
text(&s),
"0123456789",
"we are now in the buffer the intercept switched to"
);
// Whatever point the switch left in that buffer, the expansion must
// not have moved it. Unguarded, `goto_byte` runs against the
// ambient buffer and translates the LEAN buffer's pre-edit point
// (6) through the LEAN buffer's replace, landing at 2 here — a
// number with no meaning in this buffer at all.
assert_eq!(
cursor(&s),
0,
"its point is untouched — the expansion's cursor placement is \
guarded on the window and buffer still being the ones it \
edited"
);
}
// ---------------------------------------------------------------------------
// 44 / 45 — the setting and the language gate, both on the SOURCE buffer
// ---------------------------------------------------------------------------
#[test]
fn disabling_the_setting_stops_expansion() {
let (mut s, _f) = lean_editor();
exec(&s, "pmacs.config.set('lean.abbrev', false)");
type_str(&mut s, "\\alpha");
assert_eq!(text(&s), "\\alpha", "no expansion when disabled");
exec(&s, "pmacs.config.set('lean.abbrev', true)");
exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
type_str(&mut s, " \\alpha");
assert_eq!(text(&s), "\\alpha α", "and it comes back live");
}
#[test]
fn the_setting_is_read_against_the_typed_edits_source_buffer() {
// A buffer-local override must not follow the user to another
// buffer of the same language — the `editing.auto-pair` precedent,
// including its round-2 correction to resolve `rec.buffer` rather
// than `pmacs.window.buffer()`.
let (mut s, f) = lean_editor();
let dir = fresh_dir();
let other = dir.join("b.lean");
std::fs::write(&other, "").unwrap();
exec(
&s,
"pmacs.config.set_local(pmacs.window.buffer(), 'lean.abbrev', false)",
);
type_str(&mut s, "\\alpha");
assert_eq!(text(&s), "\\alpha", "disabled in THIS buffer");
let od = other.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "\\alpha");
assert_eq!(text(&s), "α", "a second lean buffer is unaffected");
let fd = f.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
assert_eq!(text(&s), "\\alpha", "and the first is still disabled");
}
#[test]
fn no_abbreviation_state_is_opened_outside_a_lean_buffer() {
let dir = fresh_dir();
let f = dir.join("a.rs");
std::fs::write(&f, "").unwrap();
let s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
let fd = f.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
let mut s = s;
type_str(&mut s, "\\alpha");
assert_eq!(text(&s), "\\alpha", "no expansion in Rust");
// And the leader opened nothing, so `[` still pairs normally.
exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
type_str(&mut s, "\\[");
assert_eq!(
text(&s),
"\\alpha\\[]",
"`\\[` in a Rust buffer pairs — the input method never armed"
);
}
// ---------------------------------------------------------------------------
// 45a / 45b / 45c / 45d / 45e — resolution rules
// ---------------------------------------------------------------------------
#[test]
fn the_shortest_key_wins_not_the_longest() {
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alp ");
assert_eq!(text(&s), "α ", "`alp` resolves through `alpha`");
exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
type_str(&mut s, "\\al ");
assert_eq!(
text(&s),
"α",
"`al` resolves through `all` (3) — NOT `alpha` (5). A \
longest-match or unique-match-only rule passes the first \
assertion and fails this one"
);
}
#[test]
fn an_unmatchable_tail_is_appended_not_dropped() {
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alp7 ");
assert_eq!(
text(&s),
"α7 ",
"`7` finished `alp`; it is kept, not swallowed, and the whole \
abbreviation is not abandoned"
);
}
#[test]
fn there_is_no_terminator_list() {
// `'+ '` is a key — a trailing SPACE is part of it. Bites against
// any hardcoded space/tab/RET terminator set.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\+ ");
assert_eq!(text(&s), "", "the space EXTENDED rather than terminating");
}
#[test]
fn a_doubled_backslash_yields_one_literal_backslash() {
// Not a terminator case: the pending text is empty, `\` is itself a
// key, and it extends-and-eagerly-matches.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\\\");
assert_eq!(text(&s), "\\", "one literal backslash");
// ...and no pending state was left open, so an ordinary letter is
// an ordinary letter.
type_str(&mut s, "n");
assert_eq!(text(&s), "\\n", "two characters, not a newline");
}
#[test]
fn a_terminating_backslash_re_arms_as_a_new_leader() {
// `al` is NOT eager, so its pending record is still open when the
// second `\` arrives: the `\` terminates it, the expansion runs,
// and the same `\` must then open a fresh abbreviation.
//
// The framing's own example — `\alpha\to` — does NOT exercise this
// branch: `alpha` is eager, so the record is already closed and the
// `\` is handled by the ordinary open-a-leader path. It passes with
// the re-arm branch deleted, which is why the non-eager case is the
// one asserted first.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\al\\to ");
assert_eq!(
text(&s),
"∀→ ",
"the terminating `\\` expanded `al` AND opened a new \
abbreviation at its own position"
);
// The criterion's example still holds, by the other route.
exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
type_str(&mut s, "\\alpha\\to ");
assert_eq!(text(&s), "∀→ α→ ");
}
#[test]
fn an_inserted_backslash_does_not_re_arm() {
// `setminus` expands to a literal `\`. That backslash is a
// programmatic replace, which arms no typed-edit record — so it
// opens no pending abbreviation. Bites against a future consumer
// that infers pending state from buffer text instead of provenance.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\setminus");
assert_eq!(text(&s), "\\", "expanded to a literal backslash");
type_str(&mut s, "n");
assert_eq!(
text(&s),
"\\n",
"the letter after it is a plain letter — the INSERTED backslash \
armed nothing"
);
}
// ---------------------------------------------------------------------------
// 45h — the tie-break by source declaration order
// ---------------------------------------------------------------------------
#[test]
fn equal_length_candidates_break_by_source_declaration_order() {
// `f<` and `f>` are both length 2. `f<` is declared first, so `\f`
// resolves to ``. This is the criterion that bites a map-shaped
// vendored table: with `pairs` iteration it passes or fails by hash
// order.
let (mut s, _f) = lean_editor();
type_str(&mut s, "\\f ");
assert_eq!(text(&s), " ", "`f<` wins over `f>` by source order");
exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
type_str(&mut s, "\\\" ");
assert_eq!(
text(&s),
" Ä ",
"`\"A` is the first of eleven equal-length candidates"
);
}
#[test]
fn reversing_the_vendored_sequence_reverses_the_tie() {
// The falsification 45h requires: run the same resolution against a
// deliberately reversed sequence and show it changes. If this did
// NOT change, the tie-break would not be reading source order at
// all and the assertion above would be passing by luck.
let (s, _f) = lean_editor();
let forward: String = eval(&s, "return pmacs.lean_input._resolve('f')");
assert_eq!(forward, "");
let reversed: String = eval(
&s,
"
local seq = pmacs.lean_abbrev
local rev = {}
for i = #seq, 1, -1 do rev[#rev + 1] = seq[i] end
-- Resolve `f` the way the module does, over the reversed order.
local best = nil
for i = 1, #rev do
local k, v = rev[i][1], rev[i][2]
if k:sub(1, 1) == 'f' then
if best == nil or #k < best.len then best = { sym = v, len = #k } end
end
end
return best.sym
",
);
assert_eq!(
reversed, "",
"reversed source order picks `f>` — the tie really is decided \
by position in the sequence"
);
}
// ---------------------------------------------------------------------------
// 45g — table integrity, limited to what the suite can actually check
// ---------------------------------------------------------------------------
#[test]
fn the_vendored_table_is_self_consistent() {
// `abbreviations.json` is not shipped, so the suite cannot diff
// against it; the full source-fidelity check belongs to the
// generator, which re-parses its own output from disk. What is
// checkable here are the properties a corrupt emit breaks.
let (s, _f) = lean_editor();
let count: i64 = eval(&s, "return #pmacs.lean_abbrev");
assert_eq!(
count, 1855,
"the declared entry count for the recorded upstream commit"
);
let (unique, cursor_ok, utf8_ok): (i64, bool, bool) = eval(
&s,
r#"
local seen, n = {}, 0
local cursor_ok, utf8_ok = true, true
for i = 1, #pmacs.lean_abbrev do
local k, v = pmacs.lean_abbrev[i][1], pmacs.lean_abbrev[i][2]
if not seen[k] then seen[k] = true; n = n + 1 end
local _, c = v:gsub("%$CURSOR", "")
if c > 1 then cursor_ok = false end
-- A Lua pattern cannot validate UTF-8; check the shape the
-- emitter guarantees instead: no lone continuation byte at the
-- start of a sequence and no truncated tail.
if k:find("[\128-\191]") == 1 then utf8_ok = false end
end
return n, cursor_ok, utf8_ok
"#,
);
assert_eq!(
unique, 1855,
"every key is unique — a collision would silently drop entries \
from the derived lookup"
);
assert!(cursor_ok, "no symbol carries more than one $CURSOR");
assert!(utf8_ok, "no key begins with a continuation byte");
// The resolution spot-set named by 45g.
for (input, want) in [
("alpha", "α"),
("to", ""),
("<>", "⟨$CURSOR⟩"),
("+ ", ""),
("\\\\", "\\"),
("n", "\\n"),
("setminus", "\\"),
("f", ""),
] {
let got: String = eval(&s, &format!("return pmacs.lean_input._resolve('{input}')"));
assert_eq!(got, want, "resolution of {input:?}");
}
// The eager set is the one the state machine branches on.
let alpha_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('alpha')");
let to_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('to')");
assert!(alpha_eager, "`alpha` has no extension");
assert!(!to_eager, "`to` is extended by `top`, `to0`, `toa`, …");
}
// ---------------------------------------------------------------------------
// Q#AP7 for the deferred expansion: it must land before lsp.lua flushes
// ---------------------------------------------------------------------------
#[test]
fn the_expansion_reaches_the_first_did_change() {
// The expansion runs on its OWN `buffer.after-edit` subscriber,
// after the typed-edit chain. That makes it a new instance of the
// Q#AP7 obligation pairing already carries: lsp.lua's subscriber
// flushes `didChange` SYNCHRONOUSLY on the signature-trigger path,
// and `(` is a trigger. A server told about `\alp(` instead of
// `α()` stays wrong until the next edit — diagnostics, semantic
// tokens and inlay hints all frozen at stale byte positions.
//
// Falsified by loading lean_input.lua after lsp.lua in
// `src/editor.rs`: the expansion would then arrive in the SECOND
// didChange, or not at all.
let dir = fresh_dir();
let sink = dir.join("changes.jsonl");
let sink_disp = sink.display().to_string();
let fake = env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned();
let f = dir.join("a.lean");
std::fs::write(&f, "").unwrap();
let mut s = EditorState::new();
s.lua_host.lua().remove_app_data::<StateDir>();
s.lua_host.lua().set_app_data(StateDir(dir.clone()));
exec(&s, "pmacs.lsp.config = {}");
exec(
&s,
&format!(
"pmacs.lsp.config.lean4 = {{
command = '{fake}',
env = {{
PMACS_FAKE_LSP_MODE = 'sighelp',
PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}',
}},
}}"
),
);
let fd = f.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
let initialized = "(function() \
for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end \
return false \
end)()";
assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init");
type_str(&mut s, "\\alp(");
assert_eq!(text(&s), "α()", "precondition: the expansion happened");
// Wait for the flush that carries the `(` keystroke. Earlier
// keystrokes have already produced their own didChanges, so
// `changes[0]` is NOT the one under test — asserting on it compares
// against `\al` and fails for the wrong reason.
let deadline = Instant::now() + Duration::from_secs(5);
let changes = loop {
s.tick_processes();
s.tick_lsp();
s.tick_async();
let c = did_change_texts(&sink);
if c.iter().any(|t| t.contains('α')) {
break c;
}
assert!(
Instant::now() < deadline,
"no didChange carrying the expansion reached the fake server; got {:?}",
did_change_texts(&sink)
);
std::thread::sleep(Duration::from_millis(10));
};
assert!(
!changes.iter().any(|t| t == "\\alp("),
"no didChange may ever carry the UNEXPANDED text — one would mean lsp.lua flushed before the deferred expansion ran (Q#AP7). Got {changes:?}"
);
assert_eq!(
changes.last().map(String::as_str),
Some("α()"),
"the flush that carries the terminator carries the expansion and pairing's closer with it"
);
}
fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool {
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
state.tick_processes();
state.tick_lsp();
state.tick_async();
let done: bool = state
.lua_host
.lua()
.load(format!("return ({flag}) == true"))
.eval()
.unwrap_or(false);
if done {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// The `text` of every `textDocument/didChange` line in the sink, in
/// arrival order.
fn did_change_texts(sink: &std::path::Path) -> Vec<String> {
let Ok(raw) = std::fs::read_to_string(sink) else {
return Vec::new();
};
raw.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange"))
.filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned))
.collect()
}
// ---------------------------------------------------------------------------
// 45i — pending state is per frontend
// ---------------------------------------------------------------------------
/// Register a second frontend on the SAME buffer, with its own window.
fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId {
let mut core = s.core.borrow_mut();
let buffer_id = core.active_buffer_id();
let text_view = {
let registry = core.registry.clone();
let reg = registry.borrow();
pmacs::text_view::TextView::new(reg.get(buffer_id).unwrap())
};
let win_id = WindowId::next();
core.windows
.insert(win_id, Window::new(win_id, buffer_id, text_view));
core.register_frontend_view(
fid,
FrontendView {
layout: Layout::single(win_id),
active: win_id,
fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
win_id
}
const B: FrontendId = FrontendId(9);
#[test]
fn a_peer_edit_to_the_shared_buffer_abandons_the_pending_record() {
let (mut s, _f) = lean_editor();
let b_win = attach_frontend(&s, B);
// B sits at the start of the buffer; A types at the end.
s.core.borrow_mut().windows.get_mut(&b_win).unwrap().cursor = 0;
type_as(&mut s, FrontendId::LOCAL, "\\al");
type_as(&mut s, B, "p");
assert!(
text(&s).contains('p'),
"B's keystroke landed as ordinary text rather than extending \
A's abbreviation, got {:?}",
text(&s)
);
type_as(&mut s, FrontendId::LOCAL, "l ");
assert!(
!text(&s).contains('∀'),
"A's record was abandoned: `revision()` is buffer-global, so \
B's edit invalidates it even though B edited elsewhere. Got {:?}",
text(&s)
);
}
#[test]
fn a_peer_buffer_switch_does_not_clear_another_frontends_record() {
let (mut s, f) = lean_editor();
let dir = fresh_dir();
let other = dir.join("b.lean");
std::fs::write(&other, "").unwrap();
let od = other.display().to_string();
let fd = f.display().to_string();
// Warm up both buffers so B's switch takes `find_or_open`'s
// already-open branch, which is the only one that fires
// `buffer.after-switch`. A fresh load fires `buffer.after-load`
// and a record-less edit instead — and that path clears pending
// state for a different reason, which would make this test green
// no matter whose entries the subscriber clears.
exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
attach_frontend(&s, B);
type_as(&mut s, FrontendId::LOCAL, "\\al");
// B switches buffers WITHOUT editing the shared buffer.
// Only B moves: the switch is scoped to B's own window, so A's
// window still shows the shared buffer with A's point where it was.
s.core.borrow_mut().active_frontend = B;
exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
s.core.borrow_mut().active_frontend = FrontendId::LOCAL;
type_as(&mut s, FrontendId::LOCAL, "l ");
assert_eq!(
text(&s),
"",
"`buffer.after-switch` clears only the ACTING frontend's \
entries — a blanket clear would discard A's half-typed \
abbreviation"
);
}
#[test]
fn detaching_a_frontend_purges_only_its_own_pending_state() {
let (mut s, _f) = lean_editor();
attach_frontend(&s, B);
type_as(&mut s, FrontendId::LOCAL, "\\al");
exec(&s, &format!("pmacs.hook.run('frontend.detached', {})", B.0));
type_as(&mut s, FrontendId::LOCAL, "l ");
assert_eq!(
text(&s),
"",
"B's detachment purged B's entries and left A's record valid"
);
}