Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for path completion #2608

Merged
merged 19 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions book/src/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
| `cursorcolumn` | Highlight all columns with a cursor | `false` |
| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
| `auto-completion` | Enable automatic pop up of auto-completion | `true` |
| `path-completion` | Enable filepath completion. Show files and directories if an existing path at the cursor was recognized, either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved). Defaults to true. | `true` |
Philipp-M marked this conversation as resolved.
Show resolved Hide resolved
| `auto-format` | Enable automatic formatting on save | `true` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` |
| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` |
Expand Down
1 change: 1 addition & 0 deletions book/src/languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ These configuration keys are available:
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `soft-wrap` | [editor.softwrap](./configuration.md#editorsoft-wrap-section)
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `path-completion` | Overrides the `editor.path-completion` config key for the language. |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
| `persistent-diagnostic-sources` | An array of LSP diagnostic sources assumed unchanged when the language server resends the same set of diagnostics. Helix can track the position for these diagnostics internally instead. Useful for diagnostics that are recomputed on save.

Expand Down
9 changes: 9 additions & 0 deletions helix-core/src/completion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use crate::Transaction;

#[derive(Debug, PartialEq, Clone)]
pub struct CompletionItem {
pub transaction: Transaction,
pub label: String,
/// Containing Markdown
pub documentation: String,
}
2 changes: 2 additions & 0 deletions helix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub use encoding_rs as encoding;
pub mod auto_pairs;
pub mod chars;
pub mod comment;
pub mod completion;
pub mod config;
pub mod diagnostic;
pub mod diff;
Expand Down Expand Up @@ -63,6 +64,7 @@ pub use selection::{Range, Selection};
pub use smallvec::{smallvec, SmallVec};
pub use syntax::Syntax;

pub use completion::CompletionItem;
pub use diagnostic::Diagnostic;

pub use line_ending::{LineEnding, NATIVE_LINE_ENDING};
Expand Down
3 changes: 3 additions & 0 deletions helix-core/src/syntax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ pub struct LanguageConfiguration {
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<FormatterConfiguration>,

/// If set, overrides `editor.path-completion`.
pub path_completion: Option<bool>,

#[serde(default)]
pub diagnostic_severity: Severity,

Expand Down
2 changes: 1 addition & 1 deletion helix-stdx/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ where

/// Normalize a path without resolving symlinks.
// Strategy: start from the first component and move up. Cannonicalize previous path,
// join component, cannonicalize new path, strip prefix and join to the final result.
// join component, canonicalize new path, strip prefix and join to the final result.
pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
let mut components = path.as_ref().components().peekable();
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
Expand Down
223 changes: 216 additions & 7 deletions helix-term/src/handlers/completion.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
use std::collections::HashSet;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use arc_swap::ArcSwap;
use futures_util::future::BoxFuture;
use futures_util::stream::FuturesUnordered;
use futures_util::FutureExt;
use helix_core::chars::char_is_word;
use helix_core::syntax::LanguageServerFeature;
use helix_core::Transaction;
use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
};
use helix_lsp::lsp;
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_stdx::path::{canonicalize, fold_home_dir};
use helix_stdx::rope::{self, RopeSliceExt};
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::lsp::CompletionEvent;
use helix_view::{DocumentId, Editor, ViewId};
use once_cell::sync::Lazy;
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt;
Expand All @@ -27,7 +34,7 @@ use crate::job::{dispatch, dispatch_blocking};
use crate::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::SignatureHelp;
use crate::ui::{self, CompletionItem, Popup};
use crate::ui::{self, CompletionItem, LspCompletionItem, PathCompletionItem, PathKind, Popup};

use super::Handlers;
pub use resolve::ResolveHandler;
Expand Down Expand Up @@ -251,15 +258,19 @@ fn request_completion(
None => Vec::new(),
}
.into_iter()
.map(|item| CompletionItem {
item,
provider: language_server_id,
resolved: false,
.map(|item| {
CompletionItem::Lsp(LspCompletionItem {
item,
provider: language_server_id,
resolved: false,
})
})
.collect();
anyhow::Ok(items)
}
.boxed()
})
.chain(path_completion(cursor, text.clone(), doc))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to support cancellation or we risk a bunch of ghost tasks spinning in the background (can also be quite slow since sometimes completions can be requested quickly). Dropping a spawned task does not cancel the thread (that is nog really possible)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added an Arc<AtomicBool> for this which is polled.
Alterntative would probably just be using tokio async functions, but as I said, it was significantly slower (probably around half the speed) than using everything in a blocking call.

.collect();

let future = async move {
Expand Down Expand Up @@ -291,6 +302,200 @@ fn request_completion(
});
}

fn path_completion(
Philipp-M marked this conversation as resolved.
Show resolved Hide resolved
cursor: usize,
text: helix_core::Rope,
doc: &helix_view::Document,
) -> Option<BoxFuture<'static, anyhow::Result<Vec<CompletionItem>>>> {
if !doc.supports_path_completion() {
return None;
}

// TODO find a good regex for most use cases (especially Windows, which is not yet covered...)
Philipp-M marked this conversation as resolved.
Show resolved Hide resolved
// currently only one path match per line is possible in unix
static PATH_REGEX: Lazy<rope::Regex> =
Lazy::new(|| rope::Regex::new(r"((?:~|\$HOME|\$\{HOME\})?(?:\.{0,2}/)+.*)$").unwrap());

let cur_line = text.char_to_line(cursor);
let start = text.line_to_char(cur_line).max(cursor.saturating_sub(1000));
let line_until_cursor = text.slice(start..cursor).regex_input_at(..);

