From 239472ba699c1e29f8dcb3380529637afeb95f21 Mon Sep 17 00:00:00 2001 From: AnotherTest Date: Mon, 11 Jan 2021 13:04:59 +0330 Subject: [PATCH] Shell: Add (basic) support for history event designators Closes #4888 --- Userland/Shell/AST.cpp | 151 +++++++++++++++++++++++++++++++ Userland/Shell/AST.h | 73 +++++++++++++-- Userland/Shell/Forward.h | 1 + Userland/Shell/NodeVisitor.cpp | 4 + Userland/Shell/NodeVisitor.h | 1 + Userland/Shell/Parser.cpp | 160 +++++++++++++++++++++++++++++++-- Userland/Shell/Parser.h | 19 ++++ Userland/Shell/Shell.cpp | 64 ++++++++++++- Userland/Shell/Shell.h | 3 + 9 files changed, 457 insertions(+), 19 deletions(-) diff --git a/Userland/Shell/AST.cpp b/Userland/Shell/AST.cpp index 91478a77a0d..42beca71f64 100644 --- a/Userland/Shell/AST.cpp +++ b/Userland/Shell/AST.cpp @@ -1201,6 +1201,157 @@ Glob::~Glob() { } +void HistoryEvent::dump(int level) const +{ + Node::dump(level); + print_indented("Event Selector", level + 1); + switch (m_selector.event.kind) { + case HistorySelector::EventKind::IndexFromStart: + print_indented("IndexFromStart", level + 2); + break; + case HistorySelector::EventKind::IndexFromEnd: + print_indented("IndexFromEnd", level + 2); + break; + case HistorySelector::EventKind::ContainingStringLookup: + print_indented("ContainingStringLookup", level + 2); + break; + case HistorySelector::EventKind::StartingStringLookup: + print_indented("StartingStringLookup", level + 2); + break; + } + print_indented(String::formatted("{}({})", m_selector.event.index, m_selector.event.text), level + 3); + + print_indented("Word Selector", level + 1); + auto print_word_selector = [&](const HistorySelector::WordSelector& selector) { + switch (selector.kind) { + case HistorySelector::WordSelectorKind::Index: + print_indented(String::formatted("Index {}", selector.selector), level + 3); + break; + case HistorySelector::WordSelectorKind::Last: + print_indented(String::formatted("Last"), level + 3); + break; + } + }; + + if (m_selector.word_selector_range.end.has_value()) { + print_indented("Range Start", level + 2); + print_word_selector(m_selector.word_selector_range.start); + print_indented("Range End", level + 2); + print_word_selector(m_selector.word_selector_range.end.value()); + } else { + print_indented("Direct Address", level + 2); + print_word_selector(m_selector.word_selector_range.start); + } +} + +RefPtr HistoryEvent::run(RefPtr shell) +{ + if (!shell) + return create({}); + + auto editor = shell->editor(); + if (!editor) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "No history available!", position()); + return create({}); + } + auto& history = editor->history(); + + // FIXME: Implement reverse iterators and find()? + auto find_reverse = [](auto it_start, auto it_end, auto finder) { + auto it = it_end; + while (it != it_start) { + --it; + if (finder(*it)) + return it; + } + return it_end; + }; + // First, resolve the event itself. + String resolved_history; + switch (m_selector.event.kind) { + case HistorySelector::EventKind::IndexFromStart: + if (m_selector.event.index >= history.size()) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "History event index out of bounds", m_selector.event.text_position); + return create({}); + } + resolved_history = history[m_selector.event.index].entry; + break; + case HistorySelector::EventKind::IndexFromEnd: + if (m_selector.event.index >= history.size()) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "History event index out of bounds", m_selector.event.text_position); + return create({}); + } + resolved_history = history[history.size() - m_selector.event.index - 1].entry; + break; + case HistorySelector::EventKind::ContainingStringLookup: { + auto it = find_reverse(history.begin(), history.end(), [&](auto& entry) { return entry.entry.contains(m_selector.event.text); }); + if (it.is_end()) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "History event did not match any entry", m_selector.event.text_position); + return create({}); + } + resolved_history = it->entry; + break; + } + case HistorySelector::EventKind::StartingStringLookup: { + auto it = find_reverse(history.begin(), history.end(), [&](auto& entry) { return entry.entry.starts_with(m_selector.event.text); }); + if (it.is_end()) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "History event did not match any entry", m_selector.event.text_position); + return create({}); + } + resolved_history = it->entry; + break; + } + } + + // Then, split it up to "words". + auto nodes = Parser { resolved_history }.parse_as_multiple_expressions(); + + // Now take the "words" as described by the word selectors. + bool is_range = m_selector.word_selector_range.end.has_value(); + if (is_range) { + auto start_index = m_selector.word_selector_range.start.resolve(nodes.size()); + auto end_index = m_selector.word_selector_range.end->resolve(nodes.size()); + if (start_index >= nodes.size()) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "History word index out of bounds", m_selector.word_selector_range.start.position); + return create({}); + } + if (end_index >= nodes.size()) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "History word index out of bounds", m_selector.word_selector_range.end->position); + return create({}); + } + + decltype(nodes) resolved_nodes; + resolved_nodes.append(nodes.data() + start_index, end_index - start_index + 1); + NonnullRefPtr list = create(position(), move(resolved_nodes)); + return list->run(shell); + } + + auto index = m_selector.word_selector_range.start.resolve(nodes.size()); + if (index >= nodes.size()) { + shell->raise_error(Shell::ShellError::EvaluatedSyntaxError, "History word index out of bounds", m_selector.word_selector_range.start.position); + return create({}); + } + return nodes[index].run(shell); +} + +void HistoryEvent::highlight_in_editor(Line::Editor& editor, Shell&, HighlightMetadata metadata) +{ + Line::Style style { Line::Style::Foreground(Line::Style::XtermColor::Green) }; + if (metadata.is_first_in_list) + style.unify_with({ Line::Style::Bold }); + editor.stylize({ m_position.start_offset, m_position.end_offset }, move(style)); +} + +HistoryEvent::HistoryEvent(Position position, HistorySelector selector) + : Node(move(position)) + , m_selector(move(selector)) +{ +} + +HistoryEvent::~HistoryEvent() +{ +} + void Execute::dump(int level) const { Node::dump(level); diff --git a/Userland/Shell/AST.h b/Userland/Shell/AST.h index 713f6043404..4001b93cb6b 100644 --- a/Userland/Shell/AST.h +++ b/Userland/Shell/AST.h @@ -454,7 +454,6 @@ public: enum class Kind : u32 { And, - ListConcatenate, Background, BarewordLiteral, BraceExpansion, @@ -464,15 +463,18 @@ public: CommandLiteral, Comment, ContinuationControl, - DynamicEvaluate, DoubleQuotedString, - Fd2FdRedirection, - FunctionDeclaration, - ForLoop, - Glob, + DynamicEvaluate, Execute, + Fd2FdRedirection, + ForLoop, + FunctionDeclaration, + Glob, + HistoryEvent, IfCond, Join, + Juxtaposition, + ListConcatenate, MatchExpr, Or, Pipe, @@ -480,12 +482,11 @@ public: ReadRedirection, ReadWriteRedirection, Sequence, - Subshell, SimpleVariable, SpecialVariable, - Juxtaposition, StringLiteral, StringPartCompose, + Subshell, SyntaxError, Tilde, VariableDeclarations, @@ -881,6 +882,62 @@ private: String m_text; }; +struct HistorySelector { + enum EventKind { + IndexFromStart, + IndexFromEnd, + StartingStringLookup, + ContainingStringLookup, + }; + enum WordSelectorKind { + Index, + Last, + }; + + struct { + EventKind kind { IndexFromStart }; + size_t index { 0 }; + Position text_position; + String text; + } event; + + struct WordSelector { + WordSelectorKind kind { Index }; + size_t selector { 0 }; + Position position; + + size_t resolve(size_t size) const + { + if (kind == Index) + return selector; + if (kind == Last) + return size - 1; + ASSERT_NOT_REACHED(); + } + }; + struct { + WordSelector start; + Optional end; + } word_selector_range; +}; + +class HistoryEvent final : public Node { +public: + HistoryEvent(Position, HistorySelector); + virtual ~HistoryEvent(); + virtual void visit(NodeVisitor& visitor) override { visitor.visit(this); } + + const HistorySelector& selector() const { return m_selector; } + +private: + NODE(HistoryEvent); + virtual void dump(int level) const override; + virtual RefPtr run(RefPtr) override; + virtual void highlight_in_editor(Line::Editor&, Shell&, HighlightMetadata = {}) override; + + HistorySelector m_selector; +}; + class Execute final : public Node { public: Execute(Position, NonnullRefPtr, bool capture_stdout = false); diff --git a/Userland/Shell/Forward.h b/Userland/Shell/Forward.h index b30448da52c..5c1b63adf9c 100644 --- a/Userland/Shell/Forward.h +++ b/Userland/Shell/Forward.h @@ -54,6 +54,7 @@ class Fd2FdRedirection; class FunctionDeclaration; class ForLoop; class Glob; +class HistoryEvent; class Execute; class IfCond; class Join; diff --git a/Userland/Shell/NodeVisitor.cpp b/Userland/Shell/NodeVisitor.cpp index af55be40d1d..44ce39a4c47 100644 --- a/Userland/Shell/NodeVisitor.cpp +++ b/Userland/Shell/NodeVisitor.cpp @@ -121,6 +121,10 @@ void NodeVisitor::visit(const AST::Glob*) { } +void NodeVisitor::visit(const AST::HistoryEvent*) +{ +} + void NodeVisitor::visit(const AST::Execute* node) { node->command()->visit(*this); diff --git a/Userland/Shell/NodeVisitor.h b/Userland/Shell/NodeVisitor.h index 4f08c927210..e252ec4ebd2 100644 --- a/Userland/Shell/NodeVisitor.h +++ b/Userland/Shell/NodeVisitor.h @@ -50,6 +50,7 @@ public: virtual void visit(const AST::FunctionDeclaration*); virtual void visit(const AST::ForLoop*); virtual void visit(const AST::Glob*); + virtual void visit(const AST::HistoryEvent*); virtual void visit(const AST::Execute*); virtual void visit(const AST::IfCond*); virtual void visit(const AST::Join*); diff --git a/Userland/Shell/Parser.cpp b/Userland/Shell/Parser.cpp index 993b6df478e..468f43288fa 100644 --- a/Userland/Shell/Parser.cpp +++ b/Userland/Shell/Parser.cpp @@ -25,6 +25,8 @@ */ #include "Parser.h" +#include "Shell.h" +#include #include #include #include @@ -114,11 +116,6 @@ static constexpr bool is_whitespace(char c) return c == ' ' || c == '\t'; } -static constexpr bool is_word_character(char c) -{ - return (c <= '9' && c >= '0') || (c <= 'Z' && c >= 'A') || (c <= 'z' && c >= 'a') || c == '_'; -} - static constexpr bool is_digit(char c) { return c <= '9' && c >= '0'; @@ -157,6 +154,28 @@ RefPtr Parser::parse() return toplevel; } +RefPtr Parser::parse_as_single_expression() +{ + auto input = Shell::escape_token_for_double_quotes(m_input); + Parser parser { input }; + return parser.parse_expression(); +} + +NonnullRefPtrVector Parser::parse_as_multiple_expressions() +{ + NonnullRefPtrVector nodes; + for (;;) { + consume_while(is_whitespace); + auto node = parse_expression(); + if (!node) + node = parse_redirection(); + if (!node) + return nodes; + nodes.append(node.release_nonnull()); + } + return nodes; +} + RefPtr Parser::parse_toplevel() { auto rule_start = push_start(); @@ -1053,7 +1072,7 @@ RefPtr Parser::parse_expression() if (strchr("&|)} ;<>\n", starting_char) != nullptr) return nullptr; - if (m_is_in_brace_expansion_spec && starting_char == ',') + if (m_extra_chars_not_allowed_in_barewords.contains_slow(starting_char)) return nullptr; if (m_is_in_brace_expansion_spec && next_is("..")) @@ -1088,6 +1107,11 @@ RefPtr Parser::parse_expression() return read_concat(create(move(list))); // Cast To List } + if (starting_char == '!') { + if (auto designator = parse_history_designator()) + return designator; + } + if (auto composite = parse_string_composite()) return read_concat(composite.release_nonnull()); @@ -1329,6 +1353,126 @@ RefPtr Parser::parse_evaluate() return inner; } +RefPtr Parser::parse_history_designator() +{ + auto rule_start = push_start(); + + ASSERT(peek() == '!'); + consume(); + + // Event selector + AST::HistorySelector selector; + RefPtr syntax_error; + selector.event.kind = AST::HistorySelector::EventKind::StartingStringLookup; + selector.event.text_position = { m_offset, m_offset, m_line, m_line }; + selector.word_selector_range = { + { AST::HistorySelector::WordSelectorKind::Index, 0, { m_offset, m_offset, m_line, m_line } }, + AST::HistorySelector::WordSelector { + AST::HistorySelector::WordSelectorKind::Last, 0, { m_offset, m_offset, m_line, m_line } }, + }; + + switch (peek()) { + case '!': + consume(); + selector.event.kind = AST::HistorySelector::EventKind::IndexFromEnd; + selector.event.index = 0; + selector.event.text = "!"; + break; + case '?': + consume(); + selector.event.kind = AST::HistorySelector::EventKind::ContainingStringLookup; + [[fallthrough]]; + default: { + TemporaryChange chars_change { m_extra_chars_not_allowed_in_barewords, { ':' } }; + + auto bareword = parse_bareword(); + if (!bareword || !bareword->is_bareword()) { + restore_to(*rule_start); + return nullptr; + } + + selector.event.text = static_ptr_cast(bareword)->text(); + selector.event.text_position = (bareword ?: syntax_error)->position(); + auto it = selector.event.text.begin(); + bool is_negative = false; + if (*it == '-') { + ++it; + is_negative = true; + } + if (it != selector.event.text.end() && AK::all_of(it, selector.event.text.end(), is_digit)) { + if (is_negative) + selector.event.kind = AST::HistorySelector::EventKind::IndexFromEnd; + else + selector.event.kind = AST::HistorySelector::EventKind::IndexFromStart; + selector.event.index = abs(selector.event.text.to_int().value()); + } + break; + } + } + + if (peek() != ':') + return create(move(selector)); + + consume(); + + // Word selectors + auto parse_word_selector = [&]() -> Optional { + auto rule_start = push_start(); + auto c = peek(); + if (isdigit(c)) { + auto num = consume_while(is_digit); + auto value = num.to_uint(); + return AST::HistorySelector::WordSelector { + AST::HistorySelector::WordSelectorKind::Index, + value.value(), + { m_rule_start_offsets.last(), m_offset, m_rule_start_lines.last(), line() } + }; + } + if (c == '^') { + consume(); + return AST::HistorySelector::WordSelector { + AST::HistorySelector::WordSelectorKind::Index, + 0, + { m_rule_start_offsets.last(), m_offset, m_rule_start_lines.last(), line() } + }; + } + if (c == '$') { + consume(); + return AST::HistorySelector::WordSelector { + AST::HistorySelector::WordSelectorKind::Last, + 0, + { m_rule_start_offsets.last(), m_offset, m_rule_start_lines.last(), line() } + }; + } + return {}; + }; + + auto start = parse_word_selector(); + if (!start.has_value()) { + syntax_error = create("Expected a word selector after ':' in a history event designator", true); + auto node = create(move(selector)); + node->set_is_syntax_error(syntax_error->syntax_error_node()); + return node; + } + selector.word_selector_range.start = start.release_value(); + + if (peek() == '-') { + consume(); + auto end = parse_word_selector(); + if (!end.has_value()) { + syntax_error = create("Expected a word selector after '-' in a history event designator word selector", true); + auto node = create(move(selector)); + node->set_is_syntax_error(syntax_error->syntax_error_node()); + return node; + } + selector.word_selector_range.end = move(end); + } else { + selector.word_selector_range.end.clear(); + } + + return create(move(selector)); +} + RefPtr Parser::parse_comment() { if (at_end()) @@ -1348,7 +1492,7 @@ RefPtr Parser::parse_bareword() StringBuilder builder; auto is_acceptable_bareword_character = [&](char c) { return strchr("\\\"'*$&#|(){} ?;<>\n", c) == nullptr - && ((m_is_in_brace_expansion_spec && c != ',') || !m_is_in_brace_expansion_spec); + && !m_extra_chars_not_allowed_in_barewords.contains_slow(c); }; while (!at_end()) { char ch = peek(); @@ -1497,6 +1641,8 @@ RefPtr Parser::parse_brace_expansion() RefPtr Parser::parse_brace_expansion_spec() { TemporaryChange is_in_brace_expansion { m_is_in_brace_expansion_spec, true }; + TemporaryChange chars_change { m_extra_chars_not_allowed_in_barewords, { ',' } }; + auto rule_start = push_start(); auto start_expr = parse_expression(); if (start_expr) { diff --git a/Userland/Shell/Parser.h b/Userland/Shell/Parser.h index c0d92e3a55f..aa2a3b9e531 100644 --- a/Userland/Shell/Parser.h +++ b/Userland/Shell/Parser.h @@ -43,6 +43,10 @@ public: } RefPtr parse(); + /// Parse the given string *as* an expression + /// that is to forefully enclose it in double-quotes. + RefPtr parse_as_single_expression(); + NonnullRefPtrVector parse_as_multiple_expressions(); struct SavedOffset { size_t offset; @@ -77,6 +81,7 @@ private: RefPtr parse_doublequoted_string_inner(); RefPtr parse_variable(); RefPtr parse_evaluate(); + RefPtr parse_history_designator(); RefPtr parse_comment(); RefPtr parse_bareword(); RefPtr parse_glob(); @@ -140,6 +145,7 @@ private: Vector m_rule_start_offsets; Vector m_rule_start_lines; + Vector m_extra_chars_not_allowed_in_barewords; bool m_is_in_brace_expansion_spec { false }; bool m_continuation_controls_allowed { false }; }; @@ -215,6 +221,7 @@ list_expression :: ' '* expression (' '+ list_expression)? expression :: evaluate expression? | string_composite expression? | comment expression? + | history_designator expression? | '(' list_expression ')' expression? evaluate :: '$' '(' pipe_sequence ')' @@ -244,6 +251,18 @@ variable :: '$' identifier comment :: '#' [^\n]* +history_designator :: '!' event_selector (':' word_selector_composite)? + +event_selector :: '!' {== '-0'} + | '?' bareword '?' + | bareword {number: index, otherwise: lookup} + +word_selector_composite :: word_selector ('-' word_selector)? + +word_selector :: number + | '^' {== 0} + | '$' {== end} + bareword :: [^"'*$&#|()[\]{} ?;<>] bareword? | '\' [^"'*$&#|()[\]{} ?;<>] bareword? diff --git a/Userland/Shell/Shell.cpp b/Userland/Shell/Shell.cpp index 34ca31c0f28..2fb4c86b658 100644 --- a/Userland/Shell/Shell.cpp +++ b/Userland/Shell/Shell.cpp @@ -1099,19 +1099,57 @@ String Shell::get_history_path() String Shell::escape_token_for_single_quotes(const String& token) { + // `foo bar \n '` -> `'foo bar \n '"'"` + StringBuilder builder; + builder.append("'"); + auto started_single_quote = true; for (auto c : token) { switch (c) { case '\'': - builder.append("'\\'"); - break; + builder.append("\"'\""); + started_single_quote = false; + continue; default: + builder.append(c); + if (!started_single_quote) { + started_single_quote = true; + builder.append("'"); + } break; } - builder.append(c); } + if (started_single_quote) + builder.append("'"); + + return builder.build(); +} + +String Shell::escape_token_for_double_quotes(const String& token) +{ + // `foo bar \n $x 'blah "hello` -> `"foo bar \\n $x 'blah \"hello"` + + StringBuilder builder; + builder.append('"'); + + for (auto c : token) { + switch (c) { + case '\"': + builder.append("\\\""); + continue; + case '\\': + builder.append("\\\\"); + continue; + default: + builder.append(c); + break; + } + } + + builder.append('"'); + return builder.build(); } @@ -1499,6 +1537,22 @@ void Shell::bring_cursor_to_beginning_of_a_line() const putc('\r', stderr); } +bool Shell::has_history_event(StringView source) +{ + struct : public AST::NodeVisitor { + virtual void visit(const AST::HistoryEvent* node) + { + has_history_event = true; + AST::NodeVisitor::visit(node); + } + + bool has_history_event { false }; + } visitor; + + Parser { source }.parse()->visit(visitor); + return visitor.has_history_event; +} + bool Shell::read_single_line() { restore_ios(); @@ -1523,7 +1577,9 @@ bool Shell::read_single_line() run_command(line); - m_editor->add_to_history(line); + if (!has_history_event(line)) + m_editor->add_to_history(line); + return true; } diff --git a/Userland/Shell/Shell.h b/Userland/Shell/Shell.h index 7a357ac8708..3530d2e0037 100644 --- a/Userland/Shell/Shell.h +++ b/Userland/Shell/Shell.h @@ -109,6 +109,8 @@ public: String resolve_path(String) const; String resolve_alias(const String&) const; + static bool has_history_event(StringView); + RefPtr get_argument(size_t); RefPtr lookup_local_variable(const String&); String local_variable_or(const String&, const String&); @@ -153,6 +155,7 @@ public: [[nodiscard]] Frame push_frame(String name); void pop_frame(); + static String escape_token_for_double_quotes(const String& token); static String escape_token_for_single_quotes(const String& token); static String escape_token(const String& token); static String unescape_token(const String& token);