Implemented the 'help' builtin, and support for :h <topic> in ex mode

:h is an alias for the 'help' builtin.

'help' takes a single argument and tries to find a suitable match among the files in '$SHED_HPATH'

if a match is found, this file is opened in your pager

calling the 'help' builtin using :h in ex mode will preserve your current pending line
This commit is contained in:
2026-03-15 18:18:53 -04:00
parent f6a3935bcb
commit 99b9440ee1
18 changed files with 1080 additions and 52 deletions

291
src/builtin/help.rs Normal file
View File

@@ -0,0 +1,291 @@
use std::{env, io::Write, path::Path};
use ariadne::Span as ASpan;
use nix::libc::STDIN_FILENO;
use crate::{
libsh::{error::{ShErr, ShErrKind, ShResult}, guards::RawModeGuard}, parse::{NdRule, Node, Redir, RedirType, execute::{exec_input, prepare_argv}, lex::{QuoteState, Span}}, procio::{IoFrame, IoMode}, readline::{complete::ScoredCandidate, markers}, state
};
const TAG_SEQ: &str = "\x1b[1;33m"; // bold yellow — searchable tags
const REF_SEQ: &str = "\x1b[4;36m"; // underline cyan — cross-references
const RESET_SEQ: &str = "\x1b[0m";
const HEADER_SEQ: &str = "\x1b[1;35m"; // bold magenta — section headers
const CODE_SEQ: &str = "\x1b[32m"; // green — inline code
const KEYWORD_2_SEQ: &str = "\x1b[1;32m"; // bold green — {keyword}
const KEYWORD_3_SEQ: &str = "\x1b[3;37m"; // italic white — [optional]
pub fn help(node: Node) -> ShResult<()> {
let NdRule::Command {
assignments: _,
argv,
} = node.class
else {
unreachable!()
};
let mut argv = prepare_argv(argv)?.into_iter().peekable();
let help = argv.next().unwrap(); // drop 'help'
// Join all of the word-split arguments into a single string
// Preserve the span too
let (topic, span) = if argv.peek().is_none() {
("help.txt".to_string(), help.1)
} else {
argv.fold((String::new(), Span::default()), |mut acc, arg| {
if acc.1 == Span::default() {
acc.1 = arg.1.clone();
} else {
let new_end = arg.1.end();
let start = acc.1.start();
acc.1.set_range(start..new_end);
}
if acc.0.is_empty() {
acc.0 = arg.0;
} else {
acc.0 = acc.0 + &format!(" {}",arg.0);
}
acc
})
};
let hpath = env::var("SHED_HPATH").unwrap_or_default();
for path in hpath.split(':') {
let path = Path::new(&path).join(&topic);
if path.is_file() {
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let filename = path.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
let unescaped = unescape_help(&contents);
let expanded = expand_help(&unescaped);
open_help(&expanded, None, Some(filename))?;
state::set_status(0);
return Ok(());
}
}
// didn't find an exact filename match, its probably a tag search
for path in hpath.split(':') {
let path = Path::new(path);
if let Ok(entries) = path.read_dir() {
for entry in entries {
let Ok(entry) = entry else { continue };
let path = entry.path();
let filename = path.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
if !path.is_file() {
continue;
}
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let unescaped = unescape_help(&contents);
let expanded = expand_help(&unescaped);
let tags = read_tags(&expanded);
for (tag, line) in &tags {
}
if let Some((matched_tag, line)) = get_best_match(&topic, &tags) {
open_help(&expanded, Some(line), Some(filename))?;
state::set_status(0);
return Ok(());
} else {
}
}
}
}
state::set_status(1);
Err(ShErr::at(
ShErrKind::NotFound,
span,
"No relevant help page found for this topic",
))
}
pub fn open_help(content: &str, line: Option<usize>, file_name: Option<String>) -> ShResult<()> {
let pager = env::var("PAGER").unwrap_or("less -R".into());
let line_arg = line.map(|ln| format!("+{ln}")).unwrap_or_default();
let prompt_arg = file_name.map(|name| format!("-Ps'{name}'")).unwrap_or_default();
let mut tmp = tempfile::NamedTempFile::new()?;
let tmp_path = tmp.path().to_string_lossy().to_string();
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
RawModeGuard::with_cooked_mode(|| {
exec_input(
format!("{pager} {line_arg} {prompt_arg} {tmp_path}"),
None,
true,
Some("help".into()),
)
})
}
pub fn get_best_match(topic: &str, tags: &[(String, usize)]) -> Option<(String, usize)> {
let mut candidates: Vec<_> = tags.iter()
.map(|(tag,line)| (ScoredCandidate::new(tag.to_string()), *line))
.collect();
for (cand,_) in candidates.iter_mut() {
cand.fuzzy_score(topic);
}
candidates.retain(|(c,_)| c.score.unwrap_or(i32::MIN) > i32::MIN);
candidates.sort_by_key(|(c,_)| c.score.unwrap_or(i32::MIN));
candidates.first().map(|(c,line)| (c.content.clone(), *line))
}
pub fn read_tags(raw: &str) -> Vec<(String, usize)> {
let mut tags = vec![];
for (line_num, line) in raw.lines().enumerate() {
let mut rest = line;
while let Some(pos) = rest.find(TAG_SEQ) {
let after_seq = &rest[pos + TAG_SEQ.len()..];
if let Some(end) = after_seq.find(RESET_SEQ) {
let tag = &after_seq[..end];
tags.push((tag.to_string(), line_num + 1));
rest = &after_seq[end + RESET_SEQ.len()..];
} else {
break;
}
}
}
tags
}
pub fn expand_help(raw: &str) -> String {
let mut result = String::new();
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
match ch {
markers::RESET => result.push_str(RESET_SEQ),
markers::TAG => result.push_str(TAG_SEQ),
markers::REFERENCE => result.push_str(REF_SEQ),
markers::HEADER => result.push_str(HEADER_SEQ),
markers::CODE => result.push_str(CODE_SEQ),
markers::KEYWORD_2 => result.push_str(KEYWORD_2_SEQ),
markers::KEYWORD_3 => result.push_str(KEYWORD_3_SEQ),
_ => result.push(ch),
}
}
result
}
pub fn unescape_help(raw: &str) -> String {
let mut result = String::new();
let mut chars = raw.chars().peekable();
let mut qt_state = QuoteState::default();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(next_ch) = chars.next() {
result.push(next_ch);
}
}
'\n' => {
result.push(ch);
qt_state = QuoteState::default();
}
'"' => {
result.push(ch);
qt_state.toggle_double();
}
'\'' => {
result.push(ch);
qt_state.toggle_single();
}
_ if qt_state.in_quote() || chars.peek().is_none_or(|ch| ch.is_whitespace()) => {
result.push(ch);
}
'*' => {
result.push(markers::TAG);
while let Some(next_ch) = chars.next() {
if next_ch == '*' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'|' => {
result.push(markers::REFERENCE);
while let Some(next_ch) = chars.next() {
if next_ch == '|' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'#' => {
result.push(markers::HEADER);
while let Some(next_ch) = chars.next() {
if next_ch == '#' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'`' => {
result.push(markers::CODE);
while let Some(next_ch) = chars.next() {
if next_ch == '`' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'{' => {
result.push(markers::KEYWORD_2);
while let Some(next_ch) = chars.next() {
if next_ch == '}' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'[' => {
result.push(markers::KEYWORD_3);
while let Some(next_ch) = chars.next() {
if next_ch == ']' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
_ => result.push(ch),
}
}
result
}

View File

@@ -25,13 +25,14 @@ pub mod source;
pub mod test; // [[ ]] thing
pub mod trap;
pub mod varcmds;
pub mod help;
pub const BUILTINS: [&str; 50] = [
pub const BUILTINS: [&str; 51] = [
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg",
"disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin",
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type",
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek",
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek", "help",
];
pub fn true_builtin() -> ShResult<()> {

View File

@@ -479,21 +479,28 @@ pub fn expand_raw(chars: &mut Peekable<Chars<'_>>) -> ShResult<String> {
let ch = chars.next().unwrap();
username.push(ch);
}
let home = if username.is_empty() {
// standard '~' expansion
env::var("HOME").unwrap_or_default()
}
else if let Ok(result) = User::from_name(&username)
&& let Some(user) = result {
// username expansion like '~user'
user.dir.to_string_lossy().to_string()
}
else if let Ok(id) = username.parse::<u32>()
&& let Ok(result) = User::from_uid(Uid::from_raw(id))
&& let Some(user) = result {
// uid expansion like '~1000'
// shed only feature btw B)
user.dir.to_string_lossy().to_string()
}
else {
// no match, use literal
format!("~{username}")
};
result.push_str(&home);
}
markers::PROC_SUB_OUT => {
@@ -1399,6 +1406,7 @@ pub fn unescape_str(raw: &str) -> String {
/// Like unescape_str but for heredoc bodies. Only processes:
/// - $var / ${var} / $(cmd) substitution markers
/// - Backslash escapes (only before $, `, \, and newline)
///
/// Everything else (quotes, tildes, globs, process subs, etc.) is literal.
pub fn unescape_heredoc(raw: &str) -> String {
let mut chars = raw.chars().peekable();
@@ -1576,6 +1584,10 @@ pub fn unescape_math(raw: &str) -> String {
#[derive(Debug)]
pub enum ParamExp {
Len, // #var_name
ToUpperFirst, // ^var_name
ToUpperAll, // ^^var_name
ToLowerFirst, // ,var_name
ToLowerAll, // ,,var_name
DefaultUnsetOrNull(String), // :-
DefaultUnset(String), // -
SetDefaultUnsetOrNull(String), // :=
@@ -1611,6 +1623,11 @@ impl FromStr for ParamExp {
))
};
if s == "^^" { return Ok(ToUpperAll) }
if s == "^" { return Ok(ToUpperFirst) }
if s == ",," { return Ok(ToLowerAll) }
if s == "," { return Ok(ToLowerFirst) }
// Handle indirect var expansion: ${!var}
if let Some(var) = s.strip_prefix('!') {
if var.ends_with('*') || var.ends_with('@') {
@@ -1716,7 +1733,7 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
while let Some(ch) = chars.next() {
match ch {
'!' | '#' | '%' | ':' | '-' | '+' | '=' | '/' | '?' => {
'!' | '#' | '%' | ':' | '-' | '+' | '^' | ',' | '=' | '/' | '?' => {
rest.push(ch);
rest.push_str(&chars.collect::<String>());
break;
@@ -1728,6 +1745,32 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
if let Ok(expansion) = rest.parse::<ParamExp>() {
match expansion {
ParamExp::Len => unreachable!(),
ParamExp::ToUpperAll => {
let value = vars.get_var(&var_name);
Ok(value.to_uppercase())
}
ParamExp::ToUpperFirst => {
let value = vars.get_var(&var_name);
let mut chars = value.chars();
let first = chars.next()
.map(|c| c.to_uppercase()
.to_string())
.unwrap_or_default();
Ok(first + chars.as_str())
}
ParamExp::ToLowerAll => {
let value = vars.get_var(&var_name);
Ok(value.to_lowercase())
}
ParamExp::ToLowerFirst => {
let value = vars.get_var(&var_name);
let mut chars = value.chars();
let first = chars.next()
.map(|c| c.to_lowercase().to_string())
.unwrap_or_default();
Ok(first + chars.as_str())
}
ParamExp::DefaultUnsetOrNull(default) => {
match vars.try_get_var(&var_name).filter(|v| !v.is_empty()) {
Some(val) => Ok(val),

View File

@@ -8,29 +8,7 @@ use ariadne::Fmt;
use crate::{
builtin::{
alias::{alias, unalias},
arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate},
autocmd::autocmd,
cd::cd,
complete::{compgen_builtin, complete_builtin},
dirstack::{dirs, popd, pushd},
echo::echo,
eval, exec,
flowctl::flowctl,
getopts::getopts,
intro,
jobctl::{self, JobBehavior, continue_job, disown, jobs},
keymap, map,
pwd::pwd,
read::{self, read_builtin},
resource::{ulimit, umask_builtin},
seek::seek,
shift::shift,
shopt::shopt,
source::source,
test::double_bracket_test,
trap::{TrapTarget, trap},
varcmds::{export, local, readonly, unset},
alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, help::help, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, map, pwd::pwd, read::{self, read_builtin}, resource::{ulimit, umask_builtin}, seek::seek, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}
},
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
@@ -1017,6 +995,7 @@ impl Dispatcher {
"ulimit" => ulimit(cmd),
"umask" => umask_builtin(cmd),
"seek" => seek(cmd),
"help" => help(cmd),
"true" | ":" => {
state::set_status(0);
Ok(())

View File

@@ -88,7 +88,6 @@ impl ParsedSrc {
Err(error) => return Err(vec![error]),
}
}
log::trace!("Tokens: {:#?}", tokens);
let mut errors = vec![];
let mut nodes = vec![];
@@ -1038,7 +1037,6 @@ impl ParseStream {
Ok(Some(node))
}
fn parse_brc_grp(&mut self, from_func_def: bool) -> ShResult<Option<Node>> {
log::debug!("Trying to parse a brace group");
let mut node_tks: Vec<Tk> = vec![];
let mut body: Vec<Node> = vec![];
let mut redirs: Vec<Redir> = vec![];
@@ -1051,7 +1049,6 @@ impl ParseStream {
self.catch_separator(&mut node_tks);
loop {
log::debug!("Parsing a brace group body");
if *self.next_tk_class() == TkRule::BraceGrpEnd {
node_tks.push(self.next_tk().unwrap());
break;
@@ -1078,7 +1075,6 @@ impl ParseStream {
}
self.catch_separator(&mut node_tks);
if !self.next_tk_is_some() {
log::debug!("Hit end of input while parsing a brace group body, entering panic mode");
self.panic_mode(&mut node_tks);
return Err(parse_err_full(
"Expected a closing brace for this brace group",
@@ -1088,15 +1084,11 @@ impl ParseStream {
}
}
log::debug!(
"Finished parsing brace group body, now looking for redirections if it's not a function definition"
);
if !from_func_def {
self.parse_redir(&mut redirs, &mut node_tks)?;
}
log::debug!("Finished parsing brace group redirections, constructing node");
let node = Node {
class: NdRule::BraceGrp { body },

View File

@@ -115,6 +115,11 @@ impl IoMode {
pub fn buffer(tgt_fd: RawFd, buf: String, flags: TkFlags) -> ShResult<Self> {
Ok(Self::Buffer { tgt_fd, buf, flags })
}
pub fn loaded_pipe(tgt_fd: RawFd, buf: &[u8]) -> ShResult<Self> {
let (rpipe, wpipe) = nix::unistd::pipe()?;
write(wpipe, buf)?;
Ok(Self::Pipe { tgt_fd, pipe: rpipe.into() })
}
pub fn get_pipes() -> (Self, Self) {
let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap();
(

View File

@@ -739,7 +739,7 @@ impl QueryEditor {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Default, Debug)]
pub struct FuzzySelector {
query: QueryEditor,
filtered: Vec<ScoredCandidate>,

View File

@@ -203,6 +203,7 @@ fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
.collect()
}
#[derive(Default,Clone,Debug)]
pub struct History {
path: PathBuf,
pub pending: Option<LineBuf>, // command, cursor_pos
@@ -214,6 +215,7 @@ pub struct History {
//search_direction: Direction,
ignore_dups: bool,
max_size: Option<u32>,
stateless: bool
}
impl History {
@@ -229,6 +231,7 @@ impl History {
//search_direction: Direction::Backward,
ignore_dups: false,
max_size: None,
stateless: true,
}
}
pub fn new() -> ShResult<Self> {
@@ -266,6 +269,7 @@ impl History {
//search_direction: Direction::Backward,
ignore_dups,
max_size,
stateless: false,
})
}
@@ -450,6 +454,9 @@ impl History {
}
pub fn save(&mut self) -> ShResult<()> {
if self.stateless {
return Ok(());
}
let mut file = OpenOptions::new()
.create(true)
.append(true)

View File

@@ -421,7 +421,7 @@ impl IndentCtx {
}
}
#[derive(Default, Clone, Debug)]
#[derive(Clone, Debug)]
pub struct LineBuf {
pub buffer: String,
pub hint: Option<String>,
@@ -440,6 +440,28 @@ pub struct LineBuf {
pub redo_stack: Vec<Edit>,
}
impl Default for LineBuf {
fn default() -> Self {
Self {
buffer: String::new(),
hint: None,
grapheme_indices: Some(vec![]),
cursor: ClampedUsize::new(0, 0, false),
select_mode: None,
select_range: None,
last_selection: None,
insert_mode_start_pos: None,
saved_col: None,
indent_ctx: IndentCtx::new(),
undo_stack: vec![],
redo_stack: vec![],
}
}
}
impl LineBuf {
pub fn new() -> Self {
let mut new = Self {

View File

@@ -132,6 +132,18 @@ pub mod markers {
pub fn is_marker(c: Marker) -> bool {
('\u{e000}'..'\u{efff}').contains(&c)
}
// Help command formatting markers
pub const TAG: Marker = '\u{e180}';
pub const REFERENCE: Marker = '\u{e181}';
pub const HEADER: Marker = '\u{e182}';
pub const CODE: Marker = '\u{e183}';
/// angle brackets
pub const KEYWORD_1: Marker = '\u{e184}';
/// curly brackets
pub const KEYWORD_2: Marker = '\u{e185}';
/// square brackets
pub const KEYWORD_3: Marker = '\u{e186}';
}
type Marker = char;
@@ -256,6 +268,7 @@ pub struct ShedVi {
pub old_layout: Option<Layout>,
pub history: History,
pub ex_history: History,
pub needs_redraw: bool,
}
@@ -277,6 +290,7 @@ impl ShedVi {
repeat_motion: None,
editor: LineBuf::new(),
history: History::new()?,
ex_history: History::empty(),
needs_redraw: true,
};
write_vars(|v| {
@@ -308,6 +322,7 @@ impl ShedVi {
repeat_motion: None,
editor: LineBuf::new(),
history: History::empty(),
ex_history: History::empty(),
needs_redraw: true,
};
write_vars(|v| {
@@ -798,7 +813,8 @@ impl ShedVi {
let Ok(cmd) = self.mode.handle_key_fallible(key) else {
// it's an ex mode error
self.mode = Box::new(ViNormal::new()) as Box<dyn ViMode>;
self.swap_mode(&mut (Box::new(ViNormal::new()) as Box<dyn ViMode>));
return Ok(None);
};
@@ -844,9 +860,22 @@ impl ShedVi {
}
let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit());
let is_shell_cmd = cmd.verb().is_some_and(|v| matches!(v.1, Verb::ShellCmd(_)));
let is_ex_cmd = cmd.flags.contains(CmdFlags::IS_EX_CMD);
log::debug!("is_ex_cmd: {is_ex_cmd}");
if is_shell_cmd {
self.old_layout = None;
}
if is_ex_cmd {
self.ex_history.push(cmd.raw_seq.clone());
self.ex_history.reset();
log::debug!("ex_history: {:?}", self.ex_history.entries());
}
let before = self.editor.buffer.clone();
self.exec_cmd(cmd, false)?;
if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) {
for key in keys {
self.handle_key(key)?;
@@ -1131,7 +1160,7 @@ impl ShedVi {
)
}
Verb::ExMode => Box::new(ViEx::new()),
Verb::ExMode => Box::new(ViEx::new(self.ex_history.clone())),
Verb::VerbatimMode => {
self.reader.verbatim_single = true;
@@ -1221,7 +1250,7 @@ impl ShedVi {
ModeReport::Normal => Box::new(ViNormal::new()),
ModeReport::Insert => Box::new(ViInsert::new()),
ModeReport::Visual => Box::new(ViVisual::new()),
ModeReport::Ex => Box::new(ViEx::new()),
ModeReport::Ex => Box::new(ViEx::new(self.ex_history.clone())),
ModeReport::Replace => Box::new(ViReplace::new()),
ModeReport::Verbatim => Box::new(ViVerbatim::new()),
ModeReport::Unknown => unreachable!(),
@@ -1266,7 +1295,7 @@ impl ShedVi {
ModeReport::Normal => Box::new(ViNormal::new()) as Box<dyn ViMode>,
ModeReport::Insert => Box::new(ViInsert::new()) as Box<dyn ViMode>,
ModeReport::Visual => Box::new(ViVisual::new()) as Box<dyn ViMode>,
ModeReport::Ex => Box::new(ViEx::new()) as Box<dyn ViMode>,
ModeReport::Ex => Box::new(ViEx::new(self.ex_history.clone())) as Box<dyn ViMode>,
ModeReport::Replace => Box::new(ViReplace::new()) as Box<dyn ViMode>,
ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box<dyn ViMode>,
ModeReport::Unknown => unreachable!(),

View File

@@ -64,6 +64,7 @@ bitflags! {
const VISUAL_LINE = 1<<1;
const VISUAL_BLOCK = 1<<2;
const EXIT_CUR_MODE = 1<<3;
const IS_EX_CMD = 1<<4;
}
}

View File

@@ -6,6 +6,7 @@ use itertools::Itertools;
use crate::bitflags;
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
use crate::readline::history::History;
use crate::readline::keys::KeyEvent;
use crate::readline::linebuf::LineBuf;
use crate::readline::vicmd::{
@@ -33,16 +34,64 @@ bitflags! {
struct ExEditor {
buf: LineBuf,
mode: ViInsert,
history: History
}
impl ExEditor {
pub fn new(history: History) -> Self {
let mut new = Self {
history,
..Default::default()
};
new.buf.update_graphemes();
new
}
pub fn clear(&mut self) {
*self = Self::default()
}
pub fn should_grab_history(&mut self, cmd: &ViCmd) -> bool {
cmd.verb().is_none()
&& (cmd
.motion()
.is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineUpCharwise)))
&& self.buf.start_of_line() == 0)
|| (cmd
.motion()
.is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineDownCharwise)))
&& self.buf.end_of_line() == self.buf.cursor_max())
}
pub fn scroll_history(&mut self, cmd: ViCmd) {
let count = &cmd.motion().unwrap().0;
let motion = &cmd.motion().unwrap().1;
let count = match motion {
Motion::LineUpCharwise => -(*count as isize),
Motion::LineDownCharwise => *count as isize,
_ => unreachable!(),
};
let entry = self.history.scroll(count);
if let Some(entry) = entry {
let buf = std::mem::take(&mut self.buf);
self.buf.set_buffer(entry.command().to_string());
if self.history.pending.is_none() {
self.history.pending = Some(buf);
}
self.buf.set_hint(None);
self.buf.move_cursor_to_end();
} else if let Some(pending) = self.history.pending.take() {
self.buf = pending;
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<()> {
let Some(cmd) = self.mode.handle_key(key) else {
let Some(mut cmd) = self.mode.handle_key(key) else {
return Ok(());
};
cmd.alter_line_motion_if_no_verb();
log::debug!("ExEditor got cmd: {:?}", cmd);
if self.should_grab_history(&cmd) {
log::debug!("Grabbing history for cmd: {:?}", cmd);
self.scroll_history(cmd);
return Ok(())
}
self.buf.exec_cmd(cmd)
}
}
@@ -53,8 +102,8 @@ pub struct ViEx {
}
impl ViEx {
pub fn new() -> Self {
Self::default()
pub fn new(history: History) -> Self {
Self { pending_cmd: ExEditor::new(history) }
}
}
@@ -62,18 +111,14 @@ impl ViMode for ViEx {
// Ex mode can return errors, so we use this fallible method instead of the normal one
fn handle_key_fallible(&mut self, key: KeyEvent) -> ShResult<Option<ViCmd>> {
use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M};
log::debug!("[ViEx] handle_key_fallible: key={:?}", key);
match key {
E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => {
let input = self.pending_cmd.buf.as_str();
log::debug!("[ViEx] Enter pressed, pending_cmd={:?}", input);
match parse_ex_cmd(input) {
Ok(cmd) => {
log::debug!("[ViEx] parse_ex_cmd Ok: {:?}", cmd);
Ok(cmd)
}
Err(e) => {
log::debug!("[ViEx] parse_ex_cmd Err: {:?}", e);
let msg = e.unwrap_or(format!("Not an editor command: {}", input));
write_meta(|m| m.post_system_message(msg.clone()));
Err(ShErr::simple(ShErrKind::ParseErr, msg))
@@ -81,12 +126,10 @@ impl ViMode for ViEx {
}
}
E(C::Char('C'), M::CTRL) => {
log::debug!("[ViEx] Ctrl-C, clearing");
self.pending_cmd.clear();
Ok(None)
}
E(C::Esc, M::NONE) => {
log::debug!("[ViEx] Esc, returning to normal mode");
Ok(Some(ViCmd {
register: RegisterName::default(),
verb: Some(VerbCmd(1, Verb::NormalMode)),
@@ -96,14 +139,12 @@ impl ViMode for ViEx {
}))
}
_ => {
log::debug!("[ViEx] forwarding key to ExEditor");
self.pending_cmd.handle_key(key).map(|_| None)
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> Option<ViCmd> {
let result = self.handle_key_fallible(key);
log::debug!("[ViEx] handle_key result: {:?}", result);
result.ok().flatten()
}
fn is_repeatable(&self) -> bool {
@@ -177,7 +218,7 @@ fn parse_ex_cmd(raw: &str) -> Result<Option<ViCmd>, Option<String>> {
verb,
motion,
raw_seq: raw.to_string(),
flags: CmdFlags::EXIT_CUR_MODE,
flags: CmdFlags::EXIT_CUR_MODE | CmdFlags::IS_EX_CMD,
}))
}
@@ -224,6 +265,10 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
let cmd = unescape_shell_cmd(&cmd);
Ok(Some(Verb::ShellCmd(cmd)))
}
_ if "help".starts_with(&cmd_name) => {
let cmd = "help ".to_string() + chars.collect::<String>().trim();
Ok(Some(Verb::ShellCmd(cmd)))
}
"normal!" => parse_normal(chars),
_ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)),
_ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)),

View File

@@ -1098,6 +1098,8 @@ impl VarTab {
.map(|hname| hname.to_string_lossy().to_string())
.unwrap_or_default();
let help_paths = format!("/usr/share/shed/doc:{home}/.local/share/shed/doc");
unsafe {
env::set_var("IFS", " \t\n");
env::set_var("HOST", hostname.clone());
@@ -1114,6 +1116,7 @@ impl VarTab {
env::set_var("SHELL", pathbuf_to_string(std::env::current_exe()));
env::set_var("SHED_HIST", format!("{}/.shedhist", home));
env::set_var("SHED_RC", format!("{}/.shedrc", home));
env::set_var("SHED_HPATH", help_paths);
}
}
pub fn init_sh_argv(&mut self) {