let (dir_path, typed_file_name) = PATH_REGEX.search(line_until_cursor).and_then(|m| {
let start_byte = text.char_to_byte(start);
let matched_path = &text
.byte_slice((start_byte + m.start())..(start_byte + m.end()))
.to_string();

// resolve home dir (~/, $HOME/, ${HOME}/) on unix
#[cfg(unix)]
let mut path = {
static HOME_DIR: Lazy<Option<OsString>> = Lazy::new(|| std::env::var_os("HOME"));

let home_resolved_path = if let Some(home) = &*HOME_DIR {
let first_separator_after_home = if matched_path.starts_with("~/") {
Some(1)
} else if matched_path.starts_with("$HOME") {
Some(5)
} else if matched_path.starts_with("${HOME}") {
Some(7)
} else {
None
};
if let Some(start_char) = first_separator_after_home {
let mut path = home.to_owned();
path.push(&matched_path[start_char..]);
path
} else {
matched_path.into()
}
} else {
matched_path.into()
};
PathBuf::from(home_resolved_path)
};
#[cfg(not(unix))]
let mut path = PathBuf::from(matched_path);

if path.is_relative() {
if let Some(doc_path) = doc.path().and_then(|dp| dp.parent()) {
path = doc_path.join(path);
} else if let Ok(work_dir) = std::env::current_dir() {
path = work_dir.join(path);
}
}
let ends_with_slash = match matched_path.chars().last() {
Some(std::path::MAIN_SEPARATOR) => true,
None => return None,
_ => false,
};
// check if there are chars after the last slash, and if these chars represent a directory
match std::fs::metadata(path.clone()).ok() {
Some(m) if m.is_dir() && ends_with_slash => Some((PathBuf::from(path.as_path()), None)),
_ if !ends_with_slash => path.parent().map(|parent_path| {
(
PathBuf::from(parent_path),
path.file_name().and_then(|f| f.to_str().map(String::from)),
)
}),
_ => None,
}
})?;

// The async file accessor functions of tokio were considered, but they were a bit slower
Philipp-M marked this conversation as resolved.
Show resolved Hide resolved
// and less ergonomic than just using the std functions in a separate "thread"
let future = tokio::task::spawn_blocking(move || {
let Ok(read_dir) = std::fs::read_dir(&dir_path) else {
return Vec::new();
};

read_dir
.filter_map(Result::ok)
.filter_map(|dir_entry| dir_entry.metadata().ok().map(|md| (dir_entry.path(), md)))
.map(|(path, md)| {
let file_name = path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a bit ugly, what dir_entry.path() does is just dir_path.join(dir_entry.file_name()) so going trough all this to extractd the file name again doesn't seem ideal. Instead just extract the file name and use that here (and just do dir_path.join(file_name) below)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be handled in the recent push.

.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();

let full_path = fold_home_dir(canonicalize(path));
let full_path_name = full_path.to_string_lossy();

let kind = if md.is_symlink() {
PathKind::Link
} else if md.is_dir() {
PathKind::Folder
} else {
#[cfg(unix)]
Philipp-M marked this conversation as resolved.
Show resolved Hide resolved
{
use std::os::unix::fs::FileTypeExt;
if md.file_type().is_block_device() {
PathKind::Block
} else if md.file_type().is_socket() {
PathKind::Socket
} else if md.file_type().is_char_device() {
PathKind::CharacterDevice
} else if md.file_type().is_fifo() {
PathKind::Fifo
} else {
PathKind::File
}
}
#[cfg(not(unix))]
PathKind::File
};

let documentation = {
Philipp-M marked this conversation as resolved.
Show resolved Hide resolved
#[cfg(unix)]
{
use std::os::unix::prelude::PermissionsExt;
let mode = md.permissions().mode();

let perms = [
(libc::S_IRUSR, 'r'),
(libc::S_IWUSR, 'w'),
(libc::S_IXUSR, 'x'),
(libc::S_IRGRP, 'r'),
(libc::S_IWGRP, 'w'),
(libc::S_IXGRP, 'x'),
(libc::S_IROTH, 'r'),
(libc::S_IWOTH, 'w'),
(libc::S_IXOTH, 'x'),
]
.into_iter()
.fold(
String::with_capacity(9),
|mut acc, (p, s)| {
// This cast is necessary on some platforms such as macos as `mode_t` is u16 there
#[allow(clippy::unnecessary_cast)]
acc.push(if mode & (p as u32) > 0 { s } else { '-' });
acc
},
);

// TODO it would be great to be able to individually color the documentation,
// but this will likely require a custom doc implementation (i.e. not `lsp::Documentation`)
// and/or different rendering in completion.rs
format!(
"type: `{kind}`\n\
permissions: `[{perms}]`\n\
full path: `{full_path_name}`",
)
}
#[cfg(not(unix))]
{
format!(
"type: `{kind}`\n\
full path: `{full_path_name}`",
)
}
};

let edit_diff = typed_file_name
.as_ref()
.map(|f| f.len())
.unwrap_or_default();

let transaction = Transaction::change(
&text,
std::iter::once((cursor - edit_diff, cursor, Some(file_name.as_str().into()))),
);

CompletionItem::Path(PathCompletionItem {
kind,
item: helix_core::CompletionItem {
transaction,
label: file_name,
documentation,
},
})
})
.collect::<Vec<_>>()
});

Some(async move { Ok(future.await?) }.boxed())
}

fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
Expand Down Expand Up @@ -346,7 +551,11 @@ pub fn trigger_auto_completion(
..
}) if triggers.iter().any(|trigger| text.ends_with(trigger)))
});
if is_trigger_char {

let trigger_path_completion =
text.ends_with(std::path::MAIN_SEPARATOR_STR) && doc.supports_path_completion();

if is_trigger_char || trigger_path_completion {
send_blocking(
tx,
CompletionEvent::TriggerChar {
Expand Down
Loading