use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use cucumber::gherkin::Step;
use cucumber::{World, WriterExt as _, given, then, when, writer};
use oo_ide::app::builtin::register_all;
use oo_ide::app_state::{AppState, Screen};
use oo_ide::commands::{CommandId, CommandRegistry};
use oo_ide::editor::buffer::Buffer;
use oo_ide::editor::fold::FoldState;
use oo_ide::editor::history::ChangeKind;
use oo_ide::editor::position::Position;
use oo_ide::editor::selection::Selection;
use oo_ide::file_index;
use oo_ide::project::Project;
use oo_ide::settings::Settings;
use oo_ide::views::View as _;
use oo_ide::views::editor::{EditorView, SearchKind, SearchMode, SearchOptions, SearchState};
use oo_ide::views::file_selector::FileSelector;
use oo_ide::widgets::focus::FocusRing;
use oo_ide::widgets::input_field::InputField;
use std::cell::RefCell;
use portable_pty::{native_pty_system, PtySize};
use vt100::Parser;
use oo_ide::views::terminal::{TerminalView, TerminalTab};
use oo_ide::operation::Operation;
use oo_ide::operation::SearchOp;
use oo_ide::log_matcher::{
CompileOptions, CompileResult, CompiledMatcher, LogMatcherDef, MatcherEngine, Message,
MessageLevel, compile_matchers,
};
use oo_ide::operation::LspCompletionItem;
use oo_ide::schema::completions_from_schema;
#[derive(World)]
#[world(init = Self::new)]
pub struct EditorWorld {
pub inner: Box<EditorWorldInner>,
}
pub struct EditorWorldInner {
pub app: AppState,
pub registry: CommandRegistry,
pub _dir: tempfile::TempDir,
pub schema_json: String,
pub schema_lines: Vec<String>,
pub schema_cursor: Position,
pub schema_completions: Vec<LspCompletionItem>,
pub schema_target: Option<PathBuf>,
#[allow(dead_code)]
pub lm_defs: Vec<LogMatcherDef>,
#[allow(dead_code)]
pub lm_warn_unused_captures: bool,
#[allow(dead_code)]
pub lm_result: Option<Result<CompileResult, Vec<Message>>>,
pub lm_engine: Option<MatcherEngine>,
pub lm_engine_matchers: Vec<CompiledMatcher>,
pub lm_engine_issues: Vec<oo_ide::issue_registry::NewIssue>,
}
impl std::fmt::Debug for EditorWorld {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EditorWorld").finish_non_exhaustive()
}
}
impl EditorWorld {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".oo")).unwrap();
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let initial_src = std::path::Path::new(&manifest_dir)
.join("tests")
.join("features")
.join("initial_tasks.yaml");
if initial_src.exists() {
let dst = dir.path().join(".oo").join("tasks.yaml");
std::fs::copy(&initial_src, &dst).expect("copy initial_tasks.yaml failed");
}
let initial_psrc = std::path::Path::new(&manifest_dir)
.join("tests").join("features").join("initial_project_state.yaml");
if initial_psrc.exists() {
let cache_dir = dir.path().join(".oo").join("cache");
std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
let dst = cache_dir.join("project_state.yaml");
std::fs::copy(&initial_psrc, &dst).expect("copy initial_project_state.yaml failed");
} else {
let feature_path = std::path::Path::new(&manifest_dir)
.join("tests").join("features").join("command_history.feature");
if feature_path.exists()
&& let Ok(feature_text) = std::fs::read_to_string(&feature_path) {
let mut lines = feature_text.lines();
let needle = "Given after creating the file .oo/project_state.yaml with the following content:";
let mut found = false;
while let Some(line) = lines.next() {
if line.trim() == needle {
while let Some(l) = lines.next() {
if l.trim().starts_with("\"\"\"") {
let mut yaml_lines = Vec::new();
for yl in lines.by_ref() {
if yl.trim().starts_with("\"\"\"") {
break;
}
yaml_lines.push(yl);
}
let yaml = yaml_lines.join("\n");
let cache_dir = dir.path().join(".oo").join("cache");
std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
let dst = cache_dir.join("project_state.yaml");
std::fs::write(&dst, yaml).expect("write project_state.yaml failed");
found = true;
break;
}
}
if found { break; }
}
}
if !found {
let begin = "# BEGIN_PROJECT_STATE";
let end = "# END_PROJECT_STATE";
let mut in_block = false;
let mut yaml_lines = Vec::new();
for l in feature_text.lines() {
if l.trim() == begin {
in_block = true;
continue;
}
if l.trim() == end {
break;
}
if in_block {
let mut s = l;
if let Some(pos) = s.find('#') {
s = &s[(pos + 1)..];
if s.starts_with(' ') {
s = &s[1..];
}
}
yaml_lines.push(s);
}
}
if !yaml_lines.is_empty() {
let yaml = yaml_lines.join("\n");
let cache_dir = dir.path().join(".oo").join("cache");
std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
let dst = cache_dir.join("project_state.yaml");
std::fs::write(&dst, yaml).expect("write project_state.yaml failed");
}
}
}
}
let settings = Settings::new(dir.path().join(".oo").join("config.yaml").as_path()).unwrap();
let mut project = Project::new(dir.path().to_path_buf()).unwrap();
project.restore_state();
eprintln!(
"DEBUG: restored persisted command_history entries = {}",
project.get_persisted_command_history().len()
);
let debug_ps = dir.path().join(".oo").join("cache").join("project_state.yaml");
if let Ok(s) = std::fs::read_to_string(&debug_ps) {
eprintln!("DEBUG: project_state.yaml contents ({}):\n{}", debug_ps.display(), s);
} else {
eprintln!("DEBUG: project_state.yaml not present at {}", debug_ps.display());
}
let buffer = Buffer::from_lines(vec![String::new()], None);
let editor = EditorView::open(buffer, FoldState::default(), &settings);
let registry = file_index::spawn_registry(dir.path().to_path_buf());
let mut app = AppState::new(
Screen::Editor(Box::new(editor)),
project,
settings,
registry,
);
app.recompute_contexts();
let mut registry = CommandRegistry::new();
register_all(&mut registry);
oo_ide::app::builtin::register_tasks(&mut registry, &app);
let persisted = app.project.get_persisted_command_history();
if !persisted.is_empty() {
for entry in persisted.iter().rev() {
if let Ok(cmd_id) = entry.0.parse::<CommandId>() {
let args: std::collections::HashMap<String, oo_ide::commands::ArgValue> =
entry
.1
.iter()
.map(|(k, v): (&String, &String)| (k.clone(), oo_ide::commands::ArgValue::String(v.clone())))
.collect();
registry.record_palette_selection(cmd_id, args);
} else {
}
}
}
eprintln!("DEBUG: registry.history() count = {}", registry.history().len());
Self {
inner: Box::new(EditorWorldInner {
app,
registry,
_dir: dir,
schema_json: String::new(),
schema_lines: vec![String::new()],
schema_cursor: Position { line: 0, column: 0 },
schema_completions: Vec::new(),
schema_target: None,
lm_defs: Vec::new(),
lm_warn_unused_captures: false,
lm_result: None,
lm_engine: None,
lm_engine_matchers: Vec::new(),
lm_engine_issues: Vec::new(),
}),
}
}
}
fn ed(world: &EditorWorld) -> &EditorView {
match &world.inner.app.screen {
Screen::Editor(e) => e,
_ => panic!("active screen is not the editor"),
}
}
fn ed_mut(world: &mut EditorWorld) -> &mut EditorView {
match &mut world.inner.app.screen {
Screen::Editor(e) => e,
_ => panic!("active screen is not the editor"),
}
}
fn run_command(world: &mut EditorWorld, cmd: &str) {
let id: CommandId = cmd.parse().expect("bad command id");
let ops = world.inner.registry.execute(&id, HashMap::new(), &world.inner.app);
let settings = &world.inner.app.settings;
for op in ops {
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, settings);
}
}
world.inner.app.recompute_contexts();
}
#[given(expr = "the buffer contains {string}")]
fn given_buffer_contains(world: &mut EditorWorld, text: String) {
let ed = ed_mut(world);
ed.buffer = Buffer::from_lines(vec![String::new()], None);
ed.buffer.insert(&text);
world.inner.app.recompute_contexts();
}
#[given(expr = "the buffer contains:")]
fn given_buffer_contains_multiline(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
let raw = step.docstring().expect("expected a docstring");
let content = raw.trim_matches('\n');
let lines: Vec<String> = content.lines().map(|l: &str| l.to_string()).collect();
let lines = if lines.is_empty() {
vec![String::new()]
} else {
lines
};
ed_mut(world).buffer = Buffer::from_lines(lines, None);
world.inner.app.recompute_contexts();
}
#[given(expr = "the cursor is at row {int}, col {int}")]
fn given_cursor(world: &mut EditorWorld, row: usize, col: usize) {
ed_mut(world).buffer.set_cursor(Position::new(row, col));
}
#[given(expr = "there is a selection from row {int} col {int} to row {int} col {int}")]
fn given_selection(
world: &mut EditorWorld,
anchor_row: usize,
anchor_col: usize,
head_row: usize,
head_col: usize,
) {
let anchor = Position::new(anchor_row, anchor_col);
let head = Position::new(head_row, head_col);
ed_mut(world).buffer.set_selection(Some(Selection {
anchor,
active: head,
}));
}
#[given(expr = "the search query is {string} and the bar is closed")]
fn given_search_query_closed(world: &mut EditorWorld, query: String) {
let ed = ed_mut(world);
let lines = ed.buffer.lines().to_vec();
let matches = find_matches_simple(&lines, &query);
let mut query_field = InputField::new("Find");
query_field.set_text(query);
ed.last_search = Some(SearchState {
query: query_field,
replacement: InputField::new("Replace"),
kind: SearchKind::Find,
mode: SearchMode::default(),
focus: FocusRing::new(vec!["search_query"]),
opts: SearchOptions::default(),
matches,
current: 0,
files: Vec::new(),
file_path_index: HashMap::new(),
selected_file: 0,
file_panel_scroll: 0,
match_panel_scroll: 0,
include_filter: InputField::new("incl").with_text("*"),
exclude_filter: InputField::new("excl"),
project_search_generation: 0,
expanded_files: HashSet::new(),
tree_cursor_path: None,
tree_cursor_match: None,
tree_scroll: 0,
project_match_cursor: None,
});
ed.search = None;
}
#[given(expr = "a character {string} has been inserted")]
fn given_char_inserted(world: &mut EditorWorld, ch: String) {
let ed = ed_mut(world);
ed.buffer.begin_transaction(ChangeKind::InsertText);
ed.buffer.insert(&ch);
ed.buffer.end_transaction();
}
#[given(expr = "after creating the file .oo/tasks.yaml with the following content:")]
fn given_create_tasks_yaml(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
let raw = step.docstring().expect("expected a docstring");
let mut content = raw.trim().to_string();
let path = world.inner._dir.path().join(".oo").join("tasks.yaml");
std::fs::write(&path, &content).expect("could not write tasks.yaml");
content = std::fs::read_to_string(&path).expect("could not read tasks.yaml back");
match oo_ide::task_config::parse_str(&content) {
Ok(parsed) => {
world.inner.app.task_config = Some(parsed.clone());
oo_ide::app::builtin::register_tasks_from_tasksfile(
&mut world.inner.registry,
world.inner.app.task_config.as_ref().unwrap(),
);
}
Err(errs) => {
panic!("tasks.yaml parse failed: {:?}", errs);
}
}
world.inner.app.recompute_contexts();
}
#[given(expr = "after creating the file .oo/project_state.yaml with the following content:")]
fn given_create_project_state(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
let raw = step.docstring().expect("expected a docstring");
let content = raw.trim().to_string();
let cache_dir = world.inner._dir.path().join(".oo").join("cache");
std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
let path = cache_dir.join("project_state.yaml");
std::fs::write(&path, &content).expect("could not write project_state.yaml");
world.inner.app.project.restore_state();
let persisted = world.inner.app.project.get_persisted_command_history();
if !persisted.is_empty() {
for entry in persisted.iter().rev() {
if let Ok(cmd_id) = entry.0.parse::<CommandId>() {
let args: std::collections::HashMap<String, oo_ide::commands::ArgValue> = entry
.1
.iter()
.map(|(k, v): (&String, &String)| (k.clone(), oo_ide::commands::ArgValue::String(v.clone())))
.collect();
world.inner.registry.record_palette_selection(cmd_id, args);
} else {
}
}
}
world.inner.app.recompute_contexts();
}
#[when(expr = "I run {string}")]
fn when_run(world: &mut EditorWorld, cmd: String) {
run_command(world, &cmd);
}
#[when(expr = "I press enter")]
fn when_press_enter(world: &mut EditorWorld) {
let ed = ed_mut(world);
ed.buffer.begin_transaction(ChangeKind::InsertText);
ed.buffer
.insert_newline_with_indent(ed.use_space, ed.indentation_width);
ed.buffer.end_transaction();
}
#[when(expr = "I press arrow_down")]
fn when_arrow_down(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_down(ed.buffer.cursor());
ed.buffer.set_cursor(new);
}
#[when(expr = "I press arrow_up")]
fn when_arrow_up(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_up(ed.buffer.cursor());
ed.buffer.set_cursor(new);
}
#[when(expr = "I press home")]
fn when_home(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_line_start(ed.buffer.cursor());
ed.buffer.set_cursor(new);
}
#[when(expr = "I press end")]
fn when_end(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_line_end(ed.buffer.cursor());
ed.buffer.set_cursor(new);
}
#[when(expr = "I press arrow_left")]
fn when_arrow_left(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_left(ed.buffer.cursor());
ed.buffer.set_cursor(new);
}
#[when(expr = "I press arrow_right")]
fn when_arrow_right(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_right(ed.buffer.cursor());
ed.buffer.set_cursor(new);
}
#[when(expr = "I press page_down")]
fn when_page_down(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_down_n(ed.buffer.cursor(), 20);
ed.buffer.set_cursor(new);
}
#[when(expr = "I press page_up")]
fn when_page_up(world: &mut EditorWorld) {
let ed = ed_mut(world);
let new = ed.buffer.offset_up_n(ed.buffer.cursor(), 20);
ed.buffer.set_cursor(new);
}
#[then(expr = "the cursor is at row {int}, col {int}")]
fn then_cursor(world: &mut EditorWorld, row: usize, col: usize) {
assert_eq!(
ed(world).buffer.cursor(),
Position::new(row, col),
"cursor mismatch"
);
}
#[then(expr = "buffer line {int} is {string}")]
fn then_line(world: &mut EditorWorld, row: usize, expected: String) {
assert_eq!(
ed(world).buffer.line(row),
Some(expected),
"line {row} mismatch"
);
}
#[then(expr = "the tasks config contains task {string}")]
fn then_tasks_contains(world: &mut EditorWorld, task_id: String) {
assert!(
world.inner.app.task_config.is_some(),
"expected task_config to be loaded"
);
let tf = world.inner.app.task_config.as_ref().unwrap();
assert!(
tf.tasks.contains_key(&task_id),
"task '{}' not found in tasks.yaml",
task_id
);
}
#[then(expr = "the command palette contains task {string}")]
fn then_command_palette_contains_task(world: &mut EditorWorld, task_id: String) {
let found = world.inner.registry
.user_commands_sorted()
.into_iter()
.any(|c| c.meta.id.group == "task" && c.meta.id.name == task_id);
assert!(
found,
"expected task command 'task.{}' to be registered",
task_id
);
}
#[then(expr = "the command history contains {string}")]
fn then_command_history_contains(world: &mut EditorWorld, cmd_id: String) {
let found = world.inner
.registry
.history()
.iter()
.any(|e| e.id.to_string() == cmd_id);
assert!(found, "expected command history to contain {}", cmd_id);
}
#[then(expr = "the buffer has {int} lines")]
fn then_line_count(world: &mut EditorWorld, n: usize) {
assert_eq!(ed(world).buffer.line_count(), n, "line count mismatch");
}
#[then(expr = "there is no selection")]
fn then_no_selection(world: &mut EditorWorld) {
assert!(
ed(world).buffer.selection().is_none(),
"expected no selection but found one"
);
}
#[then(expr = "there is a selection from row {int} col {int} to row {int} col {int}")]
fn then_selection(
world: &mut EditorWorld,
anchor_row: usize,
anchor_col: usize,
head_row: usize,
head_col: usize,
) {
let sel = ed(world)
.buffer
.selection()
.expect("expected a selection but found none");
assert_eq!(
sel.anchor,
Position::new(anchor_row, anchor_col),
"selection anchor mismatch"
);
assert_eq!(
sel.active,
Position::new(head_row, head_col),
"selection active mismatch"
);
}
#[then(expr = "the buffer is dirty")]
fn then_dirty(world: &mut EditorWorld) {
assert!(ed(world).buffer.is_dirty(), "buffer should be dirty");
}
#[then(expr = "the buffer is not dirty")]
fn then_not_dirty(world: &mut EditorWorld) {
assert!(!ed(world).buffer.is_dirty(), "buffer should not be dirty");
}
#[then(expr = "there are {int} search matches")]
fn then_search_matches(world: &mut EditorWorld, n: usize) {
let count = ed(world)
.last_search
.as_ref()
.map_or(0, |s| s.matches.len());
assert_eq!(count, n, "search match count mismatch");
}
#[then(expr = "the search bar is open with query {string}")]
fn then_search_bar_open(world: &mut EditorWorld, query: String) {
let search = ed(world)
.search
.as_ref()
.expect("expected search bar to be open");
assert_eq!(search.query.text(), query, "search bar query mismatch");
}
#[then(expr = "line {int} is folded")]
fn then_line_folded(world: &mut EditorWorld, row: usize) {
assert!(
ed(world).folds.is_folded_header(row),
"expected line {row} to be a folded header"
);
}
#[then(expr = "line {int} is not folded")]
fn then_line_not_folded(world: &mut EditorWorld, row: usize) {
assert!(
!ed(world).folds.is_folded_header(row),
"expected line {row} to not be a folded header"
);
}
#[then(expr = "word wrap is enabled")]
fn then_word_wrap_on(world: &mut EditorWorld) {
assert!(ed(world).word_wrap, "expected word wrap to be enabled");
}
#[then(expr = "word wrap is disabled")]
fn then_word_wrap_off(world: &mut EditorWorld) {
assert!(!ed(world).word_wrap, "expected word wrap to be disabled");
}
#[given(expr = "word wrap is disabled")]
fn given_word_wrap_disabled(world: &mut EditorWorld) {
ed_mut(world).word_wrap = false;
}
#[then(expr = "there is a marker at row {int}")]
fn then_marker_at(world: &mut EditorWorld, row: usize) {
assert!(
ed(world).buffer.markers.iter().any(|m| m.line == row),
"expected marker at row {row}"
);
}
#[then(expr = "there is no marker at row {int}")]
fn then_no_marker_at(world: &mut EditorWorld, row: usize) {
assert!(
!ed(world).buffer.markers.iter().any(|m| m.line == row),
"expected no marker at row {row}"
);
}
#[then(expr = "the search mode is {string}")]
fn then_search_mode(world: &mut EditorWorld, mode: String) {
let search = ed(world).search.as_ref().expect("expected search bar to be open");
match mode.as_str() {
"Inline" => assert_eq!(search.mode, SearchMode::Inline, "search mode mismatch"),
"Expanded" => assert_eq!(search.mode, SearchMode::Expanded, "search mode mismatch"),
other => panic!("unknown search mode: {}", other),
}
}
#[given(expr = "a temp file {string} with content {string}")]
fn given_temp_file_with_content(world: &mut EditorWorld, filename: String, content: String) {
let path = world.inner._dir.path().join(&filename);
std::fs::write(&path, &content).expect("write temp file");
}
#[given(expr = "the buffer for {string} has unsaved text {string}")]
fn given_buffer_with_unsaved_text(world: &mut EditorWorld, filename: String, extra: String) {
let path = world.inner._dir.path().join(&filename);
let buf = Buffer::open(&path).expect("open file for buffer");
let ed = {
let settings = &world.inner.app.settings;
let mut ed = EditorView::open(buf, FoldState::default(), settings);
let end_pos = ed.buffer.offset_line_end(ed.buffer.cursor());
ed.buffer.set_cursor(end_pos);
ed.buffer.insert(&extra);
ed
};
world.inner.app.set_screen(Screen::Editor(Box::new(ed)));
}
#[given(expr = "the buffer for {string} is open at the cursor row {int}, col {int}")]
fn given_buffer_open_at_cursor(
world: &mut EditorWorld,
filename: String,
row: usize,
col: usize,
) {
let path = world.inner._dir.path().join(&filename);
let buf = Buffer::open(&path).expect("open file for buffer");
let ed = {
let settings = &world.inner.app.settings;
let mut ed = EditorView::open(buf, FoldState::default(), settings);
ed.buffer.set_cursor(Position::new(row, col));
ed
};
world.inner.app.set_screen(Screen::Editor(Box::new(ed)));
}
#[when("I open the file selector")]
fn when_open_file_selector(world: &mut EditorWorld) {
let project_root = world.inner.app.project.project_path.clone();
let registry = world.inner.app.registry.clone();
let fs = FileSelector::new(project_root, &[], registry, Default::default(), None);
let _ = world.inner.app.set_screen(Screen::FileSelector(fs));
}
#[when(expr = "I select file {string} via OpenFile")]
fn when_select_file_via_open_file(world: &mut EditorWorld, filename: String) {
let path = world.inner._dir.path().join(&filename);
let ok = world.inner.app.open_file_preserving_stash(path);
assert!(ok, "open_file_preserving_stash failed for {filename}");
}
fn find_matches_simple(lines: &[String], query: &str) -> Vec<(usize, usize, usize)> {
if query.is_empty() {
return vec![];
}
let mut out = Vec::new();
for (row, line) in lines.iter().enumerate() {
let mut start = 0;
while let Some(pos) = line[start..].find(query) {
let byte_start = start + pos;
let byte_end = byte_start + query.len();
out.push((row, byte_start, byte_end));
start = byte_end;
}
}
out
}
#[given("the Cargo.toml schema is loaded")]
fn given_cargo_schema_loaded(world: &mut EditorWorld) {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR not set — run via `cargo test`");
let schema_path =
Path::new(&manifest_dir).join("../../extensions/rust/manifests/cargo.schema.json");
world.inner.schema_json = std::fs::read_to_string(&schema_path)
.unwrap_or_else(|e| panic!("Cannot read {}: {}", schema_path.display(), e));
world.inner.schema_target = Some(PathBuf::from("Cargo.toml"));
}
#[given("the GitLab CI schema is loaded")]
fn given_gitlab_schema_loaded(world: &mut EditorWorld) {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR not set — run via `cargo test`");
let schema_path =
Path::new(&manifest_dir).join("../../extensions/gitlab/manifests/gitlab-ci.schema.json");
world.inner.schema_json = std::fs::read_to_string(&schema_path)
.unwrap_or_else(|e| panic!("Cannot read {}: {}", schema_path.display(), e));
world.inner.schema_target = Some(PathBuf::from(".gitlab-ci.yml"));
}
#[given(expr = "a Cargo.toml buffer with content {string}")]
fn given_cargo_buffer(world: &mut EditorWorld, content: String) {
world.inner.schema_lines = if content.is_empty() {
vec![String::new()]
} else {
content.lines().map(String::from).collect()
};
}
#[given(expr = "a Cargo.toml buffer starting with {string}")]
fn given_cargo_buffer_start(world: &mut EditorWorld, first_line: String) {
world.inner.schema_lines = vec![first_line];
}
#[given(expr = "a GitLab buffer with content {string}")]
fn given_gitlab_buffer(world: &mut EditorWorld, content: String) {
world.inner.schema_lines = if content.is_empty() {
vec![String::new()]
} else {
content.lines().map(String::from).collect()
};
}
#[given(expr = "a GitLab buffer starting with {string}")]
fn given_gitlab_buffer_start(world: &mut EditorWorld, first_line: String) {
world.inner.schema_lines = vec![first_line];
}
#[given(expr = "a buffer line {string}")]
fn given_buffer_line(world: &mut EditorWorld, line: String) {
world.inner.schema_lines.push(line);
}
#[given(expr = "the cursor is at line {int} column {int}")]
fn given_schema_cursor(world: &mut EditorWorld, line: usize, column: usize) {
world.inner.schema_cursor = Position { line, column };
}
#[when("I request completions")]
fn when_schema_completions(world: &mut EditorWorld) {
let path_opt: Option<&Path> = world.inner.schema_target.as_deref();
world.inner.schema_completions = completions_from_schema(
&world.inner.schema_json,
&world.inner.schema_lines,
world.inner.schema_cursor,
path_opt,
);
}
#[then(expr = "the completions include {string}")]
fn then_includes(world: &mut EditorWorld, label: String) {
let found = world.inner.schema_completions.iter().any(|c| c.label == label);
assert!(
found,
"expected completion {:?} but got: {:?}",
label,
world.inner.schema_completions
.iter()
.map(|c| &c.label)
.collect::<Vec<_>>()
);
}
fn run_schema_integration_tests() {
let mut reg = oo_ide::schema::SchemaRegistry::new();
reg.register_from_extension(
"test-schema".into(),
"Test Schema".into(),
vec!["*.json".into()],
r#"{"type":"object","properties":{"opt":{"type":"string","enum":["x","y"]}}}"#.into(),
);
let path = Path::new("config.json");
let content = reg.resolve(path).expect("schema should be resolved");
assert_eq!(content.id, "test-schema");
let lines = vec![r#"{"opt": ""#.to_string()];
let pos = oo_ide::editor::position::Position {
line: 0,
column: lines[0].len(),
};
let items =
oo_ide::schema::completions_from_schema(&content.schema_json, &lines, pos, Some(path));
let labels: Vec<String> = items.into_iter().map(|i| i.label).collect();
assert!(
labels.contains(&"x".to_string()),
"expected 'x' in completions"
);
assert!(
labels.contains(&"y".to_string()),
"expected 'y' in completions"
);
}
fn minimal_lm_def(id: &str) -> LogMatcherDef {
let yaml = format!(
"id: {id:?}\nsource: test\nstart:\n match: \"^test\"\nend:\n condition: next_start\nemit:\n severity: error\n message: \"test message\"\n",
id = id
);
serde_saphyr::from_str(&yaml).expect("minimal_def yaml should parse")
}
#[given(expr = "a valid log matcher with id {string}")]
fn given_valid_matcher(world: &mut EditorWorld, id: String) {
world.inner.lm_defs.push(minimal_lm_def(&id));
}
#[given(expr = "a minimal log matcher with id {string}")]
fn given_minimal_matcher(world: &mut EditorWorld, id: String) {
world.inner.lm_defs.push(minimal_lm_def(&id));
}
#[given(expr = "a log matcher with id {string} and start pattern {string}")]
fn given_matcher_with_start(world: &mut EditorWorld, id: String, pattern: String) {
let yaml = format!(
"id: {id:?}\nsource: test\nstart:\n match: {pattern:?}\nend:\n condition: next_start\nemit:\n severity: error\n message: \"msg\"\n",
id = id,
pattern = pattern
);
world.inner.lm_defs
.push(serde_saphyr::from_str(&yaml).expect("yaml"));
}
#[given(expr = "a log matcher with id {string} and empty emit message")]
fn given_matcher_empty_message(world: &mut EditorWorld, id: String) {
let yaml = format!(
"id: {id:?}\nsource: test\nstart:\n match: \"^test\"\nend:\n condition: next_start\nemit:\n severity: error\n message: \"\"\n",
id = id
);
world.inner.lm_defs
.push(serde_saphyr::from_str(&yaml).expect("yaml"));
}
#[given(expr = "a log matcher with id {string} and schema_version {string}")]
fn given_matcher_schema_version(world: &mut EditorWorld, id: String, version: String) {
let yaml = format!(
"id: {id:?}\nsource: test\nschema_version: {version}\nstart:\n match: \"^test\"\nend:\n condition: next_start\nemit:\n severity: error\n message: \"msg\"\n",
id = id,
version = version
);
world.inner.lm_defs
.push(serde_saphyr::from_str(&yaml).expect("yaml"));
}
#[given(expr = "a log matcher with id {string} and end condition {string}")]
fn given_matcher_end_condition(world: &mut EditorWorld, id: String, condition: String) {
let yaml = format!(
"id: {id:?}\nsource: test\nstart:\n match: \"^test\"\nend:\n condition: {condition:?}\nemit:\n severity: error\n message: \"msg\"\n",
id = id,
condition = condition
);
world.inner.lm_defs
.push(serde_saphyr::from_str(&yaml).expect("yaml"));
}
#[given(
expr = "a log matcher with id {string} and start pattern {string} and emit message {string}"
)]
fn given_matcher_with_start_and_emit(
world: &mut EditorWorld,
id: String,
pattern: String,
emit_msg: String,
) {
let yaml = format!(
"id: {id:?}\nsource: test\nstart:\n match: {pattern:?}\nend:\n condition: next_start\nemit:\n severity: error\n message: {emit_msg:?}\n",
id = id,
pattern = pattern,
emit_msg = emit_msg
);
world.inner.lm_defs
.push(serde_saphyr::from_str(&yaml).expect("yaml"));
}
#[given("warn_unused_captures is enabled")]
fn given_warn_unused_captures(world: &mut EditorWorld) {
world.inner.lm_warn_unused_captures = true;
}
#[when("I compile the matchers")]
fn when_compile(world: &mut EditorWorld) {
let defs = world.inner.lm_defs.drain(..).collect();
let opts = CompileOptions {
warn_unused_captures: world.inner.lm_warn_unused_captures,
..Default::default()
};
world.inner.lm_result = Some(match compile_matchers(defs, opts) {
Ok(r) => Ok(r),
Err(msgs) => Err(msgs),
});
}
#[then("compilation succeeds")]
fn then_succeeds(world: &mut EditorWorld) {
match &world.inner.lm_result {
Some(Ok(_)) => {}
Some(Err(msgs)) => panic!(
"expected compilation to succeed but got errors:\n{}",
msgs.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("\n")
),
None => panic!("no compilation result"),
}
}
#[then("compilation fails")]
fn then_fails(world: &mut EditorWorld) {
match &world.inner.lm_result {
Some(Err(_)) => {}
Some(Ok(_)) => panic!("expected compilation to fail but it succeeded"),
None => panic!("no compilation result"),
}
}
#[then(expr = "the result contains matcher with id {string}")]
fn then_contains_matcher(world: &mut EditorWorld, id: String) {
let matchers = match &world.inner.lm_result {
Some(Ok(r)) => &r.matchers,
_ => panic!("compilation did not succeed"),
};
assert!(
matchers.iter().any(|m| m.id.0 == id),
"matcher '{}' not found; have: {:?}",
id,
matchers.iter().map(|m| &m.id.0).collect::<Vec<_>>()
);
}
#[then(expr = "the result contains {int} matchers")]
fn then_result_count(world: &mut EditorWorld, count: usize) {
let matchers = match &world.inner.lm_result {
Some(Ok(r)) => &r.matchers,
_ => panic!("compilation did not succeed"),
};
assert_eq!(
matchers.len(),
count,
"expected {} matchers, got {}",
count,
matchers.len()
);
}
#[then(expr = "the matcher {string} has priority {int}")]
fn then_matcher_priority(world: &mut EditorWorld, id: String, priority: u32) {
let matchers = match &world.inner.lm_result {
Some(Ok(r)) => &r.matchers,
_ => panic!("compilation did not succeed"),
};
let m = matchers
.iter()
.find(|m| m.id.0 == id)
.unwrap_or_else(|| panic!("matcher '{}' not found", id));
assert_eq!(m.priority, priority);
}
#[then(expr = "an error references field {string}")]
fn then_error_references_field(world: &mut EditorWorld, field: String) {
let msgs = match &world.inner.lm_result {
Some(Err(msgs)) => msgs,
_ => panic!("compilation did not fail"),
};
assert!(
msgs.iter().any(|m| m
.reference
.as_ref()
.is_some_and(|r| r.filename.contains(&field))),
"expected an error referencing '{}'; messages:\n{}",
field,
msgs.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("\n")
);
}
#[then(expr = "the error text includes {string}")]
fn then_error_text_includes(world: &mut EditorWorld, needle: String) {
let msgs = match &world.inner.lm_result {
Some(Err(msgs)) => msgs,
_ => panic!("compilation did not fail"),
};
assert!(
msgs.iter()
.any(|m| m.level == MessageLevel::Error && m.text.contains(&needle)),
"expected error message containing '{}'; messages:\n{}",
needle,
msgs.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("\n")
);
}
#[then(expr = "the error has a related reference labeled {string}")]
fn then_error_has_related(world: &mut EditorWorld, label: String) {
let msgs = match &world.inner.lm_result {
Some(Err(msgs)) => msgs,
_ => panic!("compilation did not fail"),
};
assert!(
msgs.iter()
.any(|m| m.related.iter().any(|r| r.label.contains(&label))),
"expected an error with related reference labeled '{}'; messages:\n{}",
label,
msgs.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("\n")
);
}
#[then(expr = "messages contain a warning mentioning {string}")]
fn then_warning_mentioning(world: &mut EditorWorld, needle: String) {
let msgs = match &world.inner.lm_result {
Some(Ok(r)) => &r.messages,
_ => panic!("compilation did not succeed"),
};
assert!(
msgs.iter()
.any(|m| m.level == MessageLevel::Warning && m.text.contains(&needle)),
"expected a warning mentioning '{}'; messages:\n{}",
needle,
msgs.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("\n")
);
}
fn make_engine_matcher(
start_pat: &str,
body_pat: Option<&str>,
end_cond: &str,
emit_msg: &str,
priority: u32,
) -> CompiledMatcher {
use oo_ide::log_matcher::{BodyRule, EmitSeverity, EmitTemplate, EndCondition, MatcherId};
use regex::Regex;
use std::sync::Arc;
let end = if end_cond == "blank_line" {
EndCondition::BlankLine
} else {
EndCondition::NextStart
};
let body = body_pat
.map(|p| {
vec![BodyRule {
pattern: Arc::new(Regex::new(p).unwrap()),
optional: false,
repeat: false,
}]
})
.unwrap_or_default();
CompiledMatcher {
id: MatcherId(format!("engine.test.{start_pat}")),
source: "test".to_string(),
priority,
schema_version: 1,
start: Arc::new(Regex::new(start_pat).unwrap()),
body,
max_lines: None,
end,
emit: EmitTemplate {
severity: EmitSeverity::Error,
message: emit_msg.to_string(),
file: Some("{{ file }}".to_string()),
line: Some("{{ line }}".to_string()),
column: None,
code: None,
},
}
}
#[given(expr = "an engine matcher with start pattern {string} and end condition {string}")]
fn given_engine_matcher_simple(world: &mut EditorWorld, start: String, end: String) {
world.inner.lm_engine_matchers
.push(make_engine_matcher(&start, None, &end, "{{ message }}", 0));
}
#[given(
expr = "an engine matcher with start pattern {string} and body pattern {string} and end condition {string}"
)]
fn given_engine_matcher_with_body(
world: &mut EditorWorld,
start: String,
body: String,
end: String,
) {
world.inner.lm_engine_matchers.push(make_engine_matcher(
&start,
Some(&body),
&end,
"{{ message }}",
0,
));
}
#[given(
expr = "an engine low-priority matcher with start pattern {string} and emit prefix {string}"
)]
fn given_engine_low_priority(world: &mut EditorWorld, start: String, prefix: String) {
let msg = format!("{prefix}: {{{{ message }}}}");
world.inner.lm_engine_matchers
.push(make_engine_matcher(&start, None, "next_start", &msg, 1));
}
#[given(
expr = "an engine high-priority matcher with start pattern {string} and emit prefix {string}"
)]
fn given_engine_high_priority(world: &mut EditorWorld, start: String, prefix: String) {
let msg = format!("{prefix}: {{{{ message }}}}");
world.inner.lm_engine_matchers
.push(make_engine_matcher(&start, None, "next_start", &msg, 100));
}
#[when(expr = "I process the line {string}")]
fn when_process_line(world: &mut EditorWorld, line: String) {
if world.inner.lm_engine.is_none() {
let matchers = std::mem::take(&mut world.inner.lm_engine_matchers);
world.inner.lm_engine = Some(MatcherEngine::new(matchers, "task:test:t"));
}
let new_issues = world.inner.lm_engine.as_mut().unwrap().process_line(&line);
world.inner.lm_engine_issues.extend(new_issues);
}
#[when("I flush the engine")]
fn when_flush_engine(world: &mut EditorWorld) {
if let Some(ref mut eng) = world.inner.lm_engine {
let flushed = eng.flush();
world.inner.lm_engine_issues.extend(flushed);
}
}
#[then(expr = "the engine emitted {int} issue")]
fn then_emitted_one(world: &mut EditorWorld, count: usize) {
assert_eq!(
world.inner.lm_engine_issues.len(),
count,
"expected {count} issue(s), got {}; issues: {:?}",
world.inner.lm_engine_issues.len(),
world.inner.lm_engine_issues
.iter()
.map(|i| &i.message)
.collect::<Vec<_>>()
);
}
#[then(expr = "the engine emitted {int} issues total")]
fn then_emitted_total(world: &mut EditorWorld, count: usize) {
assert_eq!(
world.inner.lm_engine_issues.len(),
count,
"expected {count} total issue(s), got {}",
world.inner.lm_engine_issues.len()
);
}
#[then(expr = "the engine emitted 0 issues")]
fn then_emitted_zero(world: &mut EditorWorld) {
assert!(
world.inner.lm_engine_issues.is_empty(),
"expected 0 issues, got {}",
world.inner.lm_engine_issues.len()
);
}
#[then(expr = "the issue message is {string}")]
fn then_issue_message(world: &mut EditorWorld, expected: String) {
let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
assert_eq!(issue.message, expected, "issue message mismatch");
}
#[then(expr = "the issue file is {string}")]
fn then_issue_file(world: &mut EditorWorld, expected: String) {
let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
assert_eq!(
issue.path.as_deref(),
Some(std::path::Path::new(&expected)),
"issue file path mismatch"
);
}
#[then(expr = "issue {int} has message {string}")]
fn then_issue_n_message(world: &mut EditorWorld, n: usize, expected: String) {
let issue = world.inner.lm_engine_issues.get(n - 1).unwrap_or_else(|| {
panic!(
"no issue at index {} (have {})",
n,
world.inner.lm_engine_issues.len()
)
});
assert_eq!(issue.message, expected, "issue {n} message mismatch");
}
#[then(expr = "the issue message starts with {string}")]
fn then_issue_starts_with(world: &mut EditorWorld, prefix: String) {
let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
assert!(
issue.message.starts_with(&prefix),
"expected message starting with {:?}, got {:?}",
prefix,
issue.message
);
}
#[then(expr = "the issue line is {int}")]
fn then_issue_line(world: &mut EditorWorld, expected_line: usize) {
let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
let actual = issue
.range
.map(|(p, _)| p.line + 1)
.unwrap_or_else(|| panic!("issue has no range"));
assert_eq!(actual, expected_line, "issue line mismatch");
}
#[then(expr = "the issue column is {int}")]
fn then_issue_column(world: &mut EditorWorld, expected_col: usize) {
let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
let actual = issue
.range
.map(|(p, _)| p.column)
.unwrap_or_else(|| panic!("issue has no range"));
assert_eq!(actual, expected_col, "issue column mismatch");
}
#[then("the issue has no file")]
fn then_issue_no_file(world: &mut EditorWorld) {
let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
assert!(
issue.path.is_none(),
"expected no file, got {:?}",
issue.path
);
}
#[then(expr = "the issue severity is {string}")]
fn then_issue_severity(world: &mut EditorWorld, expected: String) {
use oo_ide::issue_registry::Severity;
let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
let expected_sev = match expected.to_lowercase().as_str() {
"error" => Severity::Error,
"warning" => Severity::Warning,
"info" | "hint" => Severity::Info,
other => panic!("unknown severity: {other}"),
};
assert_eq!(issue.severity, expected_sev, "issue severity mismatch");
}
const RUST_CARGO_MATCHERS_YAML: &[&str] = &[
r#"
id: rust.cargo.error
source: cargo
priority: 100
start:
match: '^error(\[(?P<code>E\d+)\])?: (?P<message>.+)'
body:
- match: '^ *--> (?P<file>.+):(?P<line>\d+):(?P<col>\d+)'
optional: true
- match: '^\s+\|'
repeat: true
optional: true
end:
condition: next_start
emit:
severity: error
message: "{{ message }}"
file: "{{ file }}"
line: "{{ line }}"
column: "{{ col }}"
code: "{{ code }}"
"#,
r#"
id: rust.cargo.warning
source: cargo
priority: 90
start:
match: '^warning(\[(?P<code>W\d+)\])?: (?P<message>.+)'
body:
- match: '^ *--> (?P<file>.+):(?P<line>\d+):(?P<col>\d+)'
optional: true
end:
condition: next_start
emit:
severity: warning
message: "{{ message }}"
file: "{{ file }}"
line: "{{ line }}"
column: "{{ col }}"
code: "{{ code }}"
"#,
];
#[given("I use the Rust cargo matchers")]
fn given_rust_cargo_matchers(world: &mut EditorWorld) {
let defs: Vec<LogMatcherDef> = RUST_CARGO_MATCHERS_YAML
.iter()
.map(|yaml| serde_saphyr::from_str(yaml).expect("rust cargo matcher yaml should parse"))
.collect();
let opts = CompileOptions::default();
let result = compile_matchers(defs, opts);
if let Ok(ref cr) = result {
world.inner.lm_engine_matchers.extend(cr.matchers.clone());
}
world.inner.lm_result = Some(result);
}
const GCC_MATCHERS_YAML: &[&str] = &[
r#"
id: cpp.gcc.error
source: gcc
priority: 100
start:
match: '^(?P<file>[^:\s][^:]*):(?P<line>\d+):(?P<col>\d+):\s*(?:fatal\s+)?error:\s*(?P<message>.+)$'
body:
- match: '^\s+\d*\s*\|'
repeat: true
optional: true
end:
condition: next_start
emit:
severity: error
message: "{{ message }}"
file: "{{ file }}"
line: "{{ line }}"
column: "{{ col }}"
"#,
r#"
id: cpp.gcc.warning
source: gcc
priority: 90
start:
match: '^(?P<file>[^:\s][^:]*):(?P<line>\d+):(?P<col>\d+):\s*warning:\s*(?P<message>.+)$'
body:
- match: '^\s+\d*\s*\|'
repeat: true
optional: true
end:
condition: next_start
emit:
severity: warning
message: "{{ message }}"
file: "{{ file }}"
line: "{{ line }}"
column: "{{ col }}"
"#,
];
#[given("I use the GCC matchers")]
fn given_gcc_matchers(world: &mut EditorWorld) {
let defs: Vec<LogMatcherDef> = GCC_MATCHERS_YAML
.iter()
.map(|yaml| serde_saphyr::from_str(yaml).expect("gcc matcher yaml should parse"))
.collect();
let opts = CompileOptions::default();
let result = compile_matchers(defs, opts);
if let Ok(ref cr) = result {
world.inner.lm_engine_matchers.extend(cr.matchers.clone());
}
world.inner.lm_result = Some(result);
}
const PYTHON_TRACEBACK_MATCHERS_YAML: &[&str] = &[r#"
id: python.traceback
source: python
priority: 100
start:
match: '^Traceback \(most recent call last\):'
body:
- match: '^\s+File "(?P<file>.+)", line (?P<line>\d+)'
optional: true
repeat: true
- match: '^\s+.+'
optional: true
repeat: true
- match: '^(?P<message>[A-Za-z_][A-Za-z0-9_.]*:.+)$'
optional: true
end:
condition: next_start
emit:
severity: error
message: "{{ message }}"
file: "{{ file }}"
line: "{{ line }}"
"#];
const PYTHON_EXCEPTION_MATCHERS_YAML: &[&str] = &[r#"
id: python.exception
source: python
priority: 50
start:
match: '^(?P<message>[A-Za-z_][A-Za-z0-9_.]*:.+)$'
end:
condition: next_start
emit:
severity: error
message: "{{ message }}"
"#];
#[given("I use the Python traceback matcher")]
fn given_python_traceback_matchers(world: &mut EditorWorld) {
let defs: Vec<LogMatcherDef> = PYTHON_TRACEBACK_MATCHERS_YAML
.iter()
.map(|yaml| {
serde_saphyr::from_str(yaml).expect("python traceback matcher yaml should parse")
})
.collect();
let opts = CompileOptions::default();
let result = compile_matchers(defs, opts);
if let Ok(ref cr) = result {
world.inner.lm_engine_matchers.extend(cr.matchers.clone());
}
world.inner.lm_result = Some(result);
}
#[given("I use the Python exception matcher")]
fn given_python_exception_matcher(world: &mut EditorWorld) {
let defs: Vec<LogMatcherDef> = PYTHON_EXCEPTION_MATCHERS_YAML
.iter()
.map(|yaml| {
serde_saphyr::from_str(yaml).expect("python exception matcher yaml should parse")
})
.collect();
let opts = CompileOptions::default();
let result = compile_matchers(defs, opts);
if let Ok(ref cr) = result {
world.inner.lm_engine_matchers.extend(cr.matchers.clone());
}
world.inner.lm_result = Some(result);
}
#[when("the app processes the following cargo output:")]
fn when_app_processes_cargo_output(world: &mut EditorWorld, step: &Step) {
let content = step.docstring.as_deref().expect("requires docstring");
let matchers = world.inner.lm_engine_matchers.clone();
let mut engine = MatcherEngine::new(matchers, "task:build:test".to_string());
for line in content.lines() {
for issue in engine.process_line(line) {
world.inner.app.issue_registry.add_issue(issue);
}
}
for issue in engine.flush() {
world.inner.app.issue_registry.add_issue(issue);
}
}
#[then(expr = "the app issue registry has {int} issue(s)")]
fn then_registry_has_n_issues(world: &mut EditorWorld, n: usize) {
let count = world.inner.app.issue_registry.len();
assert_eq!(count, n, "expected {n} issue(s) in registry, got {count}");
}
#[then(expr = "app issue {int} message starts with {string}")]
fn then_app_issue_message_starts_with(world: &mut EditorWorld, idx: usize, prefix: String) {
let issues = world.inner.app.issue_registry.list_all();
let issue = issues
.get(idx - 1)
.unwrap_or_else(|| panic!("no issue at index {idx}"));
assert!(
issue.message.starts_with(&prefix),
"expected message starting with {:?}, got {:?}",
prefix,
issue.message
);
}
#[then(expr = "app issue {int} has file containing {string}")]
fn then_app_issue_has_file_containing(world: &mut EditorWorld, idx: usize, fragment: String) {
let issues = world.inner.app.issue_registry.list_all();
let issue = issues
.get(idx - 1)
.unwrap_or_else(|| panic!("no issue at index {idx}"));
let path_str = issue
.path
.as_ref()
.unwrap_or_else(|| panic!("issue {idx} has no file, expected it to contain {fragment:?}"))
.to_string_lossy()
.to_string();
assert!(
path_str.contains(&fragment),
"expected file containing {:?}, got {:?}",
fragment,
path_str
);
}
#[then(expr = "app issue {int} has line {int}")]
fn then_app_issue_has_line(world: &mut EditorWorld, idx: usize, expected_line: usize) {
let issues = world.inner.app.issue_registry.list_all();
let issue = issues
.get(idx - 1)
.unwrap_or_else(|| panic!("no issue at index {idx}"));
let actual = issue
.range
.map(|(p, _)| p.line + 1)
.unwrap_or_else(|| panic!("issue {idx} has no range"));
assert_eq!(actual, expected_line, "issue {idx} line mismatch");
}
#[then(expr = "app issue {int} has severity {string}")]
fn then_app_issue_has_severity(world: &mut EditorWorld, idx: usize, expected: String) {
use oo_ide::issue_registry::Severity;
let issues = world.inner.app.issue_registry.list_all();
let issue = issues
.get(idx - 1)
.unwrap_or_else(|| panic!("no issue at index {idx}"));
let expected_sev = match expected.to_lowercase().as_str() {
"error" => Severity::Error,
"warning" => Severity::Warning,
"info" | "hint" => Severity::Info,
other => panic!("unknown severity: {other}"),
};
assert_eq!(
issue.severity, expected_sev,
"issue {idx} severity mismatch"
);
}
#[given("a fresh editor session")]
fn given_fresh_editor_session(_world: &mut EditorWorld) {
}
fn simulate_lsp_diagnostics(world: &mut EditorWorld, filename: &str) {
use oo_ide::editor::position::Position;
use oo_ide::issue_registry::{NewIssue, Severity};
let uri = format!("file:///project/{filename}");
let marker = format!("lsp:{uri}");
let path = std::path::PathBuf::from(filename);
world.inner.app.issue_registry.clear_by_marker(&marker);
world.inner.app.issue_registry.add_issue(NewIssue {
marker: Some(marker),
source: "lsp".into(),
path: Some(path),
range: Some((Position::new(5, 4), Position::new(5, 10))),
message: "cannot find value `foo`".to_string(),
severity: Severity::Error,
});
}
#[given(expr = "the LSP server published a prior error for {string}")]
fn given_lsp_published_prior_error(world: &mut EditorWorld, filename: String) {
simulate_lsp_diagnostics(world, &filename);
}
#[when(expr = "the LSP server publishes diagnostics for {string}")]
fn when_lsp_publishes_diagnostics(world: &mut EditorWorld, filename: String) {
simulate_lsp_diagnostics(world, &filename);
}
#[then("the issue registry contains an error issue at line 5")]
fn then_registry_has_error_at_line_5(world: &mut EditorWorld) {
use oo_ide::issue_registry::Severity;
let issues = world.inner.app.issue_registry.list_all();
assert!(
!issues.is_empty(),
"expected at least one issue in the registry"
);
let found = issues.iter().any(|i| {
i.severity == Severity::Error
&& i.range.is_some_and(|(p, _)| p.line == 5)
});
assert!(found, "expected an Error issue at line 5, got: {issues:#?}");
}
#[then("the issue marker starts with \"lsp:\"")]
fn then_issue_marker_starts_with_lsp(world: &mut EditorWorld) {
let issues = world.inner.app.issue_registry.list_all();
assert!(
!issues.is_empty(),
"expected at least one issue"
);
let all_lsp = issues.iter().all(|i| {
i.marker.as_deref().is_some_and(|m| m.starts_with("lsp:"))
});
assert!(all_lsp, "expected all issues to have marker starting with 'lsp:'");
}
#[given(expr = "the editor has a file with path {string} and contents:")]
fn given_editor_has_file(world: &mut EditorWorld, path: String, step: &cucumber::gherkin::Step) {
let doc = step
.docstring()
.expect("expected a triple-quoted docstring with file contents");
let dir = world.inner._dir.path().to_path_buf();
let file_path = dir.join(&path);
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&file_path, doc).expect("write test file");
}
#[when(expr = "the test triggers a go-to-definition for the symbol at {string} line {int} column {int}")]
fn when_trigger_goto(world: &mut EditorWorld, filename: String, _line: usize, _column: usize) {
let dir = world.inner._dir.path().to_path_buf();
let target = dir.join(&filename);
let opened = world
.inner
.app
.open_file_preserving_stash(target.clone());
assert!(opened, "failed to open file {:?}", target);
match &mut world.inner.app.screen {
Screen::Editor(ed) => {
ed.buffer.set_cursor(Position::new(0, 0));
world.inner.app.recompute_contexts();
}
other => panic!("expected editor screen after opening file, got: {:?}", other),
}
}
#[then(expr = "the app opens file {string} at line {int}")]
fn then_app_opens_file_at_line(world: &mut EditorWorld, filename: String, line: usize) {
let expected = world.inner._dir.path().join(&filename);
match &world.inner.app.screen {
Screen::Editor(ed) => {
let path = ed
.buffer
.path
.as_ref()
.expect("editor buffer has no associated path");
assert_eq!(path, &expected, "opened file path mismatch");
assert_eq!(ed.buffer.cursor().line + 1, line, "cursor line mismatch");
}
other => panic!("expected editor screen, got {:?}", other),
}
}
#[then(expr = "the app cursor is at line {int}")]
fn then_app_cursor_at_line(world: &mut EditorWorld, line: usize) {
match &world.inner.app.screen {
Screen::Editor(ed) => {
assert_eq!(ed.buffer.cursor().line + 1, line, "cursor line mismatch");
}
other => panic!("expected editor screen, got {:?}", other),
}
}
#[then("the completion dropdown is visible")]
fn then_completion_visible(world: &mut EditorWorld) {
match &world.inner.app.screen {
Screen::Editor(ed) => {
assert!(ed.completion.is_some(), "expected completion dropdown to be visible");
}
other => panic!("expected editor screen, got {:?}", other),
}
}
#[then("the completion dropdown is not visible")]
fn then_completion_not_visible(world: &mut EditorWorld) {
match &world.inner.app.screen {
Screen::Editor(ed) => {
assert!(ed.completion.is_none(), "expected completion dropdown to not be visible");
}
other => panic!("expected editor screen, got {:?}", other),
}
}
#[then("the command executes without error")]
fn then_command_executes_without_error(_world: &mut EditorWorld) {
}
#[when("the test simulates completion response with items:")]
fn when_simulate_completion_response(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
let table = step.table().expect("expected a table");
let mut items = Vec::new();
for row in table.rows.iter().skip(1) {
let label = row.first().cloned().unwrap_or_default();
let detail = row.get(1).cloned();
let insert_text = row.get(2).cloned();
items.push(oo_ide::operation::LspCompletionItem {
label,
kind: None,
detail,
insert_text,
});
}
let trigger = {
let ed = ed_mut(world);
ed.buffer.cursor()
};
let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionResponse {
items,
trigger: Some(trigger),
version: None,
});
let settings = &world.inner.app.settings;
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, settings);
}
world.inner.app.recompute_contexts();
}
#[then(expr = "the completion has {int} items")]
fn then_completion_count(world: &mut EditorWorld, count: usize) {
match &world.inner.app.screen {
Screen::Editor(ed) => {
let c = ed.completion.as_ref().expect("completion should be active");
assert_eq!(c.items.len(), count, "completion item count mismatch");
}
other => panic!("expected editor screen, got {:?}", other),
}
}
#[then(expr = "completion item {int} label is {string}")]
fn then_completion_item_label(world: &mut EditorWorld, idx: usize, label: String) {
match &world.inner.app.screen {
Screen::Editor(ed) => {
let c = ed.completion.as_ref().expect("completion should be active");
assert!(idx < c.items.len(), "index out of bounds");
assert_eq!(c.items[idx].label, label, "completion item label mismatch");
}
other => panic!("expected editor screen, got {:?}", other),
}
}
#[given(expr = "completion is active with items:")]
fn given_completion_active(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
let table = step.table().expect("expected a table");
let mut items = Vec::new();
for row in table.rows.iter().skip(1) {
let label = row.first().cloned().unwrap_or_default();
let insert_text = row.get(1).cloned();
items.push(oo_ide::operation::LspCompletionItem {
label,
kind: None,
detail: None,
insert_text,
});
}
let trigger = {
let ed = ed_mut(world);
ed.buffer.cursor()
};
let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionResponse {
items,
trigger: Some(trigger),
version: None,
});
let settings = &world.inner.app.settings;
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, settings);
}
world.inner.app.recompute_contexts();
}
#[when("I press the down arrow")]
fn when_press_down_arrow(world: &mut EditorWorld) {
let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionMoveDown);
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, &world.inner.app.settings);
}
world.inner.app.recompute_contexts();
}
#[when("I press the up arrow")]
fn when_press_up_arrow(world: &mut EditorWorld) {
let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionMoveUp);
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, &world.inner.app.settings);
}
world.inner.app.recompute_contexts();
}
#[then(expr = "the completion selected index is {int}")]
fn then_completion_index(world: &mut EditorWorld, idx: usize) {
match &world.inner.app.screen {
Screen::Editor(ed) => {
let c = ed.completion.as_ref().expect("completion should be active");
assert_eq!(c.cursor, idx, "completion selected index mismatch");
}
other => panic!("expected editor screen, got {:?}", other),
}
}
#[when("I press Enter")]
fn when_press_enter_in_completion(world: &mut EditorWorld) {
let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionConfirm);
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, &world.inner.app.settings);
let settings = &world.inner.app.settings;
for deferred_op in ed.take_deferred_ops() {
ed.handle_operation(&deferred_op, settings);
}
}
world.inner.app.recompute_contexts();
}
#[when("I press Escape")]
fn when_press_escape_in_completion(world: &mut EditorWorld) {
let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionDismiss);
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, &world.inner.app.settings);
}
world.inner.app.recompute_contexts();
}
#[given(expr = "the terminal contains:")]
fn given_terminal_contains(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
let raw = step.docstring().expect("expected docstring");
let content = raw.trim_matches('\n');
let lines: Vec<oo_ide::vt_parser::StyledLine> = content
.lines()
.map(|l| oo_ide::vt_parser::StyledLine { text: l.to_string(), spans: Vec::new() })
.collect();
let mut tv = TerminalView::new();
let dir = world.inner._dir.path().to_path_buf();
let pty = native_pty_system();
let pair = pty.openpty(PtySize { rows: 10, cols: 80, pixel_width: 0, pixel_height: 0 }).expect("openpty failed");
let writer = pair.master.take_writer().unwrap();
let parser = RefCell::new(Parser::new(10, 80, 100));
let tab = TerminalTab {
id: 1,
title: "t".into(),
command: "cmd".into(),
cwd: dir,
master: pair.master,
writer,
parser,
links: Vec::new(),
scroll_offset: 0,
scrollback_len: lines.len(),
exited: false,
scrollback_lines: lines,
};
tv.tabs.push(tab);
tv.active = 0;
world.inner.app.screen = Screen::Terminal(Box::new(tv));
world.inner.app.recompute_contexts();
}
#[when(expr = "I open the search bar")]
fn when_open_search_bar(world: &mut EditorWorld) {
if let Screen::Terminal(tv) = &mut world.inner.app.screen {
let op = Operation::SearchLocal(SearchOp::Open { replace: false });
tv.handle_operation(&op, &world.inner.app.settings);
} else {
panic!("active screen is not terminal");
}
world.inner.app.recompute_contexts();
}
#[when(expr = "I type {string}")]
fn when_type(world: &mut EditorWorld, text: String) {
if let Screen::Terminal(tv) = &mut world.inner.app.screen {
let op = Operation::SearchLocal(SearchOp::QueryInput(oo_ide::widgets::input_field::InputFieldOp::SetText(text)));
tv.handle_operation(&op, &world.inner.app.settings);
} else { panic!("active screen is not terminal"); }
world.inner.app.recompute_contexts();
}
#[when(expr = "I press F3")]
fn when_press_f3(world: &mut EditorWorld) {
if let Screen::Terminal(tv) = &mut world.inner.app.screen {
let op = Operation::SearchLocal(SearchOp::NextMatch);
tv.handle_operation(&op, &world.inner.app.settings);
} else { panic!("active screen is not terminal"); }
world.inner.app.recompute_contexts();
}
#[then(expr = "the search matches count should be {int}")]
fn then_matches_count(world: &mut EditorWorld, count: usize) {
if let Screen::Terminal(tv) = &mut world.inner.app.screen {
let s = tv.search.as_ref().or(tv.last_search.as_ref()).expect("no search state");
assert_eq!(s.matches.len(), count);
} else { panic!("active screen is not terminal"); }
}
#[then(expr = "the current match index should be {int}")]
fn then_current_index(world: &mut EditorWorld, idx: usize) {
if let Screen::Terminal(tv) = &mut world.inner.app.screen {
let s = tv.search.as_ref().or(tv.last_search.as_ref()).expect("no search state");
let cur = s.current.map(|i| i + 1).unwrap_or(0);
assert_eq!(cur, idx);
} else { panic!("active screen is not terminal"); }
}
#[given(expr = "the editor is in Expanded search mode")]
fn given_editor_expanded_mode(world: &mut EditorWorld) {
let ed = ed_mut(world);
let mut query_field = InputField::new("Find");
query_field.set_text(String::new());
ed.search = Some(SearchState {
query: query_field,
replacement: InputField::new("Replace"),
kind: SearchKind::Find,
mode: SearchMode::Expanded,
focus: FocusRing::new(vec!["search_query"]),
opts: SearchOptions::default(),
matches: Vec::new(),
current: 0,
files: Vec::new(),
file_path_index: HashMap::new(),
selected_file: 0,
file_panel_scroll: 0,
match_panel_scroll: 0,
include_filter: InputField::new("incl").with_text("*"),
exclude_filter: InputField::new("excl"),
project_search_generation: 0,
expanded_files: HashSet::new(),
tree_cursor_path: None,
tree_cursor_match: None,
tree_scroll: 0,
project_match_cursor: None,
});
}
#[given(expr = "the editor is in Expanded search mode with query {string} and generation {int}")]
fn given_editor_expanded_with_query(world: &mut EditorWorld, query: String, generation: u64) {
let ed = ed_mut(world);
let mut query_field = InputField::new("Find");
query_field.set_text(query);
ed.search = Some(SearchState {
query: query_field,
replacement: InputField::new("Replace"),
kind: SearchKind::Find,
mode: SearchMode::Expanded,
focus: FocusRing::new(vec!["search_query"]),
opts: SearchOptions::default(),
matches: Vec::new(),
current: 0,
files: Vec::new(),
file_path_index: HashMap::new(),
selected_file: 0,
file_panel_scroll: 0,
match_panel_scroll: 0,
include_filter: InputField::new("incl").with_text("*"),
exclude_filter: InputField::new("excl"),
project_search_generation: generation,
expanded_files: HashSet::new(),
tree_cursor_path: None,
tree_cursor_match: None,
tree_scroll: 0,
project_match_cursor: None,
});
world.inner.app.project_search_generation = generation;
}
#[given(expr = "a project file {string} contains {string}")]
fn given_project_file_contains(world: &mut EditorWorld, filename: String, content: String) {
let path = world.inner._dir.path().join(&filename);
let content = content.replace("\\n", "\n");
std::fs::write(&path, &content).expect("write project file");
}
#[when(expr = "a search result arrives for {string} at line {int} with generation {int}")]
fn when_search_result_arrives(world: &mut EditorWorld, filename: String, line: usize, generation: u64) {
let file = std::path::PathBuf::from(&filename);
let op = Operation::SearchLocal(SearchOp::AddProjectResult {
file,
result: oo_ide::operation::MatchSpan {
line,
byte_start: 0,
byte_end: 1,
line_text: String::from("dummy"),
},
generation,
});
let settings = &world.inner.app.settings;
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, settings);
}
}
#[when(expr = "the search results are cleared for generation {int}")]
fn when_search_results_cleared(world: &mut EditorWorld, generation: u64) {
let op = Operation::SearchLocal(SearchOp::ClearProjectResults { generation });
let settings = &world.inner.app.settings;
if let Screen::Editor(ed) = &mut world.inner.app.screen {
ed.handle_operation(&op, settings);
}
world.inner.app.project_search_generation = generation;
}
#[when(expr = "the editor search query is updated to {string}")]
fn when_editor_query_updated(world: &mut EditorWorld, query: String) {
if let Screen::Editor(ed) = &mut world.inner.app.screen
&& let Some(s) = &mut ed.search {
s.query.set_text(query);
}
}
#[when(expr = "the project search runs synchronously")]
fn when_project_search_runs(world: &mut EditorWorld) {
let generation = 1u64;
let (query, opts, root) = {
let ed = ed(world);
let s = ed.search.as_ref().expect("search bar not open");
(s.query.text().to_owned(), s.opts.clone(), world.inner._dir.path().to_path_buf())
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<Operation>>();
let gen_shared = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(generation));
oo_ide::views::project_search::run_project_search(
&root,
&query,
&opts,
generation,
tokio_util::sync::CancellationToken::new(),
&tx,
&gen_shared,
None
);
let mut all_ops: Vec<Operation> = Vec::new();
while let Ok(batch) = rx.try_recv() {
all_ops.extend(batch);
}
let settings = &world.inner.app.settings;
if let Screen::Editor(ed) = &mut world.inner.app.screen {
for op in all_ops {
ed.handle_operation(&op, settings);
}
}
}
#[when(expr = "the project search runs synchronously for generation {int}")]
fn when_project_search_runs_for_gen(world: &mut EditorWorld, generation: u64) {
let (query, opts, root) = {
let ed = ed(world);
let s = ed.search.as_ref().expect("search bar not open");
(s.query.text().to_owned(), s.opts.clone(), world.inner._dir.path().to_path_buf())
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<Operation>>();
let gen_shared = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(generation));
oo_ide::views::project_search::run_project_search(
&root,
&query,
&opts,
generation,
tokio_util::sync::CancellationToken::new(),
&tx,
&gen_shared,
None
);
let mut all_ops: Vec<Operation> = Vec::new();
while let Ok(batch) = rx.try_recv() {
all_ops.extend(batch);
}
let settings = &world.inner.app.settings;
if let Screen::Editor(ed) = &mut world.inner.app.screen {
for op in all_ops {
ed.handle_operation(&op, settings);
}
}
}
#[then(expr = "the project search shows {int} matching file(s)")]
fn then_project_search_file_count(world: &mut EditorWorld, count: usize) {
let search = ed(world).search.as_ref().expect("search bar not open");
assert_eq!(
search.files.len(),
count,
"expected {} matching file(s), got {} (files: {:?})",
count,
search.files.len(),
search.files.iter().map(|f| &f.path).collect::<Vec<_>>()
);
}
#[then(expr = "the project search result for {string} has {int} match(es)")]
fn then_project_search_match_count(world: &mut EditorWorld, filename: String, count: usize) {
let search = ed(world).search.as_ref().expect("search bar not open");
let file_match = search
.files
.iter()
.find(|fm| fm.path.ends_with(&filename))
.unwrap_or_else(|| panic!("no results for file {:?}", filename));
assert_eq!(
file_match.matches.len(),
count,
"expected {} match(es) for {:?}, got {}",
count,
filename,
file_match.matches.len()
);
}
#[tokio::main]
async fn main() {
run_schema_integration_tests();
let file = std::fs::File::create("oo-features-junit.xml").unwrap();
EditorWorld::new();
let cucumber = EditorWorld::cucumber().with_writer(
writer::Basic::stdout()
.summarized()
.tee::<EditorWorld, _>(writer::JUnit::for_tee(file, 0))
.normalized(),
);
cucumber.run_and_exit("tests/features").await;
}