💾 Archived View for gemini.ctrl-c.club › ~nttp › toys › snaked.py captured on 2024-06-16 at 14:00:50.

View Raw

More Information

⬅️ Previous capture (2020-09-24)

-=-=-=-=-=-=-

#!/usr/bin/env python3
# coding=utf-8
# 
# Snaked: line editor written in and for Python.
# 2020-09-08 Felix Pleșoianu <https://felix.plesoianu.ro/>
# 
# 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.

"""Snaked: line editor written in and for Python."""

from __future__ import division
from __future__ import print_function

try:
	import readline
except ImportError as e:
	print("(Command line editing is unavailable.)\n")

import os
import sys
import glob
import shlex
import cmd

VERSION = "1.3.1"

app_banner = """
Welcome to Snaked, a line editor written in and for Python,
version {}. Type ? to see a list of commands.
Prefix lines with > to append text, or with . to insert.
""".format(VERSION)

help_text = {}
help_text["about"] = """
Snaked is a line editor with some less usual features:

- modern user interface, with familiar commands in plain English;
- ability to open multiple files and shuffle text between them;
- commands for running arbitrary Python code;
- operating system interface, mainly for directory navigation;
- built-in help for all commands plus adjacent topics.

Editing text from the command line doesn't have to be cryptic!
"""
help_text["cursor"] = """
The cursor is a number indicating where new lines are inserted.
When opening a file, the cursor is placed at the end by default.
It can be set with the `at` command, or implicitly as part of
navigation, search, cut / copy / paste and so on.

The cursor is always between one and the file's line count.
It never becomes smaller than the mark during normal operation.
"""
help_text["mark"] = """
The mark is a numeric index that sits opposite the cursor to
define the selection. When opening a file it's set to the end by
default. Several commands set the mark implicitly: those for
navigation, search and editing.

The mark is always between one and the file's line count.
It never becomes larger than the cursor during normal operation.
"""
help_text["ranges"] = """
Several commands accept a range of line numbers. Valid ranges include:

	10-
	-20
	10-20
	-
	15
	1-end

Note that lines are numbered from 1, and ranges are inclusive.
"""
help_text["selection"] = """
In Snaked, the selection is simply all lines between mark and
cursor, inclusively. That can be none at all, if they happen to
be at the same position. Navigation and search commands always
set the selection. Commands like cut / copy / paste work on the
selection if no explicit range is given. Pasting text replaces
the selection if any, while inserting lines extends it instead.

Since the `at` command sets both mark and cursor to the same
position in the file, it effectively clears the selection.
"""

def parse_range(text, limit):
	if text.isdigit():
		n = parse_ln(text, limit)
		return n, n
	ln = text.split("-")
	if len(ln) == 2:
		return parse_ln(ln[0], limit), parse_ln(ln[1], limit)
	else:
		raise ValueError("Bad range: " + text) 

def parse_ln(text, limit):
	if text == "":
		return 0
	elif text == "end":
		return limit
	elif text.isdigit():
		return min(int(text), limit)
	else:
		raise ValueError("Bad range index: " + text)

def shell_parse(text):
	try:
		args = shlex.split(text)
	except ValueError as e:
		print(e)
		return None
	if len(args) == 0:
		return ""
	elif len(args) == 1:
		return args[0]
	else:
		print("Too many arguments.")
		return None

class TextFile:
	def __init__(self, name=None):
		self.name = name
		self.lines = []
		self.mark = 0
		self.cursor = 0
		self.modified = False

	def __str__(self):
		return self.name if self.name != None else "(untitled)"

	def canonize(self):
		if self.name == None:
			raise RuntimeError("Text file is untitled.")
		else:
			self.name = os.path.abspath(
				os.path.expanduser(self.name))
			return self

	def load(self):
		if self.name == None:
			raise RuntimeError("Text file is untitled.")
		else:
			with open(self.name, "r") as f:
				lines = f.readlines()
			self.lines = [i.rstrip("\n") for i in lines]
			self.mark = len(lines)
			self.cursor = self.mark
			return self

