Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
use std::{collections::HashSet, ops::Range};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use settings::{macros::define_settings_group, Setting, SupportedPlatforms, SyncToCloud};
|
||||
use string_offset::ByteOffset;
|
||||
use galaxyui::elements::SmartSelectFn;
|
||||
|
||||
use galaxyui::text::{
|
||||
word_boundaries::WordBoundariesPolicy,
|
||||
words::{is_default_word_boundary, DEFAULT_WORD_BOUNDARY_CHARS},
|
||||
};
|
||||
|
||||
/// Upper limit for how many characters in either direction we'll search for patterns. Need to
|
||||
/// limit this to avoid running regex on absurdly long words
|
||||
pub const SMART_SELECT_MATCH_WINDOW_LIMIT: u32 = 1000;
|
||||
|
||||
pub const DEFAULT_WORD_CHAR_ALLOWLIST: &str = "-.~/\\";
|
||||
|
||||
lazy_static! {
|
||||
static ref DEFAULT_WORD_BOUNDARY_CHAR_SET: HashSet<char> = HashSet::from(DEFAULT_WORD_BOUNDARY_CHARS);
|
||||
|
||||
/// These regexes are the specifications for all recognized smart-select objects, sorted in
|
||||
/// precedence-order (highest precedence first). The precedence is determined by specificity.
|
||||
/// It is possible for multiple patterns to apply to the same double-click. In that case, we
|
||||
/// want the more "specific" pattern to take precedence. For example, double-clicking on the
|
||||
/// path portion of a URL will match the URL regex, filepath regex, and identifier regex. In
|
||||
/// order to make sure the whole URL actually gets selected, we make the URL highest
|
||||
/// precedence.
|
||||
static ref REGEXES: [Regex; 5] = [
|
||||
// URL pattern, any scheme
|
||||
// https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax
|
||||
// Note: This regex is not 100% rigorously correct for all valid URLs. For example, the
|
||||
// "web+" scheme is not recognized here. Punctuation characters in the username/password
|
||||
// component (though use of those in URLs in cleartext is discouraged). The "//" is
|
||||
// actually optional. Overall we don't need perfect recall for recognizing all URLs.
|
||||
// These compromises were made for precision.
|
||||
// [a-z][a-z\d.-]* - scheme e.g. https, ssh
|
||||
// :// - literal
|
||||
// ([\w.-]+(:[\w.-]+)?@)? - maybe a username/password e.g. andy:secret@
|
||||
// ([\w-]+((\.[\w-]+)+)|(\[[:\da-f]+\])) - host, either a domain name (google.com), or
|
||||
// an IPv4 or IPv6 address
|
||||
// (:\d{1,5})? - maybe a port number
|
||||
// ([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])? - maybe query string and/or fragment. this
|
||||
// syntax isn't actuall well-defined in general for URLs
|
||||
Regex::new(
|
||||
r"(?i)[a-z][a-z\d.-]*://(([\w.-]+(:[\w.-]+)?@)?([\w-]+((\.[\w-]+)+)|(\[[:\da-f]+\]))(:\d{1,5})?)?([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?"
|
||||
)
|
||||
.expect("URL regex malformed"),
|
||||
// Email pattern
|
||||
// https://en.wikipedia.org/wiki/Email_address#Syntax
|
||||
// Note: This regex is not 100% rigorously correct either. The "local-part" of the address
|
||||
// cannot contain consecutive dots ".." but this regex does not enforce that. It also does
|
||||
// not check the UTF-8 byte range for the characters.
|
||||
// [\w\d!#$%&'*+-/=?^`{|}~.]+ - "local-part" of the email address
|
||||
// @ - literal "at sign"
|
||||
// [a-z\d-]+\.[a-z\d.-]+[a-z\d-] - domain with arbitrary subdomains (foo@a.b.c.com)
|
||||
Regex::new(
|
||||
r"(?i)[\w\d!#$%&'*+-/=?^`{|}~.]+@[a-z\d-]+\.[a-z\d.-]+[a-z\d-]"
|
||||
)
|
||||
.expect("email regex malformed"),
|
||||
// Float in scientific notation pattern, e.g. 6.02e+23
|
||||
// -? - may have negative sign
|
||||
// \d - mantissa, integer portion
|
||||
// (\.\d+)? - mantissa, may have non-integer portion
|
||||
// e - literal "e"
|
||||
// [+-]? - may have sign
|
||||
// \d+ - exponent part
|
||||
Regex::new(
|
||||
r"(?i)-?\d(\.\d+)?(e[+-]?\d+)"
|
||||
)
|
||||
.expect("scientific notation regex malformed"),
|
||||
// Filepath pattern
|
||||
// Note: Filepaths may contain ANY punctuation characters or whitespace, but we aren't
|
||||
// attempting to match that all.
|
||||
// (~|\b[a-z]:|[\w.*-]+)? - On *nix, may start with tilde. On windows, may start with a
|
||||
// drive letter. Or the prefix might be totally ordinary.
|
||||
// [/\\] - A slash of some kind is required.
|
||||
// [/\\\w.*-]* - All stuff after the slash, any number of word-chars, (back)slashes,
|
||||
// dots, asterisks, and dashes
|
||||
Regex::new(
|
||||
r"(?i)(~|\b[a-z]:|[\w.*-]+)?[/\\][/\\\w.*-]*"
|
||||
)
|
||||
.expect("filepath regex malformed"),
|
||||
// Identifier pattern
|
||||
// This is the least rigorously-defined pattern, so it's last. It is a common set of
|
||||
// characters people use in names for things. Underscores are already considered part of
|
||||
// words universally, but hyphens and dots are also commonly-used separators in names.
|
||||
// Other than filepaths, hyphenated names were the most commonly-requested entities to be
|
||||
// double-clicked. This pattern also happens to recognize floats and IP addresses
|
||||
// \w+ - Ordinary word-chars
|
||||
// ([.-]\w+)* - Any dot or dash separators must be followed by more word characters.
|
||||
// Cannot have multiple consecutive separators.
|
||||
Regex::new(
|
||||
r"(?i)\w+([.-]\w+)*"
|
||||
)
|
||||
.expect("identifier regex"),
|
||||
];
|
||||
}
|
||||
|
||||
define_settings_group!(SemanticSelection, settings: [
|
||||
smart_select_enabled: SmartSelectEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "SmartSelect",
|
||||
toml_path: "terminal.smart_select.enabled",
|
||||
description: "Whether double-click smart selection is enabled for URLs, emails, file paths, and identifiers.",
|
||||
},
|
||||
word_char_allowlist: WordCharAllowlist {
|
||||
type: String,
|
||||
default: DEFAULT_WORD_CHAR_ALLOWLIST.to_owned(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "WordCharAllowlist",
|
||||
toml_path: "terminal.smart_select.word_char_allowlist",
|
||||
description: "Characters that are considered part of a word for double-click selection when smart select is disabled.",
|
||||
},
|
||||
]);
|
||||
|
||||
impl SemanticSelection {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn mock(smart_select_enabled: bool, word_char_allowlist: impl Into<String>) -> Self {
|
||||
Self {
|
||||
smart_select_enabled: SmartSelectEnabled::new(Some(smart_select_enabled)),
|
||||
word_char_allowlist: WordCharAllowlist::new(Some(word_char_allowlist.into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn smart_select_enabled(&self) -> bool {
|
||||
*self.smart_select_enabled.value()
|
||||
}
|
||||
|
||||
pub fn word_char_allowlist_changed_from_default(&self) -> bool {
|
||||
*self.word_char_allowlist.value() != DEFAULT_WORD_CHAR_ALLOWLIST
|
||||
}
|
||||
|
||||
pub fn word_char_allowlist_string(&self) -> String {
|
||||
self.word_char_allowlist.value().clone()
|
||||
}
|
||||
|
||||
fn to_char_set(char_list: &str) -> HashSet<char> {
|
||||
HashSet::from_iter(char_list.chars().filter(|c| !c.is_whitespace()))
|
||||
}
|
||||
|
||||
fn word_char_allowlist_set(&self) -> HashSet<char> {
|
||||
Self::to_char_set(self.word_char_allowlist.value())
|
||||
}
|
||||
|
||||
/// The core logic for smart-select. This takes the extracted string window and runs the known
|
||||
/// regexes on it in precedence-order. It needs the byte offset of the cursor in order to
|
||||
/// guarantee that the range of the matching pattern actually contains the cursor, otherwise we
|
||||
/// might match something in the window which doesn't overlap with the cursor.
|
||||
pub fn smart_search(
|
||||
&self,
|
||||
content: &str,
|
||||
click_offset: ByteOffset,
|
||||
) -> Option<Range<ByteOffset>> {
|
||||
if !self.smart_select_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Self::smart_select(content, click_offset)
|
||||
}
|
||||
|
||||
pub fn smart_select_fn(&self) -> Option<SmartSelectFn> {
|
||||
if !self.smart_select_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self::smart_select)
|
||||
}
|
||||
|
||||
fn smart_select(content: &str, click_offset: ByteOffset) -> Option<Range<ByteOffset>> {
|
||||
for regex in REGEXES.iter() {
|
||||
for hit in regex.find_iter(content) {
|
||||
if hit.range().contains(&click_offset.as_usize()) {
|
||||
return Some(
|
||||
ByteOffset::from(hit.range().start)..ByteOffset::from(hit.range().end),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// This function determines if a particular character is word-breaking depending on the
|
||||
/// semantic selection settings. Used specifically by the GridHandler.
|
||||
pub fn is_word_boundary_char(&self, c: char) -> bool {
|
||||
if !self.smart_select_enabled() && self.word_char_allowlist_set().contains(&c) {
|
||||
return false;
|
||||
}
|
||||
is_default_word_boundary(c)
|
||||
}
|
||||
|
||||
/// This function fulfills the same purpose as Self::is_word_boundary_char, but in the way
|
||||
/// specific to the Editor Buffer. That data structure has a custom iterator which is
|
||||
/// configurable via a WordBoundariesPolicy enum. Ideally, WordBoundariesPolicy would hold a
|
||||
/// reference to this model so it could call Self::is_word_boundary_char and share the exact
|
||||
/// same logic with the GridHandler. However, in order to do that, the reference to this
|
||||
/// model would need to live for 'static which is not feasible.
|
||||
pub fn word_boundary_policy(&self) -> WordBoundariesPolicy {
|
||||
if self.smart_select_enabled() {
|
||||
WordBoundariesPolicy::Default
|
||||
} else {
|
||||
WordBoundariesPolicy::Custom(
|
||||
DEFAULT_WORD_BOUNDARY_CHAR_SET
|
||||
.difference(&self.word_char_allowlist_set())
|
||||
.copied()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "test-util")]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_url_selection() {
|
||||
let sel = SemanticSelection::mock(true, "");
|
||||
assert_eq!(
|
||||
sel.smart_search("http://stackoverflow.com foo", 5.into()),
|
||||
Some(ByteOffset::range(0..24))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("click here:http://stackoverflow.com", 15.into()),
|
||||
Some(ByteOffset::range(11..35))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("word here https://andy:foo@stackoverflow.com/questions/28265036/how?foo=bar&baz=food#thing-here other/stuff", 35.into()),
|
||||
Some(ByteOffset::range(10..95))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_other_url_selection() {
|
||||
let sel = SemanticSelection::mock(true, "");
|
||||
assert_eq!(
|
||||
sel.smart_search("ssh://git@github.com:acarl005/dotfiles.git", 0.into()),
|
||||
Some(ByteOffset::range(0..42))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("data hdfs://hadoopNS/data/users.csv there", 20.into()),
|
||||
Some(ByteOffset::range(5..35))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("send files here&ftp://thing.网站/file please", 30.into()),
|
||||
Some(ByteOffset::range(16..39))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("http://[2001:db8::1]:80", 10.into()),
|
||||
Some(ByteOffset::range(0..23))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_selection() {
|
||||
let sel = SemanticSelection::mock(true, "");
|
||||
assert_eq!(
|
||||
sel.smart_search(
|
||||
"mail to: acarl005@g.ucla.edu andy+1.hello@warp.dev",
|
||||
15.into()
|
||||
),
|
||||
Some(ByteOffset::range(9..28))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search(
|
||||
"mail to: acarl005@g.ucla.edu andy+1.hello@warp.dev",
|
||||
33.into()
|
||||
),
|
||||
Some(ByteOffset::range(29..50))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_numerical_selection() {
|
||||
let sel = SemanticSelection::mock(true, "");
|
||||
assert_eq!(
|
||||
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 2.into()),
|
||||
Some(ByteOffset::range(0..8))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 11.into()),
|
||||
Some(ByteOffset::range(9..15))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 18.into()),
|
||||
Some(ByteOffset::range(16..22))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 27.into()),
|
||||
Some(ByteOffset::range(23..30))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filepath_selection() {
|
||||
let sel = SemanticSelection::mock(true, "");
|
||||
assert_eq!(
|
||||
sel.smart_search("~/.config/nvim/init.lua C:\\system\\file\\path", 2.into()),
|
||||
Some(ByteOffset::range(0..23))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("~/.config/nvim/init.lua C:\\system\\file\\path", 30.into()),
|
||||
Some(ByteOffset::range(24..43))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("scp ./foo.txt andy@ubuntu:/etc/foo.txt", 7.into()),
|
||||
Some(ByteOffset::range(4..13))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("scp ./foo.txt andy@ubuntu:/etc/foo.txt", 28.into()),
|
||||
Some(ByteOffset::range(26..38))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identifier_selection() {
|
||||
let sel = SemanticSelection::mock(true, "");
|
||||
assert_eq!(
|
||||
sel.smart_search("192.168.0.1:3000 api-service-pod foo-bar--baz", 6.into()),
|
||||
Some(ByteOffset::range(0..11))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("192.168.0.1:3000 api-service-pod foo-bar--baz", 30.into()),
|
||||
Some(ByteOffset::range(17..32))
|
||||
);
|
||||
assert_eq!(
|
||||
sel.smart_search("192.168.0.1:3000 api-service-pod foo-bar--baz", 33.into()),
|
||||
Some(ByteOffset::range(33..40))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user