Cursor aware Component Browser (#5770)

Closes #5220

This PR implements the cursor-aware behavior of the CB, as described in [pivotal issue](https://www.pivotaltracker.com/n/projects/2539304/stories/183521918)


https://user-images.githubusercontent.com/6566674/221807206-39f93cb4-8253-421d-a33a-33ac0aa56e54.mp4


https://user-images.githubusercontent.com/6566674/223124947-259153ca-e656-4349-87b5-47c06fd21af2.mp4

# Important Notes
- The `intended_method` of the node's metadata is marked as deprecated, and all usages are removed.
- It seems *all usages of Scala parser are removed from IDE*. We no longer use it to parse documentation for snippets.

This is how the snippets docs look now:

<img width="410" alt="Screenshot 2023-02-28 at 13 18 11" src="https://user-images.githubusercontent.com/6566674/221808028-d69c54e4-2842-4f1c-aa16-781d3f7765a1.png">
This commit is contained in:
Ilya Bogdanov 2023-03-22 21:10:37 +04:00 committed by GitHub
parent e5fef2fef3
commit 1b30a5275f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1643 additions and 1410 deletions

View File

@ -73,7 +73,8 @@ impl Info {
}
}
/// Obtain the qualified name of the module.
/// Return the module path as [`QualifiedName`]. Returns [`Err`] if the path is not a valid
/// module name.
pub fn qualified_module_name(&self) -> FallibleResult<QualifiedName> {
QualifiedName::from_all_segments(&self.module)
}
@ -108,12 +109,6 @@ impl Info {
self.hash(&mut hasher);
hasher.finish()
}
/// Return the module path as [`QualifiedName`]. Returns [`Err`] if the path is not a valid
/// module name.
pub fn module_qualified_name(&self) -> FallibleResult<QualifiedName> {
QualifiedName::from_all_segments(self.module.iter())
}
}
impl Display for Info {

View File

@ -672,7 +672,7 @@ pub trait TraversableAst: Sized {
/// Recursively traverses AST to retrieve AST node located by given crumbs sequence.
fn get_traversing(&self, crumbs: &[Crumb]) -> FallibleResult<&Ast>;
/// Get the `Ast` node corresponging to `Self`.
/// Get the `Ast` node corresponding to `Self`.
fn my_ast(&self) -> FallibleResult<&Ast> {
self.get_traversing(&[])
}

View File

@ -511,10 +511,11 @@ impl Chain {
while !self.args.is_empty() {
self.fold_arg()
}
// TODO[ao] the only case when target is none is when chain have None target and empty
// arguments list. Such Chain cannot be generated from Ast, but someone could think that
// this is still a valid chain. To consider returning error here.
self.target.unwrap().arg
if let Some(target) = self.target {
target.arg
} else {
SectionSides { opr: self.operator.into() }.into()
}
}
/// True if all operands are set, i.e. there are no section shapes in this chain.

View File

@ -51,6 +51,9 @@ pub const RAW_BLOCK_QUOTES: &str = "\"\"\"";
/// Quotes opening block of the formatted text.
pub const FMT_BLOCK_QUOTES: &str = "'''";
/// A list of possible delimiters of the text literals.
pub const STRING_DELIMITERS: &[&str] = &[RAW_BLOCK_QUOTES, FMT_BLOCK_QUOTES, "\"", "'"];
// =============

View File

@ -26,7 +26,6 @@ use engine_protocol::language_server;
use parser::Parser;
use span_tree::action::Action;
use span_tree::action::Actions;
use span_tree::generate::context::CalledMethodInfo;
use span_tree::generate::Context as SpanTreeContext;
use span_tree::SpanTree;
@ -790,6 +789,20 @@ impl Handle {
module::locate(&module_ast, &self.id)
}
/// The span of the definition of this graph in the module's AST.
pub fn definition_span(&self) -> FallibleResult<enso_text::Range<enso_text::Byte>> {
let def = self.definition()?;
self.module.ast().range_of_descendant_at(&def.crumbs)
}
/// The location of the last byte of the definition of this graph in the module's AST.
pub fn definition_end_location(&self) -> FallibleResult<enso_text::Location<enso_text::Byte>> {
let module_ast = self.module.ast();
let module_repr: enso_text::Rope = module_ast.repr().into();
let def_span = self.definition_span()?;
Ok(module_repr.offset_to_location_snapped(def_span.end))
}
/// Updates the AST of the definition of this graph.
#[profile(Debug)]
pub fn update_definition_ast<F>(&self, f: F) -> FallibleResult
@ -1023,24 +1036,6 @@ impl Handle {
}
}
// === Span Tree Context ===
/// Span Tree generation context for a graph that does not know about execution.
///
/// It just applies the information from the metadata.
impl span_tree::generate::Context for Handle {
fn call_info(&self, id: node::Id, name: Option<&str>) -> Option<CalledMethodInfo> {
let db = &self.suggestion_db;
let metadata = self.module.node_metadata(id).ok()?;
let db_entry = db.lookup_method(metadata.intended_method?)?;
// If the name is different than intended method than apparently it is not intended anymore
// and should be ignored.
let matching = if let Some(name) = name { name == db_entry.name } else { true };
matching.then(|| db_entry.invocation_info(db, &self.parser))
}
}
impl model::undo_redo::Aware for Handle {
fn undo_redo_repository(&self) -> Rc<model::undo_redo::Repository> {
self.module.undo_redo_repository()
@ -1069,15 +1064,11 @@ pub mod tests {
use engine_protocol::language_server::MethodPointer;
use enso_text::index::*;
use parser::Parser;
use span_tree::generate::MockContext;
/// Returns information about all the connections between graph's nodes.
///
/// Will use `self` as the context for span tree generation.
pub fn connections(graph: &Handle) -> FallibleResult<Connections> {
graph.connections(graph)
graph.connections(&span_tree::generate::context::Empty)
}
/// All the data needed to set up and run the graph controller in mock environment.
@ -1253,45 +1244,6 @@ main =
})
}
#[test]
fn span_tree_context_handling_metadata_and_name() {
let entry = crate::test::mock::data::suggestion_entry_foo();
let mut test = Fixture::set_up();
test.data.suggestions.insert(0, entry.clone());
test.data.code = "main = bar".to_owned();
test.run(|graph| async move {
let nodes = graph.nodes().unwrap();
assert_eq!(nodes.len(), 1);
let id = nodes[0].info.id();
graph
.module
.set_node_metadata(id, NodeMetadata {
intended_method: entry.method_id(),
..default()
})
.unwrap();
let get_invocation_info = || {
let node = &graph.nodes().unwrap()[0];
assert_eq!(node.info.id(), id);
let expression = node.info.expression().repr();
graph.call_info(id, Some(expression.as_str()))
};
// Now node is `bar` while the intended method is `foo`.
// No invocation should be reported, as the name is mismatched.
assert!(get_invocation_info().is_none());
// Now the name should be good and we should the information about node being a call.
graph.set_expression(id, &entry.name).unwrap();
crate::test::assert_call_info(get_invocation_info().unwrap(), &entry);
// Now we remove metadata, so the information is no more.
graph.module.remove_node_metadata(id).unwrap();
assert!(get_invocation_info().is_none());
})
}
#[test]
fn graph_controller_used_names_in_inline_def() {
let mut test = Fixture::set_up();
@ -1751,7 +1703,6 @@ main =
struct Case {
dest_node_expr: &'static str,
dest_node_expected: &'static str,
info: Option<CalledMethodInfo>,
}
@ -1763,57 +1714,28 @@ main =
let expected = format!("{}{}", MAIN_PREFIX, self.dest_node_expected);
let this = self.clone();
test.run(|graph| async move {
let error_message = format!("{this:?}");
let ctx = match this.info.clone() {
Some(info) => {
let nodes = graph.nodes().expect(&error_message);
let dest_node_id = nodes.last().expect(&error_message).id();
MockContext::new_single(dest_node_id, info)
}
None => MockContext::default(),
};
let connections = graph.connections(&ctx).expect(&error_message);
let connection = connections.connections.first().expect(&error_message);
graph.disconnect(connection, &ctx).expect(&error_message);
let new_main = graph.definition().expect(&error_message).ast.repr();
assert_eq!(new_main, expected, "{error_message}");
let connections = connections(&graph).unwrap();
let connection = connections.connections.first().unwrap();
graph.disconnect(connection, &span_tree::generate::context::Empty).unwrap();
let new_main = graph.definition().unwrap().ast.repr();
assert_eq!(new_main, expected, "Case {this:?}");
})
}
}
let info = || {
Some(CalledMethodInfo {
parameters: vec![
span_tree::ArgumentInfo::named("arg1"),
span_tree::ArgumentInfo::named("arg2"),
span_tree::ArgumentInfo::named("arg3"),
],
..default()
})
};
#[rustfmt::skip]
let cases = &[
Case { info: None, dest_node_expr: "var + a", dest_node_expected: "_ + a" },
Case { info: None, dest_node_expr: "a + var", dest_node_expected: "a + _" },
Case { info: None, dest_node_expr: "var + b + c", dest_node_expected: "b + c" },
Case { info: None, dest_node_expr: "a + var + c", dest_node_expected: "a + c" },
Case { info: None, dest_node_expr: "a + b + var", dest_node_expected: "a + b" },
Case { info: None, dest_node_expr: "foo var", dest_node_expected: "foo _" },
Case { info: None, dest_node_expr: "foo var a", dest_node_expected: "foo a" },
Case { info: None, dest_node_expr: "foo a var", dest_node_expected: "foo a" },
Case { info: info(), dest_node_expr: "foo var", dest_node_expected: "foo" },
Case { info: info(), dest_node_expr: "foo var a", dest_node_expected: "foo arg2=a" },
Case { info: info(), dest_node_expr: "foo a var", dest_node_expected: "foo a" },
Case { info: info(), dest_node_expr: "foo arg2=var a", dest_node_expected: "foo a" },
Case { info: info(), dest_node_expr: "foo arg1=var a", dest_node_expected: "foo arg2=a" },
Case { dest_node_expr: "var + a", dest_node_expected: "_ + a" },
Case { dest_node_expr: "a + var", dest_node_expected: "a + _" },
Case { dest_node_expr: "var + b + c", dest_node_expected: "b + c" },
Case { dest_node_expr: "a + var + c", dest_node_expected: "a + c" },
Case { dest_node_expr: "a + b + var", dest_node_expected: "a + b" },
Case { dest_node_expr: "foo var", dest_node_expected: "foo _" },
Case { dest_node_expr: "foo var a", dest_node_expected: "foo a" },
Case { dest_node_expr: "foo a var", dest_node_expected: "foo a" },
Case { dest_node_expr: "foo arg2=var a", dest_node_expected: "foo a" },
Case { dest_node_expr: "foo arg1=var a", dest_node_expected: "foo a" },
Case { dest_node_expr: "foo arg2=var a c", dest_node_expected: "foo a c" },
Case {
info: info(),
dest_node_expr: "foo arg2=var a c",
dest_node_expected: "foo a arg3=c"
},
Case {
info: None,
dest_node_expr: "f\n bar a var",
dest_node_expected: "f\n bar a _",
},

View File

@ -75,7 +75,6 @@ pub enum Notification {
/// Handle providing executed graph controller interface.
#[derive(Clone, CloneRef, Debug)]
pub struct Handle {
#[allow(missing_docs)]
/// A handle to basic graph operations.
graph: Rc<RefCell<controller::Graph>>,
/// Execution Context handle, its call stack top contains `graph`'s definition.
@ -343,30 +342,26 @@ impl Handle {
/// Span Tree generation context for a graph that does not know about execution.
/// Provides information based on computed value registry, using metadata as a fallback.
impl Context for Handle {
fn call_info(&self, id: ast::Id, name: Option<&str>) -> Option<CalledMethodInfo> {
let lookup_registry = || {
let info = self.computed_value_info_registry().get(&id)?;
let method_call = info.method_call.as_ref()?;
let suggestion_db = self.project.suggestion_db();
let maybe_entry = suggestion_db.lookup_by_method_pointer(method_call).map(|e| {
let invocation_info = e.invocation_info(&suggestion_db, &self.parser());
invocation_info.with_called_on_type(false)
});
fn call_info(&self, id: ast::Id, _name: Option<&str>) -> Option<CalledMethodInfo> {
let info = self.computed_value_info_registry().get(&id)?;
let method_call = info.method_call.as_ref()?;
let suggestion_db = self.project.suggestion_db();
let maybe_entry = suggestion_db.lookup_by_method_pointer(method_call).map(|e| {
let invocation_info = e.invocation_info(&suggestion_db, &self.parser());
invocation_info.with_called_on_type(false)
});
// When the entry was not resolved but the `defined_on_type` has a `.type` suffix,
// try resolving it again with the suffix stripped. This indicates that a method was
// called on type, either because it is a static method, or because it uses qualified
// method syntax.
maybe_entry.or_else(|| {
let defined_on_type = method_call.defined_on_type.strip_suffix(".type")?.to_owned();
let method_call = MethodPointer { defined_on_type, ..method_call.clone() };
let entry = suggestion_db.lookup_by_method_pointer(&method_call)?;
let invocation_info = entry.invocation_info(&suggestion_db, &self.parser());
Some(invocation_info.with_called_on_type(true))
})
};
let fallback = || self.graph.borrow().call_info(id, name);
lookup_registry().or_else(fallback)
// When the entry was not resolved but the `defined_on_type` has a `.type` suffix,
// try resolving it again with the suffix stripped. This indicates that a method was
// called on type, either because it is a static method, or because it uses qualified
// method syntax.
maybe_entry.or_else(|| {
let defined_on_type = method_call.defined_on_type.strip_suffix(".type")?.to_owned();
let method_call = MethodPointer { defined_on_type, ..method_call.clone() };
let entry = suggestion_db.lookup_by_method_pointer(&method_call)?;
let invocation_info = entry.invocation_info(&suggestion_db, &self.parser());
Some(invocation_info.with_called_on_type(true))
})
}
}
@ -387,10 +382,8 @@ pub mod tests {
use super::*;
use crate::model::execution_context::ExpressionId;
use crate::model::module::NodeMetadata;
use crate::test;
use engine_protocol::language_server::types::test::value_update_with_method_ptr;
use engine_protocol::language_server::types::test::value_update_with_type;
use wasm_bindgen_test::wasm_bindgen_test;
use wasm_bindgen_test::wasm_bindgen_test_configure;
@ -477,38 +470,4 @@ pub mod tests {
notifications.expect_pending();
}
#[wasm_bindgen_test]
fn span_tree_context() {
use crate::test::assert_call_info;
use crate::test::mock;
let mut data = mock::Unified::new();
let entry1 = data.suggestions.get(&1).unwrap().clone();
let entry2 = data.suggestions.get(&2).unwrap().clone();
data.set_inline_code(&entry1.name);
let mock::Fixture { graph, executed_graph, module, .. } = &data.fixture();
let id = graph.nodes().unwrap()[0].info.id();
let get_invocation_info = || executed_graph.call_info(id, Some(&entry1.name));
assert!(get_invocation_info().is_none());
// Check that if we set metadata, executed graph can see this info.
module
.set_node_metadata(id, NodeMetadata {
intended_method: entry1.method_id(),
..default()
})
.unwrap();
let info = get_invocation_info().unwrap();
assert_call_info(info, &entry1);
// Now send update that expression actually was computed to be a call to the second
// suggestion entry and check that executed graph provides this info over the metadata one.
let method_pointer = entry2.clone().try_into().unwrap();
let update = value_update_with_method_ptr(id, method_pointer);
executed_graph.computed_value_info_registry().apply_updates(vec![update]);
let info = get_invocation_info().unwrap();
assert_call_info(info, &entry2);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,10 +2,7 @@
use crate::prelude::*;
use crate::model::SuggestionDatabase;
use double_representation::module::MethodId;
use double_representation::name::QualifiedNameRef;
use ordered_float::OrderedFloat;
@ -21,7 +18,7 @@ pub mod hardcoded;
// === Action ===
// ==============
#[derive(Clone, CloneRef, Debug, Eq, PartialEq)]
#[derive(Clone, CloneRef, Debug, PartialEq)]
/// Suggestion for code completion: possible functions, arguments, etc.
pub enum Suggestion {
/// The suggestion from Suggestion Database received from the Engine.
@ -35,18 +32,7 @@ impl Suggestion {
pub fn code_to_insert(&self, generate_this: bool) -> Cow<str> {
match self {
Suggestion::FromDatabase(s) => s.code_to_insert(generate_this),
Suggestion::Hardcoded(s) => s.code.into(),
}
}
pub(crate) fn required_imports(
&self,
db: &SuggestionDatabase,
current_module: QualifiedNameRef,
) -> impl IntoIterator<Item = model::suggestion_database::entry::Import> {
match self {
Suggestion::FromDatabase(s) => s.required_imports(db, current_module),
Suggestion::Hardcoded(_) => default(),
Suggestion::Hardcoded(s) => s.code.as_str().into(),
}
}
@ -89,7 +75,7 @@ pub enum ProjectManagement {
}
/// A single action on the Searcher list. See also `controller::searcher::Searcher` docs.
#[derive(Clone, CloneRef, Debug, Eq, PartialEq)]
#[derive(Clone, CloneRef, Debug, PartialEq)]
pub enum Action {
/// Add to the searcher input a suggested code and commit editing (new node is inserted or
/// existing is modified). This action can be also used to complete searcher input without
@ -215,7 +201,7 @@ impl Eq for MatchInfo {}
/// The single list entry.
#[allow(missing_docs)]
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct ListEntry {
pub category: CategoryId,
pub match_info: MatchInfo,

View File

@ -7,6 +7,7 @@ use crate::prelude::*;
use crate::model::suggestion_database;
use controller::searcher::action::MatchKind;
use controller::searcher::Filter;
use convert_case::Case;
use convert_case::Casing;
use double_representation::name::QualifiedName;
@ -145,7 +146,7 @@ impl Component {
pub fn name(&self) -> &str {
match &self.data {
Data::FromDatabase { entry, .. } => entry.name.as_str(),
Data::Virtual { snippet } => snippet.name,
Data::Virtual { snippet } => snippet.name.as_str(),
}
}
@ -174,13 +175,13 @@ impl Component {
/// Update matching info.
///
/// It should be called each time the filtering pattern changes.
pub fn update_matching_info(&self, pattern: impl Str) {
pub fn update_matching_info(&self, filter: Filter) {
// Match the input pattern to the component label.
let label = self.to_string();
let label_matches = fuzzly::matches(&label, pattern.as_ref());
let label_matches = fuzzly::matches(&label, filter.pattern.clone_ref());
let label_subsequence = label_matches.and_option_from(|| {
let metric = fuzzly::metric::default();
fuzzly::find_best_subsequence(label, pattern.as_ref(), metric)
fuzzly::find_best_subsequence(label, filter.pattern.clone_ref(), metric)
});
let label_match_info = label_subsequence
.map(|subsequence| MatchInfo::Matches { subsequence, kind: MatchKind::Label });
@ -190,10 +191,10 @@ impl Component {
Data::FromDatabase { entry, .. } => entry.code_to_insert(true).to_string(),
Data::Virtual { snippet } => snippet.code.to_string(),
};
let code_matches = fuzzly::matches(&code, pattern.as_ref());
let code_matches = fuzzly::matches(&code, filter.pattern.clone_ref());
let code_subsequence = code_matches.and_option_from(|| {
let metric = fuzzly::metric::default();
fuzzly::find_best_subsequence(code, pattern.as_ref(), metric)
fuzzly::find_best_subsequence(code, filter.pattern.clone_ref(), metric)
});
let code_match_info = code_subsequence.map(|subsequence| {
let subsequence = fuzzly::Subsequence { indices: Vec::new(), ..subsequence };
@ -202,9 +203,10 @@ impl Component {
// Match the input pattern to an entry's aliases and select the best alias match.
let alias_matches = self.aliases().filter_map(|alias| {
if fuzzly::matches(alias, pattern.as_ref()) {
if fuzzly::matches(alias, filter.pattern.clone_ref()) {
let metric = fuzzly::metric::default();
let subsequence = fuzzly::find_best_subsequence(alias, pattern.as_ref(), metric);
let subsequence =
fuzzly::find_best_subsequence(alias, filter.pattern.clone_ref(), metric);
subsequence.map(|subsequence| (subsequence, alias))
} else {
None
@ -223,6 +225,18 @@ impl Component {
let match_info_iter = [alias_match_info, code_match_info, label_match_info].into_iter();
let best_match_info = match_info_iter.flatten().max_by(|lhs, rhs| lhs.cmp(rhs));
*self.match_info.borrow_mut() = best_match_info.unwrap_or(MatchInfo::DoesNotMatch);
// Filter out components with FQN not matching the context.
if let Some(context) = filter.context {
if let Data::FromDatabase { entry, .. } = &self.data {
if !entry.qualified_name().to_string().contains(context.as_str()) {
*self.match_info.borrow_mut() = MatchInfo::DoesNotMatch;
}
} else {
// Remove virtual entries if the context is present.
*self.match_info.borrow_mut() = MatchInfo::DoesNotMatch;
}
}
}
/// Check whether the component contains the "PRIVATE" tag.
@ -382,22 +396,22 @@ impl List {
}
/// Update matching info in all components according to the new filtering pattern.
pub fn update_filtering(&self, pattern: impl AsRef<str>) {
let pattern = pattern.as_ref();
pub fn update_filtering(&self, filter: Filter) {
let pattern = &filter.pattern;
for component in &*self.all_components {
component.update_matching_info(pattern)
component.update_matching_info(filter.clone_ref())
}
let pattern_not_empty = !pattern.is_empty();
let filtering_enabled = !pattern.is_empty() || filter.context.is_some();
let submodules_order =
if pattern_not_empty { Order::ByMatch } else { Order::ByNameNonModulesThenModules };
let favorites_order = if pattern_not_empty { Order::ByMatch } else { Order::Initial };
if filtering_enabled { Order::ByMatch } else { Order::ByNameNonModulesThenModules };
let favorites_order = if filtering_enabled { Order::ByMatch } else { Order::Initial };
for group in self.all_groups_not_in_favorites() {
group.update_match_info_and_sorting(submodules_order);
}
for group in self.favorites.iter() {
group.update_match_info_and_sorting(favorites_order);
}
self.filtered.set(pattern_not_empty);
self.filtered.set(filtering_enabled);
}
/// All groups from [`List`] without the groups found in [`List::favorites`].
@ -517,7 +531,7 @@ pub(crate) mod tests {
builder.extend_list_and_allow_favorites_with_ids(&suggestion_db, 0..=4);
let list = builder.build();
list.update_filtering("fu");
list.update_filtering(Filter { pattern: "fu".into(), context: None });
let match_infos = list.top_modules().next().unwrap()[0]
.entries
.borrow()
@ -529,22 +543,22 @@ pub(crate) mod tests {
assert_ids_of_matches_entries(&list.favorites[0], &[4, 2]);
assert_ids_of_matches_entries(&list.local_scope, &[2]);
list.update_filtering("x");
list.update_filtering(Filter { pattern: "x".into(), context: None });
assert_ids_of_matches_entries(&list.top_modules().next().unwrap()[0], &[4]);
assert_ids_of_matches_entries(&list.favorites[0], &[4]);
assert_ids_of_matches_entries(&list.local_scope, &[]);
list.update_filtering("Sub");
list.update_filtering(Filter { pattern: "Sub".into(), context: None });
assert_ids_of_matches_entries(&list.top_modules().next().unwrap()[0], &[3]);
assert_ids_of_matches_entries(&list.favorites[0], &[]);
assert_ids_of_matches_entries(&list.local_scope, &[]);
list.update_filtering("y");
list.update_filtering(Filter { pattern: "y".into(), context: None });
assert_ids_of_matches_entries(&list.top_modules().next().unwrap()[0], &[]);
assert_ids_of_matches_entries(&list.favorites[0], &[]);
assert_ids_of_matches_entries(&list.local_scope, &[]);
list.update_filtering("");
list.update_filtering(Filter { pattern: "".into(), context: None });
assert_ids_of_matches_entries(&list.top_modules().next().unwrap()[0], &[2, 3]);
assert_ids_of_matches_entries(&list.favorites[0], &[4, 2]);
assert_ids_of_matches_entries(&list.local_scope, &[2]);

View File

@ -580,7 +580,7 @@ mod tests {
components: vec![qn_of_db_entry_1.clone()],
}];
builder.set_grouping_and_order_of_favorites(&db, &groups);
let snippet = component::hardcoded::Snippet { name: "test snippet", ..default() };
let snippet = component::hardcoded::Snippet { name: "test snippet".into(), ..default() };
let snippet_iter = std::iter::once(Rc::new(snippet));
builder.insert_virtual_components_in_favorites_group(GROUP_NAME, project, snippet_iter);
builder.extend_list_and_allow_favorites_with_ids(&db, std::iter::once(1));
@ -607,7 +607,7 @@ mod tests {
components: vec![qn_of_db_entry_1.clone()],
}];
builder.set_grouping_and_order_of_favorites(&db, &groups);
let snippet = component::hardcoded::Snippet { name: "test snippet", ..default() };
let snippet = component::hardcoded::Snippet { name: "test snippet".into(), ..default() };
let snippet_iter = std::iter::once(Rc::new(snippet));
const GROUP_2_NAME: &str = "Group 2";
builder.insert_virtual_components_in_favorites_group(GROUP_2_NAME, project, snippet_iter);

View File

@ -7,8 +7,11 @@
use crate::prelude::*;
use crate::controller::searcher::input;
use double_representation::name::QualifiedName;
use enso_doc_parser::DocSection;
use enso_suggestion_database::documentation_ir::EntryDocumentation;
use enso_suggestion_database::SuggestionDatabase;
use ide_view::component_browser::component_list_panel::grid::entry::icon::Id as IconId;
@ -20,6 +23,10 @@ use ide_view::component_browser::component_list_panel::grid::entry::icon::Id as
/// Name of the favorites component group in the `Standard.Base` library where virtual components
/// created from the [`INPUT_SNIPPETS`] should be added.
pub const INPUT_GROUP_NAME: &str = "Input";
/// Qualified name of the `Text` type.
const TEXT_ENTRY: &str = "Standard.Base.Main.Data.Text.Text";
/// Qualified name of the `Number` type.
const NUMBER_ENTRY: &str = "Standard.Base.Main.Data.Numbers.Number";
thread_local! {
/// Snippets describing virtual components displayed in the [`INPUT_GROUP_NAME`] favorites
@ -29,7 +36,7 @@ thread_local! {
pub static INPUT_SNIPPETS: Vec<Rc<Snippet>> = vec![
Snippet::new("text input", "\"\"", IconId::TextInput)
.with_return_types(["Standard.Base.Data.Text.Text"])
.with_documentation(
.with_documentation_str(
"A text input node.\n\n\
An empty text. The value can be edited and used as an input for other nodes.",
)
@ -40,7 +47,7 @@ thread_local! {
"Standard.Base.Data.Numbers.Decimal",
"Standard.Base.Data.Numbers.Integer",
])
.with_documentation(
.with_documentation_str(
"A number input node.\n\n\
A zero number. The value can be edited and used as an input for other nodes.",
)
@ -49,39 +56,24 @@ thread_local! {
}
// === Filtering by Return Type ===
/// Return a filtered copy of [`INPUT_SNIPPETS`] containing only snippets which have at least one
/// of their return types on the given list of return types.
pub fn input_snippets_with_matching_return_type(
return_types: impl IntoIterator<Item = QualifiedName>,
) -> Vec<Rc<Snippet>> {
let rt_set: HashSet<_> = return_types.into_iter().collect();
let rt_of_snippet_is_in_set =
|s: &&Rc<Snippet>| s.return_types.iter().any(|rt| rt_set.contains(rt));
INPUT_SNIPPETS
.with(|snippets| snippets.iter().filter(rt_of_snippet_is_in_set).cloned().collect_vec())
}
// ===============
// === Snippet ===
// ===============
/// A hardcoded snippet of code with a description and syntactic metadata.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Snippet {
/// The name displayed in the [Component Browser](crate::controller::searcher).
pub name: &'static str,
pub name: ImString,
/// The code inserted when picking the snippet.
pub code: &'static str,
pub code: ImString,
/// A list of types that the return value of this snippet's code typechecks as. Used by the
/// [Component Browser](crate::controller::searcher) to decide whether to display the
/// snippet when filtering components by return type.
pub return_types: Vec<QualifiedName>,
/// The documentation bound to the snippet.
pub documentation: Option<Vec<DocSection>>,
pub documentation: Option<EntryDocumentation>,
/// The ID of the icon bound to this snippet's entry in the [Component
/// Browser](crate::controller::searcher).
pub icon: IconId,
@ -89,8 +81,31 @@ pub struct Snippet {
impl Snippet {
/// Construct a hardcoded snippet with given name, code, and icon.
fn new(name: &'static str, code: &'static str, icon: IconId) -> Self {
Self { name, code, icon, ..default() }
fn new(name: &str, code: &str, icon: IconId) -> Self {
Self { name: name.into(), code: code.into(), icon, ..default() }
}
/// Construct a hardcoded snippet for a single literal.
pub fn from_literal(literal: &input::Literal, db: &SuggestionDatabase) -> Self {
use input::Literal::*;
let text_repr = literal.to_string();
let snippet = match literal {
Text { closing_delimiter: missing_quotation_mark, .. } => {
let missing_quote = missing_quotation_mark.as_ref();
let code = &missing_quote.map(ToString::to_string).unwrap_or_default();
Self::new(&text_repr, code, IconId::TextInput)
}
Number(_) => Self::new(&text_repr, "", IconId::NumberInput),
};
let entry_path = match literal {
Text { .. } => TEXT_ENTRY,
Number(_) => NUMBER_ENTRY,
};
// `unwrap()` is safe here, because we test the validity of `entry_path` in the tests.
let qualified_name = QualifiedName::from_text(entry_path).unwrap();
let entry = db.lookup_by_qualified_name(&qualified_name);
let docs = entry.map(|(entry_id, _)| db.documentation_for_entry(entry_id));
snippet.with_documentation(docs.unwrap_or_default())
}
/// Returns a modified suggestion with [`Snippet::return_types`] field set. This method is only
@ -104,8 +119,34 @@ impl Snippet {
/// Returns a modified suggestion with the [`Snippet::documentation`] field set. This method
/// is only intended to be used when defining hardcoded suggestions.
fn with_documentation(mut self, documentation: &str) -> Self {
self.documentation = Some(enso_doc_parser::parse(documentation));
fn with_documentation_str(mut self, documentation: &str) -> Self {
let docs = EntryDocumentation::builtin(&enso_doc_parser::parse(documentation));
self.documentation = Some(docs);
self
}
/// Returns a modified suggestion with the [`Snippet::documentation`] field set.
fn with_documentation(mut self, documentation: EntryDocumentation) -> Self {
self.documentation = Some(documentation);
self
}
}
// =============
// === Tests ===
// =============
#[cfg(test)]
mod tests {
use super::*;
/// Test that the qualified names used for hardcoded snippets can be constructed. We don't check
/// if the entries are actually available in the suggestion database.
#[test]
fn test_qualified_names_construction() {
QualifiedName::from_text(TEXT_ENTRY).unwrap();
QualifiedName::from_text(NUMBER_ENTRY).unwrap();
}
}

View File

@ -0,0 +1,700 @@
//! A module containing structures keeping and interpreting the input of the searcher controller.
use crate::prelude::*;
use crate::controller::searcher::action;
use crate::controller::searcher::Filter;
use crate::controller::searcher::RequiredImport;
use ast::HasTokens;
use double_representation::name::QualifiedName;
use enso_text as text;
use parser::Parser;
// ==============
// === Errors ===
// ==============
#[derive(Clone, Debug, Fail)]
#[allow(missing_docs)]
#[fail(display = "Not a char boundary: index {} at '{}'.", index, string)]
pub struct NotACharBoundary {
index: usize,
string: String,
}
// ===============
// === Literal ===
// ===============
/// A text or number literal.
#[derive(Clone, Debug, PartialEq)]
pub enum Literal {
/// A literal of a number type.
Number(ast::known::Number),
/// A literal of a text type.
Text {
/// The string representation of the finished text literal. Includes the closing
/// delimiter even if the user didn't type it yet.
text: ImString,
/// The missing closing delimiter if the user didn't type it yet.
closing_delimiter: Option<&'static str>,
},
}
impl Display for Literal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Text { text, .. } => write!(f, "{text}"),
Self::Number(number) => write!(f, "{}", number.repr()),
}
}
}
impl Literal {
/// Construct a string literal. Returns `None` if the given string is not starting with a
/// quotation mark.
fn try_new_text(text: &str) -> Option<Self> {
for delimiter in ast::repr::STRING_DELIMITERS {
if text.starts_with(delimiter) {
let delimiter_only = text == *delimiter;
let closing_delimiter = if text.ends_with(delimiter) && !delimiter_only {
None
} else {
Some(*delimiter)
};
let text = if let Some(delimiter) = closing_delimiter {
let mut text = text.to_owned();
text.push_str(delimiter);
text.into()
} else {
text.into()
};
return Some(Self::Text { text, closing_delimiter });
}
}
None
}
}
// ====================
// === AstWithRange ===
// ====================
/// A helper structure binding given AST node to the range of Component Browser input.
#[derive(Clone, Debug)]
pub struct AstWithRange<A = Ast> {
ast: A,
range: text::Range<text::Byte>,
}
// =========================
// === AstPositionFinder ===
// =========================
/// A structure locating what Ast overlaps with given text offset.
#[derive(Clone, Debug, Default)]
struct AstAtPositionFinder {
current_offset: text::Byte,
target_offset: text::Byte,
result: Vec<AstWithRange>,
}
impl AstAtPositionFinder {
fn new(target_offset: text::Byte) -> Self {
Self { target_offset, ..default() }
}
/// Find all AST nodes containing the character in the `target` offset. The most nested AST
/// nodes will be last on the returned list.
fn find(ast: &Ast, target: text::Byte) -> Vec<AstWithRange> {
let mut finder = Self::new(target);
ast.feed_to(&mut finder);
finder.result
}
}
impl ast::TokenConsumer for AstAtPositionFinder {
fn feed(&mut self, token: ast::Token) {
match token {
ast::Token::Ast(ast) if self.current_offset <= self.target_offset => {
let start = self.current_offset;
ast.shape().feed_to(self);
let end = self.current_offset;
if end > self.target_offset {
self.result
.push(AstWithRange { ast: ast.clone_ref(), range: (start..end).into() });
}
}
other => self.current_offset += other.len(),
}
}
}
// =================
// === EditedAst ===
// =================
/// Information about the AST node which is currently modified in the Searcher Input's expression.
#[derive(Clone, Debug, Default)]
pub struct EditedAst {
/// The edited name. The left side of the name should be used as searching pattern, and picked
/// suggestion should replace the entire name. `None` if no name is edited - in that case
/// suggestion should be inserted at the cursor position.
pub edited_name: Option<AstWithRange>,
/// Accessor chain (like `Foo.Bar.buz`) which is currently edited.
pub edited_accessor_chain: Option<AstWithRange<ast::opr::Chain>>,
/// The currently edited literal.
pub edited_literal: Option<Literal>,
}
impl EditedAst {
/// Create EditedAst structure basing on the result of [`AstAtPositionFinder::find`].
fn new(stack: Vec<AstWithRange>) -> Self {
let mut stack = stack.into_iter();
if let Some(leaf) = stack.next() {
let leaf_parent = stack.next();
let leaf_id = leaf.ast.id;
let to_accessor_chain = |a: AstWithRange| {
let ast = ast::opr::Chain::try_new_of(&a.ast, ast::opr::predefined::ACCESS)?;
let target_id = ast.target.as_ref().map(|arg| arg.arg.id);
let leaf_is_not_target = !target_id.contains(&leaf_id);
leaf_is_not_target.then_some(AstWithRange { ast, range: a.range })
};
match leaf.ast.shape() {
ast::Shape::Var(_) | ast::Shape::Cons(_) => Self {
edited_name: Some(leaf),
edited_accessor_chain: leaf_parent.and_then(to_accessor_chain),
edited_literal: None,
},
ast::Shape::Opr(_) => Self {
edited_name: None,
edited_accessor_chain: leaf_parent.and_then(to_accessor_chain),
edited_literal: None,
},
ast::Shape::Infix(_) => Self {
edited_name: None,
edited_accessor_chain: to_accessor_chain(leaf),
edited_literal: None,
},
ast::Shape::Number(_) => {
// Unwrapping is safe here, because we know that the shape is a number.
let ast = ast::known::Number::try_from(leaf.ast).unwrap();
Self { edited_literal: Some(Literal::Number(ast)), ..default() }
}
ast::Shape::Tree(tree) =>
if let Some(text) = tree.leaf_info.as_ref() {
let edited_literal = Literal::try_new_text(text);
Self { edited_literal, ..default() }
} else {
default()
},
_ => default(),
}
} else {
default()
}
}
}
// ================
// === InputAst ===
// ================
/// Searcher input content parsed to [`ast::BlockLine`] node if possible.
#[allow(missing_docs)]
#[derive(Clone, Debug)]
pub enum InputAst {
Line(ast::BlockLine<Ast>),
Invalid(String),
}
impl Default for InputAst {
fn default() -> Self {
Self::Invalid(default())
}
}
impl Display for InputAst {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InputAst::Line(ast) => write!(f, "{}", ast.repr()),
InputAst::Invalid(string) => write!(f, "{string}"),
}
}
}
// =============
// === Input ===
// =============
/// The Component Browser Input.
///
/// This structure is responsible for keeping and interpreting the searcher input - it provides
/// the information:
/// * [what part of the input is a filtering pattern](Input::pattern), and
/// * [what part should be replaced when inserting a suggestion](Input::after_inserting_suggestion).
///
/// Both information are deduced from the _edited name_. The edited name is a concrete
/// identifier which we deduce the user is currently editing - we assume this is an identifier where
/// the text cursor is positioned (including the "end" of the identifier, like `foo|`).
#[allow(missing_docs)]
#[derive(Clone, Debug, Default)]
pub struct Input {
/// The input in the AST form.
pub ast: InputAst,
/// The current cursor position in the input.
pub cursor_position: text::Byte,
/// The edited part of the input: the name being edited and it's context. See [`EditedAst`] for
/// details.
pub edited_ast: EditedAst,
}
impl Input {
/// Create the structure from the code already parsed to AST.
pub fn new(ast: ast::BlockLine<Ast>, cursor_position: text::Byte) -> Self {
// We primarily check the character on the left of the cursor (`cursor_position_before`),
// to properly handle the situation when the cursor is at end of the edited name.
// But if there is no identifier on the left then we check the right side.
let cursor_position_before =
(cursor_position > text::Byte(0)).then(|| cursor_position - text::ByteDiff(1));
let ast_stack_on_left =
cursor_position_before.map(|cp| AstAtPositionFinder::find(&ast.elem, cp));
let edited_ast_on_left = ast_stack_on_left.map_or_default(EditedAst::new);
let edited_ast = if edited_ast_on_left.edited_name.is_none() {
let ast_stack_on_right = AstAtPositionFinder::find(&ast.elem, cursor_position);
let edited_ast_on_right = EditedAst::new(ast_stack_on_right);
if edited_ast_on_right.edited_name.is_some() {
edited_ast_on_right
} else {
edited_ast_on_left
}
} else {
edited_ast_on_left
};
let ast = InputAst::Line(ast);
Self { ast, cursor_position, edited_ast }
}
/// Create the structure parsing the string.
pub fn parse(parser: &Parser, expression: impl Str, cursor_position: text::Byte) -> Self {
match parser.parse_line(expression.as_ref()) {
Ok(ast) => Self::new(ast, cursor_position),
Err(_) => Self {
ast: InputAst::Invalid(expression.into()),
cursor_position,
edited_ast: default(),
},
}
}
/// Return the filtering pattern for the input.
pub fn filter(&self) -> Filter {
let pattern = if let Some(edited) = &self.edited_ast.edited_name {
let cursor_position_in_name = self.cursor_position - edited.range.start;
let name = ast::identifier::name(&edited.ast);
name.map_or_default(|name| {
let range = ..cursor_position_in_name.value as usize;
name.get(range).unwrap_or_default().into()
})
} else {
default()
};
let context = self.context().map(|c| c.into_ast().repr().to_im_string());
Filter { pattern, context }
}
/// Return the accessor chain being the context of the edited name, i.e. the preceding fully
/// qualified name parts or this argument.
pub fn context(&self) -> Option<ast::opr::Chain> {
self.edited_ast.edited_accessor_chain.clone().map(|mut chain| {
chain.ast.args.pop();
chain.ast
})
}
/// The range of the text which contains the currently edited name, if any.
pub fn edited_name_range(&self) -> Option<text::Range<text::Byte>> {
self.edited_ast.edited_name.as_ref().map(|name| name.range)
}
/// The range of the text which contains the currently edited accessor chain, if any.
pub fn accessor_chain_range(&self) -> Option<text::Range<text::Byte>> {
self.edited_ast.edited_accessor_chain.as_ref().map(|chain| chain.range)
}
/// The input as AST node. Returns [`None`] if the input is not a valid AST.
pub fn ast(&self) -> Option<&Ast> {
match &self.ast {
InputAst::Line(ast) => Some(&ast.elem),
InputAst::Invalid(_) => None,
}
}
/// Currently edited literal, if any.
pub fn edited_literal(&self) -> Option<&Literal> {
self.edited_ast.edited_literal.as_ref()
}
}
// ============================
// === Inserting Suggestion ===
// ============================
/// The description of the results of inserting suggestion.
#[derive(Clone, Debug)]
pub struct InsertedSuggestion {
/// An entire input after inserting the suggestion.
pub new_input: String,
/// The replaced range in the old input.
pub replaced: text::Range<text::Byte>,
/// The range of the inserted code in the new input. It does not contain any additional spaces
/// (in contrary to [`inserted_text`] field.
pub inserted_code: text::Range<text::Byte>,
/// The range of the entire inserted text in the new input. It contains code and all additional
/// spaces.
pub inserted_text: text::Range<text::Byte>,
/// An import that needs to be added when applying the suggestion.
pub import: Option<RequiredImport>,
}
impl InsertedSuggestion {
/// The text change resulting from this insertion.
pub fn input_change(&self) -> text::Change<text::Byte, String> {
let to_insert = self.new_input[self.inserted_text].to_owned();
text::Change { range: self.replaced, text: to_insert }
}
}
// === Insert Context ===
/// A helper abstraction responsible for generating proper code for the suggestion.
///
/// If the context contains a qualified name, we want to reuse it in the generated code sample.
/// For example, if the user types `Foo.Bar.` and accepts a method `Foo.Bar.baz` we insert
/// `Foo.Bar.baz` with `Foo` import instead of inserting `Bar.baz` with `Bar` import.
struct InsertContext<'a> {
suggestion: &'a action::Suggestion,
context: Option<ast::opr::Chain>,
generate_this: bool,
}
impl InsertContext<'_> {
/// Whether the context of the edited expression contains a qualified name. Qualified name is a
/// infix chain of `ACCESS` operators with identifiers.
fn has_qualified_name(&self) -> bool {
if let Some(ref context) = self.context {
let every_operand_is_name = context.enumerate_operands().flatten().all(|opr| {
matches!(opr.item.arg.shape(), ast::Shape::Cons(_) | ast::Shape::Var(_))
});
let every_operator_is_access = context
.enumerate_operators()
.all(|opr| opr.item.ast().repr() == ast::opr::predefined::ACCESS);
every_operand_is_name && every_operator_is_access
} else {
false
}
}
/// All the segments of the qualified name already written by the user.
fn qualified_name_segments(&self) -> Option<Vec<ImString>> {
if self.has_qualified_name() {
// Unwrap is safe here, because `has_qualified_name` would not return true in case of no
// context.
let context = self.context.as_ref().unwrap();
let name_segments = context
.enumerate_operands()
.flatten()
.filter_map(|opr| ast::identifier::name(&opr.item.arg).map(ImString::new))
.collect_vec();
Some(name_segments)
} else {
None
}
}
/// A list of name segments to replace the user input, and also a required import for the
/// final expression. The returned list of segments contains both the segments already entered
/// by the user and the segments that need to be added.
fn segments_to_replace(&self) -> Option<(Vec<ImString>, Option<RequiredImport>)> {
if let Some(existing_segments) = self.qualified_name_segments() {
if let action::Suggestion::FromDatabase(entry) = self.suggestion {
let name = entry.qualified_name();
let all_segments = name.segments().cloned().collect_vec();
// A list of search windows is reversed, because we want to look from the end, as it
// gives the shortest possible name.
let window_size = existing_segments.len();
let mut windows = all_segments.windows(window_size).rev();
let window_position_from_end = windows.position(|w| w == existing_segments)?;
let pos = all_segments.len().saturating_sub(window_position_from_end + window_size);
let name_segments = all_segments.get(pos..).unwrap_or(&[]).to_vec();
let import_segments = all_segments.get(..=pos).unwrap_or(&[]).to_vec();
// Valid qualified name requires at least 2 segments (namespace and project name).
// Not enough segments to build a qualified name is not a mistake here it just
// means we don't need any import, as the entry is already in scope.
let minimal_count_of_segments = 2;
let import = if import_segments.len() < minimal_count_of_segments {
None
} else if let Ok(import) = QualifiedName::from_all_segments(import_segments.clone())
{
Some(RequiredImport::Name(import))
} else {
error!("Invalid import formed in `segments_to_replace`: {import_segments:?}.");
None
};
Some((name_segments, import))
} else {
None
}
} else {
None
}
}
fn code_to_insert(&self) -> (Cow<str>, Option<RequiredImport>) {
match self.suggestion {
action::Suggestion::FromDatabase(entry) => {
if let Some((segments, import)) = self.segments_to_replace() {
(Cow::from(segments.iter().join(ast::opr::predefined::ACCESS)), import)
} else {
let code = entry.code_to_insert(self.generate_this);
let import = Some(RequiredImport::Entry(entry.clone_ref()));
(code, import)
}
}
action::Suggestion::Hardcoded(snippet) => (Cow::from(snippet.code.as_str()), None),
}
}
}
// === Input ===
impl Input {
/// Return an information about input change after inserting given suggestion.
pub fn after_inserting_suggestion(
&self,
suggestion: &action::Suggestion,
has_this: bool,
) -> FallibleResult<InsertedSuggestion> {
let context = self.context();
let generate_this = !has_this;
let context = InsertContext { suggestion, context, generate_this };
let default_range = (self.cursor_position..self.cursor_position).into();
let replaced = if context.has_qualified_name() {
self.accessor_chain_range().unwrap_or(default_range)
} else {
self.edited_name_range().unwrap_or(default_range)
};
let (code_to_insert, import) = context.code_to_insert();
debug!("Code to insert: \"{code_to_insert}\"");
let end_of_inserted_code = replaced.start + text::Bytes(code_to_insert.len());
let end_of_inserted_text = end_of_inserted_code + text::Bytes(1);
let mut new_input = self.ast.to_string();
let raw_range = replaced.start.value..replaced.end.value;
Self::ensure_on_char_boundary(&new_input, raw_range.start)?;
Self::ensure_on_char_boundary(&new_input, raw_range.end)?;
new_input.replace_range(raw_range, &code_to_insert);
new_input.insert(end_of_inserted_code.value, ' ');
Ok(InsertedSuggestion {
new_input,
replaced,
inserted_code: (replaced.start..end_of_inserted_code).into(),
inserted_text: (replaced.start..end_of_inserted_text).into(),
import,
})
}
/// Check if the `index` is at a char boundary inside `string` and can be used as a range
/// boundary. Otherwise, return an error.
fn ensure_on_char_boundary(string: &str, index: usize) -> FallibleResult<()> {
if !string.is_char_boundary(index) {
Err(NotACharBoundary { index, string: string.into() }.into())
} else {
Ok(())
}
}
}
// ============
// === Test ===
// ============
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edited_literal() {
struct Case {
input: &'static str,
expected: Option<Literal>,
}
let cases: Vec<Case> = vec![
Case { input: "", expected: None },
Case {
input: "'",
expected: Some(Literal::Text {
text: "''".into(),
closing_delimiter: Some("'"),
}),
},
Case {
input: "\"\"\"",
expected: Some(Literal::Text {
text: "\"\"\"\"\"\"".into(),
closing_delimiter: Some("\"\"\""),
}),
},
Case {
input: "\"text",
expected: Some(Literal::Text {
text: "\"text\"".into(),
closing_delimiter: Some("\""),
}),
},
Case {
input: "\"text\"",
expected: Some(Literal::Text {
text: "\"text\"".into(),
closing_delimiter: None,
}),
},
Case {
input: "'text",
expected: Some(Literal::Text {
text: "'text'".into(),
closing_delimiter: Some("'"),
}),
},
Case {
input: "'text'",
expected: Some(Literal::Text {
text: "'text'".into(),
closing_delimiter: None,
}),
},
Case {
input: "x = 'text",
expected: Some(Literal::Text {
text: "'text'".into(),
closing_delimiter: Some("'"),
}),
},
Case {
input: "x = \"\"\"text",
expected: Some(Literal::Text {
text: "\"\"\"text\"\"\"".into(),
closing_delimiter: Some("\"\"\""),
}),
},
];
let parser = Parser::new();
let input = Input::parse(&parser, "123", text::Byte(3));
assert!(matches!(input.edited_literal(), Some(Literal::Number(n)) if n.repr() == "123"));
let input = Input::parse(&parser, "x = 123", text::Byte(7));
assert!(matches!(input.edited_literal(), Some(Literal::Number(n)) if n.repr() == "123"));
for case in cases {
let input = Input::parse(&parser, case.input, text::Byte(case.input.len()));
assert_eq!(input.edited_literal(), case.expected.as_ref());
}
}
#[test]
fn parsing_input() {
#[derive(Debug, Default)]
struct Case {
input: String,
cursor_position: text::Byte,
expected_accessor_chain_range: Option<text::Range<text::Byte>>,
expected_name_range: Option<text::Range<text::Byte>>,
expected_pattern: String,
}
impl Case {
fn new(
input: impl Into<String>,
cursor_position: impl Into<text::Byte>,
expected_accessor_chain_range: Option<impl Into<text::Range<text::Byte>>>,
expected_name_range: Option<impl Into<text::Range<text::Byte>>>,
expected_pattern: impl Into<String>,
) -> Self {
Self {
input: input.into(),
cursor_position: cursor_position.into(),
expected_accessor_chain_range: expected_accessor_chain_range.map(Into::into),
expected_name_range: expected_name_range.map(Into::into),
expected_pattern: expected_pattern.into(),
}
}
fn run(self, parser: &Parser) {
debug!("Running case {} cursor position {}", self.input, self.cursor_position);
let input = Input::parse(parser, self.input, self.cursor_position);
let pattern = input.filter().pattern.clone_ref();
assert_eq!(input.cursor_position, self.cursor_position);
assert_eq!(input.edited_ast.edited_name.map(|a| a.range), self.expected_name_range);
assert_eq!(
input.edited_ast.edited_accessor_chain.map(|a| a.range),
self.expected_accessor_chain_range
);
assert_eq!(pattern, self.expected_pattern);
}
}
let none_range: Option<text::Range<text::Byte>> = None;
let cases = [
Case::new("foo", 3, none_range, Some(0..3), "foo"),
Case::new("foo", 1, none_range, Some(0..3), "f"),
Case::new("foo", 0, none_range, Some(0..3), ""),
Case::new("(2 + foo) * bar", 2, none_range, none_range, ""),
Case::new("(2 + foo)*bar", 5, none_range, Some(5..8), ""),
Case::new("(2 + foo)*bar", 7, none_range, Some(5..8), "fo"),
Case::new("(2 + foo)*bar", 8, none_range, Some(5..8), "foo"),
Case::new("(2 + foo)*bar", 9, none_range, none_range, ""),
Case::new("(2 + foo)*b", 10, none_range, Some(10..11), ""),
Case::new("(2 + foo)*b", 11, none_range, Some(10..11), "b"),
Case::new("Standard.Base.foo", 0, none_range, Some(0..8), ""),
Case::new("Standard.Base.foo", 4, none_range, Some(0..8), "Stan"),
Case::new("Standard.Base.foo", 8, none_range, Some(0..8), "Standard"),
Case::new("Standard.Base.foo", 9, Some(0..13), Some(9..13), ""),
Case::new("Standard.Base.foo", 11, Some(0..13), Some(9..13), "Ba"),
Case::new("Standard.Base.foo", 13, Some(0..13), Some(9..13), "Base"),
Case::new("Standard.Base.foo", 14, Some(0..17), Some(14..17), ""),
Case::new("Standard.Base.foo", 15, Some(0..17), Some(14..17), "f"),
Case::new("Standard.Base.foo", 17, Some(0..17), Some(14..17), "foo"),
Case::new("Standard . Base . foo", 17, Some(0..21), none_range, ""),
Case::new("Standard . Base.foo", 16, Some(11..19), Some(16..19), ""),
Case::new("Standard.Base.", 14, Some(0..14), none_range, ""),
Case::new("(2 + Standard.Base.foo)*b", 21, Some(5..22), Some(19..22), "fo"),
Case::new("(2 + Standard.Base.)*b", 19, Some(5..19), none_range, ""),
];
let parser = Parser::new();
for case in cases {
case.run(&parser);
}
}
}

View File

@ -392,9 +392,7 @@ pub enum NodeEditStatus {
/// The node was edited and had a previous expression.
Edited {
/// Expression of the node before the edit was started.
previous_expression: String,
/// Intended method of the node before editing (if known).
previous_intended_method: Option<MethodId>,
previous_expression: String,
},
/// The node was created and did not previously exist.
Created,
@ -411,6 +409,9 @@ pub struct NodeMetadata {
///
/// The methods may be defined for different types, so the name alone don't specify them.
#[serde(default, deserialize_with = "enso_prelude::deserialize_or_default")]
#[deprecated(
note = "No longer used anywhere, but is kept for compatibility with old projects"
)]
pub intended_method: Option<MethodId>,
/// Information about uploading file.
///
@ -803,7 +804,6 @@ main = 5
let id = ast::Id::from_str("1d6660c6-a70b-4eeb-b5f7-82f05a51df25").unwrap();
let node = file.metadata.ide.node.get(&id).unwrap();
assert_eq!(node.position, Some(Position::new(-75.5, 52.0)));
assert_eq!(node.intended_method, None);
assert_eq!(file.metadata.rest, serde_json::Value::Object(default()));
}
}

View File

@ -323,13 +323,12 @@ fn restore_edited_node_in_graph(
graph.remove_node(node_id)?;
md_entry.remove();
}
Some(NodeEditStatus::Edited { previous_expression, previous_intended_method }) => {
Some(NodeEditStatus::Edited { previous_expression }) => {
debug!(
"Restoring edited node {node_id} to original expression \
\"{previous_expression}\"."
);
graph.edit_node(node_id, Parser::new().parse_line_ast(previous_expression)?)?;
md_entry.get_mut().intended_method = previous_intended_method;
}
None => {}
}

View File

@ -315,7 +315,7 @@ impl Project {
let content_roots = ContentRoots::new_from_connection(language_server);
let content_roots = Rc::new(content_roots);
let notifications = notification::Publisher::default();
let urm = Rc::new(model::undo_redo::Manager::new());
let urm = default();
let properties = Rc::new(RefCell::new(properties));
let ret = Project {

View File

@ -343,7 +343,6 @@ impl Repository {
/// Owns [`Repository`] and keeps track of open modules.
#[derive(Debug, Default)]
pub struct Manager {
#[allow(missing_docs)]
/// Repository with undo and redo stacks.
pub repository: Rc<Repository>,
/// Currently available modules.
@ -535,7 +534,8 @@ main =
assert_eq!(sum_node.expression().to_string(), "2 + 2");
assert_eq!(product_node.expression().to_string(), "5 * 5");
let sum_tree = SpanTree::<()>::new(&sum_node.expression(), graph).unwrap();
let context = &span_tree::generate::context::Empty;
let sum_tree = SpanTree::<()>::new(&sum_node.expression(), context).unwrap();
let sum_input =
sum_tree.root_ref().leaf_iter().find(|n| n.is_argument()).unwrap().crumbs;
let connection = controller::graph::Connection {
@ -543,7 +543,7 @@ main =
destination: controller::graph::Endpoint::new(sum_node.id(), sum_input),
};
graph.connect(&connection, &span_tree::generate::context::Empty).unwrap();
graph.connect(&connection, context).unwrap();
});
}

View File

@ -19,11 +19,11 @@ use crate::presenter::searcher::provider::ControllerComponentsProviderExt;
use enso_frp as frp;
use enso_suggestion_database::documentation_ir::EntryDocumentation;
use enso_suggestion_database::documentation_ir::Placeholder;
use enso_text as text;
use ide_view as view;
use ide_view::component_browser::component_list_panel::grid as component_grid;
use ide_view::component_browser::component_list_panel::BreadcrumbId;
use ide_view::component_browser::component_list_panel::SECTION_NAME_CRUMB_INDEX;
use ide_view::graph_editor::component::node as node_view;
use ide_view::graph_editor::GraphEditor;
use ide_view::project::SearcherParams;
@ -96,8 +96,8 @@ impl Model {
}
#[profile(Debug)]
fn input_changed(&self, new_input: &str) {
if let Err(err) = self.controller.set_input(new_input.to_owned()) {
fn input_changed(&self, new_input: &str, cursor_position: text::Byte) {
if let Err(err) = self.controller.set_input(new_input.to_owned(), cursor_position) {
error!("Error while setting new searcher input: {err}.");
}
}
@ -143,7 +143,7 @@ impl Model {
fn suggestion_accepted(
&self,
id: component_grid::GroupEntryId,
) -> Option<(ViewNodeId, node_view::Expression)> {
) -> Option<(ViewNodeId, text::Range<text::Byte>, ImString)> {
let provider = self.controller.provider();
let component: FallibleResult<_> =
provider.component_by_view_id(id).ok_or_else(|| NoSuchComponent(id).into());
@ -156,10 +156,9 @@ impl Model {
self.controller.use_suggestion(suggestion)
});
match new_code {
Ok(new_code) => {
Ok(text::Change { range, text }) => {
self.update_breadcrumbs();
let new_code_and_trees = node_view::Expression::new_plain(new_code);
Some((self.input_view, new_code_and_trees))
Some((self.input_view, range, text.into()))
}
Err(err) => {
error!("Error while applying suggestion: {err}.");
@ -248,7 +247,7 @@ impl Model {
component::Data::FromDatabase { id, .. } =>
self.controller.documentation_for_entry(*id),
component::Data::Virtual { snippet } =>
snippet.documentation.as_ref().map_or_default(EntryDocumentation::builtin),
snippet.documentation.clone().unwrap_or_default(),
}
} else {
default()
@ -292,9 +291,13 @@ impl Searcher {
let network = frp::Network::new("presenter::Searcher");
let graph = &model.view.graph().frp;
let browser = model.view.searcher();
frp::extend! { network
eval model.view.searcher_input_changed ((expr) model.input_changed(expr));
eval model.view.searcher_input_changed ([model]((expr, selections)) {
let cursor_position = selections.last().map(|sel| sel.end).unwrap_or_default();
model.input_changed(expr, cursor_position);
});
action_list_changed <- source::<()>();
@ -302,7 +305,6 @@ impl Searcher {
model.controller.reload_list());
}
let browser = model.view.searcher();
let grid = &browser.model().list.model().grid;
let navigator = &browser.model().list.model().section_navigator;
let breadcrumbs = &browser.model().list.model().breadcrumbs;
@ -316,8 +318,8 @@ impl Searcher {
let provider = provider::Component::provide_new_list(controller_provider, &grid);
*model.provider.borrow_mut() = Some(provider);
});
new_input <- grid.suggestion_accepted.filter_map(f!((e) model.suggestion_accepted(*e)));
graph.set_node_expression <+ new_input;
input_edit <- grid.suggestion_accepted.filter_map(f!((e) model.suggestion_accepted(*e)));
graph.edit_node_expression <+ input_edit;
entry_selected <- grid.active.filter_map(|&s| s?.as_entry_id());
selected_entry_changed <- entry_selected.on_change().constant(());
@ -380,7 +382,7 @@ impl Searcher {
/// The expression to be used for newly created nodes when initialising the searcher without
/// an existing node.
const DEFAULT_INPUT_EXPRESSION: &str = "Nothing";
let SearcherParams { input, source_node } = parameters;
let SearcherParams { input, source_node, .. } = parameters;
let view_data = graph_editor.model.nodes.get_cloned_ref(&input);
@ -443,6 +445,10 @@ impl Searcher {
view: view::project::View,
parameters: SearcherParams,
) -> FallibleResult<Self> {
// We get the position for searcher before initializing the input node, because the
// added node will affect the AST, and the position will become incorrect.
let position_in_code = graph_controller.graph().definition_end_location()?;
let mode = Self::init_input_node(
parameters,
graph_presenter,
@ -455,13 +461,15 @@ impl Searcher {
&project_controller.model,
graph_controller,
mode,
parameters.cursor_position,
position_in_code,
)?;
// Clear input on a new node. By default this will be set to whatever is used as the default
// content of the new node.
if let Mode::NewNode { source_node, .. } = mode {
if source_node.is_none() {
if let Err(e) = searcher_controller.set_input("".to_string()) {
if let Err(e) = searcher_controller.set_input("".to_string(), text::Byte(0)) {
error!("Failed to clear input when creating searcher for a new node: {e:?}.");
}
}

View File

@ -33,9 +33,7 @@ pub fn create_providers_from_controller(controller: &controller::Searcher) -> An
match controller.actions() {
Actions::Loading => as_any(Rc::new(list_view::entry::EmptyProvider)),
Actions::Loaded { list } => {
let user_action = controller.current_user_action();
let intended_function = controller.intended_function_suggestion();
let provider = Action { actions: list, user_action, intended_function };
let provider = Action { actions: list };
as_any(Rc::new(provider))
}
Actions::Error(err) => {
@ -61,9 +59,7 @@ where P: list_view::entry::ModelProvider<view::searcher::Entry>
/// An searcher actions provider, based on the action list retrieved from the searcher controller.
#[derive(Clone, Debug)]
pub struct Action {
actions: Rc<controller::searcher::action::List>,
user_action: controller::searcher::UserAction,
intended_function: Option<controller::searcher::action::Suggestion>,
actions: Rc<controller::searcher::action::List>,
}
impl Action {
@ -126,11 +122,7 @@ impl list_view::entry::ModelProvider<GlyphHighlightedLabel> for Action {
impl ide_view::searcher::DocumentationProvider for Action {
fn get(&self) -> Option<String> {
use controller::searcher::UserAction::*;
self.intended_function.as_ref().and_then(|function| match self.user_action {
StartingTypingArgument => function.documentation_html().map(ToOwned::to_owned),
_ => None,
})
None
}
fn get_for_entry(&self, id: usize) -> Option<String> {

View File

@ -210,12 +210,12 @@ pub mod mock {
&self,
module: model::Module,
db: Rc<model::SuggestionDatabase>,
) -> crate::controller::Graph {
) -> controller::Graph {
let parser = self.parser.clone_ref();
let method = self.method_pointer();
let definition =
module.lookup_method(self.project_name.clone(), &method).expect("Lookup failed.");
crate::controller::Graph::new(module, db, parser, definition)
controller::Graph::new(module, db, parser, definition)
.expect("Graph could not be created")
}
@ -291,11 +291,14 @@ pub mod mock {
let data = self.clone();
let searcher_target = executed_graph.graph().nodes().unwrap().last().unwrap().id();
let searcher_mode = controller::searcher::Mode::EditNode { node_id: searcher_target };
let position_in_code = executed_graph.graph().definition_end_location().unwrap();
let searcher = controller::Searcher::new_from_graph_controller(
ide.clone_ref(),
&project,
executed_graph.clone_ref(),
searcher_mode,
enso_text::Byte(0),
position_in_code,
)
.unwrap();
executor.run_until_stalled();

View File

@ -1,6 +1,5 @@
use super::prelude::*;
use crate::controller::graph::NodeTrees;
use crate::ide;
use crate::transport::test_utils::TestWithMockedTransport;
@ -50,112 +49,3 @@ fn failure_to_open_project_is_reported() {
// Further investigation needed, tracked by https://github.com/enso-org/ide/issues/1575
fixture.run_until_stalled();
}
// ====================================
// === SpanTree in Graph Controller ===
// ====================================
#[wasm_bindgen_test]
fn span_tree_args() {
use crate::test::mock::*;
use span_tree::Node;
let data = Unified::new();
let fixture = data.fixture_customize(|_, json_client, _| {
// The searcher requests for completion when we clear the input.
controller::searcher::test::expect_completion(json_client, &[1]);
// Additional completion request happens after picking completion.
controller::searcher::test::expect_completion(json_client, &[1]);
});
let Fixture { graph, executed_graph, searcher, suggestion_db, .. } = &fixture;
let entry = suggestion_db.lookup(1).unwrap();
searcher.set_input("".into()).unwrap();
searcher
.use_suggestion(controller::searcher::action::Suggestion::FromDatabase(entry.clone_ref()))
.unwrap();
let id = searcher.commit_node().unwrap();
let get_node = || graph.node(id).unwrap();
let get_inputs = || NodeTrees::new(&get_node().info, executed_graph).unwrap().inputs;
let get_param = |n| {
let inputs = get_inputs();
let mut args = inputs.root_ref().leaf_iter().filter(|n| n.is_function_parameter());
args.nth(n).and_then(|node| node.argument_info())
};
let parser = executed_graph.parser();
let mut invocation_info = entry.invocation_info(suggestion_db, &parser);
let expected_this_param = invocation_info.parameters.remove(0).with_call_id(Some(id));
let expected_arg1_param = invocation_info.parameters.remove(0).with_call_id(Some(id));
// === Method notation, without prefix application ===
assert_eq!(get_node().info.expression().repr(), "Base.foo");
match get_inputs().root.children.as_slice() {
// The tree here should have two nodes under root - one with given Ast and second for
// an additional prefix application argument.
[_, second] => {
let Node { children, kind, .. } = &second.node;
assert!(children.is_empty());
assert_eq!(kind.argument_info().as_ref(), Some(&expected_arg1_param));
}
_ => panic!("Expected only two children in the span tree's root"),
};
// === Method notation, with prefix application ===
graph.set_expression(id, "Base.foo 50").unwrap();
match get_inputs().root.children.as_slice() {
// The tree here should have two nodes under root - one with given Ast and second for
// an additional prefix application argument.
[_target, argument] => {
let Node { children, kind, .. } = &argument.node;
assert!(children.is_empty());
assert_eq!(kind.argument_info().as_ref(), Some(&expected_arg1_param));
}
inputs =>
panic!("Expected two children in the span tree's root but got {:?}", inputs.len()),
};
// === Function notation, without prefix application ===
assert_eq!(entry.name, "foo");
graph.set_expression(id, "foo").unwrap();
assert_eq!(get_param(0).as_ref(), Some(&expected_this_param));
assert_eq!(get_param(1).as_ref(), Some(&expected_arg1_param));
assert_eq!(get_param(2).as_ref(), None);
// === Function notation, with prefix application ===
graph.set_expression(id, "foo Base").unwrap();
assert_eq!(get_param(0).as_ref(), Some(&expected_this_param));
assert_eq!(get_param(1).as_ref(), Some(&expected_arg1_param));
assert_eq!(get_param(2).as_ref(), None);
// === Changed function name, should not have known parameters ===
graph.set_expression(id, "bar").unwrap();
assert_eq!(get_param(0), None);
assert_eq!(get_param(1), None);
assert_eq!(get_param(2), None);
graph.set_expression(id, "bar Base").unwrap();
assert_eq!(get_param(0), Some(default()));
assert_eq!(get_param(1), None);
assert_eq!(get_param(2), None);
graph.set_expression(id, "Base.bar").unwrap();
assert_eq!(get_param(0), Some(default()));
assert_eq!(get_param(1), Some(default()));
assert_eq!(get_param(2), None);
// === Oversaturated call ===
graph.set_expression(id, "foo Base 10 20 30").unwrap();
assert_eq!(get_param(0).as_ref(), Some(&expected_this_param));
assert_eq!(get_param(1).as_ref(), Some(&expected_arg1_param));
assert_eq!(get_param(2).as_ref(), Some(&default()));
assert_eq!(get_param(3).as_ref(), Some(&default()));
assert_eq!(get_param(4).as_ref(), None);
}

View File

@ -452,7 +452,13 @@ impl Entry {
let is_extension_method =
self_type_module.map_or(false, |stm| *stm != self.defined_in);
let extension_method_import = is_extension_method.and_option_from(|| {
self.defined_in_entry(db).map(|e| e.required_imports(db, in_module.clone_ref()))
self.defined_in_entry(db).map(|e| {
if e.kind == Kind::Module && e.defined_in == in_module {
default()
} else {
e.required_imports(db, in_module.clone_ref())
}
})
});
let self_type_import = self
.is_static
@ -469,7 +475,7 @@ impl Entry {
.into_iter()
.flatten()
.collect(),
Kind::Module | Kind::Type if defined_in_same_module => default(),
Kind::Type if defined_in_same_module => default(),
Kind::Module => {
let import = if let Some(reexport) = &self.reexported_in {
Import::Unqualified {
@ -1125,8 +1131,12 @@ mod test {
}
expect_imports(&static_method, &current_modules[0], &[]);
expect_imports(&module_method, &current_modules[0], &[]);
expect_imports(&submodule, &current_modules[0], &[]);
// The following asserts are disabled, because we actually add import for the module even if
// we're inside it. It is necessary, because otherwise `Project.foo` expressions
// currently do not work without importing `local.Project` into the scope.
// See https://github.com/enso-org/enso/issues/5616.
// expect_imports(&module_method, &current_modules[0], &[]);
// expect_imports(&submodule, &current_modules[0], &[]);
expect_imports(&extension_method, &current_modules[0], &[
"from Standard.Base import Number",
]);

View File

@ -21,6 +21,7 @@ use ide_view_component_list_panel_icons::SIZE;
use ide_view_graph_editor::component::node::action_bar;
// =============
// === Frame ===
// =============

View File

@ -299,6 +299,7 @@ ensogl::define_endpoints_2! {
set_disabled (bool),
set_input_connected (span_tree::Crumbs,Option<Type>,bool),
set_expression (Expression),
edit_expression (text::Range<text::Byte>, ImString),
set_skip_macro (bool),
set_freeze_macro (bool),
set_comment (Comment),
@ -750,6 +751,7 @@ impl Node {
);
eval filtered_usage_type (((a,b)) model.set_expression_usage_type(a,b));
eval input.set_expression ((a) model.set_expression(a));
model.input.edit_expression <+ input.edit_expression;
out.expression <+ model.input.frp.expression;
out.expression_span <+ model.input.frp.on_port_code_update;
out.requested_widgets <+ model.input.frp.requested_widgets;

View File

@ -1,22 +1,25 @@
//! Definition of the `ActionBar` component for the `visualization::Container`.
pub mod icon;
use crate::prelude::*;
use ensogl::display::shape::*;
use enso_config::ARGS;
use enso_frp as frp;
use ensogl::application::Application;
use ensogl::display;
use ensogl::display::shape::*;
use ensogl_component::toggle_button;
use ensogl_component::toggle_button::ColorableShape;
use ensogl_component::toggle_button::ToggleButton;
use ensogl_hardcoded_theme as theme;
// ==============
// === Export ===
// ==============
pub mod icon;
// ==================
// === Constants ===

View File

@ -23,6 +23,8 @@ use ensogl::display;
use ensogl::gui::cursor;
use ensogl::Animation;
use ensogl_component::text;
use ensogl_component::text::buffer::selection::Selection;
use ensogl_component::text::FromInContextSnapped;
use ensogl_hardcoded_theme as theme;
@ -852,6 +854,12 @@ ensogl::define_endpoints! {
/// Set the node expression.
set_expression (node::Expression),
/// Edit the node expression: if the node is currently edited, the given range will be
/// replaced with the string, and the text cursor will be placed after the inserted string.
///
/// If the node is **not** edited, nothing changes.
edit_expression (text::Range<Byte>, ImString),
/// Set the mode in which the cursor will indicate that editing of the node is possible.
set_edit_ready_mode (bool),
@ -893,6 +901,8 @@ ensogl::define_endpoints! {
pointer_style (cursor::Style),
width (f32),
expression (ImString),
expression_edit (ImString, Vec<Selection<Byte>>),
editing (bool),
ports_visible (bool),
body_hover (bool),
@ -1017,9 +1027,21 @@ impl Area {
model.set_expression(expr, *is_editing, &frp_endpoints)
)
);
legit_edit <- frp.input.edit_expression.gate(&frp.input.set_editing);
model.label.select <+ legit_edit.map(|(range, _)| (range.start.into(), range.end.into()));
model.label.insert <+ legit_edit._1();
frp.output.source.expression <+ expression.map(|e| e.code.clone_ref());
expression_changed_by_user <- model.label.content.gate(&frp.input.set_editing);
frp.output.source.expression <+ expression_changed_by_user.ref_into();
frp.output.source.expression_edit <+ model.label.selections.map2(
&expression_changed_by_user,
f!([model](selection, full_content) {
let full_content = full_content.into();
let to_byte = |loc| text::Byte::from_in_context_snapped(&model.label, loc);
let selections = selection.iter().map(|sel| sel.map(to_byte)).collect_vec();
(full_content, selections)
})
);
// === Expression Type ===

View File

@ -72,6 +72,8 @@ use ensogl::system::web::traits::*;
use ensogl::Animation;
use ensogl::DEPRECATED_Animation;
use ensogl::Easing;
use ensogl_component::text;
use ensogl_component::text::buffer::selection::Selection;
use ensogl_component::tooltip::Tooltip;
use ensogl_hardcoded_theme as theme;
@ -590,13 +592,13 @@ ensogl::define_endpoints_2! {
// === VCS Status ===
set_node_vcs_status ((NodeId,Option<node::vcs::Status>)),
set_node_vcs_status ((NodeId, Option<node::vcs::Status>)),
set_detached_edge_targets (EdgeEndpoint),
set_detached_edge_sources (EdgeEndpoint),
set_edge_source ((EdgeId,EdgeEndpoint)),
set_edge_target ((EdgeId,EdgeEndpoint)),
set_edge_source ((EdgeId, EdgeEndpoint)),
set_edge_target ((EdgeId, EdgeEndpoint)),
unset_edge_source (EdgeId),
unset_edge_target (EdgeId),
connect_nodes ((EdgeEndpoint,EdgeEndpoint)),
@ -611,6 +613,7 @@ ensogl::define_endpoints_2! {
edit_node (NodeId),
collapse_nodes ((Vec<NodeId>,NodeId)),
set_node_expression ((NodeId,node::Expression)),
edit_node_expression ((NodeId, text::Range<text::Byte>, ImString)),
set_node_skip ((NodeId,bool)),
set_node_freeze ((NodeId,bool)),
set_node_comment ((NodeId,node::Comment)),
@ -618,10 +621,10 @@ ensogl::define_endpoints_2! {
set_expression_usage_type ((NodeId,ast::Id,Option<Type>)),
update_node_widgets ((NodeId,WidgetUpdates)),
cycle_visualization (NodeId),
set_visualization ((NodeId,Option<visualization::Path>)),
set_visualization ((NodeId, Option<visualization::Path>)),
register_visualization (Option<visualization::Definition>),
set_visualization_data ((NodeId,visualization::Data)),
set_error_visualization_data ((NodeId,visualization::Data)),
set_visualization_data ((NodeId, visualization::Data)),
set_error_visualization_data ((NodeId, visualization::Data)),
enable_visualization (NodeId),
disable_visualization (NodeId),
@ -693,11 +696,12 @@ ensogl::define_endpoints_2! {
node_hovered (Option<Switch<NodeId>>),
node_selected (NodeId),
node_deselected (NodeId),
node_position_set ((NodeId, Vector2)),
node_position_set_batched ((NodeId, Vector2)),
node_expression_set ((NodeId, ImString)),
node_position_set ((NodeId,Vector2)),
node_position_set_batched ((NodeId,Vector2)),
node_expression_set ((NodeId,ImString)),
node_expression_span_set ((NodeId, span_tree::Crumbs, ImString)),
node_comment_set ((NodeId, String)),
node_expression_edited ((NodeId,ImString,Vec<Selection<text::Byte>>)),
node_comment_set ((NodeId,String)),
node_entered (NodeId),
node_exited (),
node_editing_started (NodeId),
@ -1588,6 +1592,7 @@ impl GraphEditorModelWithNetwork {
));
eval node.expression((t) model.frp.private.output.node_expression_set.emit((node_id,t.into())));
eval node.expression_span([model]((crumbs,code)) {
let args = (node_id, crumbs.clone(), code.clone());
model.frp.private.output.node_expression_span_set.emit(args)
@ -1598,6 +1603,10 @@ impl GraphEditorModelWithNetwork {
model.frp.private.output.widgets_requested.emit(args)
});
let node_expression_edit = node.model().input.expression_edit.clone_ref();
model.frp.private.output.node_expression_edited <+ node_expression_edit.map(
move |(expr, selection)| (node_id, expr.clone_ref(), selection.clone())
);
model.frp.private.output.request_import <+ node.request_import;
@ -1947,6 +1956,20 @@ impl GraphEditorModel {
}
}
fn edit_node_expression(
&self,
node_id: impl Into<NodeId>,
range: impl Into<text::Range<text::Byte>>,
inserted_str: impl Into<ImString>,
) {
let node_id = node_id.into();
let range = range.into();
let inserted_str = inserted_str.into();
if let Some(node) = self.nodes.get_cloned_ref(&node_id) {
node.edit_expression(range, inserted_str);
}
}
fn set_node_skip(&self, node_id: impl Into<NodeId>, skip: &bool) {
let node_id = node_id.into();
if let Some(node) = self.nodes.get_cloned_ref(&node_id) {
@ -2809,8 +2832,6 @@ fn new_graph_editor(app: &Application) -> GraphEditor {
let node_input_touch = TouchNetwork::<EdgeEndpoint>::new(network,mouse);
let node_output_touch = TouchNetwork::<EdgeEndpoint>::new(network,mouse);
node_expression_set <- source();
out.node_expression_set <+ node_expression_set;
on_output_connect_drag_mode <- node_output_touch.down.constant(true);
on_output_connect_follow_mode <- node_output_touch.selected.constant(false);
@ -3147,15 +3168,6 @@ fn new_graph_editor(app: &Application) -> GraphEditor {
}
// === Set Node Expression ===
frp::extend! { network
set_node_expression_string <- inputs.set_node_expression.map(|(id,expr)| (*id,expr.code.clone()));
out.node_expression_set <+ set_node_expression_string;
}
// === Set Node SKIP and FREEZE macros ===
frp::extend! { network
@ -3642,8 +3654,9 @@ fn new_graph_editor(app: &Application) -> GraphEditor {
model.profiling_statuses.remove <+ out.node_removed;
out.on_visualization_select <+ out.node_removed.map(|&id| Switch::Off(id));
eval inputs.set_node_expression (((id,expr)) model.set_node_expression(id,expr));
port_to_refresh <= inputs.set_node_expression.map(f!(((id,_))model.node_in_edges(id)));
eval inputs.set_node_expression (((id, expr)) model.set_node_expression(id, expr));
eval inputs.edit_node_expression (((id, range, ins)) model.edit_node_expression(id, range, ins));
port_to_refresh <= inputs.set_node_expression.map(f!(((id, _))model.node_in_edges(id)));
eval port_to_refresh ((id) model.set_edge_target_connection_status(*id,true));
// === Remove implementation ===

View File

@ -25,6 +25,8 @@ use ensogl::display;
use ensogl::system::web;
use ensogl::system::web::dom;
use ensogl::DEPRECATED_Animation;
use ensogl_component::text;
use ensogl_component::text::selection::Selection;
use ensogl_hardcoded_theme::Theme;
use ide_view_graph_editor::NodeSource;
@ -49,19 +51,21 @@ const INPUT_CHANGE_DELAY_MS: i32 = 200;
#[derive(Clone, Copy, Debug, Default)]
pub struct SearcherParams {
/// The node being an Expression Input.
pub input: NodeId,
pub input: NodeId,
/// The node being a source for the edited node data - usually it's output shall be a `this`
/// port for inserted expression.
pub source_node: Option<NodeSource>,
pub source_node: Option<NodeSource>,
/// A position of the cursor in the input node.
pub cursor_position: text::Byte,
}
impl SearcherParams {
fn new_for_new_node(node_id: NodeId, source_node: Option<NodeSource>) -> Self {
Self { input: node_id, source_node }
Self { input: node_id, source_node, cursor_position: default() }
}
fn new_for_edited_node(node_id: NodeId) -> Self {
Self { input: node_id, source_node: None }
fn new_for_edited_node(node_id: NodeId, cursor_position: text::Byte) -> Self {
Self { input: node_id, source_node: None, cursor_position }
}
}
@ -107,7 +111,7 @@ ensogl::define_endpoints! {
/// Is **not** emitted with every graph's node expression change, only when
/// [`INPUT_CHANGE_DELAY_MS`] passes since last change, so we won't needlessly update the
/// Component Browser when user is quickly typing in the input.
searcher_input_changed (ImString),
searcher_input_changed (ImString, Vec<Selection<text::Byte>>),
is_searcher_opened (bool),
adding_new_node (bool),
old_expression_of_edited_node (Expression),
@ -367,6 +371,7 @@ impl View {
// TODO[WD]: This should not be needed after the theme switching issue is implemented.
// See: https://github.com/enso-org/ide/issues/795
let input_change_delay = frp::io::timer::Timeout::new(network);
let searcher_open_delay = frp::io::timer::Timeout::new(network);
if let Some(window_control_buttons) = &*model.window_control_buttons {
let initial_size = &window_control_buttons.size.value();
@ -456,20 +461,47 @@ impl View {
// === Editing ===
existing_node_edited <- graph.node_being_edited.filter_map(|x| *x).gate_not(&frp.adding_new_node);
frp.source.searcher <+ existing_node_edited.map(
|&node| Some(SearcherParams::new_for_edited_node(node))
);
searcher_input_change_opt <- graph.node_expression_set.map2(&frp.searcher, |(node_id, expr), searcher| {
(searcher.as_ref()?.input == *node_id).then(|| expr.clone_ref())
node_edited_by_user <- graph.node_being_edited.gate_not(&frp.adding_new_node);
existing_node_edited <- graph.node_expression_edited.gate_not(&frp.is_searcher_opened);
open_searcher <- existing_node_edited.map2(&node_edited_by_user,
|(id, _, _), edited| edited.map_or(false, |edited| *id == edited)
).on_true();
searcher_open_delay.restart <+ open_searcher.constant(0);
cursor_position <- existing_node_edited.map2(
&node_edited_by_user,
|(node_id, _, selections), edited| {
edited.map_or(None, |edited| {
let position = || selections.last().map(|sel| sel.end).unwrap_or_default();
(*node_id == edited).then(position)
})
}
).filter_map(|pos| *pos);
edited_node <- node_edited_by_user.filter_map(|node| *node);
position_and_edited_node <- cursor_position.map2(&edited_node, |pos, id| (*pos, *id));
prepare_params <- position_and_edited_node.sample(&searcher_open_delay.on_expired);
frp.source.searcher <+ prepare_params.map(|(pos, node_id)| {
Some(SearcherParams::new_for_edited_node(*node_id, *pos))
});
searcher_input_change <- searcher_input_change_opt.filter_map(|expr| expr.clone());
searcher_input_change_opt <- graph.node_expression_edited.map2(&frp.searcher,
|(node_id, expr, selections), searcher| {
let input_change = || (*node_id, expr.clone_ref(), selections.clone());
(searcher.as_ref()?.input == *node_id).then(input_change)
}
);
searcher_input_change <- searcher_input_change_opt.filter_map(|change| change.clone());
input_change_delay.restart <+ searcher_input_change.constant(INPUT_CHANGE_DELAY_MS);
update_searcher_input_on_commit <- frp.output.editing_committed.constant(());
input_change_delay.cancel <+ update_searcher_input_on_commit;
update_searcher_input <- any(&input_change_delay.on_expired, &update_searcher_input_on_commit);
frp.source.searcher_input_changed <+ searcher_input_change.sample(&update_searcher_input);
input_change_and_searcher <- map2(&searcher_input_change, &frp.searcher,
|c, s| (c.clone(), *s)
);
updated_input <- input_change_and_searcher.sample(&update_searcher_input);
input_changed <- updated_input.filter_map(|((node_id, expr, selections), searcher)| {
let input_change = || (expr.clone_ref(), selections.clone());
(searcher.as_ref()?.input == *node_id).then(input_change)
});
frp.source.searcher_input_changed <+ input_changed;
// === Adding Node ===

View File

@ -130,6 +130,7 @@ ensogl_core::define_endpoints! {
cursors_select (Option<Transform>),
set_cursor (Location),
add_cursor (Location),
set_single_selection (selection::Shape),
set_newest_selection_end (Location),
set_oldest_selection_end (Location),
insert (ImString),
@ -220,6 +221,9 @@ impl Buffer {
sel_on_set_cursor <- input.set_cursor.map(f!((t) m.set_cursor(*t)));
sel_on_add_cursor <- input.add_cursor.map(f!((t) m.add_cursor(*t)));
sel_on_set_single_selection <- input.set_single_selection.map(
f!((t) m.set_single_selection(*t))
);
sel_on_set_newest_end <- input.set_newest_selection_end.map
(f!((t) m.set_newest_selection_end(*t)));
sel_on_set_oldest_end <- input.set_oldest_selection_end.map
@ -247,6 +251,7 @@ impl Buffer {
output.source.selection_non_edit_mode <+ sel_on_keep_oldest_cursor;
output.source.selection_non_edit_mode <+ sel_on_set_cursor;
output.source.selection_non_edit_mode <+ sel_on_add_cursor;
output.source.selection_non_edit_mode <+ sel_on_set_single_selection;
output.source.selection_non_edit_mode <+ sel_on_set_newest_end;
output.source.selection_non_edit_mode <+ sel_on_set_oldest_end;
output.source.selection_non_edit_mode <+ sel_on_remove_all;
@ -446,10 +451,14 @@ impl BufferModel {
self.oldest_selection().snap_selections_to_start()
}
fn new_cursor(&self, location: Location) -> Selection {
fn new_selection(&self, shape: selection::Shape) -> Selection {
let id = self.next_selection_id.get();
self.next_selection_id.set(selection::Id { value: id.value + 1 });
Selection::new_cursor(location, id)
Selection { shape, id }
}
fn new_cursor(&self, location: Location) -> Selection {
self.new_selection(selection::Shape::new_cursor(location))
}
/// Returns the last used selection or a new one if no active selection exists. This allows for
@ -467,6 +476,12 @@ impl BufferModel {
selection
}
fn set_single_selection(&self, shape: selection::Shape) -> selection::Group {
let last_selection = self.selection.borrow().last().cloned();
let opt_existing = last_selection.map(|t| t.with_shape(shape));
opt_existing.unwrap_or_else(|| self.new_selection(shape)).into()
}
fn set_newest_selection_end(&self, location: Location) -> selection::Group {
let mut group = self.selection.borrow().clone();
group.newest_mut().for_each(|s| s.end = location);
@ -813,6 +828,7 @@ pub enum LocationLike {
LocationUBytesLine(Location<Byte, Line>),
LocationColumnViewLine(Location<Column, ViewLine>),
LocationUBytesViewLine(Location<Byte, ViewLine>),
Byte(Byte),
}
impl Default for LocationLike {
@ -831,6 +847,7 @@ impl LocationLike {
Location::from_in_context_snapped(buffer, loc),
LocationLike::LocationUBytesViewLine(loc) =>
Location::from_in_context_snapped(buffer, loc),
LocationLike::Byte(byte) => Location::from_in_context_snapped(buffer, byte),
}
}
}

View File

@ -303,6 +303,7 @@ ensogl_core::define_endpoints_2! {
set_cursor (LocationLike),
add_cursor (LocationLike),
select (LocationLike, LocationLike),
paste_string (ImString),
insert (ImString),
set_property (RangeLike, Option<formatting::Property>),
@ -341,6 +342,7 @@ ensogl_core::define_endpoints_2! {
width (f32),
height (f32),
changed (Rc<Vec<buffer::Change>>),
selections (buffer::selection::Group),
content (Rope),
hovered (bool),
selection_color (color::Lch),
@ -408,6 +410,9 @@ impl Text {
loc_on_set <- input.set_cursor.map(f!([m](t) t.expand(&m)));
loc_on_add <- input.add_cursor.map(f!([m](t) t.expand(&m)));
shape_on_select <- input.select.map(
f!([m]((s, e)) buffer::selection::Shape(s.expand(&m), e.expand(&m)))
);
mouse_on_set <- mouse.position.sample(&input.set_cursor_at_mouse_position);
mouse_on_add <- mouse.position.sample(&input.add_cursor_at_mouse_position);
@ -422,8 +427,9 @@ impl Text {
loc_on_set <- any(loc_on_set,loc_on_mouse_set,loc_on_set_at_front,loc_on_set_at_end);
loc_on_add <- any(loc_on_add,loc_on_mouse_add,loc_on_add_at_front,loc_on_add_at_end);
eval loc_on_set ((loc) m.buffer.frp.set_cursor(loc));
eval loc_on_add ((loc) m.buffer.frp.add_cursor(loc));
m.buffer.frp.set_cursor <+ loc_on_set;
m.buffer.frp.add_cursor <+ loc_on_add;
m.buffer.frp.set_single_selection <+ shape_on_select;
// === Cursor Transformations ===
@ -579,6 +585,8 @@ impl Text {
// read the new content, so it should be up-to-date.
out.content <+ m.buffer.frp.text_change.map(f_!(m.buffer.text()));
out.changed <+ m.buffer.frp.text_change;
out.selections <+ m.buffer.frp.selection_non_edit_mode;
out.selections <+ m.buffer.frp.selection_edit_mode.map(|m| m.selection_group.clone());
// === Text Width And Height Updates ===
@ -1914,6 +1922,14 @@ where T: for<'t> FromInContextSnapped<&'t buffer::Buffer, S>
}
}
impl<S, T> FromInContextSnapped<&Text, S> for T
where T: for<'t> FromInContextSnapped<&'t TextModel, S>
{
fn from_in_context_snapped(context: &Text, arg: S) -> Self {
T::from_in_context_snapped(&context.data, arg)
}
}
impl display::Object for TextModel {
fn display_object(&self) -> &display::object::Instance {
&self.display_object