class Editor(cmd.Cmd):
	#intro = app_banner
	prompt = ""
	
	def __init__(self, quiet=False, safe=False, strict=False):
		cmd.Cmd.__init__(self)
		self.files = [TextFile()]
		self.current = 0
		self.step_size = 20
		self.clipboard = []
		self.search_string = ""
		self.quiet = bool(quiet)
		self.safe = bool(safe)
		self.strict = bool(strict)
	
	@property
	def modified(self):
		"""Return true if any open file is modified."""
		return any(i.modified for i in self.files)
	
	def do_eval(self, args):
		"""Evaluate a Python expression and show the result."""
		if self.safe:
			print("Command disabled in safe mode: eval.")
		else:
			try:
				print(eval(args))
			except Exception as e:
				print(e)
	
	def do_exec(self, args):
		"""Execute arbitrary Python code exactly as entered."""
		if self.safe:
			print("Command disabled in safe mode: exec.")
		else:
			try:
				exec(args)
			except Exception as e:
				print(e)
	
	def do_pwd(self, args):
		"""Print out the current working directory."""
		print(os.getcwd())
	
	def do_dir(self, args):
		"""List contents of the current working directory. """
		if args == "":
			args = "*"
		print("\n".join(sorted(glob.glob(args))))
	
	def do_cd(self, args):
		"""Change current working directory to given path."""
		args = shell_parse(args)
		if args == None:
			return
		elif args == "":
			args = "~"
		try:
			os.chdir(os.path.expanduser(args))
			if not self.quiet:
				print("Now working in", os.getcwd())
		except OSError as e:
			print("Can't change directory:", e,
				file=sys.stderr)
	
	def complete_cd(self, text, line, begidx, endidx):
		return glob.glob(os.path.expanduser(text) + "*")
	
	def do_new(self, args):
		"""Start a new file and set it as current."""
		if self.strict:
			print("Command disabled in strict mode: new")
		else:
			self.current = len(self.files)
			self.files.append(TextFile())
			if not self.quiet: print("New file created.")
	
	def open_file(self, fn):
		try:
			f = TextFile(fn).canonize().load()
			self.current = len(self.files)
			self.files.append(f)
			return True
		except OSError as e:
			print("Can't open file:", e, file=sys.stderr)
			return False
		except IOError as e:
			print("Can't open file:", e, file=sys.stderr)
			return False
	
	def do_open(self, args):
		"""Open a file from disc or list open files."""
		if self.strict:
			print("Command disabled in strict mode: open")
			return
		args = shell_parse(args)
		if args == None:
			return
		elif args == "":
			for i, f in enumerate(self.files):
				print("{:7d} {}".format(i + 1, f),
					end='')
				if i == self.current:
					print(" (current)")
				else:
					print()
			return
		elif self.open_file(args):
			if not self.quiet:
				print("Opened",
					self.files[self.current].name)

	
	def complete_open(self, text, line, begidx, endidx):
		return glob.glob(os.path.expanduser(text) + "*")
	
	def do_close(self, args):
		"""Close current file and set next file as current."""
		if self.strict:
			print("Command disabled in strict mode: close")
			return
		elif self.files[self.current].modified and args != "!":
			print("File was modified.",
				"Enter `close !` to override.")
			return
		elif not self.quiet:
			print("Closing file",
				self.files[self.current])
		del self.files[self.current]
		if len(self.files) == 0:
			self.files.append(TextFile())
		if self.current >= len(self.files):
			self.current = 0
		if not self.quiet:
			print("Current file now",
				self.files[self.current])
	
	def do_save(self, args):
		"""Save current file, possibly under a new name."""
		args = shell_parse(args)
		if args == None:
			return
		elif args != "":
			args = os.path.abspath(
				os.path.expanduser(args))
		elif self.files[self.current].name != None:
			args = self.files[self.current].name
		else:
			print("File not saved yet."
				"Please save with name.")
			return
		
		lines = self.files[self.current].lines
		lines = [i + "\n" for i in lines]
		
		try:
			with open(args, "w") as f:
				f.writelines(lines)
			self.files[self.current].name = args
			self.files[self.current].modified = False
			if not self.quiet: print("Saved", args)
		except OSError as e:
			print("Can't save file:", e, file=sys.stderr)
		except IOError as e:
			print("Can't save file:", e, file=sys.stderr)
	
	def complete_save(self, text, line, begidx, endidx):
		return glob.glob(os.path.expanduser(text) + "*")
	
	def do_reload(self, args):
		"""Reload current file from disk if present."""
		cf = self.files[self.current]
		if cf.name == None:
			print("File is new and unsaved.")
		elif not os.path.exists(cf.name):
			print("File not present on disk.")
		elif args != "!":
			print("Enter `reload !` to confirm.")
		else:
			cf.load()
			cf.modified = False
			if not self.quiet: print("Reloaded", cf.name)
	
	def do_focus(self, args):
		"""Show or set current file by index."""
		if args == "":
			i = self.current
			f = self.files[i]
			print("Currently working on file:")
			print("{:2d} {}".format(i + 1, f))
		elif args.isdigit():
			self.current = int(args) - 1
			if self.current < 0:
				self.current = 0
			elif self.current >= len(self.files):
				self.current = len(self.files) - 1
		else:
			print("File number expected.",
				file=sys.stderr)
	
	def do_at(self, args):
		"""Show or set the current insertion cursor."""
		cf = self.files[self.current]
		if args == "":
			print("Currently inserting at line:",
				cf.cursor + 1)
		elif args.isdigit():
			cf.mark = int(args) - 1
			if cf.mark < 0:
				cf.mark = 0
			elif cf.mark > len(cf.lines):
				cf.mark = len(cf.lines)
			cf.cursor = cf.mark
		elif args == "end":
			cf.mark = len(cf.lines)
			cf.cursor = cf.mark
		else:
			print("Line number expected.",
				file=sys.stderr)
	
	def help_about(self):
		print(help_text["about"])
	
	def help_cursor(self):
		print(help_text["cursor"])
	
	def help_mark(self):
		print(help_text["mark"])
	
	def help_ranges(self):
		print(help_text["ranges"])
	
	def help_selection(self):
		print(help_text["selection"])
	
	def select_range(self, text):
		try:
			cf = self.files[self.current]
			m, c = parse_range(text, len(cf.lines))
			if c == 0: c = len(cf.lines)
			if m > c: m, c = c, m
			if m > 0: m -= 1
			cf.mark, cf.cursor = m, c
		except ValueError as e:
			print(e, file=sys.stderr)
	
	def print_selection(self):
		cf = self.files[self.current]
		selection = cf.lines[cf.mark:cf.cursor]
		for i, ln in enumerate(selection, cf.mark):
			print("{:7d}".format(i + 1), ln)
	
	def do_first(self, args):
		"""List the first N lines in the current file."""
		cf = self.files[self.current]
		if args == "":
			cf.mark = 0
			cf.cursor = self.step_size
			self.print_selection()
		elif args.isdigit():
			cf.mark = 0
			cf.cursor = min(int(args), len(cf.lines))
			self.print_selection()
		else:
			print("Line number expected.",
				file=sys.stderr)
	
	def do_next(self, args):
		"""List the next N lines in the current file."""
		cf = self.files[self.current]
		ll = len(cf.lines)
		if cf.mark >= ll:
			print("(already at end of file)")
		elif args == "":
			cf.mark = cf.cursor
			cf.cursor = min(cf.mark + self.step_size, ll)
			self.print_selection()
		elif args.isdigit():
			cf.mark = cf.cursor
			cf.cursor = min(cf.mark + int(args), ll)
			self.print_selection()
		else:
			print("Line number expected.",
				file=sys.stderr)
	
	def do_last(self, args):
		"""List the last N lines in the current file."""
		cf = self.files[self.current]
		if args == "":
			cf.mark = max(0,
				len(cf.lines) - self.step_size)
			cf.cursor = len(cf.lines)
			self.print_selection()
		elif args.isdigit():
			cf.mark = max(len(cf.lines) - int(args), 0)
			cf.cursor = len(cf.lines)
			self.print_selection()
		else:
			print("Line number expected.",
				file=sys.stderr)
	
	def do_lines(self, args):
		"""List lines in given range from current file."""
		if args == "":
			print("Line range expected.",
				file=sys.stderr)
			return
		try:
			self.select_range(args)
			self.print_selection()
		except ValueError as e:
			print(e, file=sys.stderr)
	
	def do_delete(self, args):
		"""Delete all lines in selection or given range."""
		if args != "":
			self.select_range(args)
		cf = self.files[self.current]
		if not self.quiet:
			print(len(cf.lines[cf.mark:cf.cursor]),
				"lines deleted.")
		del cf.lines[cf.mark:cf.cursor]
		cf.cursor = cf.mark
		cf.modified = True
	
	def do_cut(self, args):
		"""Cut selection or given range to clipboard."""
		if args != "":
			self.select_range(args)
		cf = self.files[self.current]
		self.clipboard = cf.lines[cf.mark:cf.cursor]
		del cf.lines[cf.mark:cf.cursor]
		cf.cursor = cf.mark
		cf.modified = True
		if not self.quiet:
			print(len(self.clipboard),
				"lines cut to clipboard.")
	
	def do_copy(self, args):
		"""Copy selection or given range to clipboard."""
		if args != "":
			self.select_range(args)
		cf = self.files[self.current]
		self.clipboard = cf.lines[cf.mark:cf.cursor]
		if not self.quiet:
			print(len(self.clipboard),
				"lines copied to clipboard.")
	
	def do_paste(self, args):
		"""Paste contents of clipboard at cursor position."""
		if len(self.clipboard) == 0:
			print("Clipboard is empty.")
		else:
			cf = self.files[self.current]
			cf.lines[cf.mark:cf.cursor] = self.clipboard
			cf.modified = True
			if not self.quiet:
				print(len(self.clipboard),
					"lines pasted.")
	
	def do_back(self, args):
		"""Delete the last line in the selection, if any."""
		cf = self.files[self.current]
		if cf.cursor > cf.mark:
			cf.cursor -= 1
			del cf.lines[cf.cursor]
			cf.modified = True
			if not self.quiet:
				print("Previous line deleted.")
		else:
			print("No more lines in selection.")
	
	def do_find(self, args):
		"""Start a search or (with no argument) continue it."""
		cf = self.files[self.current]
		args = shell_parse(args)
		if args == None:
			return
		elif args != "":
			self.search_string = args
			for i, ln in enumerate(cf.lines):
				if self.search_string in ln:
					cf.mark = i
					cf.cursor = i + 1
					self.print_selection()
					return
			print("Search string not found.")
			self.search_string = ""
		elif self.search_string == "":
			print("No search string given.")
		else:
			lines = cf.lines[cf.cursor:]
			for i, ln in enumerate(lines, cf.cursor):
				if self.search_string in ln:
					cf.mark = i
					cf.cursor = i + 1
					self.print_selection()
					return
			print("Search string not found.")
			self.search_string = ""
	
	def do_replace(self, args):
		"""Replace found string with arg in selected line."""
		if self.search_string == "":
			print("No search in progress.")
		else:
			args = shell_parse(args)
			if args == None: return
			cf = self.files[self.current]
			ln = cf.lines[cf.mark]
			ln = ln.replace(self.search_string, args)
			cf.lines[cf.mark] = ln
			cf.modified = True
			if not self.quiet:
				print("Replaced all in line",
					cf.mark + 1)
	
	def do_shell(self, args):
		"""Run the given operating system command as-is."""
		if self.safe:
			print("Command disabled in safe mode: shell.")
		else:
			os.system(args)
	
	def do_quit(self, args):
		"""Quit program and return to the operating system."""
		if self.modified and args != "!":
			print("Some files were modified.",
				"Enter `quit !` to quit anyway.")
			return False
		if not self.quiet:
			print("\nThanks for using Snaked!",
				"See you around!")
		return True
	
	def default(self, line):
		if line == "quit!" or line == "q!":
			return self.do_quit("!")
		elif line == "wq":
			self.do_save("")
			return self.do_quit("")
		elif line == "q":
			return self.do_quit("")
		elif line[0] == "/":
			self.do_find(line[1:])
		elif line == "n":
			self.do_find("")
		elif line[0] == '>':
			cf = self.files[self.current]
			if cf.cursor < len(cf.lines):
				cf.mark = len(cf.lines)
				cf.cursor = cf.mark
			cf.lines.append(line[1:])
			cf.cursor += 1
			cf.modified = True
		elif line[0] == '.':
			cf = self.files[self.current]
			cf.lines.insert(cf.cursor, line[1:])
			cf.cursor += 1
			cf.modified = True
		elif line == "EOF":
			return self.do_quit("")
		else:
			print("\nUnknown command.",
				"Type `help` for a list.")

	def emptyline(self):
		print("No command given.")

if __name__ == "__main__":
	import argparse

	cmdline = argparse.ArgumentParser(
		description="Line editor written in and for Python.")
	cmdline.add_argument("-v", "--version", action="version",
		version="Snaked version {}.".format(VERSION))
	cmdline.add_argument("-q", "--quiet", action="store_true",
		help="don't print confirmation messages")
	cmdline.add_argument("-r", "--strict", action="store_true",
		help="disable new, open and close commands")
	cmdline.add_argument("-s", "--safe", action="store_true",
		help="disable access to Python and the OS")
	cmdline.add_argument("file", nargs="*",
		help="open given file before prompting for commands")
	
	args = cmdline.parse_args()
	
	editor = Editor(
		quiet=args.quiet,
		safe=args.safe,
		strict=args.strict)

	if len(args.file) > 0:
		for i in args.file:
			if os.path.exists(i):
				editor.open_file(i)
			else:
				f = TextFile(i).canonize()
				editor.current = len(editor.files)
				editor.files.append(f)
		if len(editor.files) > 1:
			# No need for the default new file anymore.
			del editor.files[0]
			editor.current = 0
	
	if editor.quiet:
		editor.cmdloop(None)
	else:
		editor.cmdloop(app_banner)