From 05f6a734aaef0904e05d4a89bfd52820a6896964 Mon Sep 17 00:00:00 2001 From: I3oris Date: Sun, 13 Nov 2022 16:19:39 +0100 Subject: [PATCH 1/6] Add `REPLy` into the Crystal project. --- lib/reply/.gitignore | 9 + lib/reply/CHANGELOG.md | 61 ++ lib/reply/LICENSE | 21 + lib/reply/README.md | 104 +++ lib/reply/examples/crystal_repl.cr | 130 +++ lib/reply/examples/repl.cr | 11 + lib/reply/shard.yml | 14 + lib/reply/spec/auto_completion_spec.cr | 283 ++++++ lib/reply/spec/char_reader_spec.cr | 74 ++ lib/reply/spec/expression_editor_spec.cr | 434 ++++++++++ lib/reply/spec/history_spec.cr | 178 ++++ lib/reply/spec/reader_spec.cr | 613 +++++++++++++ lib/reply/spec/spec_helper.cr | 141 +++ lib/reply/src/auto_completion.cr | 263 ++++++ lib/reply/src/char_reader.cr | 198 +++++ lib/reply/src/expression_editor.cr | 1002 ++++++++++++++++++++++ lib/reply/src/history.cr | 109 +++ lib/reply/src/reader.cr | 440 ++++++++++ lib/reply/src/reply.cr | 5 + lib/reply/src/term_cursor.cr | 179 ++++ lib/reply/src/term_size.cr | 80 ++ 21 files changed, 4349 insertions(+) create mode 100644 lib/reply/.gitignore create mode 100644 lib/reply/CHANGELOG.md create mode 100644 lib/reply/LICENSE create mode 100644 lib/reply/README.md create mode 100644 lib/reply/examples/crystal_repl.cr create mode 100644 lib/reply/examples/repl.cr create mode 100644 lib/reply/shard.yml create mode 100644 lib/reply/spec/auto_completion_spec.cr create mode 100644 lib/reply/spec/char_reader_spec.cr create mode 100644 lib/reply/spec/expression_editor_spec.cr create mode 100644 lib/reply/spec/history_spec.cr create mode 100644 lib/reply/spec/reader_spec.cr create mode 100644 lib/reply/spec/spec_helper.cr create mode 100644 lib/reply/src/auto_completion.cr create mode 100644 lib/reply/src/char_reader.cr create mode 100644 lib/reply/src/expression_editor.cr create mode 100644 lib/reply/src/history.cr create mode 100644 lib/reply/src/reader.cr create mode 100644 lib/reply/src/reply.cr create mode 100644 lib/reply/src/term_cursor.cr create mode 100644 lib/reply/src/term_size.cr diff --git a/lib/reply/.gitignore b/lib/reply/.gitignore new file mode 100644 index 000000000000..0bbd4a9f41e1 --- /dev/null +++ b/lib/reply/.gitignore @@ -0,0 +1,9 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf + +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock diff --git a/lib/reply/CHANGELOG.md b/lib/reply/CHANGELOG.md new file mode 100644 index 000000000000..cce13877b562 --- /dev/null +++ b/lib/reply/CHANGELOG.md @@ -0,0 +1,61 @@ +## RELPy v0.3.0 + +### New features +* Windows support: REPLy is now working on Windows 10. +All features expect to work like linux except the key binding 'alt-enter' +that becomes 'ctrl-enter' on windows. +* Implement saving history in a file. + * Add `Reader#history_file` which allow to specify the file location. + * Add `History#max_size=` which allow to change the history max size. (default: 10_000) + +### Internals +* Windows: use `GetConsoleScreenBufferInfo` for `Term::Size` and `ReadConsoleA` for +`read_char`. +* Windows: Disable some specs on windows. +* Small refactoring on `colorized_lines`. +* Refactor: Remove unneeded ivar `@max_prompt_size`. +* Improve performances for `move_cursor_to`. +* Remove unneeded ameba exception. +* Remove useless printing of `Term::Cursor.show` at exit. + +## RELPy v0.2.1 + +### Bug fixs +* Reduce blinking on ws-code (computation are now done before clearing the screen). Disallow `sync` and `flush_on_newline` during `update` which help to reduce blinking too, (ic#10), thanks @cyangle! +* Align the expression when prompt size change (e.g. line number increase), which avoid a cursor bug in this case. +* Fix wrong history index after submitting an empty entry. + +### Internal +* Write spec to avoid bug with autocompletion with '=' characters (ic#11), thanks @cyangle! + +## RELPy v0.2.0 + +### New features + +* BREAKING CHANGE: `word_delimiters` is now a `Array(Char)` property instead of `Regex` to return in a overridden function. +* `ctrl-n`, `ctrl-p` keybinding for navigate histories (#1), thanks @zw963! +* `delete_after`, `delete_before` (`ctrl-k`, `ctrl-u`) (#2), thanks @zw963! +* `move_word_forward`, `move_word_backward` (`alt-f`/`ctrl-right`, `alt-b`/`ctrl-left`) (#2), thanks @zw963! +* `delete_word`, `word_back` (`alt-backspace`/`ctrl-backspace`, `alt-d`/`ctrl-delete`) (#2), thanks @zw963! +* `delete` or `eof` on `ctrl-d` (#2), thanks @zw963! +* Bind `ctrl-b`/`ctrl-f` with move cursor backward/forward (#2), thanks @zw963! + +### Bug fixs +* Fix ioctl window size magic number on darwin and bsd (#3), thanks @shinzlet! + +### Internal +* Refactor: move word functions (`delete_word`, `move_word_forward`, etc.) from `Reader` to the `ExpressionEditor`. +* Add this CHANGELOG. + + +## RELPy v0.1.0 +First version extracted from IC. + +### New features +* Multiline input +* History +* Pasting of large expressions +* Hook for Syntax highlighting +* Hook for Auto formatting +* Hook for Auto indentation +* Hook for Auto completion (Experimental) diff --git a/lib/reply/LICENSE b/lib/reply/LICENSE new file mode 100644 index 000000000000..4fe9a4e24252 --- /dev/null +++ b/lib/reply/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 I3oris + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/reply/README.md b/lib/reply/README.md new file mode 100644 index 000000000000..ae33523e4824 --- /dev/null +++ b/lib/reply/README.md @@ -0,0 +1,104 @@ +# REPLy + +REPLy is a shard that provide a term reader for a REPL (Read Eval Print Loop). + +## Features + +It includes the following features: +* Multiline input +* History +* Pasting of large expressions +* Hook for Syntax highlighting +* Hook for Auto formatting +* Hook for Auto indentation +* Hook for Auto completion (Experimental) +* Work on Windows 10 + +It doesn't support yet: +* History reverse i-search +* Customizable hotkeys +* Unicode characters + +NOTE: REPLy was extracted from https://github.com/I3oris/ic, it was first designed to fit exactly the usecase of a crystal interpreter, so don't hesitate to open an issue to make REPLy more generic and suitable for your project if needed. + +## Installation + +1. Add the dependency to your `shard.yml`: + + ```yaml + dependencies: + reply: + github: I3oris/reply + ``` + +2. Run `shards install` + +## Usage + +### Minimal example + +```crystal +require "reply" + +reader = Reply::Reader.new +reader.read_loop do |expression| + # Eval expression here + puts " => #{expression}" +end +``` + +### Customize the Interface + +```crystal +require "reply" + +class MyReader < Reply::Reader + def prompt(io : IO, line_number : Int32, color? : Bool) : Nil + # Display a custom prompt + end + + def highlight(expression : String) : String + # Highlight the expression + end + + def continue?(expression : String) : Bool + # Return whether the interface should continue on multiline, depending of the expression + end + + def format(expression : String) : String? + # Reformat when expression is submitted + end + + def indentation_level(expression_before_cursor : String) : Int32? + # Compute the indentation from the expression + end + + def save_in_history?(expression : String) : Bool + # Return whether the expression is saved in history + end + + def auto_complete(name_filter : String, expression : String) : {String, Array(String)} + # Return the auto-completion result from expression + end +end +``` + +## Similar Project +* [fancyline](https://github.com/Papierkorb/fancyline) +* [crystal-readline](https://github.com/crystal-lang/crystal-readline) + +## Development + +Free to pull request! + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [I3oris](https://github.com/I3oris) - creator and maintainer diff --git a/lib/reply/examples/crystal_repl.cr b/lib/reply/examples/crystal_repl.cr new file mode 100644 index 000000000000..97cf3a1d88e7 --- /dev/null +++ b/lib/reply/examples/crystal_repl.cr @@ -0,0 +1,130 @@ +require "../src/reply" +require "crystal/syntax_highlighter/colorize" +require "compiler/crystal/tools/formatter" + +CRYSTAL_KEYWORD = %w( + abstract alias annotation asm begin break case class + def do else elsif end ensure enum extend for fun + if in include instance_sizeof lib macro module + next of offsetof out pointerof private protected require + rescue return select sizeof struct super + then type typeof union uninitialized unless until + verbatim when while with yield +) + +CONTINUE_ERROR = [ + "expecting identifier 'end', not 'EOF'", + "expecting token 'CONST', not 'EOF'", + "expecting any of these tokens: IDENT, CONST, `, <<, <, <=, ==, ===, !=, =~, !~, >>, >, >=, +, -, *, /, //, !, ~, %, &, |, ^, **, [], []?, []=, <=>, &+, &-, &*, &** (not 'EOF')", + "expecting any of these tokens: ;, NEWLINE (not 'EOF')", + "expecting token ')', not 'EOF'", + "expecting token ']', not 'EOF'", + "expecting token '}', not 'EOF'", + "expecting token '%}', not 'EOF'", + "expecting token '}', not ','", + "expected '}' or named tuple name, not EOF", + "unexpected token: NEWLINE", + "unexpected token: EOF", + "unexpected token: EOF (expecting when, else or end)", + "unexpected token: EOF (expecting ',', ';' or '\n')", + "Unexpected EOF on heredoc identifier", + "unterminated parenthesized expression", + "unterminated call", + "Unterminated string literal", + "unterminated hash literal", + "Unterminated command literal", + "unterminated array literal", + "unterminated tuple literal", + "unterminated macro", + "Unterminated string interpolation", + "invalid trailing comma in call", + "unknown token: '\\u{0}'", +] + +# `"`, `:`, `'`, are not a delimiter because symbols and strings are treated as one word. +# '=', !', '?' are not a delimiter because they could make part of method name. +WORD_DELIMITERS = {{" \n\t+-*/,;@&%<>^\\[](){}|.~".chars}} + +class CrystalReader < Reply::Reader + def prompt(io : IO, line_number : Int32, color? : Bool) : Nil + io << "crystal".colorize.blue.toggle(color?) + io << ':' + io << sprintf("%03d", line_number) + io << "> " + end + + def highlight(expression : String) : String + Crystal::SyntaxHighlighter::Colorize.highlight!(expression) + end + + def continue?(expression : String) : Bool + Crystal::Parser.new(expression).parse + false + rescue e : Crystal::CodeError + e.message.in? CONTINUE_ERROR + end + + def format(expression : String) : String? + Crystal.format(expression).chomp rescue nil + end + + def indentation_level(expression_before_cursor : String) : Int32? + parser = Crystal::Parser.new(expression_before_cursor) + parser.parse rescue nil + + parser.type_nest + parser.def_nest + parser.fun_nest + end + + def reindent_line(line) + case line.strip + when "end", ")", "]", "}" + 0 + when "else", "elsif", "rescue", "ensure", "in", "when" + -1 + else + nil + end + end + + def save_in_history?(expression : String) : Bool + !expression.blank? + end + + def history_file : Path | String | IO | Nil + "history.txt" + end + + def auto_complete(name_filter : String, expression : String) : {String, Array(String)} + return "Keywords:", CRYSTAL_KEYWORD.dup + end + + def auto_completion_display_title(io : IO, title : String) + io << title + end + + def auto_completion_display_selected_entry(io : IO, entry : String) + io << entry.colorize.red.bright + end + + def auto_completion_display_entry(io : IO, entry_matched : String, entry_remaining : String) + io << entry_matched.colorize.red.bright << entry_remaining + end +end + +reader = CrystalReader.new +reader.word_delimiters = WORD_DELIMITERS + +reader.read_loop do |expression| + case expression + when "clear_history" + reader.clear_history + when "reset" + reader.reset + when "exit" + break + when .presence + # Eval expression here + print " => " + puts Crystal::SyntaxHighlighter::Colorize.highlight!(expression) + end +end diff --git a/lib/reply/examples/repl.cr b/lib/reply/examples/repl.cr new file mode 100644 index 000000000000..200276ff8e11 --- /dev/null +++ b/lib/reply/examples/repl.cr @@ -0,0 +1,11 @@ +require "../src/reply" + +class MyReader < Reply::Reader +end + +reader = MyReader.new + +reader.read_loop do |expression| + # Eval expression here + puts " => #{expression}" +end diff --git a/lib/reply/shard.yml b/lib/reply/shard.yml new file mode 100644 index 000000000000..ae9551f95bdb --- /dev/null +++ b/lib/reply/shard.yml @@ -0,0 +1,14 @@ +name: reply +version: 0.3.0 +description: "Shard to create a REPL interface" + +authors: + - I3oris + +crystal: 1.5.0 + +license: MIT + +development_dependencies: + ameba: + github: crystal-ameba/ameba diff --git a/lib/reply/spec/auto_completion_spec.cr b/lib/reply/spec/auto_completion_spec.cr new file mode 100644 index 000000000000..1a826d91c7c8 --- /dev/null +++ b/lib/reply/spec/auto_completion_spec.cr @@ -0,0 +1,283 @@ +require "./spec_helper" + +RESULTS = { + "Int32", [ + "abs", "abs2", "bit", "bit_length", "bits", "bits_set?", "ceil", "chr", + "clamp", "class", "clone", "crystal_type_id", "day", "days", "digits", "divisible_by?", + "divmod", "downto", "dup", "even?", "fdiv", "floor", "format", "gcd", + "hash", "hour", "hours", "humanize", "humanize_bytes", "in?", "inspect", "itself", + "lcm", "leading_zeros_count", "microsecond", "microseconds", "millisecond", "milliseconds", "minute", "minutes", + "modulo", "month", "months", "nanosecond", "nanoseconds", "negative?", "not_nil!", "odd?", + "popcount", "positive?", "pred", "pretty_inspect", "pretty_print", "remainder", "round", "round_away", + "round_even", "second", "seconds", "sign", "significant", "step", "succ", "tap", + "tdiv", "times", "to", "to_f", "to_f!", "to_f32", "to_f32!", "to_f64", + "to_f64!", "to_i", "to_i!", "to_i128", "to_i128!", "to_i16", "to_i16!", "to_i32", + "to_i32!", "to_i64", "to_i64!", "to_i8", "to_i8!", "to_io", "to_s", "to_u", + "to_u!", "to_u128", "to_u128!", "to_u16", "to_u16!", "to_u32", "to_u32!", "to_u64", + "to_u64!", "to_u8", "to_u8!", "trailing_zeros_count", "trunc", "try", "unsafe_as", "unsafe_chr", + "unsafe_div", "unsafe_mod", "unsafe_shl", "unsafe_shr", "upto", "week", "weeks", "year", + "years", "zero?", "as", "as?", "is_a?", "nil?", "responds_to?", + ], +} + +module Reply + describe AutoCompletion do + describe "displays entries" do + it "for many entries" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n" \ + "abs bits clamp \n" \ + "abs2 bits_set? class \n" \ + "bit ceil clone \n" \ + "bit_length chr crystal_type_id..\n", + height: 5 + end + + it "for many entries with larger screen" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + handler.verify_display max_height: 5, + with_width: 54, + display: "Int32:\n" \ + "abs bits clamp \n" \ + "abs2 bits_set? class \n" \ + "bit ceil clone \n" \ + "bit_length chr crystal_type_id..\n", + height: 5 + handler.verify_display max_height: 5, + with_width: 55, + display: "Int32:\n" \ + "abs bits clamp day \n" \ + "abs2 bits_set? class days \n" \ + "bit ceil clone digits \n" \ + "bit_length chr crystal_type_id divisible_by?..\n", + height: 5 + end + + it "for many entries with higher screen" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n" \ + "abs bits clamp \n" \ + "abs2 bits_set? class \n" \ + "bit ceil clone \n" \ + "bit_length chr crystal_type_id..\n", + height: 5 + handler.verify_display max_height: 6, + with_width: 40, + display: "Int32:\n" \ + "abs bits_set? clone \n" \ + "abs2 ceil crystal_type_id \n" \ + "bit chr day \n" \ + "bit_length clamp days \n" \ + "bits class digits.. \n", + height: 6 + end + + it "for few entries" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("ab", "42.") + handler.open + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n" \ + "abs \n" \ + "abs2 \n", + height: 3 + end + + it "when closed" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.close + handler.verify_display max_height: 5, + with_width: 40, + display: "", + height: 0 + end + + it "when cleared" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.clear + handler.verify_display max_height: 5, min_height: 3, + with_width: 40, + display: "\n\n\n", + height: 3 + handler.verify_display max_height: 5, min_height: 5, + with_width: 40, + display: "\n\n\n\n\n", + height: 5 + handler.verify_display max_height: 5, + with_width: 40, + display: "", + height: 0 + end + + it "when max height is zero" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + handler.verify_display max_height: 0, + with_width: 40, + display: "", + height: 0 + end + + it "for no entry" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("___nop___", "42.") + handler.open + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n", + height: 1 + end + end + + describe "moves selection" do + it "selection next" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + "abs bit_length \n" \ + "abs2 bits \n" \ + "bit bits_set?.. \n", + height: 4 + + handler.selection_next + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + ">abs bit_length \n" \ + "abs2 bits \n" \ + "bit bits_set?.. \n", + height: 4 + + 3.times { handler.selection_next } + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + "abs >bit_length \n" \ + "abs2 bits \n" \ + "bit bits_set?.. \n", + height: 4 + end + + it "selection next on next column" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + 6.times { handler.selection_next } + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + "abs bit_length \n" \ + "abs2 bits \n" \ + "bit >bits_set?..\n", + height: 4 + + handler.selection_next + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + "bit_length >ceil \n" \ + "bits chr \n" \ + "bits_set? clamp..\n", + height: 4 + end + + it "selection previous" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + 2.times { handler.selection_next } + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + "abs bit_length \n" \ + ">abs2 bits \n" \ + "bit bits_set?.. \n", + height: 4 + + handler.selection_previous + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + ">abs bit_length \n" \ + "abs2 bits \n" \ + "bit bits_set?.. \n", + height: 4 + + handler.selection_previous + handler.verify_display max_height: 4, + with_width: 20, + display: "Int32:\n" \ + "nil? \n" \ + ">responds_to? \n" \ + "\n", + height: 4 + end + end + + describe "name filter" do + it "changes" do + handler = SpecHelper.auto_completion(returning: RESULTS) + handler.complete_on("", "42.") + handler.open + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n" \ + "abs bits clamp \n" \ + "abs2 bits_set? class \n" \ + "bit ceil clone \n" \ + "bit_length chr crystal_type_id..\n", + height: 5 + handler.name_filter = "to" + + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n" \ + "to to_f32! to_i! to_i16! \n" \ + "to_f to_f64 to_i128 to_i32 \n" \ + "to_f! to_f64! to_i128! to_i32! \n" \ + "to_f32 to_i to_i16 to_i64.. \n", + height: 5 + + handler.name_filter = "to_nop" + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n", + height: 1 + + handler.name_filter = "to_" + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n" \ + "to_f to_f64 to_i128 to_i32 \n" \ + "to_f! to_f64! to_i128! to_i32! \n" \ + "to_f32 to_i to_i16 to_i64 \n" \ + "to_f32! to_i! to_i16! to_i64!..\n", + height: 5 + + handler.name_filter = "to_i32!" + handler.verify_display max_height: 5, + with_width: 40, + display: "Int32:\n" \ + "to_i32! \n", + height: 2 + end + end + end +end diff --git a/lib/reply/spec/char_reader_spec.cr b/lib/reply/spec/char_reader_spec.cr new file mode 100644 index 000000000000..268a16621901 --- /dev/null +++ b/lib/reply/spec/char_reader_spec.cr @@ -0,0 +1,74 @@ +require "./spec_helper" + +module Reply + describe CharReader do + it "read chars" do + reader = SpecHelper.char_reader + + reader.verify_read('a', expect: ['a']) + reader.verify_read("Hello", expect: ["Hello"]) + end + + it "read ANSI escape sequence" do + reader = SpecHelper.char_reader + + reader.verify_read("\e[A", expect: :up) + reader.verify_read("\e[B", expect: :down) + reader.verify_read("\e[C", expect: :right) + reader.verify_read("\e[D", expect: :left) + reader.verify_read("\e[3~", expect: :delete) + reader.verify_read("\e[3;5~", expect: :ctrl_delete) + reader.verify_read("\e[1;5A", expect: :ctrl_up) + reader.verify_read("\e[1;5B", expect: :ctrl_down) + reader.verify_read("\e[1;5C", expect: :ctrl_right) + reader.verify_read("\e[1;5D", expect: :ctrl_left) + reader.verify_read("\e[H", expect: :home) + reader.verify_read("\e[F", expect: :end) + reader.verify_read("\eOH", expect: :home) + reader.verify_read("\eOF", expect: :end) + reader.verify_read("\e[1~", expect: :home) + reader.verify_read("\e[4~", expect: :end) + + reader.verify_read("\e\t", expect: :shift_tab) + reader.verify_read("\e\r", expect: :alt_enter) + reader.verify_read("\e\u007f", expect: :alt_backspace) + reader.verify_read("\eb", expect: :alt_b) + reader.verify_read("\ed", expect: :alt_d) + reader.verify_read("\ef", expect: :alt_f) + reader.verify_read("\e", expect: :escape) + reader.verify_read("\t", expect: :tab) + + reader.verify_read('\0', expect: [] of CharReader::Sequence) + reader.verify_read('\t', expect: :tab) + reader.verify_read('\b', expect: :ctrl_backspace) + reader.verify_read('\u007F', expect: :backspace) + reader.verify_read('\u0001', expect: :ctrl_a) + reader.verify_read('\u0002', expect: :ctrl_b) + reader.verify_read('\u0003', expect: :ctrl_c) + reader.verify_read('\u0004', expect: :ctrl_d) + reader.verify_read('\u0005', expect: :ctrl_e) + reader.verify_read('\u0006', expect: :ctrl_f) + reader.verify_read('\u000b', expect: :ctrl_k) + reader.verify_read('\u000e', expect: :ctrl_n) + reader.verify_read('\u0010', expect: :ctrl_p) + reader.verify_read('\u0015', expect: :ctrl_u) + reader.verify_read('\u0018', expect: :ctrl_x) + + {% if flag?(:win32) %} + reader.verify_read('\n', expect: :ctrl_enter) + reader.verify_read('\r', expect: :enter) + {% else %} + reader.verify_read('\n', expect: :enter) + {% end %} + end + + it "read large buffer" do + reader = SpecHelper.char_reader(buffer_size: 1024) + + reader.verify_read( + "a"*10_000, + expect: ["a" * 1024]*9 + ["a"*(10_000 - 9*1024)] + ) + end + end +end diff --git a/lib/reply/spec/expression_editor_spec.cr b/lib/reply/spec/expression_editor_spec.cr new file mode 100644 index 000000000000..b37354a827d0 --- /dev/null +++ b/lib/reply/spec/expression_editor_spec.cr @@ -0,0 +1,434 @@ +require "./spec_helper" + +module Reply + describe ExpressionEditor do + it "computes expression_height" do + editor = SpecHelper.expression_editor + editor.current_line = ":Hi" + editor.expression_height.should eq 1 + + editor.current_line = "print :Hel" \ + "lo" + editor.expression_height.should eq 2 + + editor.current_line = ":at_edge__" + editor.expression_height.should eq 2 + + editor << "#{""}puts :this" \ + "symbol_is_a_too" \ + "_mutch_long_sym" \ + "bol" + editor.insert_new_line(indent: 0) + editor << ":with_a_ne" \ + "line" + + editor.expression_height.should eq 4 + 2 + end + + it "gives previous, current, and next line" do + editor = SpecHelper.expression_editor + editor << "aaa" + editor.previous_line?.should be_nil + editor.current_line.should eq "aaa" + editor.next_line?.should be_nil + + editor.insert_new_line(indent: 0) + editor << "bbb" + editor.insert_new_line(indent: 0) + editor << "ccc" + editor.move_cursor_up + + editor.previous_line?.should eq "aaa" + editor.current_line.should eq "bbb" + editor.next_line?.should eq "ccc" + end + + it "tells if cursor is on last line" do + editor = SpecHelper.expression_editor + editor.cursor_on_last_line?.should be_true + + editor << "aaa" + editor.insert_new_line(indent: 0) + editor.cursor_on_last_line?.should be_true + + editor << "bbb" + 2.times { editor.move_cursor_left } + editor.cursor_on_last_line?.should be_true + + editor.move_cursor_up + editor.cursor_on_last_line?.should be_false + end + + it "gets expression_before_cursor" do + editor = SpecHelper.expression_editor + editor << "print :Hel" \ + "lo" + editor.insert_new_line(indent: 0) + editor << "puts :Bye" + + editor.expression_before_cursor.should eq "print :Hello\nputs :Bye" + editor.expression_before_cursor(x: 0, y: 0).should eq "" + editor.expression_before_cursor(x: 9, y: 0).should eq "print :He" + end + + it "modify previous, current, and next line" do + editor = SpecHelper.expression_editor + editor << "aaa" + + editor.current_line = "AAA" + editor.verify("AAA", x: 3, y: 0) + + editor.insert_new_line(indent: 0) + editor << "bbb" + editor.insert_new_line(indent: 0) + editor << "ccc" + editor.move_cursor_up + + editor.previous_line = "AAA" + editor.current_line = "BBB" + editor.next_line = "CCC" + + editor.verify("AAA\nBBB\nCCC", x: 3, y: 1) + end + + it "deletes line" do + editor = SpecHelper.expression_editor + editor << "aaa\nbbb\nccc\n" + + editor.delete_line(1) + editor.verify("aaa\nccc\n") + + editor.delete_line(0) + editor.verify("ccc\n") + + editor.delete_line(1) + editor.verify("ccc") + + editor.delete_line(0) + editor.verify("") + end + + it "clears expression" do + editor = SpecHelper.expression_editor + editor << "aaa\nbbb\nccc\n" + editor.clear_expression + editor.expression.should be_empty + end + + it "insert chars" do + editor = SpecHelper.expression_editor + editor << 'a' + editor.verify("a", x: 1, y: 0) + + editor.move_cursor_left + editor << 'b' + editor.verify("ba", x: 1, y: 0) + + editor << '\n' + editor.verify("b\na", x: 0, y: 1) + end + + it "ignores control characters" do + editor = SpecHelper.expression_editor + editor << "abc" + + editor << '\b' + editor.verify("abc", x: 3, y: 0) + + editor << '\u007F' << '\u0002' + editor.verify("abc", x: 3, y: 0) + + editor << "def\u007F\b\eghi\u0007" + editor.verify("abcdefghi", x: 9, y: 0) + end + + it "inserts new line" do + editor = SpecHelper.expression_editor + editor << "aaa" + editor.insert_new_line(indent: 1) + editor.verify("aaa\n ", x: 2, y: 1) + + editor << "bbb" + editor.move_cursor_up + editor.insert_new_line(indent: 5) + editor.insert_new_line(indent: 0) + editor.verify("aaa\n \n\n bbb", x: 0, y: 2) + end + + it "does delete & back" do + editor = SpecHelper.expression_editor + editor << "abc\n\ndef\nghi" + editor.move_cursor_up + 2.times { editor.move_cursor_left } + + editor.delete + editor.verify("abc\n\ndf\nghi", x: 1, y: 2) + + editor.back + editor.verify("abc\n\nf\nghi", x: 0, y: 2) + + editor.back + editor.verify("abc\nf\nghi", x: 0, y: 1) + + editor.back + editor.verify("abcf\nghi", x: 3, y: 0) + + editor.delete + editor.verify("abc\nghi", x: 3, y: 0) + + editor.delete + editor.verify("abcghi", x: 3, y: 0) + end + + # Empty interpolations `#{""}` are used to better show the influence of the prompt to line wrapping. '#{""}' takes 5 characters, like the prompt used for this spec: 'p:00>'. + it "moves cursor left" do + editor = SpecHelper.expression_editor + editor << "#{""}print :Hel" \ + "lo\n" + editor << "#{""}print :loo" \ + "ooooooooooooooo" \ + "oooooong\n" + editor << "#{""}:end" + + editor.verify(x: 4, y: 2) + + 4.times { editor.move_cursor_left } + editor.verify(x: 0, y: 2) + + editor.move_cursor_left + editor.verify(x: 33, y: 1) + + 34.times { editor.move_cursor_left } + editor.verify(x: 12, y: 0) + + 20.times { editor.move_cursor_left } + editor.verify(x: 0, y: 0, scroll_offset: 1) + end + + # Empty interpolations `#{""}` are used to better show the influence of the prompt to line wrapping. '#{""}' takes 5 characters, like the prompt used for this spec: 'p:00>'. + it "moves cursor right" do + editor = SpecHelper.expression_editor + editor << "#{""}print :Hel" \ + "lo\n" + editor << "#{""}print :loo" \ + "ooooooooooooooo" \ + "oooooong\n" + editor << "#{""}:end" + editor.move_cursor_to_begin + + 12.times { editor.move_cursor_right } + editor.verify(x: 12, y: 0, scroll_offset: 1) + + editor.move_cursor_right + editor.verify(x: 0, y: 1, scroll_offset: 1) + + 34.times { editor.move_cursor_right } + editor.verify(x: 0, y: 2) + + 10.times { editor.move_cursor_right } + editor.verify(x: 4, y: 2) + end + + # Empty interpolations `#{""}` are used to better show the influence of the prompt to line wrapping. '#{""}' takes 5 characters, like the prompt used for this spec: 'p:00>'. + it "moves cursor up" do + editor = SpecHelper.expression_editor + editor << "#{""}print :Hel" \ + "lo\n" + editor << "#{""}print :loo" \ + "ooooooooooooooo" \ + "oooooong\n" + editor << "#{""}:end" + + editor.verify(x: 4, y: 2) + + editor.move_cursor_up + editor.verify(x: 33, y: 1) + + editor.move_cursor_up + editor.verify(x: 18, y: 1) + + editor.move_cursor_up + editor.verify(x: 3, y: 1) + + editor.move_cursor_up + editor.verify(x: 12, y: 0) + + editor.move_cursor_up + editor.verify(x: 0, y: 0, scroll_offset: 1) + end + + # Empty interpolations `#{""}` are used to better show the influence of the prompt to line wrapping. '#{""}' takes 5 characters, like the prompt used for this spec: 'p:00>'. + it "moves cursor down" do + editor = SpecHelper.expression_editor + editor << "#{""}print :Hel" \ + "lo\n" + editor << "#{""}print :loo" \ + "ooooooooooooooo" \ + "oooooong\n" + editor << "#{""}:end" + editor.move_cursor_to_begin + + editor.move_cursor_down + editor.verify(x: 12, y: 0, scroll_offset: 1) + + editor.move_cursor_down + editor.verify(x: 0, y: 1, scroll_offset: 1) + + editor.move_cursor_down + editor.verify(x: 15, y: 1, scroll_offset: 1) + + editor.move_cursor_down + editor.verify(x: 30, y: 1, scroll_offset: 1) + + editor.move_cursor_down + editor.verify(x: 0, y: 2, scroll_offset: 0) + end + + it "moves cursor to" do + editor = SpecHelper.expression_editor + editor << "#{""}print :Hel" \ + "lo\n" + editor << "#{""}print :loo" \ + "ooooooooooooooo" \ + "oooooong\n" + editor << "#{""}:end" + editor.move_cursor_to(x: 0, y: 1, allow_scrolling: false) + editor.verify(x: 0, y: 1) + + editor.move_cursor_to(x: 3, y: 2, allow_scrolling: false) + editor.verify(x: 3, y: 2) + + editor.move_cursor_to_begin(allow_scrolling: false) + editor.verify(x: 0, y: 0) + + editor.move_cursor_to(x: 21, y: 1, allow_scrolling: false) + editor.verify(x: 21, y: 1) + + editor.move_cursor_to(x: 12, y: 0, allow_scrolling: false) + editor.verify(x: 12, y: 0) + + editor.move_cursor_to_end(allow_scrolling: false) + editor.verify(x: 4, y: 2) + + editor.move_cursor_to_end_of_line(y: 0, allow_scrolling: false) + editor.verify(x: 12, y: 0) + + editor.move_cursor_to_end_of_line(y: 1, allow_scrolling: false) + editor.verify(x: 33, y: 1) + end + + it "replaces" do + editor = SpecHelper.expression_editor + editor << "aaa\nbbb\nccc" + + editor.replace(["AAA", "BBBCCC"]) + editor.verify("AAA\nBBBCCC", x: 3, y: 1) + editor.lines.should eq ["AAA", "BBBCCC"] + + editor.replace(["aaa", "", "ccc", "ddd"]) + editor.verify("aaa\n\nccc\nddd", x: 0, y: 1) + editor.lines.should eq ["aaa", "", "ccc", "ddd"] + + editor.replace([""]) + editor.verify("", x: 0, y: 0) + editor.lines.should eq [""] + end + + it "ends editing" do + editor = SpecHelper.expression_editor + editor << "aaa" + + editor.end_editing + editor.verify("aaa", x: 3, y: 0) + + editor.end_editing(["aaa", "bbb", "ccc"]) + editor.verify("aaa\nbbb\nccc", x: 3, y: 2) + end + + it "prompts next" do + editor = SpecHelper.expression_editor + editor << "aaa" + + editor.prompt_next + editor.verify("", x: 0, y: 0) + end + + it "scroll up and down" do + editor = SpecHelper.expression_editor + editor << "#{""}print :Hel" \ + "lo\n" + editor << "#{""}print :Wor" \ + "ld\n" + editor << "#{""}print :loo" \ + "ooooooooooooooo" \ + "oooooong\n" + editor << "#{""}:end" + + 4.times { editor.move_cursor_up } + editor.expression_height.should eq 8 + + editor.verify(x: 12, y: 1, scroll_offset: 0) + + editor.scroll_up + editor.verify(x: 12, y: 1, scroll_offset: 1) + + editor.scroll_down + editor.verify(x: 12, y: 1, scroll_offset: 0) + + editor.scroll_down + editor.verify(x: 12, y: 1, scroll_offset: 0) + + editor.scroll_up + editor.scroll_up + editor.scroll_up + editor.verify(x: 12, y: 1, scroll_offset: 3) + + editor.scroll_up + editor.verify(x: 12, y: 1, scroll_offset: 3) + + editor.scroll_down + editor.verify(x: 12, y: 1, scroll_offset: 2) + end + + it "is aligned when prompt size change" do + editor = ExpressionEditor.new do |line_number, _color?| + "*" * line_number + ">" # A prompt that increase its size at each line + end + editor.output = IO::Memory.new + editor.color = false + editor.height = 50 + editor.width = 15 + + editor.update { editor << '1' } + editor.verify_output "\e[1G\e[J" + <<-END + >1 + END + + editor.update { editor << '\n' << '2' } + + editor.output = IO::Memory.new # Reset the output because we want verify only the last update + editor.update # It currently need an extra update for the alignment to be taken in account. + editor.verify_output "\e[1A\e[1G\e[J" \ + "> 1\n" \ + "*>2" + + editor.update do + editor << '\n' << '3' + editor << '\n' << '4' + editor << '\n' << '5' + end + + editor.output = IO::Memory.new + editor.update + editor.verify_output "\e[4A\e[1G\e[J" \ + "> 1\n" \ + "*> 2\n" \ + "**> 3\n" \ + "***> 4\n" \ + "****>5" + end + + # TODO: + # header + end +end diff --git a/lib/reply/spec/history_spec.cr b/lib/reply/spec/history_spec.cr new file mode 100644 index 000000000000..9d01953e4729 --- /dev/null +++ b/lib/reply/spec/history_spec.cr @@ -0,0 +1,178 @@ +require "./spec_helper" + +module Reply + ENTRIES = [ + [%(puts "Hello World")], + [%(i = 0)], + [ + %(while i < 10), + %( puts i), + %( i += 1), + %(end), + ], + ] + + describe History do + it "submits entry" do + history = SpecHelper.history + + history.verify([] of Array(String), index: 0) + + history << [%(puts "Hello World")] + history.verify(ENTRIES[0...1], index: 1) + + history << [%(i = 0)] + history.verify(ENTRIES[0...2], index: 2) + + history << [ + %(while i < 10), + %( puts i), + %( i += 1), + %(end), + ] + history.verify(ENTRIES, index: 3) + end + + it "submit dupplicate entry" do + history = SpecHelper.history(with: ENTRIES) + + history.verify(ENTRIES, index: 3) + + history << [%(i = 0)] + history.verify([ENTRIES[0], ENTRIES[2], ENTRIES[1]], index: 3) + end + + it "clears" do + history = SpecHelper.history(with: ENTRIES) + + history.clear + history.verify([] of Array(String), index: 0) + end + + it "navigates" do + history = SpecHelper.history(with: ENTRIES) + + history.verify(ENTRIES, index: 3) + + # Before down: current edition... + # After down: current edition... + history.down(["current edition..."]).should be_nil + history.verify(ENTRIES, index: 3) + + # Before up: current edition... + # After up: while i < 10 + # puts i + # i += 1 + # end + history.up(["current edition..."]).should eq ENTRIES[2] + history.verify(ENTRIES, index: 2) + + # Before up: while i < 10 + # puts i + # i += 1 + # end + # After up: i = 0 + history.up(ENTRIES[2]).should eq ENTRIES[1] + history.verify(ENTRIES, index: 1) + + # Before up (edited): edited_i = 0 + # After up: puts "Hello World" + history.up([%(edited_i = 0)]).should eq ENTRIES[0] + history.verify(ENTRIES, index: 0) + + # Before up: puts "Hello World" + # After up: puts "Hello World" + history.up(ENTRIES[0]).should be_nil + history.verify(ENTRIES, index: 0) + + # Before down: puts "Hello World" + # After down: edited_i = 0 + history.down(ENTRIES[0]).should eq [%(edited_i = 0)] + history.verify(ENTRIES, index: 1) + + # Before down down: edited_i = 0 + # After down down: current edition... + history.down([%(edited_i = 0)]).should eq ENTRIES[2] + history.down(ENTRIES[2]).should eq [%(current edition...)] + history.verify(ENTRIES, index: 3) + end + + it "saves and loads" do + entries = ([ + [%(foo)], + [%q(\)], + [ + %q(bar), + %q("baz" \), + %q("\n\\"), + %q(), + %q(\), + ], + [%q(a\\\b)], + ]) + history = SpecHelper.history(with: entries) + + io = IO::Memory.new + history.save(io) + io.to_s.should eq <<-'HISTORY' + foo + \\ + bar\ + "baz" \\\ + "\\n\\\\"\ + \ + \\ + a\\\\\\b + HISTORY + + io.rewind + history.load(io) + + history.verify(entries, index: 4) + end + + it "saves and loads empty" do + history = SpecHelper.history + + io = IO::Memory.new + history.save(io) + io.to_s.should be_empty + + io.rewind + history.load(io) + + history.verify([] of Array(String), index: 0) + end + + it "respects max size" do + history = SpecHelper.history + + history.max_size = 4 + + history << ["1"] + history << ["2"] + history << ["3"] + history << ["4"] + history.verify([["1"], ["2"], ["3"], ["4"]], index: 4) + + history << ["5"] + history.verify([["2"], ["3"], ["4"], ["5"]], index: 4) + + history.max_size = 2 + history << ["6"] + history.verify([["5"], ["6"]], index: 2) + + history.max_size = 0 # minimum possible is 1 + history << ["7"] + history.verify([["7"]], index: 1) + + history.max_size = -314 # minimum possible is 1 + history << ["8"] + history.verify([["8"]], index: 1) + + history.max_size = 3 + history << ["9"] + history.verify([["8"], ["9"]], index: 2) + end + end +end diff --git a/lib/reply/spec/reader_spec.cr b/lib/reply/spec/reader_spec.cr new file mode 100644 index 000000000000..4e9f446f3de0 --- /dev/null +++ b/lib/reply/spec/reader_spec.cr @@ -0,0 +1,613 @@ +{% skip_file if flag?(:win32) %} +# FIXME: We skip all these specs because the file descriptor is blocking, making +# the spec to hang out, and we cannot change it. # (`blocking=` is not implemented on window) + +require "./spec_helper" + +module Reply + describe Reader do + it "reads char" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should eq "a" + reader.read_next(from: pipe_out).should eq "♥💎" + end + + SpecHelper.send(pipe_in, 'a') + SpecHelper.send(pipe_in, '\n') + SpecHelper.send(pipe_in, '♥') + SpecHelper.send(pipe_in, '💎') + SpecHelper.send(pipe_in, '\n') + end + + it "reads string" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should eq "Hello" + reader.read_next(from: pipe_out).should eq "class Foo\n def foo\n 42\n end\nend" + end + + SpecHelper.send(pipe_in, "Hello") + SpecHelper.send(pipe_in, '\n') + + SpecHelper.send(pipe_in, <<-END) + class Foo + def foo + 42 + end + end + END + SpecHelper.send(pipe_in, '\n') + end + + it "uses directional arrows" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, <<-END) + class Foo + def foo + 42 + end + end + END + SpecHelper.send(pipe_in, "\e[A") # up + SpecHelper.send(pipe_in, "\e[C") # right + SpecHelper.send(pipe_in, "\e[B") # down + SpecHelper.send(pipe_in, "\e[D") # left + reader.editor.verify(x: 2, y: 4) + + SpecHelper.send(pipe_in, '\0') + end + + it "uses ctrl-n & ctrl-p" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + reader.read_next(from: pipe_out) + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, "x = 42") + SpecHelper.send(pipe_in, '\n') + SpecHelper.send(pipe_in, <<-END) + puts "Hello", + "World" + END + SpecHelper.send(pipe_in, '\n') + + SpecHelper.send(pipe_in, '\u0010') # ctrl-p (up) + reader.editor.verify(%(puts "Hello",\n "World")) + + SpecHelper.send(pipe_in, '\u0010') # ctrl-p (up) + SpecHelper.send(pipe_in, '\u0010') # ctrl-p (up) + reader.editor.verify("x = 42") + + SpecHelper.send(pipe_in, '\u000e') # ctrl-n (down) + reader.editor.verify(%(puts "Hello",\n "World")) + + SpecHelper.send(pipe_in, '\0') + end + + it "uses ctrl-f & ctrl-b" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, "x=42") + reader.editor.verify(x: 4, y: 0) + + SpecHelper.send(pipe_in, '\u0006') # ctrl-f (right) + reader.editor.verify(x: 4, y: 0) + + SpecHelper.send(pipe_in, '\u0002') # ctrl-b (left) + reader.editor.verify(x: 3, y: 0) + + SpecHelper.send(pipe_in, '\u0002') # ctrl-b (left) + SpecHelper.send(pipe_in, '\u0002') # ctrl-b (left) + SpecHelper.send(pipe_in, '\u0002') # ctrl-b (left) + reader.editor.verify(x: 0, y: 0) + + SpecHelper.send(pipe_in, '\u0002') # ctrl-b (left) + reader.editor.verify(x: 0, y: 0) + + SpecHelper.send(pipe_in, '\u0006') # ctrl-f (right) + reader.editor.verify(x: 1, y: 0) + + SpecHelper.send(pipe_in, '\0') + end + + it "uses back" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should eq "Hey" + reader.read_next(from: pipe_out).should eq "ab" + reader.read_next(from: pipe_out).should eq "" + end + + SpecHelper.send(pipe_in, "Hello") + SpecHelper.send(pipe_in, '\u{7f}') # back + SpecHelper.send(pipe_in, '\u{7f}') + SpecHelper.send(pipe_in, '\u{7f}') + SpecHelper.send(pipe_in, 'y') + SpecHelper.send(pipe_in, '\n') + + SpecHelper.send(pipe_in, "a\nb") + SpecHelper.send(pipe_in, "\e[D") # left + SpecHelper.send(pipe_in, '\u{7f}') + SpecHelper.send(pipe_in, '\n') + + SpecHelper.send(pipe_in, "") + SpecHelper.send(pipe_in, '\u{7f}') + SpecHelper.send(pipe_in, '\n') + end + + it "deletes" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should eq "Hey" + reader.read_next(from: pipe_out).should eq "ab" + reader.read_next(from: pipe_out).should eq "" + end + + SpecHelper.send(pipe_in, "Hello") + SpecHelper.send(pipe_in, "\e[D") # left + SpecHelper.send(pipe_in, "\e[D") + SpecHelper.send(pipe_in, "\e[D") + SpecHelper.send(pipe_in, "\e[3~") # delete + SpecHelper.send(pipe_in, "\e[3~") + SpecHelper.send(pipe_in, "\e[3~") + SpecHelper.send(pipe_in, 'y') + SpecHelper.send(pipe_in, '\n') + + SpecHelper.send(pipe_in, "a\nb") + SpecHelper.send(pipe_in, "\e[D") + SpecHelper.send(pipe_in, "\e[D") + SpecHelper.send(pipe_in, "\e[3~") + SpecHelper.send(pipe_in, '\n') + + SpecHelper.send(pipe_in, "") + SpecHelper.send(pipe_in, "\e[3~") + SpecHelper.send(pipe_in, '\n') + end + + it "deletes or eof" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + channel = Channel(Symbol).new + spawn do + reader.read_next(from: pipe_out).should be_nil + channel.send(:finished) + end + + SpecHelper.send(pipe_in, "a\nb") + SpecHelper.send(pipe_in, '\u0001') # ctrl-a (move cursor to begin) + reader.editor.verify("a\nb") + + SpecHelper.send(pipe_in, '\u0004') # ctrl-d (delete or eof) + reader.editor.verify("\nb") + + SpecHelper.send(pipe_in, '\u0004') # ctrl-d (delete or eof) + reader.editor.verify("b") + + SpecHelper.send(pipe_in, '\u0004') # ctrl-d (delete or eof) + reader.editor.verify("") + + SpecHelper.send(pipe_in, '\u0004') # ctrl-d (delete or eof) + channel.receive.should eq :finished + end + + it "uses tabulation" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, "42.") + reader.auto_completion.verify(open: false) + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello world hey)) + reader.editor.verify("42.") + + SpecHelper.send(pipe_in, 'w') + reader.auto_completion.verify(open: true, entries: %w(world), name_filter: "w") + reader.editor.verify("42.w") + + SpecHelper.send(pipe_in, '\u{7f}') # back + reader.auto_completion.verify(open: true, entries: %w(hello world hey)) + reader.editor.verify("42.") + + SpecHelper.send(pipe_in, 'h') + reader.auto_completion.verify(open: true, entries: %w(hello hey), name_filter: "h") + reader.editor.verify("42.h") + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello hey), name_filter: "h", selection_pos: 0) + reader.editor.verify("42.hello") + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello hey), name_filter: "h", selection_pos: 1) + reader.editor.verify("42.hey") + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello hey), name_filter: "h", selection_pos: 0) + reader.editor.verify("42.hello") + + SpecHelper.send(pipe_in, "\e\t") # shit_tab + reader.auto_completion.verify(open: true, entries: %w(hello hey), name_filter: "h", selection_pos: 1) + reader.editor.verify("42.hey") + + SpecHelper.send(pipe_in, '\u{7f}') # back + SpecHelper.send(pipe_in, 'l') + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello), name_filter: "hel", selection_pos: 0) + reader.editor.verify("42.hello") + + SpecHelper.send(pipe_in, ' ') + reader.auto_completion.verify(open: false, cleared: true) + reader.editor.verify("42.hello ") + + SpecHelper.send(pipe_in, '\0') + end + + it "roll over auto completion entries with equal" do + reader = SpecHelper.reader(SpecReaderWithEqual) + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello world= hey)) + reader.editor.verify("") + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello world= hey), selection_pos: 0) + reader.editor.verify("hello") + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello world= hey), selection_pos: 1) + reader.editor.verify("world=") + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello world= hey), selection_pos: 2) + reader.editor.verify("hey") + + SpecHelper.send(pipe_in, '\0') + end + + it "uses escape" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, "42.") + reader.auto_completion.verify(open: false) + + SpecHelper.send(pipe_in, '\t') + reader.auto_completion.verify(open: true, entries: %w(hello world hey)) + + SpecHelper.send(pipe_in, '\e') # escape + reader.auto_completion.verify(open: false) + end + + it "uses alt-enter" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should eq "Hello\nWorld" + end + + SpecHelper.send(pipe_in, "Hello") + SpecHelper.send(pipe_in, "\e\r") # alt-enter + SpecHelper.send(pipe_in, "World") + reader.editor.verify("Hello\nWorld") + SpecHelper.send(pipe_in, "\n") + end + + it "uses ctrl-c" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should be_nil + end + + SpecHelper.send(pipe_in, "Hello") + SpecHelper.send(pipe_in, '\u{3}') # ctrl-c + reader.editor.verify("") + + SpecHelper.send(pipe_in, '\0') + end + + it "uses ctrl-d & ctrl-x" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should be_nil + reader.read_next(from: pipe_out).should be_nil + end + + SpecHelper.send(pipe_in, "Hello") + SpecHelper.send(pipe_in, '\u{4}') # ctrl-d + + SpecHelper.send(pipe_in, "World") + SpecHelper.send(pipe_in, '\u{24}') # ctrl-x + end + + it "uses ctrl-u & ctrl-k" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, <<-END) + Lorem ipsum + dolor sit + amet. + END + SpecHelper.send(pipe_in, '\u0010') # ctrl-p (up) + reader.editor.verify(x: 5, y: 1) + + SpecHelper.send(pipe_in, '\u000b') # ctrl-k (delete after) + reader.editor.verify(<<-END, x: 5, y: 1) + Lorem ipsum + dolor + amet. + END + + SpecHelper.send(pipe_in, '\u000b') # ctrl-k (delete after) + reader.editor.verify(<<-END, x: 5, y: 1) + Lorem ipsum + doloramet. + END + + SpecHelper.send(pipe_in, '\u0015') # ctrl-u (delete before) + reader.editor.verify(<<-END, x: 0, y: 1) + Lorem ipsum + amet. + END + + SpecHelper.send(pipe_in, '\u000b') # ctrl-k (delete after) + reader.editor.verify(<<-END, x: 0, y: 1) + Lorem ipsum + + END + SpecHelper.send(pipe_in, '\u0015') # ctrl-u (delete before) + SpecHelper.send(pipe_in, '\u0015') # ctrl-u (delete before) + reader.editor.verify("", x: 0, y: 0) + + SpecHelper.send(pipe_in, '\u000b') # ctrl-k (delete after) + SpecHelper.send(pipe_in, '\u0015') # ctrl-u (delete before) + reader.editor.verify("", x: 0, y: 0) + end + + it "moves word forward" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, <<-END) + lorem ipsum + +"dolor", sit: + amet() + END + + SpecHelper.send(pipe_in, '\u0001') # ctrl-a (move cursor to begin) + reader.editor.verify(x: 0, y: 0) + + SpecHelper.send(pipe_in, "\ef") # Alt-f (move_word_forward) + reader.editor.verify(x: 5, y: 0) + + SpecHelper.send(pipe_in, "\ef") # Alt-f (move_word_forward) + reader.editor.verify(x: 13, y: 0) + + SpecHelper.send(pipe_in, "\e[1;5C") # Ctrl-right (move_word_forward) + reader.editor.verify(x: 7, y: 1) + + SpecHelper.send(pipe_in, "\e[1;5C") # Ctrl-right (move_word_forward) + reader.editor.verify(x: 13, y: 1) + + SpecHelper.send(pipe_in, "\e[1;5C") # Ctrl-right (move_word_forward) + reader.editor.verify(x: 14, y: 1) + + SpecHelper.send(pipe_in, "\ef") # Alt-f (move_word_forward) + reader.editor.verify(x: 4, y: 2) + + SpecHelper.send(pipe_in, "\ef") # Alt-f (move_word_forward) + reader.editor.verify(x: 6, y: 2) + + SpecHelper.send(pipe_in, "\ef") # Alt-f (move_word_forward) + reader.editor.verify(x: 6, y: 2) + + SpecHelper.send(pipe_in, "\0") + end + + it "moves word backward" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, <<-END) + lorem ipsum + +"dolor", sit: + amet() + END + + reader.editor.verify(x: 6, y: 2) + + SpecHelper.send(pipe_in, "\eb") # Alt-b (move_word_backward) + reader.editor.verify(x: 0, y: 2) + + SpecHelper.send(pipe_in, "\eb") # Alt-b (move_word_backward) + reader.editor.verify(x: 10, y: 1) + + SpecHelper.send(pipe_in, "\e[1;5D") # Ctrl-left (move_word_backward) + reader.editor.verify(x: 2, y: 1) + + SpecHelper.send(pipe_in, "\e[1;5D") # Ctrl-left (move_word_backward) + reader.editor.verify(x: 0, y: 1) + + SpecHelper.send(pipe_in, "\e[1;5D") # Ctrl-left (move_word_backward) + reader.editor.verify(x: 8, y: 0) + + SpecHelper.send(pipe_in, "\eb") # Alt-b (move_word_backward) + reader.editor.verify(x: 0, y: 0) + + SpecHelper.send(pipe_in, "\eb") # Alt-b (move_word_backward) + reader.editor.verify(x: 0, y: 0) + + SpecHelper.send(pipe_in, "\0") + end + + it "uses delete word and word back" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, <<-END) + lorem ipsum + +"dolor", sit: + amet() + END + + SpecHelper.send(pipe_in, "\e[A") # up + reader.editor.verify(x: 6, y: 1) + + SpecHelper.send(pipe_in, '\b') # Ctrl-backspace (delete_word) + reader.editor.verify(<<-END, x: 2, y: 1) + lorem ipsum + +"r", sit: + amet() + END + + SpecHelper.send(pipe_in, '\b') # Ctrl-backspace (delete_word) + reader.editor.verify(<<-END, x: 0, y: 1) + lorem ipsum + r", sit: + amet() + END + + SpecHelper.send(pipe_in, '\b') # Ctrl-backspace (delete_word) + reader.editor.verify(<<-END, x: 8, y: 0) + lorem r", sit: + amet() + END + + SpecHelper.send(pipe_in, "\ed") # Alt-d (word_back) + reader.editor.verify(<<-END, x: 8, y: 0) + lorem ", sit: + amet() + END + + SpecHelper.send(pipe_in, "\ed") # Alt-d (word_back) + SpecHelper.send(pipe_in, "\e[3;5~") # Ctrl-delete (word_back) + SpecHelper.send(pipe_in, "\e[3;5~") # Ctrl-delete (word_back) + reader.editor.verify(<<-END, x: 8, y: 0) + lorem () + END + + SpecHelper.send(pipe_in, '\b') # Ctrl-backspace (delete_word) + SpecHelper.send(pipe_in, "\e\u007f") # Alt-backspace (delete_word) + reader.editor.verify(<<-END, x: 0, y: 0) + () + END + + SpecHelper.send(pipe_in, "\ed") # Alt-d (word_back) + SpecHelper.send(pipe_in, "\ed") # Alt-d (word_back) + reader.editor.verify("", x: 0, y: 0) + + SpecHelper.send(pipe_in, "\0") + end + + it "sets history to last after empty entry" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out).should eq "a" + reader.read_next(from: pipe_out).should eq "b" + reader.read_next(from: pipe_out).should eq "" + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, 'a') + SpecHelper.send(pipe_in, '\n') + SpecHelper.send(pipe_in, 'b') + SpecHelper.send(pipe_in, '\n') + + SpecHelper.send(pipe_in, "\e[A") # up + reader.editor.verify("b") + SpecHelper.send(pipe_in, "\e[A") # up + reader.editor.verify("a") + + SpecHelper.send(pipe_in, "\u{7f}") # back + SpecHelper.send(pipe_in, '\n') + reader.editor.verify("") + + SpecHelper.send(pipe_in, "\e[A") # up + reader.editor.verify("b") + SpecHelper.send(pipe_in, "\e[A") # up + reader.editor.verify("a") + + SpecHelper.send(pipe_in, '\0') + end + + it "resets" do + reader = SpecHelper.reader + pipe_out, pipe_in = IO.pipe + + spawn do + reader.read_next(from: pipe_out) + reader.read_next(from: pipe_out) + end + + SpecHelper.send(pipe_in, "Hello\nWorld") + SpecHelper.send(pipe_in, '\n') + reader.line_number.should eq 3 + + reader.reset + reader.line_number.should eq 1 + + SpecHelper.send(pipe_in, '\0') + end + end +end diff --git a/lib/reply/spec/spec_helper.cr b/lib/reply/spec/spec_helper.cr new file mode 100644 index 000000000000..432220b98f98 --- /dev/null +++ b/lib/reply/spec/spec_helper.cr @@ -0,0 +1,141 @@ +require "spec" +require "../src/reply" + +module Reply + class AutoCompletion + def verify(open, entries = [] of String, name_filter = "", cleared = false, selection_pos = nil) + self.open?.should eq open + self.cleared?.should eq cleared + self.name_filter.should eq name_filter + self.entries.should eq entries + @selection_pos.should eq selection_pos + end + + def verify_display(max_height, min_height, with_width, display, height) + height_got = nil + + display_got = String.build do |io| + height_got = self.display_entries(io, color?: false, width: with_width, max_height: max_height, min_height: min_height) + end + display_got.should eq display + height_got.should eq height + (display_got.split("\n").size - 1).should eq height + end + + def verify_display(max_height, with_width, display, height) + verify_display(max_height, 0, with_width, display, height) + end + end + + class ExpressionEditor + def verify(expression : String) + self.expression.should eq expression + end + + def verify(x : Int32, y : Int32, scroll_offset = 0) + {self.x, self.y}.should eq({x, y}) + @scroll_offset.should eq scroll_offset + end + + def verify(expression : String, x : Int32, y : Int32, scroll_offset = 0) + self.verify(expression) + self.verify(x, y, scroll_offset) + end + + def verify_output(output) + self.output.to_s.should eq output + end + end + + class History + def verify(entries, index) + @history.should eq Deque(Array(String)).new(entries) + @index.should eq index + end + end + + struct CharReader + def verify_read(to_read, expect : CharReader::Sequence) + verify_read(to_read, [expect]) + end + + def verify_read(to_read, expect : Array) + chars = [] of Char | CharReader::Sequence | String? + io = IO::Memory.new + io << to_read + io.rewind + loop do + c = self.read_char(io) + break if c == CharReader::Sequence::EOF + chars << c + end + chars.should eq expect + end + end + + class SpecReader < Reader + def auto_complete(current_word : String, expression_before : String) + return "title", %w(hello world hey) + end + + getter auto_completion + end + + class SpecReaderWithEqual < Reader + def initialize + super + self.word_delimiters = {{" \n\t+-*/,;@&%<>^\\[](){}|.~".chars}} + end + + def auto_complete(current_word : String, expression_before : String) + return "title", %w(hello world= hey) + end + + getter auto_completion + end + + module SpecHelper + def self.auto_completion(returning results) + results = results.clone + AutoCompletion.new do + results + end + end + + def self.expression_editor + editor = ExpressionEditor.new do |line_number, _color?| + # Prompt size = 5 + "p:#{sprintf("%02d", line_number)}>" + end + editor.output = IO::Memory.new + editor.color = false + editor.height = 5 + editor.width = 15 + editor + end + + def self.history(with entries = [] of Array(String)) + history = History.new + entries.each { |e| history << e } + history + end + + def self.char_reader(buffer_size = 64) + CharReader.new(buffer_size) + end + + def self.reader(type = SpecReader) + reader = type.new + reader.output = IO::Memory.new + reader.color = false + reader.editor.height = 15 + reader.editor.width = 30 + reader + end + + def self.send(io, value) + io << value + Fiber.yield + end + end +end diff --git a/lib/reply/src/auto_completion.cr b/lib/reply/src/auto_completion.cr new file mode 100644 index 000000000000..ee4940fac71c --- /dev/null +++ b/lib/reply/src/auto_completion.cr @@ -0,0 +1,263 @@ +require "./term_cursor" +require "./term_size" +require "colorize" + +module Reply + # Interface of auto-completion. + # + # It provides following important methods: + # + # * `complete_on`: Trigger the auto-completion given a *word_on_cursor* and expression before. + # Stores the list of entries, and returns the *replacement* string. + # + # * `name_filter=`: Update the filtering of entries. + # + # * `display_entries`: Displays on screen the stored entries. + # Highlight the one selected. (initially `nil`). + # + # * `selection_next`/`selection_previous`: Increases/decrease the selected entry. + # + # * `open`/`close`: Toggle display, clear entries if close. + # + # * `clear`: Like `close`, but display a empty space instead of nothing. + private class AutoCompletion + getter? open = false + getter? cleared = false + @selection_pos : Int32? = nil + + @title = "" + @all_entries = [] of String + getter entries = [] of String + property name_filter = "" + + def initialize(&@auto_complete : String, String -> {String, Array(String)}) + @display_title = ->default_display_title(IO, String) + @display_entry = ->default_display_entry(IO, String, String) + @display_selected_entry = ->default_display_selected_entry(IO, String) + end + + def complete_on(current_word : String, expression_before : String) : String? + @title, @all_entries = @auto_complete.call(current_word, expression_before) + self.name_filter = current_word + + @entries.empty? ? nil : common_root(@entries) + end + + def name_filter=(@name_filter) + @selection_pos = nil + @entries = @all_entries.select(&.starts_with?(@name_filter)) + end + + # If open, displays completion entries by columns, minimizing the height. + # Highlight the selected entry (initially `nil`). + # + # If cleared, displays `clear_size` space. + # + # If closed, do nothing. + # + # Returns the actual displayed height. + def display_entries(io, color? = true, width = Term::Size.width, max_height = 10, min_height = 0) : Int32 # ameba:disable Metrics/CyclomaticComplexity + if cleared? + min_height.times { io.puts } + return min_height + end + + return 0 unless open? + return 0 if max_height <= 1 + + height = 0 + + # Print title: + if color? + @display_title.call(io, @title) + else + io << @title << ":" + end + io.puts + height += 1 + + if @entries.empty? + (min_height - height).times { io.puts } + return {height, min_height}.max + end + + nb_rows = compute_nb_row(@entries, max_nb_row: max_height - height, width: width) + + columns = @entries.in_groups_of(nb_rows, filled_up_with: "") + column_widths = columns.map &.max_of &.size.+(2) + + nb_cols = nb_colomns_in_width(column_widths, width) + + col_start = 0 + if pos = @selection_pos + col_end = pos // nb_rows + + if col_end >= nb_cols + nb_cols = nb_colomns_in_width(column_widths[..col_end].reverse_each, width) + + col_start = col_end - nb_cols + 1 + end + end + + nb_rows.times do |r| + nb_cols.times do |c| + c += col_start + + entry = columns[c][r] + col_width = column_widths[c] + + # `...` on the last column and row: + if (r == nb_rows - 1) && (c - col_start == nb_cols - 1) && columns[c + 1]? + entry += ".." + end + + # Entry to display: + entry_str = entry.ljust(col_width) + + if r + c*nb_rows == @selection_pos + # Colorize selection: + if color? + @display_selected_entry.call(io, entry_str) + else + io << ">" + entry_str[...-1] # if no color, remove last spaces to let place to '*'. + end + else + # Display entry_str, with @name_filter prefix in bright: + unless entry.empty? + if color? + io << @display_entry.call(io, @name_filter, entry_str.lchop(@name_filter)) + else + io << entry_str + end + end + end + end + io << Term::Cursor.clear_line_after if color? + io.puts + end + + height += nb_rows + + (min_height - height).times { io.puts } + + {height, min_height}.max + end + + # Increases selected entry. + def selection_next + return nil if @entries.empty? + + if (pos = @selection_pos).nil? + new_pos = 0 + else + new_pos = (pos + 1) % @entries.size + end + @selection_pos = new_pos + @entries[new_pos] + end + + # Decreases selected entry. + def selection_previous + return nil if @entries.empty? + + if (pos = @selection_pos).nil? + new_pos = 0 + else + new_pos = (pos - 1) % @entries.size + end + @selection_pos = new_pos + @entries[new_pos] + end + + def open + @open = true + @cleared = false + end + + def close + @selection_pos = nil + @entries.clear + @name_filter = "" + @all_entries.clear + @open = false + @cleared = false + end + + def clear + close + @cleared = true + end + + def set_display_title(&@display_title : IO, String ->) + end + + def set_display_entry(&@display_entry : IO, String, String ->) + end + + def set_display_selected_entry(&@display_selected_entry : IO, String ->) + end + + protected def default_display_title(io, title) + io << title.colorize.underline << ":" + end + + protected def default_display_entry(io, entry_matched, entry_remaining) + io << entry_matched.colorize.bright << entry_remaining + end + + protected def default_display_selected_entry(io, entry) + io << entry.colorize.bright.on_dark_gray + end + + private def nb_colomns_in_width(column_widths, width) + nb_cols = 0 + w = 0 + column_widths.each do |col_width| + w += col_width + break if w > width + nb_cols += 1 + end + nb_cols + end + + # Computes the min number of rows required to display entries: + # * if all entries cannot fit in `max_nb_row` rows, returns `max_nb_row`, + # * if there are less than 10 entries, returns `entries.size` because in this case, it's more convenient to display them in one column. + private def compute_nb_row(entries, max_nb_row, width) + if entries.size > 10 + # test possible nb rows: (1 to max_nb_row) + 1.to max_nb_row do |r| + w = 0 + # Sum the width of each given column: + entries.each_slice(r, reuse: true) do |col| + w += col.max_of &.size + 2 + end + + # If *w* goes past *width*, we found min row required: + return r if w < width + end + end + + {entries.size, max_nb_row}.min + end + + # Finds the common root text between given entries. + private def common_root(entries) + return "" if entries.empty? + return entries[0] if entries.size == 1 + + i = 0 + entry_iterators = entries.map &.each_char + + loop do + char_on_first_entry = entries[0][i]? + same = entry_iterators.all? do |entry| + entry.next == char_on_first_entry + end + i += 1 + break if !same + end + entries[0][...(i - 1)] + end + end +end diff --git a/lib/reply/src/char_reader.cr b/lib/reply/src/char_reader.cr new file mode 100644 index 000000000000..3da5ca06d804 --- /dev/null +++ b/lib/reply/src/char_reader.cr @@ -0,0 +1,198 @@ +module Reply + private struct CharReader + enum Sequence + EOF + UP + DOWN + RIGHT + LEFT + ENTER + ESCAPE + DELETE + BACKSPACE + CTRL_A + CTRL_B + CTRL_C + CTRL_D + CTRL_E + CTRL_F + CTRL_K + CTRL_N + CTRL_P + CTRL_U + CTRL_X + CTRL_UP + CTRL_DOWN + CTRL_LEFT + CTRL_RIGHT + CTRL_ENTER + CTRL_DELETE + CTRL_BACKSPACE + ALT_B + ALT_D + ALT_F + ALT_ENTER + ALT_BACKSPACE + TAB + SHIFT_TAB + HOME + END + end + + def initialize(buffer_size = 8192) + @slice_buffer = Bytes.new(buffer_size) + end + + def read_char(from io : T = STDIN) forall T + {% if flag?(:win32) && T <= IO::FileDescriptor %} + handle = LibC._get_osfhandle(io.fd) + raise RuntimeError.from_errno("_get_osfhandle") if handle == -1 + + raw(io) do + LibC.ReadConsoleA(LibC::HANDLE.new(handle), @slice_buffer, @slice_buffer.size, out nb_read, nil) + + parse_escape_sequence(@slice_buffer[0...nb_read]) + end + {% else %} + nb_read = raw(io, &.read(@slice_buffer)) + parse_escape_sequence(@slice_buffer[0...nb_read]) + {% end %} + end + + private def parse_escape_sequence(chars : Bytes) : Char | Sequence | String? + return String.new(chars) if chars.size > 6 + return Sequence::EOF if chars.empty? + + case chars[0]? + when '\e'.ord + case chars[1]? + when '['.ord + case chars[2]? + when 'A'.ord then Sequence::UP + when 'B'.ord then Sequence::DOWN + when 'C'.ord then Sequence::RIGHT + when 'D'.ord then Sequence::LEFT + when 'Z'.ord then Sequence::SHIFT_TAB + when '3'.ord + if {chars[3]?, chars[4]?} == {';'.ord, '5'.ord} + case chars[5]? + when '~'.ord then Sequence::CTRL_DELETE + end + elsif chars[3]? == '~'.ord + Sequence::DELETE + end + when '1'.ord + if {chars[3]?, chars[4]?} == {';'.ord, '5'.ord} + case chars[5]? + when 'A'.ord then Sequence::CTRL_UP + when 'B'.ord then Sequence::CTRL_DOWN + when 'C'.ord then Sequence::CTRL_RIGHT + when 'D'.ord then Sequence::CTRL_LEFT + end + elsif chars[3]? == '~'.ord # linux console HOME + Sequence::HOME + end + when '4'.ord # linux console END + if chars[3]? == '~'.ord + Sequence::END + end + when 'H'.ord # xterm HOME + Sequence::HOME + when 'F'.ord # xterm END + Sequence::END + end + when '\t'.ord + Sequence::SHIFT_TAB + when '\r'.ord + Sequence::ALT_ENTER + when 0x7f + Sequence::ALT_BACKSPACE + when 'O'.ord + if chars[2]? == 'H'.ord # gnome terminal HOME + Sequence::HOME + elsif chars[2]? == 'F'.ord # gnome terminal END + Sequence::END + end + when 'b' + Sequence::ALT_B + when 'd' + Sequence::ALT_D + when 'f' + Sequence::ALT_F + when Nil + Sequence::ESCAPE + end + when '\r'.ord + Sequence::ENTER + when '\n'.ord + {% if flag?(:win32) %} + Sequence::CTRL_ENTER + {% else %} + Sequence::ENTER + {% end %} + when '\t'.ord + Sequence::TAB + when '\b'.ord + Sequence::CTRL_BACKSPACE + when ctrl('a') + Sequence::CTRL_A + when ctrl('b') + Sequence::CTRL_B + when ctrl('c') + Sequence::CTRL_C + when ctrl('d') + Sequence::CTRL_D + when ctrl('e') + Sequence::CTRL_E + when ctrl('f') + Sequence::CTRL_F + when ctrl('k') + Sequence::CTRL_K + when ctrl('n') + Sequence::CTRL_N + when ctrl('p') + Sequence::CTRL_P + when ctrl('u') + Sequence::CTRL_U + when ctrl('x') + Sequence::CTRL_X + when '\0'.ord + Sequence::EOF + when 0x7f + Sequence::BACKSPACE + else + if chars.size == 1 + chars[0].chr + end + end || String.new(chars) + end + + private def raw(io : T, &) forall T + {% if T.has_method?(:raw) %} + if io.tty? + io.raw { yield io } + else + yield io + end + {% else %} + yield io + {% end %} + end + + private def ctrl(k) + (k.ord & 0x1f) + end + end +end + +{% if flag?(:win32) %} + lib LibC + STD_INPUT_HANDLE = -10 + + fun ReadConsoleA(hConsoleInput : Void*, + lpBuffer : Void*, + nNumberOfCharsToRead : UInt32, + lpNumberOfCharsRead : UInt32*, + pInputControl : Void*) : UInt8 + end +{% end %} diff --git a/lib/reply/src/expression_editor.cr b/lib/reply/src/expression_editor.cr new file mode 100644 index 000000000000..5c3d7aec24b9 --- /dev/null +++ b/lib/reply/src/expression_editor.cr @@ -0,0 +1,1002 @@ +require "./term_cursor" +require "./term_size" + +module Reply + # The `ExpressionEditor` allows to edit and display an expression. + # + # Its main task is to provide the display of the prompt and a multiline expression within + # the term bounds, and ensure the correspondence between the cursor on screen and the cursor on the expression. + # + # Usage example: + # ``` + # # new editor: + # @editor = ExpressionEditor.new( + # prompt: ->(expr_line_number : Int32) { "prompt>" } + # ) + # + # # edit some code: + # @editor.update do + # @editor << %(puts "World") + # + # insert_new_line(indent: 1) + # @editor << %(puts "!") + # end + # + # # move cursor: + # @editor.move_cursor_up + # 4.times { @editor.move_cursor_left } + # + # # edit: + # @editor.update do + # @editor << "Hello " + # end + # + # @editor.end_editing + # + # @editor.expression # => %(puts "Hello World"\n puts "!") + # puts "=> ok" + # + # # clear and restart edition: + # @editor.prompt_next + # ``` + # + # The above displays: + # ``` + # prompt>puts "Hello World" + # prompt> puts "!" + # => ok + # prompt> + # ``` + # + # Methods that modify the expression should be placed inside an `update` so the screen can be refreshed taking in account + # the adding or removing of lines, and doesn't boilerplate the display. + class ExpressionEditor + getter lines : Array(String) = [""] + getter expression : String? { lines.join('\n') } + getter expression_height : Int32? { lines.sum { |l| line_height(l) } } + property? color = true + property output : IO = STDOUT + + # Tracks the cursor position relatively to the expression's lines, (y=0 corresponds to the first line and x=0 the first char) + # This position is independent of text wrapping so its position will not match to real cursor on screen. + # + # `|` : cursor position + # + # ``` + # prompt>def very_looo + # ooo|ng_name <= wrapping + # prompt> bar + # prompt>end + # ``` + # For example here the cursor position is x=16, y=0, but real cursor is at x=3,y=1 from the beginning of expression. + getter x = 0 + getter y = 0 + + # The editor height, if not set (`nil`), equal to term height. + setter height : Int32? = nil + + # The editor width, if not set (`nil`), equal to term width. + setter width : Int32? = nil + + @prompt : Int32, Bool -> String + @prompt_size : Int32 + + @scroll_offset = 0 + @header_height = 0 + + @header : IO, Int32 -> Int32 = ->(io : IO, previous_height : Int32) { 0 } + @highlight = ->(code : String) { code } + + # The list of characters delimiting words. + # + # default: ` \n\t+-*/,;@&%<>"'^\\[](){}|.~:=!?` + property word_delimiters : Array(Char) = {{" \n\t+-*/,;@&%<>\"'^\\[](){}|.~:=!?".chars}} + + # Creates a new `ExpressionEditor` with the given *prompt*. + def initialize(&@prompt : Int32, Bool -> String) + @prompt_size = @prompt.call(0, false).size # uncolorized size + end + + # Sets a `Proc` allowing to display a header above the prompt. (used by auto-completion) + # + # *io*: The IO in which the header should be displayed. + # *previous_hight*: Previous header height, useful to keep a header size constant. + # Should returns the exact *height* printed in the io. + def set_header(&@header : IO, Int32 -> Int32) + end + + # Sets the `Proc` to highlight the expression. + def set_highlight(&@highlight : String -> String) + end + + private def move_cursor(x, y) + @x += x + @y += y + end + + private def move_real_cursor(x, y) + @output.print Term::Cursor.move(x, -y) + end + + private def move_abs_cursor(@x, @y) + end + + private def reset_cursor + @x = @y = 0 + end + + # Returns true is the char at *x*, *y* is a word char. + private def word_char?(x) + if x >= 0 && (ch = self.current_line[x]?) + !ch.in? self.word_delimiters + end + end + + def current_line + @lines[@y] + end + + def previous_line? + if @y > 0 + @lines[@y - 1] + end + end + + def next_line? + @lines[@y + 1]? + end + + # Returns the word under the cursor following `word_delimiters`. + def current_word + word_begin, word_end = self.current_word_begin_end + + self.current_line[word_begin..word_end] + end + + # Returns begin and end position of `current_word`. + def current_word_begin_end + return 0, 0 if self.current_line.empty? + + word_begin = {@x - 1, 0}.max + word_end = @x + while word_char?(word_begin) + word_begin -= 1 + end + + while word_char?(word_end) + word_end += 1 + end + + {word_begin + 1, word_end - 1} + end + + def empty? + @lines.empty? || (@lines.size == 1 && @lines.first.empty?) + end + + def cursor_on_last_line? + (@y == @lines.size - 1) + end + + def expression_before_cursor(x = @x, y = @y) + String.build do |io| + @lines[...y].each { |line| io << line << '\n' } + io << @lines[y][...x] + end + end + + # Following functions modifies the expression, they should be called inside + # an `update` block to see the changes in the screen: # + + # Should be called inside an `update`. + def previous_line=(line) + @lines[@y - 1] = line + @expression = @expression_height = nil + end + + # Should be called inside an `update`. + def current_line=(line) + @lines[@y] = line + @expression = @expression_height = nil + end + + # Should be called inside an `update`. + def next_line=(line) + @lines[@y + 1] = line + @expression = @expression_height = nil + end + + # Replaces the word under the cursor by *replacement*, then moves cursor at the end of *replacement*. + # Should be called inside an `update`. + def current_word=(replacement) + word_begin, word_end = self.current_word_begin_end + + if word_begin == word_end == 0 + self.current_line = replacement + else + self.current_line = self.current_line.sub(word_begin..word_end, replacement) + end + + move_abs_cursor(x: word_begin + replacement.size, y: @y) + end + + # Should be called inside an `update`. + def delete_line(y) + @lines.delete_at(y) + @expression = @expression_height = nil + end + + # Should be called inside an `update`. + def clear_expression + @lines.clear << "" + @expression = @expression_height = nil + end + + # Should be called inside an `update`. + # + # If *char* is `\n` or `\r`, inserts a new line with indent 0. + # Does nothing if the char is an `ascii_control?`. + def <<(char : Char) : self + return insert_new_line(0) if char.in? '\n', '\r' + return self if char.ascii_control? + + if @x >= current_line.size + self.current_line = current_line + char + else + self.current_line = current_line.insert(@x, char) + end + + move_cursor(x: +1, y: 0) + self + end + + # Should be called inside an `update`. + def <<(str : String) : self + str.each_char do |ch| + self << ch + end + self + end + + # Should be called inside an `update`. + def insert_new_line(indent) + case @x + when current_line.size + @lines.insert(@y + 1, " "*indent) + when .< current_line.size + @lines.insert(@y + 1, " "*indent + current_line[@x..]) + self.current_line = current_line[...@x] + end + + @expression = @expression_height = nil + move_abs_cursor(x: indent*2, y: @y + 1) + self + end + + # Should be called inside an `update`. + def delete + case @x + when current_line.size + if next_line = next_line? + self.current_line = current_line + next_line + + delete_line(@y + 1) + end + when .< current_line.size + self.current_line = current_line.delete_at(@x) + end + end + + # Should be called inside an `update`. + def back + case @x + when 0 + if prev_line = previous_line? + self.previous_line = prev_line + current_line + + move_cursor(x: prev_line.size, y: -1) + delete_line(@y + 1) + end + when .> 0 + self.current_line = current_line.delete_at(@x - 1) + move_cursor(x: -1, y: 0) + end + end + + # Should be called inside an `update`. + def delete_word + self.delete if @x == current_line.size + + word_end = self.next_word_end + self.current_line = current_line[...@x] + current_line[(word_end + 1)..] + end + + # Should be called inside an `update`. + def word_back + self.back if @x == 0 + + x = @x + + word_begin = self.previous_word_begin + move_abs_cursor(x: word_begin, y: @y) + + self.current_line = current_line[...word_begin] + current_line[x..] + end + + # Should be called inside an `update`. + def delete_after_cursor + if @x == current_line.size + self.delete + elsif !current_line.empty? + self.current_line = current_line[...@x] + end + end + + # Should be called inside an `update`. + def delete_before_cursor + if @x == 0 + self.back + elsif !current_line.empty? + self.current_line = current_line[@x..] + + move_abs_cursor(x: 0, y: @y) + end + end + + # End modifying functions. # + + # Gives the size of the last part of the line when it's wrapped + # + # prompt>def very_looo + # ooooooooong <= last part + # prompt> bar + # prompt>end + # + # e.g. here "ooooooooong".size = 10 + private def last_part_size(line_size) + (@prompt_size + line_size) % self.width + end + + # Returns the height of this line, (1 on common lines, more on wrapped lines): + private def line_height(line) + 1 + (@prompt_size + line.size) // self.width + end + + private def height_above_cursor(x = @x, y = @y) + height = @lines.each.first(@y).sum { |l| line_height(l) } + height += line_height(current_line[...@x]) - 1 + height + end + + # The editor width, if not set (`nil`), equal to term width. + def width + @width || Term::Size.width + end + + # The editor height, if not set (`nil`), equal to term height. + def height + @height || Term::Size.height + end + + # Returns the max height that could take an expression on screen. + # + # The expression scrolls if it's higher than epression_max_height. + private def epression_max_height + self.height - @header_height + end + + def move_cursor_left(allow_scrolling = true) + case @x + when 0 + # Wrap the cursor at the end of the previous line: + # + # `|`: cursor pos + # `*`: wanted pos + # + # ``` + # prompt>def very_looo + # ooooooooong* + # prompt>| bar + # prompt>end + # ``` + if prev_line = previous_line? + scroll_up_if_needed if allow_scrolling + + # Wrap real cursor: + size_of_last_part = last_part_size(prev_line.size) + move_real_cursor(x: -@prompt_size + size_of_last_part, y: -1) + + # Wrap cursor: + move_cursor(x: prev_line.size, y: -1) + end + when .> 0 + # Move the cursor left, wrap the real cursor if needed: + # + # `|`: cursor pos + # `*`: wanted pos + # + # ``` + # prompt>def very_looo* + # |oooooooong + # prompt> bar + # prompt>end + # ``` + if last_part_size(@x) == 0 + scroll_up_if_needed if allow_scrolling + move_real_cursor(x: self.width + 1, y: -1) + else + move_real_cursor(x: -1, y: 0) + end + move_cursor(x: -1, y: 0) + end + end + + def move_cursor_right(allow_scrolling = true) + case @x + when current_line.size + # Wrap the cursor at the beginning of the next line: + # + # `|`: cursor pos + # `*`: wanted pos + # + # ``` + # prompt>def very_looo + # ooooooooong| + # prompt>* bar + # prompt>end + # ``` + if next_line? + scroll_down_if_needed if allow_scrolling + + # Wrap real cursor: + size_of_last_part = last_part_size(current_line.size) + move_real_cursor(x: -size_of_last_part + @prompt_size, y: +1) + + # Wrap cursor: + move_cursor(x: -current_line.size, y: +1) + end + when .< current_line.size + # Move the cursor right, wrap the real cursor if needed: + # + # `|`: cursor pos + # `*`: wanted pos + # + # ``` + # prompt>def very_looo| + # *oooooooong + # prompt> bar + # prompt>end + # ``` + if last_part_size(@x) == (self.width - 1) + scroll_down_if_needed if allow_scrolling + + move_real_cursor(x: -self.width, y: +1) + else + move_real_cursor(x: +1, y: 0) + end + + # move cursor right + move_cursor(x: +1, y: 0) + end + end + + def move_cursor_up(allow_scrolling = true) + scroll_up_if_needed if allow_scrolling + + if (@prompt_size + @x) >= self.width + if @x >= self.width + # Here, we have: + # ``` + # prompt>def *very_looo + # ooooooooooo|ooooooooo + # ooooooooong + # prompt> bar + # prompt>end + # ``` + # So we need only to move real cursor up + # and move back @x by term-width. + # + move_real_cursor(x: 0, y: -1) + move_cursor(x: -self.width, y: 0) + else + # Here, we have: + # ``` + # prompt>*def very_looo + # ooo|ooooooooooooooooo + # ooooooooong + # prompt> bar + # prompt>end + # ``` + # + move_real_cursor(x: self.width - @x, y: -1) + move_cursor(x: 0 - @x, y: 0) + end + + true + elsif prev_line = previous_line? + # Here, there are a previous line in which we can move up, we want to + # move on the last part of the previous line: + size_of_last_part = last_part_size(prev_line.size) + + if size_of_last_part < @prompt_size + @x + # ``` + # prompt>def very_looo + # oooooooooooooooooooo + # ong* <= last part + # prompt> ba|aar + # prompt>end + # ``` + move_real_cursor(x: -@x - @prompt_size + size_of_last_part, y: -1) + move_abs_cursor(x: prev_line.size, y: @y - 1) + else + # ``` + # prompt>def very_looo + # oooooooooooooooooooo + # ooooooooooo*ooong <= last part + # prompt> ba|aar + # prompt>end + # ``` + move_real_cursor(x: 0, y: -1) + x = prev_line.size - size_of_last_part + @prompt_size + @x + move_abs_cursor(x: x, y: @y - 1) + end + true + else + false + end + end + + def move_cursor_down(allow_scrolling = true) + scroll_down_if_needed if allow_scrolling + + size_of_last_part = last_part_size(current_line.size) + real_x = last_part_size(@x) + + remaining = current_line.size - @x + + if remaining > size_of_last_part + # on middle + if remaining > self.width + # Here, there are enough remaining to just move down + # ``` + # prompt>def very|_loooo + # ooooooooooooooo*oooooo + # ong + # prompt> bar + # prompt>end + # ``` + # + move_real_cursor(x: 0, y: +1) + move_cursor(x: self.width, y: 0) + else + # Here, we goes to end of current line: + # ``` + # prompt>def very_loooo + # ooooooooooooooo|ooooo + # ong* + # prompt> bar + # prompt>end + # ``` + move_real_cursor(x: -real_x + size_of_last_part, y: +1) + move_abs_cursor(x: current_line.size, y: @y) + end + true + elsif next_line = next_line? + case real_x + when .< @prompt_size + # Here, we are behind the prompt so we want goes to the beginning of the next line: + # ``` + # prompt>def very_loooo + # ooooooooooooooooooooo + # ong| + # prompt>* bar + # prompt>end + # ``` + move_real_cursor(x: -real_x + @prompt_size, y: +1) + move_abs_cursor(x: 0, y: @y + 1) + when .< @prompt_size + next_line.size + # Here, we can just move down on the next line: + # ``` + # prompt>def very_loooo + # ooooooooooooooooooooo + # ooooooooong| + # prompt> ba*r + # prompt>end + # ``` + move_real_cursor(x: 0, y: +1) + move_abs_cursor(x: real_x - @prompt_size, y: @y + 1) + else + # Finally, here, we want to move at end of the next line: + # ``` + # prompt>def very_loooo + # ooooooooooooooooooooo + # ooooooooooooooong| + # prompt> bar* + # prompt>end + # ``` + x = real_x - (@prompt_size + next_line.size) + move_real_cursor(x: -x, y: +1) + move_abs_cursor(x: next_line.size, y: @y + 1) + end + true + else + false + end + end + + def move_cursor_to(x, y, allow_scrolling = true) + until @y == y + (@y > y) ? move_cursor_up(allow_scrolling: false) : move_cursor_down(allow_scrolling: false) + end + + until @x == x + (@x > x) ? move_cursor_left(allow_scrolling: false) : move_cursor_right(allow_scrolling: false) + end + + if allow_scrolling && update_scroll_offset + update + end + end + + def move_cursor_to_begin(allow_scrolling = true) + move_cursor_to(0, 0, allow_scrolling: allow_scrolling) + end + + def move_cursor_to_end(allow_scrolling = true) + y = @lines.size - 1 + + move_cursor_to(@lines[y].size, y, allow_scrolling: allow_scrolling) + end + + def move_cursor_to_end_of_line(y = @y, allow_scrolling = true) + move_cursor_to(@lines[y].size, y, allow_scrolling: allow_scrolling) + end + + def move_word_forward + self.move_cursor_right if @x == self.current_line.size + + word_end = self.next_word_end + self.move_cursor_to(x: word_end + 1, y: @y) + end + + def move_word_backward + self.move_cursor_left if @x == 0 + + word_begin = self.previous_word_begin + self.move_cursor_to(x: word_begin, y: @y) + end + + private def next_word_end + x = @x + while word_char?(x) == false + x += 1 + end + + while word_char?(x) + x += 1 + end + x - 1 + end + + private def previous_word_begin + x = @x - 1 + while word_char?(x) == false + x -= 1 + end + + while word_char?(x) + x -= 1 + end + x + 1 + end + + # Refresh the screen. + # + # It clears the display of the current expression, + # then yields for modifications, and displays the new expression. + # + # if *force_full_view* is true, whole expression is displayed, even if it overflow the term width, otherwise + # the expression is bound and can be scrolled. + def update(force_full_view = false, &) + height_to_clear = self.height_above_cursor + with self yield + + @expression = @expression_height = nil + + # Updated expression can be smaller so we might need to adjust the cursor: + @y = @y.clamp(0, @lines.size - 1) + @x = @x.clamp(0, @lines[@y].size) + + print_expression_and_header(height_to_clear, force_full_view) + end + + def update(force_full_view = false) + height_to_clear = self.height_above_cursor + + print_expression_and_header(height_to_clear, force_full_view) + end + + # Calls the header proc and saves the *header_height* + private def update_header : String + String.build do |io| + @header_height = @header.call(io, @header_height) + end + end + + def replace(lines : Array(String)) + update { @lines = lines } + end + + # Prints the full expression (without view bounds), and eventually replace it by *replacement*. + def end_editing(replacement : Array(String)? = nil) + if replacement + update(force_full_view: true) do + @lines = replacement + end + else + update(force_full_view: true) + end + + move_cursor_to_end(allow_scrolling: false) + @output.puts + end + + # Clears the expression and start a new prompt on a next line. + def prompt_next + @scroll_offset = 0 + @lines = [""] + @expression = @expression_height = nil + reset_cursor + @prompt_size = 0 + print_prompt(@output, 0) + end + + private def print_prompt(io, line_index) + line_prompt_size = @prompt.call(line_index, false).size # uncolorized size + @prompt_size = {line_prompt_size, @prompt_size}.max + io.print @prompt.call(line_index, color?) + + # Padding to align the lines when the prompt size change + (@prompt_size - line_prompt_size).times do + io.print ' ' + end + end + + def scroll_up + if @scroll_offset < expression_height() - epression_max_height() + @scroll_offset += 1 + update + end + end + + def scroll_down + if @scroll_offset > 0 + @scroll_offset -= 1 + update + end + end + + private def scroll_up_if_needed + if update_scroll_offset(y_shift: -1) + update + end + end + + private def scroll_down_if_needed + if update_scroll_offset(y_shift: +1) + update + end + end + + # Updates the scroll offset in a way that (cursor + y_shift) is still between the view bounds + # Returns true if the offset has been effectively modified. + private def update_scroll_offset(y_shift = 0) + start, end_ = self.view_bounds + real_y = self.height_above_cursor + y_shift + + # case 1: cursor is before view start, we need to increase the scroll by the difference. + if real_y < start + @scroll_offset += start - real_y + true + + # case 2: cursor is after view end, we need to decrease the scroll by the difference. + elsif real_y > end_ + @scroll_offset -= real_y - end_ + true + else + false + end + end + + protected def expression_scrolled? + expression_height() > epression_max_height() + end + + # Returns y-start and end positions of the expression that should be displayed on the screen. + # This take account of @scroll_offset, and the size start-end should never be greater than screen height. + private def view_bounds + end_ = expression_height() - 1 + + start = {0, end_ + 1 - epression_max_height()}.max + + @scroll_offset = @scroll_offset.clamp(0, start) # @scroll_offset could not be greater than start. + + start -= @scroll_offset + end_ -= @scroll_offset + {start, end_} + end + + private def print_line(io, colorized_line, line_index, line_size, prompt?, first?, is_last_part?) + if prompt? + io.puts unless first? + print_prompt(io, line_index) + end + io.print colorized_line + + # ``` + # prompt>begin | + # prompt> foooooooooooooooooooo| + # | <- If the line size match exactly the screen width, we need to add a + # prompt> bar | extra line feed, so computes based on `%` or `//` stay exact. + # prompt>end | + # ``` + io.puts if is_last_part? && last_part_size(line_size) == 0 + end + + private def sync_output + if (output = @output).is_a?(IO::FileDescriptor) && output.tty? + # Disallowing the synchronization reduce blinking on some terminal like vscode (#10) + output.sync = false + output.flush_on_newline = false + output.print Term::Cursor.hide + yield + output.print Term::Cursor.show + output.flush_on_newline = true + output.sync = true + output.flush + else + yield + end + end + + # Prints the colorized expression, this former is clipped if it's higher than screen. + # The `header` is print just above. + # The only displayed part of the expression is delimited by `view_bounds` and depend of the value of + # `@scroll_offset`. + # Lines that takes more than one line (if wrapped) are cut in consequence. + # + # *height_to_clear* is the height we have to clear between the previous cursor pos and the beginning of the prompt. + # if *force_full_view* is true, all expression is dumped on screen, without clipping. + private def print_expression_and_header(height_to_clear, force_full_view = false) + height_to_clear += @header_height + header = update_header() + + if force_full_view + start, end_ = 0, Int32::MAX + else + update_scroll_offset() + + start, end_ = view_bounds() + end + + first = true + + y = 0 + + colorized_lines = + if self.color? + @highlight.call(self.expression).split('\n') + else + self.lines + end + + # While printing, real cursor move, but @x/@y don't, so we track the moved cursor position to be able to + # restore real cursor at @x/@y position. + cursor_move_x = cursor_move_y = 0 + + display = String.build do |io| + # Iterate over the uncolored lines because we need to know the true size of each line: + @lines.each_with_index do |line, line_index| + line_height = line_height(line) + + break if y > end_ + if y + line_height <= start + y += line_height + next + end + + if start <= y && y + line_height - 1 <= end_ + # The line can hold entirely between the view bounds, print it: + print_line(io, colorized_lines[line_index], line_index, line.size, prompt?: true, first?: first, is_last_part?: true) + first = false + + cursor_move_x = line.size + cursor_move_y = line_index + + y += line_height + else + # The line cannot holds entirely between the view bounds. + # We need to cut the line into each part and display only parts that hold in the view + colorized_parts = parts_from_colorized(colorized_lines[line_index]) + + colorized_parts.each_with_index do |colorized_part, part_number| + if start <= y <= end_ + # The part holds on the view, we can print it. + print_line(io, colorized_part, line_index, line.size, prompt?: part_number == 0, first?: first, is_last_part?: part_number == line_height - 1) + first = false + + cursor_move_x = {line.size, (part_number + 1)*self.width - @prompt_size - 1}.min + cursor_move_y = line_index + end + y += 1 + end + end + end + end + + sync_output do + # Rewind cursor from *height_to_clear*, then print the header and the clipped expression + move_real_cursor(x: 0, y: -height_to_clear) + @output.print Term::Cursor.column(1) + @output.print Term::Cursor.clear_screen_down + @output.print header + @output.print display + + # Retrieve the real cursor at its corresponding cursor position (`@x`, `@y`) + x_save, y_save = @x, @y + @y = cursor_move_y + @x = cursor_move_x + + move_cursor_to(x_save, y_save, allow_scrolling: false) + end + end + + # Splits the given *line* (colorized) into parts delimited by wrapping. + # + # Because *line* is colorized, it's hard to know when it's wrap based on its size (colors sequence might appear anywhere in the string) + # Here we does the following: + # * Create a `String::Builder` for the first part (`part_builder`) + # * Iterate over the *line*, parsing the color sequence + # * Count cursor `x` for each char unless color sequences + # * If count goes over term width: + # reset `x` to 0, and create a new `String::Builder` for next part. + private def parts_from_colorized(line) + parts = Array(String).new + + color_sequence = "" + part_builder = String::Builder.new + + x = @prompt_size + chars = line.each_char + until (c = chars.next).is_a? Iterator::Stop + # Parse color sequence: + if c == '\e' && chars.next == '[' + color_sequence = String.build do |seq| + seq << '\e' << '[' + + until (c = chars.next) == 'm' + break if c.is_a? Iterator::Stop + seq << c + end + seq << 'm' + end + part_builder << color_sequence + else + part_builder << c + x += 1 + end + + if x >= self.width + # Wrapping: save part and create a new builder for next part + part_builder << "\e[0m" + parts << part_builder.to_s + part_builder = String::Builder.new + part_builder << color_sequence # We also add the previous color sequence because color need to be preserved. + x = 0 + end + end + parts << part_builder.to_s + parts + end + end +end diff --git a/lib/reply/src/history.cr b/lib/reply/src/history.cr new file mode 100644 index 000000000000..3f12f0f01f95 --- /dev/null +++ b/lib/reply/src/history.cr @@ -0,0 +1,109 @@ +module Reply + class History + getter history = Deque(Array(String)).new + getter max_size = 10_000 + @index = 0 + + # Hold the history lines being edited, always contains one element more than @history + # because it can also contain the current line (not yet in history) + @edited_history = [nil] of Array(String)? + + def <<(lines) + lines = lines.dup # make history elements independent + + if l = @history.delete_element(lines) + # re-insert duplicate elements at the end + @history.push(l) + else + # delete oldest entries until history size is `max_size` + while @history.size >= max_size + @history.delete_at(0) + end + + @history.push(lines) + end + set_to_last + end + + def clear + @history.clear + @edited_history.clear.push(nil) + @index = 0 + end + + def up(current_edited_lines : Array(String)) + unless @index == 0 + @edited_history[@index] = current_edited_lines + + @index -= 1 + (@edited_history[@index]? || @history[@index]).dup + end + end + + def down(current_edited_lines : Array(String)) + unless @index == @history.size + @edited_history[@index] = current_edited_lines + + @index += 1 + (@edited_history[@index]? || @history[@index]).dup + end + end + + def max_size=(max_size) + @max_size = max_size.clamp 1.. + end + + def load(file : Path | String) + File.touch(file) unless File.exists?(file) + File.open(file, "r") { |f| load(f) } + end + + def load(io : IO) + str = io.gets_to_end + if str.empty? + @history = Deque(Array(String)).new + else + history = + str.gsub(/(\\\n|\\\\)/, { + "\\\n": '\e', # replace temporary `\\n` by `\e` because we first split by `\n` but want to avoid `\\n`. (`\e` could not exist in a history line) + "\\\\": '\\', # replace `\\` by `\`. + }) + .split('\n') # split each expression + .map(&.split('\e')) # split each expression by lines + + @history = Deque(Array(String)).new(history) + end + + @edited_history.clear + (@history.size + 1).times do + @edited_history << nil + end + @index = @history.size + end + + def save(file : Path | String) + File.open(file, "w") { |f| save(f) } + end + + def save(io : IO) + @history.join(io, '\n') do |entry, io2| + entry.join(io2, "\\\n") do |line, io3| + io3 << line.gsub('\\', "\\\\") + end + end + end + + # Sets the index to last added value + protected def set_to_last + @index = @history.size + @edited_history.fill(nil).push(nil) + end + end +end + +class Deque(T) + # Add this method because unlike `Array`, `Deque#delete` return a Boolean instead of the element. + def delete_element(obj) : T? + internal_delete { |i| i == obj } + end +end diff --git a/lib/reply/src/reader.cr b/lib/reply/src/reader.cr new file mode 100644 index 000000000000..dafe84569e54 --- /dev/null +++ b/lib/reply/src/reader.cr @@ -0,0 +1,440 @@ +require "./history" +require "./expression_editor" +require "./char_reader" +require "./auto_completion" + +module Reply + # Reader for your REPL. + # + # Create a subclass of it and override methods to customize behavior. + # + # ``` + # class MyReader < Reply::Reader + # def prompt(io, line_number, color?) + # io << "reply> " + # end + # end + # ``` + # + # Run the REPL with `run`: + # + # ``` + # reader = MyReader.new + # + # reader.run do |expression| + # # Eval expression here + # puts " => #{expression}" + # end + # ``` + # + # Or with `read_next`: + # ``` + # loop do + # expression = reader.read_next + # break unless expression + # + # # Eval expression here + # puts " => #{expression}" + # end + # ``` + class Reader + # General architecture: + # + # ``` + # SDTIN -> CharReader -> Reader -> ExpressionEditor -> STDOUT + # ^ ^ + # | | + # History AutoCompletion + # ``` + + getter history = History.new + getter editor : ExpressionEditor + @auto_completion : AutoCompletion + @char_reader = CharReader.new + getter line_number = 1 + + delegate :color?, :color=, :lines, :output, :output=, to: @editor + delegate :word_delimiters, :word_delimiters=, to: @editor + + def initialize + @editor = ExpressionEditor.new do |expr_line_number, color?| + String.build do |io| + prompt(io, @line_number + expr_line_number, color?) + end + end + + @auto_completion = AutoCompletion.new(&->auto_complete(String, String)) + @auto_completion.set_display_title(&->auto_completion_display_title(IO, String)) + @auto_completion.set_display_entry(&->auto_completion_display_entry(IO, String, String)) + @auto_completion.set_display_selected_entry(&->auto_completion_display_selected_entry(IO, String)) + + @editor.set_header do |io, previous_height| + @auto_completion.display_entries(io, color?, max_height: {10, Term::Size.height - 1}.min, min_height: previous_height) + end + + @editor.set_highlight(&->highlight(String)) + + if file = self.history_file + @history.load(file) + end + end + + # Override to customize the prompt. + # + # Toggle the colorization following *color?*. + # + # default: `$:001> ` + def prompt(io : IO, line_number : Int32, color? : Bool) + io << "$:" + io << sprintf("%03d", line_number) + io << "> " + end + + # Override to enable expression highlighting. + # + # default: uncolored `expression` + def highlight(expression : String) + expression + end + + # Override this method to makes the interface continue on multiline, depending of the expression. + # + # default: `false` + def continue?(expression : String) + false + end + + # Override to enable reformatting after submitting. + # + # default: unchanged `expression` + def format(expression : String) + nil + end + + # Override to return the expected indentation level in function of expression before cursor. + # + # default: `0` + def indentation_level(expression_before_cursor : String) + 0 + end + + # Override to select with expression is saved in history. + # + # default: `!expression.blank?` + def save_in_history?(expression : String) + !expression.blank? + end + + # Override to indicate the `Path|String|IO` where the history is saved. If `nil`, the history is not persistent. + # + # default: `nil` + def history_file + nil + end + + # Override to integrate auto-completion. + # + # *current_word* is picked following `word_delimiters`. + # It expects to return `Tuple` with: + # * a title : `String` + # * the auto-completion results : `Array(String)` + # + # default: `{"", [] of String}` + def auto_complete(current_word : String, expression_before : String) + return "", [] of String + end + + # Override to customize how title is displayed. + # + # default: `title` underline + `":"` + def auto_completion_display_title(io : IO, title : String) + @auto_completion.default_display_title(io, title) + end + + # Override to customize how entry is displayed. + # + # Entry is split in two (`entry_matched` + `entry_remaining`). `entry_matched` correspond + # to the part already typed when auto-completion was triggered. + # + # default: `entry_matched` bright + `entry_remaining` normal. + def auto_completion_display_entry(io : IO, entry_matched : String, entry_remaining : String) + @auto_completion.default_display_entry(io, entry_matched, entry_remaining) + end + + # Override to customize how the selected entry is displayed. + # + # default: `entry` bright on dark grey + def auto_completion_display_selected_entry(io : IO, entry : String) + @auto_completion.default_display_selected_entry(io, entry) + end + + # Override to enable line re-indenting. + # + # This methods is called each time a character is entered. + # + # You should return either: + # * `nil`: keep the line as it + # * `Int32` value: re-indent the line by an amount equal to the returned value, relatively to `indentation_level`. + # (0 to follow `indentation_level`) + # + # See `example/crystal_repl`. + # + # default: `nil` + def reindent_line(line : String) + nil + end + + def read_next(from io : IO = STDIN) : String? # ameba:disable Metrics/CyclomaticComplexity + @editor.prompt_next + + loop do + read = @char_reader.read_char(from: io) + case read + in Char then on_char(read) + in String then on_string(read) + in .enter? then on_enter { |line| return line } + in .up? then on_up + in .ctrl_p? then on_up + in .down? then on_down + in .ctrl_n? then on_down + in .left? then on_left + in .ctrl_b? then on_left + in .right? then on_right + in .ctrl_f? then on_right + in .ctrl_up? then on_ctrl_up { |line| return line } + in .ctrl_down? then on_ctrl_down { |line| return line } + in .ctrl_left? then on_ctrl_left { |line| return line } + in .ctrl_right? then on_ctrl_right { |line| return line } + in .tab? then on_tab + in .shift_tab? then on_tab(shift_tab: true) + in .escape? then on_escape + in .alt_enter? then on_enter(alt_enter: true) { } + in .ctrl_enter? then on_enter(ctrl_enter: true) { } + in .alt_backspace? then @editor.update { word_back } + in .ctrl_backspace? then @editor.update { word_back } + in .backspace? then on_back + in .home?, .ctrl_a? then on_begin + in .end?, .ctrl_e? then on_end + in .delete? then @editor.update { delete } + in .ctrl_k? then @editor.update { delete_after_cursor } + in .ctrl_u? then @editor.update { delete_before_cursor } + in .alt_f? then @editor.move_word_forward + in .alt_b? then @editor.move_word_backward + in .ctrl_delete? then @editor.update { delete_word } + in .alt_d? then @editor.update { delete_word } + in .ctrl_c? then on_ctrl_c + in .ctrl_d? + if @editor.empty? + output.puts + return nil + else + @editor.update { delete } + end + in .eof?, .ctrl_x? + output.puts + return nil + end + + if read.is_a?(CharReader::Sequence) && (read.tab? || read.enter? || read.alt_enter? || read.shift_tab? || read.escape? || read.backspace? || read.ctrl_c?) + else + if @auto_completion.open? + auto_complete_insert_char(read) + @editor.update + end + end + end + end + + def read_loop(& : String -> _) + loop do + yield read_next || break + end + end + + # Reset the line number and close auto-completion results. + def reset + @line_number = 1 + @auto_completion.close + end + + # Clear the history and the `history_file`. + def clear_history + @history.clear + if file = self.history_file + @history.save(file) + end + end + + private def on_char(char) + @editor.update do + @editor << char + line = @editor.current_line.rstrip(' ') + + if @editor.x == line.size + # Re-indent line after typing a char. + if shift = self.reindent_line(line) + indent = self.indentation_level(@editor.expression_before_cursor) + new_indent = (indent + shift).clamp 0.. + @editor.current_line = " "*new_indent + @editor.current_line.lstrip(' ') + end + end + end + end + + private def on_string(string) + @editor.update do + @editor << string + end + end + + private def on_enter(alt_enter = false, ctrl_enter = false, &) + @auto_completion.close + if alt_enter || ctrl_enter || (@editor.cursor_on_last_line? && continue?(@editor.expression)) + @editor.update do + insert_new_line(indent: self.indentation_level(@editor.expression_before_cursor)) + end + else + submit_expr + yield @editor.expression + end + end + + private def on_up + has_moved = @editor.move_cursor_up + + if !has_moved && (new_lines = @history.up(@editor.lines)) + @editor.replace(new_lines) + @editor.move_cursor_to_end + end + end + + private def on_down + has_moved = @editor.move_cursor_down + + if !has_moved && (new_lines = @history.down(@editor.lines)) + @editor.replace(new_lines) + @editor.move_cursor_to_end_of_line(y: 0) + end + end + + private def on_left + @editor.move_cursor_left + end + + private def on_right + @editor.move_cursor_right + end + + private def on_back + auto_complete_remove_char if @auto_completion.open? + @editor.update { back } + end + + # If overridden, can yield an expression to giveback to `run`. + # This is made because the `PryInterface` in `IC` can override these hotkeys and yield + # command like `step`/`next`. + # + # TODO: It need a proper design to override hotkeys. + private def on_ctrl_up(& : String ->) + @editor.scroll_down + end + + private def on_ctrl_down(& : String ->) + @editor.scroll_up + end + + private def on_ctrl_left(& : String ->) + @editor.move_word_backward + end + + private def on_ctrl_right(& : String ->) + @editor.move_word_forward + end + + private def on_ctrl_c + @auto_completion.close + @editor.end_editing + output.puts "^C" + @history.set_to_last + @editor.prompt_next + end + + private def on_tab(shift_tab = false) + line = @editor.current_line + + # Retrieve the word under the cursor + word_begin, word_end = @editor.current_word_begin_end + current_word = line[word_begin..word_end] + + if @auto_completion.open? + if shift_tab + replacement = @auto_completion.selection_previous + else + replacement = @auto_completion.selection_next + end + else + # Get whole expression before cursor, allow auto-completion to deduce the receiver type + expr = @editor.expression_before_cursor(x: word_begin) + + # Compute auto-completion, return `replacement` (`nil` if no entry, full name if only one entry, or the begin match of entries otherwise) + replacement = @auto_completion.complete_on(current_word, expr) + + if replacement && @auto_completion.entries.size >= 2 + @auto_completion.open + end + end + + # Replace the current_word by the replacement word + if replacement + @editor.update { @editor.current_word = replacement } + end + end + + private def on_escape + @auto_completion.close + @editor.update + end + + private def on_begin + @editor.move_cursor_to_begin + end + + private def on_end + @editor.move_cursor_to_end + end + + private def auto_complete_insert_char(char) + if char.is_a? Char && !char.in?(@editor.word_delimiters) + @auto_completion.name_filter = @editor.current_word + elsif @editor.expression_scrolled? || char.is_a?(String) + @auto_completion.close + else + @auto_completion.clear + end + end + + private def auto_complete_remove_char + char = @editor.current_line[@editor.x - 1]? + if !char.in?(@editor.word_delimiters) + @auto_completion.name_filter = @editor.current_word[...-1] + else + @auto_completion.clear + end + end + + private def submit_expr(*, history = true) + formated = format(@editor.expression).try &.split('\n') + @editor.end_editing(replacement: formated) + + @line_number += @editor.lines.size + if history && save_in_history?(@editor.expression) + @history << @editor.lines + else + @history.set_to_last + end + if file = self.history_file + @history.save(file) + end + end + end +end diff --git a/lib/reply/src/reply.cr b/lib/reply/src/reply.cr new file mode 100644 index 000000000000..fa143a91deaa --- /dev/null +++ b/lib/reply/src/reply.cr @@ -0,0 +1,5 @@ +require "./reader" + +module Reply + VERSION = "0.3.0" +end diff --git a/lib/reply/src/term_cursor.cr b/lib/reply/src/term_cursor.cr new file mode 100644 index 000000000000..64eaf87db5cd --- /dev/null +++ b/lib/reply/src/term_cursor.cr @@ -0,0 +1,179 @@ +# from https://github.com/crystal-term/cursor +module Reply::Term + module Cursor + extend self + + ESC = "\e" + CSI = "\e[" + DEC_RST = "l" + DEC_SET = "h" + DEC_TCEM = "?25" + + # Make cursor visible + def show + CSI + DEC_TCEM + DEC_SET + end + + # Hide cursor + def hide + CSI + DEC_TCEM + DEC_RST + end + + # Switch off cursor for the block + def invisible(stream = STDOUT, &block) + stream.print(hide) + yield + ensure + stream.print(show) + end + + # Save current position + def save + # TODO: Should be CSI + "s" on Windows + ESC + "7" + end + + # Restore cursor position + def restore + # TODO: Should be CSI + "u" on Windows + ESC + "8" + end + + # Query cursor current position + def current + CSI + "6n" + end + + # Set the cursor absolute position + def move_to(row : Int32? = nil, column : Int32? = nil) + return CSI + "H" if row.nil? && column.nil? + CSI + "#{(column || 0) + 1};#{(row || 0) + 1}H" + end + + # Move cursor relative to its current position + def move(x, y) + (x < 0 ? backward(-x) : (x > 0 ? forward(x) : "")) + + (y < 0 ? down(-y) : (y > 0 ? up(y) : "")) + end + + # Move cursor up by n + def up(n : Int32? = nil) + CSI + "#{(n || 1)}A" + end + + # :ditto: + def cursor_up(n) + up(n) + end + + # Move the cursor down by n + def down(n : Int32? = nil) + CSI + "#{(n || 1)}B" + end + + # :ditto: + def cursor_down(n) + down(n) + end + + # Move the cursor backward by n + def backward(n : Int32? = nil) + CSI + "#{n || 1}D" + end + + # :ditto: + def cursor_backward(n) + backward(n) + end + + # Move the cursor forward by n + def forward(n : Int32? = nil) + CSI + "#{n || 1}C" + end + + # :ditto: + def cursor_forward(n) + forward(n) + end + + # Cursor moves to nth position horizontally in the current line + def column(n : Int32? = nil) + CSI + "#{n || 1}G" + end + + # Cursor moves to the nth position vertically in the current column + def row(n : Int32? = nil) + CSI + "#{n || 1}d" + end + + # Move cursor down to beginning of next line + def next_line + CSI + 'E' + column(1) + end + + # Move cursor up to beginning of previous line + def prev_line + CSI + 'A' + column(1) + end + + # Erase n characters from the current cursor position + def clear_char(n : Int32? = nil) + CSI + "#{n}X" + end + + # Erase the entire current line and return to beginning of the line + def clear_line + CSI + "2K" + column(1) + end + + # Erase from the beginning of the line up to and including + # the current cursor position. + def clear_line_before + CSI + "1K" + end + + # Erase from the current position (inclusive) to + # the end of the line + def clear_line_after + CSI + "0K" + end + + # Clear a number of lines + def clear_lines(n, direction = :up) + n.times.reduce([] of String) do |acc, i| + dir = direction == :up ? up : down + acc << clear_line + ((i == n - 1) ? "" : dir) + end.join + end + + # Clear a number of rows + def clear_rows(n, direction = :up) + clear_lines(n, direction) + end + + # Clear screen down from current position + def clear_screen_down + CSI + "J" + end + + # Clear screen up from current position + def clear_screen_up + CSI + "1J" + end + + # Clear the screen with the background colour and moves the cursor to home + def clear_screen + CSI + "2J" + end + + # Scroll display up one line + def scroll_up + ESC + "M" + end + + # Scroll display down one line + def scroll_down + ESC + "D" + end + end +end diff --git a/lib/reply/src/term_size.cr b/lib/reply/src/term_size.cr new file mode 100644 index 000000000000..48bad754021c --- /dev/null +++ b/lib/reply/src/term_size.cr @@ -0,0 +1,80 @@ +{% if flag?(:win32) %} + lib LibC + struct COORD + x : Int16 + y : Int16 + end + + struct SMALL_RECT + left : Int16 + top : Int16 + right : Int16 + bottom : Int16 + end + + struct CONSOLE_SCREEN_BUFFER_INFO + dwSize : COORD + dwCursorPosition : COORD + wAttributes : UInt16 + srWindow : SMALL_RECT + dwMaximumWindowSize : COORD + end + + STD_OUTPUT_HANDLE = -11 + + fun GetConsoleScreenBufferInfo(hConsoleOutput : Void*, lpConsoleScreenBufferInfo : CONSOLE_SCREEN_BUFFER_INFO*) : Void + fun GetStdHandle(nStdHandle : UInt32) : Void* + end + + module Reply::Term::Size + def self.size : {Int32, Int32} + LibC.GetConsoleScreenBufferInfo(LibC.GetStdHandle(LibC::STD_OUTPUT_HANDLE), out csbi) + col = csbi.srWindow.right - csbi.srWindow.left + 1 + row = csbi.srWindow.bottom - csbi.srWindow.top + 1 + + {col.to_i32, row.to_i32} + end + end +{% else %} + lib LibC + struct Winsize + row : LibC::Short + col : LibC::Short + x_pixel : LibC::Short + y_pixel : LibC::Short + end + + # TIOCGWINSZ is a magic number passed to ioctl that requests the current + # terminal window size. It is platform dependent (see + # https://stackoverflow.com/a/4286840). + {% begin %} + {% if flag?(:darwin) || flag?(:bsd) %} + TIOCGWINSZ = 0x40087468 + {% elsif flag?(:unix) %} + TIOCGWINSZ = 0x5413 + {% end %} + {% end %} + + fun ioctl(fd : LibC::Int, request : LibC::SizeT, winsize : LibC::Winsize*) : LibC::Int + end + + module Reply::Term::Size + # Gets the terminals width + def self.size : {Int32, Int32} + ret = LibC.ioctl(1, LibC::TIOCGWINSZ, out screen_size) + raise "Error retrieving terminal size: ioctl TIOCGWINSZ: #{Errno.value}" if ret < 0 + + {screen_size.col.to_i32, screen_size.row.to_i32} + end + end +{% end %} + +module Reply::Term::Size + def self.width + size[0] + end + + def self.height + size[1] + end +end From d23eb066d7ba7ca5d8b7d64bf995abb7a02599f0 Mon Sep 17 00:00:00 2001 From: I3oris Date: Sat, 5 Nov 2022 16:53:55 +0100 Subject: [PATCH 2/6] Integrate `REPLy` as term reader for the interpreter and for `pry`. --- .../crystal/interpreter/interpreter.cr | 28 ++-- src/compiler/crystal/interpreter/prompt.cr | 116 ---------------- .../crystal/interpreter/pry_reader.cr | 34 +++++ src/compiler/crystal/interpreter/repl.cr | 70 ++++------ .../crystal/interpreter/repl_reader.cr | 131 ++++++++++++++++++ 5 files changed, 212 insertions(+), 167 deletions(-) delete mode 100644 src/compiler/crystal/interpreter/prompt.cr create mode 100644 src/compiler/crystal/interpreter/pry_reader.cr create mode 100644 src/compiler/crystal/interpreter/repl_reader.cr diff --git a/src/compiler/crystal/interpreter/interpreter.cr b/src/compiler/crystal/interpreter/interpreter.cr index ba507e95fdb0..ad02f5e52cd2 100644 --- a/src/compiler/crystal/interpreter/interpreter.cr +++ b/src/compiler/crystal/interpreter/interpreter.cr @@ -69,6 +69,9 @@ class Crystal::Repl::Interpreter # - when doing `finish`, we'd like to exit the current frame @pry_max_target_frame : Int32? + # The input reader for the pry interface, it's stored here notably to hold the history. + @pry_reader : PryReader + # The set of local variables for interpreting code. getter local_vars : LocalVars @@ -123,6 +126,9 @@ class Crystal::Repl::Interpreter @block_level = 0 @compiled_def = nil + + @pry_reader = PryReader.new + @pry_reader.color = @context.program.color? end def self.new(interpreter : Interpreter, compiled_def : CompiledDef, stack : Pointer(UInt8), block_level : Int32) @@ -141,6 +147,9 @@ class Crystal::Repl::Interpreter @call_stack_leave_index = @call_stack.size @compiled_def = compiled_def + + @pry_reader = PryReader.new + @pry_reader.color = @context.program.color? end # Interprets the give node under the given context. @@ -1230,12 +1239,8 @@ class Crystal::Repl::Interpreter interpreter = Interpreter.new(self, compiled_def, local_vars, closure_context, stack_bottom, block_level) - prompt = Prompt.new(@context, show_nest: false) - while @pry - prefix = String.build do |io| - io.print "pry" - io.print '(' + @pry_reader.prompt_info = String.build do |io| unless owner.is_a?(Program) if owner.metaclass? io.print owner.instance_type @@ -1246,10 +1251,9 @@ class Crystal::Repl::Interpreter end end io.print compiled_def.def.name - io.print ')' end - input = prompt.prompt(prefix) + input = @pry_reader.read_next unless input self.pry = false break @@ -1284,10 +1288,13 @@ class Crystal::Repl::Interpreter end begin - line_node = prompt.parse( - input: input, + parser = Parser.new( + input, + string_pool: @context.program.string_pool, var_scopes: [meta_vars.keys.to_set], ) + line_node = parser.parse + next unless line_node main_visitor = MainVisitor.new(from_main_visitor: main_visitor) @@ -1317,7 +1324,8 @@ class Crystal::Repl::Interpreter # to their new type) local_vars = interpreter.local_vars - prompt.display(value) + print " => " + puts SyntaxHighlighter::Colorize.highlight!(value.to_s) rescue ex : EscapingException print "Unhandled exception: " print ex diff --git a/src/compiler/crystal/interpreter/prompt.cr b/src/compiler/crystal/interpreter/prompt.cr deleted file mode 100644 index ea6077cc4aa5..000000000000 --- a/src/compiler/crystal/interpreter/prompt.cr +++ /dev/null @@ -1,116 +0,0 @@ -# Allows reading a prompt for the interpreter. -class Crystal::Repl::Prompt - property line_number : Int32 - - def initialize(@context : Context, @show_nest : Bool) - @buffer = "" - @nest = 0 - @incomplete = false - @line_number = 1 - end - - # Asks for a line of input, prefixed with the given prefix. - # Returns nil if the user pressed CTRL-C. - def prompt(prefix) : String? - prompt = String.build do |io| - io.print prefix - if @show_nest - io.print ':' - io.print @nest - end - io.print(@incomplete ? '*' : '>') - io.print ' ' - if @nest == 0 && @incomplete - io.print " " - else - io.print " " * @nest if @nest > 0 - end - end - - print prompt - line = gets - return unless line - - if @context.program.color? - # Go back one line to print it again colored - print "\033[F" - print prompt - - colored_line = line - begin - colored_line = Crystal::SyntaxHighlighter::Colorize.highlight(colored_line) - rescue - # Ignore highlight errors - end - - puts colored_line - end - - new_buffer = - if @buffer.empty? - line - else - "#{@buffer}\n#{line}" - end - - new_buffer - end - - # Parses the given input with the given var_scopes. - # The input must be that returned from `#prompt`. - # Returns a parsed ASTNode if the input was valid Crystal syntax. - # If the input was partial Crystal syntax, `nil` is returned - # but the partial input is remembered. Next time `#prompt` is called, - # the returned value there will be the new complete input (what there - # was before plus the new input, separated by a new line). - def parse(input : String, var_scopes : Array(Set(String))) : ASTNode? - parser = Parser.new( - input, - string_pool: @context.program.string_pool, - var_scopes: var_scopes, - ) - - begin - node = parser.parse - - @nest = 0 - @buffer = "" - @line_number += 1 - @incomplete = false - - parser.warnings.report(STDOUT) - - node - rescue ex : Crystal::SyntaxException - # TODO: improve this - case ex.message - when "unexpected token: EOF", - "expecting identifier 'end', not 'EOF'" - @nest = parser.type_nest + parser.def_nest + parser.fun_nest - @buffer = input - @line_number += 1 - @incomplete = @nest == 0 - when "expecting token ']', not 'EOF'", - "unterminated array literal", - "unterminated hash literal", - "unterminated tuple literal" - @nest = parser.type_nest + parser.def_nest + parser.fun_nest - @buffer = input - @line_number += 1 - @incomplete = true - else - puts "Error: #{ex.message}" - @nest = 0 - @buffer = "" - @incomplete = false - end - nil - end - end - - # Displays a value, preceding it with "=> ". - def display(value : Value) - print "=> " - puts value - end -end diff --git a/src/compiler/crystal/interpreter/pry_reader.cr b/src/compiler/crystal/interpreter/pry_reader.cr new file mode 100644 index 000000000000..d7b57fcfaded --- /dev/null +++ b/src/compiler/crystal/interpreter/pry_reader.cr @@ -0,0 +1,34 @@ +require "./repl_reader" + +class Crystal::PryReader < Crystal::ReplReader + property prompt_info = "" + + def prompt(io, line_number, color?) + io << "pry(" + io << @prompt_info + io << ')' + + io.print(@incomplete ? '*' : '>') + io << ' ' + end + + def continue?(expression : String) : Bool + if expression == "*s" || expression == "*d" + @incomplete = false + else + super + end + end + + def on_ctrl_down + yield "next" + end + + def on_ctrl_left + yield "finish" + end + + def on_ctrl_right + yield "step" + end +end diff --git a/src/compiler/crystal/interpreter/repl.cr b/src/compiler/crystal/interpreter/repl.cr index 6a776f66c22f..93b3a6cef65c 100644 --- a/src/compiler/crystal/interpreter/repl.cr +++ b/src/compiler/crystal/interpreter/repl.cr @@ -6,7 +6,6 @@ class Crystal::Repl def initialize @program = Program.new @context = Context.new(@program) - @line_number = 1 @main_visitor = MainVisitor.new(@program) @interpreter = Interpreter.new(@context) @@ -15,52 +14,33 @@ class Crystal::Repl def run load_prelude - prompt = Prompt.new(@context, show_nest: true) + reader = ReplReader.new(repl: self) + reader.color = @context.program.color? - while true - input = prompt.prompt("icr:#{prompt.line_number}") - unless input - # Explicitly call exit on ctrl+D so at_exit handlers run - interpret_exit + reader.read_loop do |expression| + case expression + when "exit" break - end - - if input.blank? - prompt.line_number += 1 - next - end + when .presence + parser = new_parser(expression) + parser.warnings.report(STDOUT) - node = prompt.parse( - input: input, - var_scopes: [@interpreter.local_vars.names_at_block_level_zero.to_set], - ) - next unless node + node = parser.parse + next unless node - begin value = interpret(node) - prompt.display(value) - rescue ex : EscapingException - @nest = 0 - @buffer = "" - @line_number += 1 - - print "Unhandled exception: " - print ex - rescue ex : Crystal::CodeError - @nest = 0 - @buffer = "" - @line_number += 1 - - ex.color = true - ex.error_trace = true - puts ex - rescue ex : Exception - @nest = 0 - @buffer = "" - @line_number += 1 - - ex.inspect_with_backtrace(STDOUT) + print " => " + puts SyntaxHighlighter::Colorize.highlight!(value.to_s) end + rescue ex : EscapingException + print "Unhandled exception: " + print ex + rescue ex : Crystal::CodeError + ex.color = @context.program.color? + ex.error_trace = true + puts ex + rescue ex : Exception + ex.inspect_with_backtrace(STDOUT) end end @@ -155,4 +135,12 @@ class Crystal::Repl puts "Error while calling Crystal.exit: #{ex.message}" end end + + protected def new_parser(source) + Parser.new( + source, + string_pool: @context.program.string_pool, + var_scopes: [@interpreter.local_vars.names_at_block_level_zero.to_set] + ) + end end diff --git a/src/compiler/crystal/interpreter/repl_reader.cr b/src/compiler/crystal/interpreter/repl_reader.cr new file mode 100644 index 000000000000..535ec53e64a9 --- /dev/null +++ b/src/compiler/crystal/interpreter/repl_reader.cr @@ -0,0 +1,131 @@ +require "reply" + +class Crystal::ReplReader < Reply::Reader + KEYWORDS = %w( + abstract alias annotation asm begin break case class + def do else elsif end ensure enum extend for fun + if in include instance_sizeof lib macro module + next of offsetof out pointerof private protected require + rescue return select sizeof struct super + then type typeof union uninitialized unless until + verbatim when while with yield + ) + METHOD_KEYWORDS = %w(as as? is_a? nil? responds_to?) + CONTINUE_ERROR = [ + "expecting identifier 'end', not 'EOF'", + "expecting token 'CONST', not 'EOF'", + "expecting any of these tokens: IDENT, CONST, `, <<, <, <=, ==, ===, !=, =~, !~, >>, >, >=, +, -, *, /, //, !, ~, %, &, |, ^, **, [], []?, []=, <=>, &+, &-, &*, &** (not 'EOF')", + "expecting any of these tokens: ;, NEWLINE (not 'EOF')", + "expecting token ')', not 'EOF'", + "expecting token ']', not 'EOF'", + "expecting token '}', not 'EOF'", + "expecting token '%}', not 'EOF'", + "expecting token '}', not ','", + "expected '}' or named tuple name, not EOF", + "unexpected token: NEWLINE", + "unexpected token: EOF", + "unexpected token: EOF (expecting when, else or end)", + "unexpected token: EOF (expecting ',', ';' or '\n')", + "Unexpected EOF on heredoc identifier", + "unterminated parenthesized expression", + "unterminated call", + "Unterminated string literal", + "unterminated hash literal", + "Unterminated command literal", + "unterminated array literal", + "unterminated tuple literal", + "unterminated macro", + "Unterminated string interpolation", + "invalid trailing comma in call", + "unknown token: '\\u{0}'", + ] + @incomplete = false + @repl : Repl? + + def initialize(@repl = nil) + super() + + # `"`, `:`, `'`, are not a delimiter because symbols and strings are treated as one word. + # '=', !', '?' are not a delimiter because they could make part of method name. + self.word_delimiters = {{" \n\t+-*/,;@&%<>^\\[](){}|.~".chars}} + end + + def prompt(io : IO, line_number : Int32, color? : Bool) : Nil + io << "icr:" + io << line_number + + io.print(@incomplete ? '*' : '>') + io << ' ' + end + + def highlight(expression : String) : String + SyntaxHighlighter::Colorize.highlight!(expression) + end + + def continue?(expression : String) : Bool + new_parser(expression).parse + @incomplete = false + false + rescue e : CodeError + @incomplete = e.message.in?(CONTINUE_ERROR) + if (message = e.message) && message.matches? /Unterminated heredoc: can't find ".*" anywhere before the end of file/ + @incomplete = true + end + + @incomplete + end + + def format(expression : String) : String? + Crystal.format(expression).chomp rescue nil + end + + def indentation_level(expression_before_cursor : String) : Int32? + parser = new_parser(expression_before_cursor) + parser.parse rescue nil + + parser.type_nest + parser.def_nest + parser.fun_nest + end + + def reindent_line(line) + case line.strip + when "end", ")", "]", "}" + 0 + when "else", "elsif", "rescue", "ensure", "in", "when" + -1 + else + nil + end + end + + def save_in_history?(expression : String) : Bool + !expression.blank? + end + + def auto_complete(name_filter : String, expression : String) : {String, Array(String)} + if expression.ends_with? '.' + return "Keywords:", METHOD_KEYWORDS.dup + else + return "Keywords:", KEYWORDS.dup + end + end + + def auto_completion_display_title(io : IO, title : String) + io << title + end + + def auto_completion_display_selected_entry(io : IO, entry : String) + io << entry.colorize.red.bright + end + + def auto_completion_display_entry(io : IO, entry_matched : String, entry_remaining : String) + io << entry_matched.colorize.red.bright << entry_remaining + end + + private def new_parser(source) + if repl = @repl + repl.new_parser(source) + else + Parser.new(source) + end + end +end From 8ea318eb73302f439834e5d54341fcb5700fac2d Mon Sep 17 00:00:00 2001 From: I3oris Date: Mon, 14 Nov 2022 22:00:11 +0100 Subject: [PATCH 3/6] Make the implementation of `Reply::Term::Size` more portable. --- lib/reply/src/term_size.cr | 140 +++++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 35 deletions(-) diff --git a/lib/reply/src/term_size.cr b/lib/reply/src/term_size.cr index 48bad754021c..3482fef0019e 100644 --- a/lib/reply/src/term_size.cr +++ b/lib/reply/src/term_size.cr @@ -1,3 +1,103 @@ +# Implementation inspired from https://github.com/crystal-term/screen/blob/master/src/term-screen.cr. +module Reply::Term::Size + extend self + + DEFAULT_SIZE = {27, 80} + + def width + self.size[0] + end + + def height + self.size[1] + end + + private def check_size(size) + if size && (rows = size[0]) && (cols = size[1]) && rows != 0 && cols != 0 + {rows, cols} + end + end + + {% if flag?(:win32) %} + def size : {Int32, Int32} + check_size(size_from_screen_buffer) || + check_size(size_from_ansicon) || + DEFAULT_SIZE + end + + # Detect terminal size Windows GetConsoleScreenBufferInfo + private def size_from_screen_buffer + LibC.GetConsoleScreenBufferInfo(LibC.GetStdHandle(LibC::STD_OUTPUT_HANDLE), out csbi) + col = csbi.srWindow.right - csbi.srWindow.left + 1 + row = csbi.srWindow.bottom - csbi.srWindow.top + 1 + + {col.to_i32, row.to_i32} + end + + # Detect terminal size from Windows ANSICON + private def size_from_ansicon + return unless ENV["ANSICON"]?.to_s =~ /\((.*)x(.*)\)/ + + rows, cols = [$2, $1].map(&.to_i) + {rows, cols} + end + {% else %} + def size : {Int32, Int32} + size_from_ioctl(0) || # STDIN + size_from_ioctl(1) || # STDOUT + size_from_ioctl(2) || # STDERR + check_size(size_from_tput) || + check_size(size_from_stty) || + check_size(size_from_env) || + DEFAULT_SIZE + end + + # Read terminal size from Unix ioctl + private def size_from_ioctl(fd) + winsize = uninitialized LibC::Winsize + ret = LibC.ioctl(fd, LibC::TIOCGWINSZ, pointerof(winsize)) + return if ret < 0 + + {winsize.ws_col.to_i32, winsize.ws_row.to_i32} + end + + # Detect terminal size from tput utility + private def size_from_tput + return unless STDOUT.tty? + + lines = `tput lines`.to_i? + cols = `tput cols`.to_i? + + {lines, cols} + rescue + nil + end + + # Detect terminal size from stty utility + private def size_from_stty + return unless STDOUT.tty? + + parts = `stty size`.split(/\s+/) + return unless parts.size > 1 + lines, cols = parts.map(&.to_i?) + + {lines, cols} + rescue + nil + end + + # Detect terminal size from environment + private def size_from_env + return unless ENV["COLUMNS"]?.to_s =~ /^\d+$/ + + rows = ENV["LINES"]? || ENV["ROWS"]? + cols = ENV["COLUMNS"]? + + {rows.try &.to_i?, cols.try &.to_i?} + end + {% end %} +end + {% if flag?(:win32) %} lib LibC struct COORD @@ -25,23 +125,13 @@ fun GetConsoleScreenBufferInfo(hConsoleOutput : Void*, lpConsoleScreenBufferInfo : CONSOLE_SCREEN_BUFFER_INFO*) : Void fun GetStdHandle(nStdHandle : UInt32) : Void* end - - module Reply::Term::Size - def self.size : {Int32, Int32} - LibC.GetConsoleScreenBufferInfo(LibC.GetStdHandle(LibC::STD_OUTPUT_HANDLE), out csbi) - col = csbi.srWindow.right - csbi.srWindow.left + 1 - row = csbi.srWindow.bottom - csbi.srWindow.top + 1 - - {col.to_i32, row.to_i32} - end - end {% else %} lib LibC struct Winsize - row : LibC::Short - col : LibC::Short - x_pixel : LibC::Short - y_pixel : LibC::Short + ws_row : UShort + ws_col : UShort + ws_xpixel : UShort + ws_ypixel : UShort end # TIOCGWINSZ is a magic number passed to ioctl that requests the current @@ -55,26 +145,6 @@ {% end %} {% end %} - fun ioctl(fd : LibC::Int, request : LibC::SizeT, winsize : LibC::Winsize*) : LibC::Int - end - - module Reply::Term::Size - # Gets the terminals width - def self.size : {Int32, Int32} - ret = LibC.ioctl(1, LibC::TIOCGWINSZ, out screen_size) - raise "Error retrieving terminal size: ioctl TIOCGWINSZ: #{Errno.value}" if ret < 0 - - {screen_size.col.to_i32, screen_size.row.to_i32} - end + fun ioctl(fd : Int, request : ULong, ...) : Int end {% end %} - -module Reply::Term::Size - def self.width - size[0] - end - - def self.height - size[1] - end -end From d27caebb220d9fdfe477ff37979ebf6a493ea9a9 Mon Sep 17 00:00:00 2001 From: I3oris Date: Mon, 14 Nov 2022 22:08:05 +0100 Subject: [PATCH 4/6] Fix spec on windows due to '\n\r'. --- lib/reply/spec/history_spec.cr | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/reply/spec/history_spec.cr b/lib/reply/spec/history_spec.cr index 9d01953e4729..d553ea057e42 100644 --- a/lib/reply/spec/history_spec.cr +++ b/lib/reply/spec/history_spec.cr @@ -114,16 +114,16 @@ module Reply io = IO::Memory.new history.save(io) - io.to_s.should eq <<-'HISTORY' - foo - \\ - bar\ - "baz" \\\ - "\\n\\\\"\ - \ - \\ - a\\\\\\b - HISTORY + io.to_s.should eq( + %q(foo) + "\n" + + %q(\\) + "\n" + + %q(bar\) + "\n" + + %q("baz" \\\) + "\n" + + %q("\\n\\\\"\) + "\n" + + %q(\) + "\n" + + %q(\\) + "\n" + + %q(a\\\\\\b) + ) io.rewind history.load(io) From e386dd997cf11ad000c16f211ea26179442f3135 Mon Sep 17 00:00:00 2001 From: I3oris Date: Wed, 16 Nov 2022 22:04:36 +0100 Subject: [PATCH 5/6] Correct wrong order of `rows`/`cols` in alternative `Term::Size` solutions. --- lib/reply/src/term_size.cr | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/reply/src/term_size.cr b/lib/reply/src/term_size.cr index 3482fef0019e..d0a4c2e79699 100644 --- a/lib/reply/src/term_size.cr +++ b/lib/reply/src/term_size.cr @@ -2,7 +2,7 @@ module Reply::Term::Size extend self - DEFAULT_SIZE = {27, 80} + DEFAULT_SIZE = {80, 27} def width self.size[0] @@ -13,8 +13,8 @@ module Reply::Term::Size end private def check_size(size) - if size && (rows = size[0]) && (cols = size[1]) && rows != 0 && cols != 0 - {rows, cols} + if size && (cols = size[0]) && (rows = size[1]) && cols != 0 && rows != 0 + {cols, rows} end end @@ -28,10 +28,10 @@ module Reply::Term::Size # Detect terminal size Windows GetConsoleScreenBufferInfo private def size_from_screen_buffer LibC.GetConsoleScreenBufferInfo(LibC.GetStdHandle(LibC::STD_OUTPUT_HANDLE), out csbi) - col = csbi.srWindow.right - csbi.srWindow.left + 1 - row = csbi.srWindow.bottom - csbi.srWindow.top + 1 + cols = csbi.srWindow.right - csbi.srWindow.left + 1 + rows = csbi.srWindow.bottom - csbi.srWindow.top + 1 - {col.to_i32, row.to_i32} + {cols.to_i32, rows.to_i32} end # Detect terminal size from Windows ANSICON @@ -39,7 +39,7 @@ module Reply::Term::Size return unless ENV["ANSICON"]?.to_s =~ /\((.*)x(.*)\)/ rows, cols = [$2, $1].map(&.to_i) - {rows, cols} + {cols, rows} end {% else %} def size : {Int32, Int32} @@ -68,7 +68,7 @@ module Reply::Term::Size lines = `tput lines`.to_i? cols = `tput cols`.to_i? - {lines, cols} + {cols, lines} rescue nil end @@ -81,7 +81,7 @@ module Reply::Term::Size return unless parts.size > 1 lines, cols = parts.map(&.to_i?) - {lines, cols} + {cols, lines} rescue nil end @@ -93,7 +93,7 @@ module Reply::Term::Size rows = ENV["LINES"]? || ENV["ROWS"]? cols = ENV["COLUMNS"]? - {rows.try &.to_i?, cols.try &.to_i?} + {cols.try &.to_i?, rows.try &.to_i?} end {% end %} end From 62e9e802e4e422075b35994802e5afa9676848f0 Mon Sep 17 00:00:00 2001 From: I3oris Date: Wed, 23 Nov 2022 21:08:49 +0100 Subject: [PATCH 6/6] Compute the term-size only once for each input. Fix slow performance when the size is taken from `tput` (if `ioctl` fails). --- lib/reply/src/reader.cr | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/reply/src/reader.cr b/lib/reply/src/reader.cr index dafe84569e54..f8bb5bbb03fd 100644 --- a/lib/reply/src/reader.cr +++ b/lib/reply/src/reader.cr @@ -189,6 +189,8 @@ module Reply loop do read = @char_reader.read_char(from: io) + + @editor.width, @editor.height = Term::Size.size case read in Char then on_char(read) in String then on_string(read)