lib/tty/editor.rb in tty-editor-0.5.1 vs lib/tty/editor.rb in tty-editor-0.6.0

- old
+ new

@@ -1,134 +1,155 @@ # frozen_string_literal: true -require 'tty-prompt' -require 'tty-which' -require 'tempfile' -require 'fileutils' -require 'shellwords' +require "fileutils" +require "shellwords" +require "tempfile" +require "tty-prompt" -require_relative 'editor/version' +require_relative "editor/version" module TTY # A class responsible for launching an editor # # @api public class Editor + Error = Class.new(StandardError) + + # Raised when user provides unnexpected or incorrect argument + InvalidArgumentError = Class.new(Error) + # Raised when command cannot be invoked class CommandInvocationError < RuntimeError; end # Raised when editor cannot be found class EditorNotFoundError < RuntimeError; end - # Check if editor exists + # List possible command line text editors # - # @return [Boolean] + # @return [Array[String]] # - # @api private - def self.exist?(cmd) - TTY::Which.exist?(cmd) - end + # @api public + EXECUTABLES = [ + "nano -w", "notepad", "vim", "vi", "emacs", + "code", "subl -n -w", "mate -w", "atom", + "pico", "qe", "mg", "jed" + ].freeze - # Check if Windowz + # Check if editor command exists # + # @example + # exist?("vim") # => true + # # @return [Boolean] # - # @api public - def self.windows? - ::File::ALT_SEPARATOR == "\\" + # @api private + def self.exist?(command) + exts = ENV.fetch("PATHEXT", "").split(::File::PATH_SEPARATOR) + ENV.fetch("PATH", "").split(::File::PATH_SEPARATOR).any? do |dir| + file = ::File.join(dir, command) + ::File.exist?(file) || exts.any? { |ext| ::File.exist?("#{file}#{ext}") } + end end # Check editor from environment variables # # @return [Array[String]] # # @api public def self.from_env - [ENV['VISUAL'], ENV['EDITOR']].compact + [ENV["VISUAL"], ENV["EDITOR"]].compact end - # List possible executable for editor command + # Find available text editors # - # @return [Array[String]] - # - # @api public - def self.executables - ['vim', 'vi', 'emacs', 'nano', 'nano-tiny', 'pico', 'mate -w'] - end - - # Find available command - # # @param [Array[String]] commands # the commands to use intstead of defaults # # @return [Array[String]] + # the existing editor commands # # @api public def self.available(*commands) - return commands unless commands.empty? - - if !from_env.all?(&:empty?) - [from_env.find { |e| !e.empty? }] - elsif windows? - ['notepad'] - else - executables.uniq.select(&method(:exist?)) - end + execs = if !commands.empty? + commands.map(&:to_s) + elsif from_env.any? + [from_env.first] + else + EXECUTABLES + end + execs.compact.map(&:strip).reject(&:empty?).uniq + .select { |exec| exist?(exec.split.first) } end # Open file in system editor # # @example - # TTY::Editor.open('filename.rb') + # TTY::Editor.open("/path/to/filename") # - # @param [String] file - # the name of the file + # @example + # TTY::Editor.open("file1", "file2", "file3") # + # @example + # TTY::Editor.open(text: "Some text") + # + # @param [Array<String>] files + # the files to open in an editor + # @param [String] :command + # the editor command to use, by default auto detects + # @param [String] :text + # the text to edit in an editor + # @param [Hash] :env + # environment variables to forward to the editor + # # @return [Object] # # @api public - def self.open(*args) - editor = new(*args) - - yield(editor) if block_given? - - editor.open + def self.open(*files, text: nil, **options, &block) + editor = new(**options, &block) + editor.open(*files, text: text) end # Initialize an Editor # - # @param [String] file - # @param [Hash[Symbol]] options - # @option options [Hash] :command + # @param [String] :command # the editor command to use, by default auto detects - # @option options [Hash] :env + # @param [Hash] :env # environment variables to forward to the editor + # @param [IO] :input + # the standard input + # @param [IO] :output + # the standard output + # @param [Boolean] :raise_on_failure + # whether or not raise on command failure, false by default + # @param [Boolean] :show_menu + # whether or not show commands menu, true by default # # @api public - def initialize(*args, **options) - @filename = args.unshift.first - @env = options.fetch(:env) { {} } - @command = options[:command] - if @filename - if ::File.exist?(@filename) && !::FileTest.file?(@filename) - raise ArgumentError, "Don't know how to handle `#{@filename}`. " \ - "Please provida a file path or content" - elsif ::File.exist?(@filename) && !options[:content].to_s.empty? - ::File.open(@filename, 'a') { |f| f.write(options[:content]) } - elsif !::File.exist?(@filename) - ::File.write(@filename, options[:content]) - end - elsif options[:content] - @filename = tempfile_path(options[:content]) - end + def initialize(command: nil, raise_on_failure: false, show_menu: true, + prompt: "Select an editor?", env: {}, + input: $stdin, output: $stdout, &block) + @env = env + @command = nil + @input = input + @output = output + @raise_on_failure = raise_on_failure + @show_menu = show_menu + @prompt = prompt + + block.(self) if block + + command(*Array(command)) end # Read or update environment vars # + # @return [Hash] + # # @api public def env(value = (not_set = true)) return @env if not_set + @env = value end # Finds command using a configured command(s) or detected shell commands. # @@ -144,66 +165,118 @@ return @command if @command && commands.empty? execs = self.class.available(*commands) if execs.empty? raise EditorNotFoundError, - 'Could not find editor to use. Please specify $VISUAL or $EDITOR' + "could not find a text editor to use. Please specify $VISUAL or "\ + "$EDITOR or install one of the following editors: " \ + "#{EXECUTABLES.map { |ed| ed.split.first }.join(", ")}." end - exec = choose_exec_from(execs) - @command = TTY::Which.which(exec.to_s) + @command = choose_exec_from(execs) end + # Run editor command in a shell + # + # @param [Array<String>] files + # the files to open in an editor + # @param [String] :text + # the text to edit in an editor + # + # @raise [TTY::CommandInvocationError] + # + # @return [Boolean] + # whether editor command suceeded or not + # # @api private - def choose_exec_from(execs) - if execs.size > 1 - prompt = TTY::Prompt.new - prompt.enum_select('Select an editor?', execs) - else - execs[0] + def open(*files, text: nil) + validate_arguments(files, text) + text_written = false + + filepaths = files.reduce([]) do |paths, filename| + if !::File.exist?(filename) + ::File.write(filename, text || "") + text_written = true + end + paths + [filename] end + + if !text.nil? && !text_written + tempfile = create_tempfile(text) + filepaths << tempfile.path + end + + run(filepaths) + ensure + tempfile.unlink if tempfile end - # Escape file path + private + + # Run editor command with file arguments # + # @param [Array<String>] filepaths + # the file paths to open in an editor + # + # @return [Boolean] + # whether command succeeded or not + # # @api private - def escape_file - Shellwords.shellescape(@filename) + def run(filepaths) + command_path = "#{command} #{filepaths.shelljoin}" + status = system(env, *Shellwords.split(command_path)) + if @raise_on_failure && !status + raise CommandInvocationError, + "`#{command_path}` failed with status: #{$? ? $?.exitstatus : nil}" + end + !!status end - # Build command path to invoke + # Check if filename and text arguments are valid # - # @return [String] + # @raise [InvalidArgumentError] # # @api private - def command_path - "#{command} #{escape_file}" + def validate_arguments(files, text) + return if files.empty? + + if files.all? { |file| ::File.exist?(file) } && !text.nil? + raise InvalidArgumentError, + "cannot give a path to an existing file and text at the same time." + elsif filename = files.find { |file| ::File.exist?(file) && !::FileTest.file?(file) } + raise InvalidArgumentError, "don't know how to handle `#{filename}`. " \ + "Please provide a file path or text" + end end - # Create tempfile with content + # Create tempfile with text # - # @param [String] content + # @param [String] text # - # @return [String] + # @return [Tempfile] + # # @api private - def tempfile_path(content) - tempfile = Tempfile.new('tty-editor') - tempfile << content + def create_tempfile(text) + tempfile = Tempfile.new("tty-editor") + tempfile << text tempfile.flush - unless tempfile.nil? - tempfile.close - end - tempfile.path + tempfile.close + tempfile end - # Inovke editor command in a shell + # Render an editor selection prompt to the terminal # - # @raise [TTY::CommandInvocationError] + # @return [String] + # the chosen editor # # @api private - def open - status = system(env, *Shellwords.split(command_path)) - return status if status - fail CommandInvocationError, - "`#{command_path}` failed with status: #{$? ? $?.exitstatus : nil}" + def choose_exec_from(execs) + if @show_menu && execs.size > 1 + prompt = TTY::Prompt.new(input: @input, output: @output, env: @env) + exec = prompt.enum_select(@prompt, execs) + @output.print(prompt.cursor.up + prompt.cursor.clear_line) + exec + else + execs[0] + end end end # Editor end # TTY