/* NED (NotED) text editor ======================= NED is ED [1] like text editor but instead of being line oriented it is cursor driven with Point and Mark cursors for edits and selection. With Point free to move between words it's easy to edit long lines. Deliberately quiet giving visual feedback only when asked, with single letter commands that can be chained in any order to produce complex actions. Integration with shell commands enables extensibility. [1] https://wikipedia.org/wiki/Ed_(text_editor) Build and run ------------- Written in c89, should compile with any C compiler but I'm yet to test small compilers like TCC and compile on BSD systems. $ cc -o ned ned.c # compile $ ./ned -h # print usage help message $ ./ned -v # version, author and license $ ./ned # run with empty file $ ./ned ned.c # run and open ned.c source file Concepts -------- Point Main cursor used to move around and insert text. Mark Second cursor placed at Point with ',' command. Region AKA selection is a region between Point and Mark. Dirt File is dirty when it was modified and not saved. Clip Clipboard used to yank (copy) and paste text. Prompt AKA input, list of commands send to standard input. Cmd Single command, usualy single character, in prompt. Arg Optional argument that some commands take. Seq Sequence of commands in single input. Commands -------- q Quit editor. Fails when file is dirty. Use "*q" to force. e[NAME] Edit file. Without argument current file will be reopen. With argument, being relative path to file, new file will be open. Can fail when currently open file is dirty, force with "*e". w Write changes to drive. * Mark file as not dirty regardless of the current state. h Help by printing last error message depicted by "?". p Print selection. l Print full line that Point is on. n Like "l" command but prefix line with line number. : Print Point column number in current line. f Print open file name. Suffix with "*" when file is dirty. Goto line number placing Point at the beginning of the line. Relative number start either with "+" or "-". The short for "+1" is just "+" and short for "-1" is just "-". 0 Move Point at the last possible position after last byte. .[NUM] Move point to absolute byte position or by relative amount. Without argument print current point position. ^ Move point to beginning of the line. $ Move point to end of the line. % Move point to matching bracket. Works only when point is on one of ()[]{}<>. /[RE/] Move point to next occurence of regex. Use previous search phrase when argument is ommited. ?[RE?] Same as previous search command but search backwards. Both search commands reuse the same previous command when argument is omitted. i[IN] Insert text. Without argument you enter insert mode where each typed line is being inserted exactyl as it is. Terminate input session with single dot, or use single comma to insert that block of text without last new line character. With argument you can insert text without going into insert mode. Wrap argument with double quotes, escaping other double quotes inside when needed. You can also escape new line, tab and insert capture groups from last serach result. i First line Second line . i Third line Forth line without trailing new line character , i"Insert inline" i"Escape \"quote\"" i"\n new line and \t tab" i"Insert \0 \1 .. \9 capture groups from last search" d Delete selection. y Yank (copy) selection. x Paste selection at point. , Place mark cursor at current point cursor position. ; Swap mark with point. s Select next search. After this command is called the next search result will be selected making it possible to do find and replace. s/RE/di"str" # enable select, search, delete, insert s?RE?d # enable select, search backwards, delete s//p # find, select and print content of HTML tag ! Run shell command. Without argument run previous command. | Run shell command passing selection as standard input and replacing selection with standard output. @[NUM] Repeat input. Can only be placed as first command in prompt. Without argument it repeats commands 10000 times, with argument it repeats commands given number of times. Repeat loop can be broken on error. It's a way to for example find all occurences of regex in file or do global find and replace. 1 # position at the start of the file @s/RE/di"str" # global find and replace 1 @10/TODO/n # find 10 next TODOs @20n+ # print next 20 lines with line number Motivation ---------- After looking at BusyBox [1] implementation of basic Ed [2] I thought that it is a fun weekend project especially that I have been using Ed daily since 2020 as my $EDITOR env variable when using git or pass. Quite quickly I realized that writing basic Ed is super simple so instead of making a pure clone I decided to add a spin to it breaking with line orinted editing tradition. Although program is simple and implementation short it was not easy to make. Finding minimal set of commands which can accomplish basic text editing tasks and making them composable producing complex bhaviour was difficult. Few commands where made specially for line oriented workflow as this is the most common scenario and it helps to have those commands when transitioning from Ed to Ned. But most commands work regardless of file structure. It's easy to edit in the middle of long lines or code that spreads logically across multiple lines (like Lisp). Possible command combinations are endles. Ned accomplished that by doing as little as possible. First version was twice as long but as I kept working on it the simpler patters where revealed. Now it has less than 500 lines of code (measured with cloc [3]); > It's amazing how productive doing nothing can be. > - Kevin Flynn [1] https://www.busybox.net/ [2] https://github.com/mirror/busybox/blob/master/editors/ed.c [3] https://github.com/AlDanial/cloc Thoughts -------- When working on Ned I was frustrated that my commands require more typing than Ed commands to achieve the same result. This lead me to realization that all cursor driven editors require more typing. For example in Ed to change third line you type "3c". Where in Emacs, Vim or anything else this always require more typing which gets even more apprent when Ed global commands are examined like "g/TODO/d". This is a natural result of Ed being line oriented. Line oriented tasks will always be easier in line oriented tool. Another example I want to bring is regex reverse search. Searching backwards with regular expressions is difficult as regular expression engines in general don't work backwards. In Ned reverse search is very slow as it checks for all matches from top of the file to the current cursor position remembering last that it found. In Ed where each line is a separate string in double linked list this is trivial as searchgin going through lines forward or backwards is essentially the same. When using Ned I realized that all I need is Ed . Often when I could benefit from Ned free moving cursors I prefered to rewrite whole line anyway and the "rewrite whole line" strategy is the solution to most Ed shortcomings of being line oriented. The only thing that is extreamly difficult in Ed and very easy in Ned is editing logical parts of code that spreads across multiple lines. Still I don't see myself switching to Ned even tho I have made it exacly like I wanted and I'm very happy with end result. License ------- Copyright (C) 2026 irek@gabr.pl This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, see https://www.gnu.org/licenses */ #define VERSION "v1.0" #include #include #include #include #include #include /* Regex search adds a bunch of unexpected complexity. Open file content is stored in g.text as string of g.end size and null terminated but only because regex.h stdlib requires it. Other functions relay solely on g.end ignoring null. Null-terminator is maintained by "e", "i" and "d" as those commands change g.text size. Other strings in g struct are regular null-terminated strings. */ #define LEN(arr) (int)((sizeof (arr)) / (sizeof (arr)[0])) typedef const char* Err; /* error message */ typedef size_t Pos; /* cursor position in text */ static struct { /* global state */ int dirty; /* non 0 for not saved file */ int select; /* non 0 selects next search */ Err error; /* last error */ char last[4096]; /* last input */ char* search; /* last search phrase */ char* match[10]; /* last search matches */ char* clip; /* clipboard */ char* name; /* relative file path */ char* text; /* file content */ Pos end; /* text size, last byte pos */ Pos point; /* text cursor */ Pos mark; /* mark cursor */ } g = {0}; static Pos getlinebeg (); static Pos getlineend (); static Pos consumenum (char**); static char* consumearg (char**); static char* consumeregex (char**, char delimiter); static void onprompt (char*); static Err runcmd (char**); static Err gotobracket (); static Err search (char*, int reverse); static Err insert (char*); Pos getlinebeg() { Pos p = g.point; while (p--) if (g.text[p] == '\n') return p + 1; return 0; } Pos getlineend() { Pos p = g.point; for (; p < g.end; p++) if (g.text[p] == '\n') return p; return g.end; } Pos consumenum(char **str) { Pos n=0; int skip=0; sscanf(*str, "%lu%n", &n, &skip); *str += skip; return n; } char* consumearg(char **str) { static char buf[4096]; Pos len, si=0, bi=0; char *src, *match; src = *str; if (src[0] == '"') si++; while (bi < LEN(buf)-1) { if (src[si] == '\\') { switch (src[++si]) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': match = g.match[src[si] - '0']; len = strlen(match); if (len > LEN(buf) - bi - 1) len = LEN(buf) - bi - 1; memcpy(buf + bi, match, len); bi += len; break; case 't': buf[bi++] = '\t'; break; case 'n': buf[bi++] = '\n'; break; default: buf[bi++] = src[si]; } si++; continue; } if (src[0] == '"' && src[si] == src[0]) si++; if (src[si] <= ' ') break; buf[bi++] = src[si++]; } buf[bi] = 0; (*str) += si; return buf; } char* consumeregex(char **pt, char delimiter) { static char buf[4096]; int pi=0, bi=0; while (bi < LEN(buf) - 1) { if ((*pt)[pi] == '\\' && (*pt)[pi+1] == delimiter) pi++; else if ((*pt)[pi] == delimiter) break; buf[bi++] = (*pt)[pi++]; } buf[bi] = 0; (*pt) += pi; return buf; } void onprompt(char *prompt) { int repeat; char *pt; if (*prompt == '\n') printf("%s", g.last); else strcpy(g.last, prompt); prompt = g.last; repeat = 0; if (*prompt == '@') { prompt++; repeat = consumenum(&prompt); /* TODO(irek): I want to avoid infinite repeat loop but what should be the default max value? */ if (repeat == 0) repeat = 10000; repeat--; /* because of do while loop */ } do { pt = prompt; while (1) { while (*pt && *pt <= ' ') pt++; if (!*pt) break; g.error = runcmd(&pt); if (g.error) { puts("?"); return; } } } while(repeat--); } Err runcmd(char **pt) { Pos pos, n, beg, end; char cmd, *arg; FILE *fp; if ((unsigned char)(**pt - '1') < 9) { /* is digit but not 0 */ n = consumenum(pt); for (n--, pos=0; n && pos 0) return "Not found"; g.point = pos; return 0; } cmd = **pt; (*pt)++; switch (cmd) { case 'q': if (g.dirty) return "Unsaved, run *q to ignore"; exit(0); case 'e': if (g.dirty) return "Unsaved, run *e to ignore"; arg = consumearg(pt); if (*arg) { free(g.name); g.name = strdup(arg); } fp = fopen(g.name, "rw"); if (!fp) return "Failed to open file"; fseek(fp, 0L, SEEK_END); g.end = ftell(fp); g.text = realloc(g.text, g.end + 1); g.point = 0; g.mark = g.end; g.dirty = 0; fseek(fp, 0L, SEEK_SET); fread(g.text, 1, g.end, fp); g.text[g.end] = 0; if (fclose(fp)) return "Failed to close file"; return 0; case 'w': fp = fopen(g.name, "w"); if (!fp) return "Failed to open file"; fwrite(g.text, 1, g.end, fp); g.dirty = 0; if (fclose(fp)) return "Failed to close file"; return 0; case '*': g.dirty = 0; return 0; case 'h': puts(g.error ? g.error : "Ok"); return 0; case 'p': beg = g.point < g.mark ? g.point : g.mark; end = g.point > g.mark ? g.point : g.mark; printf("%.*s\n", (int)(end - beg), g.text + beg); return 0; case 'n': for (n=1, pos=0; pos < g.point; pos++) if (g.text[pos] == '\n') n++; printf("%lu\t", n); /* fallthrough */ case 'l': beg = getlinebeg(); end = getlineend(); printf("%.*s\n", (int)(end - beg), g.text + beg); return 0; case ':': printf("%lu\n", g.point - getlinebeg()); return 0; case 'f': printf("%s%s\n", g.name, g.dirty ? "*" : ""); return 0; case '+': n = consumenum(pt); if (n == 0) n = 1; while (n--) { pos = getlineend(); if (pos == g.end) return "Not found"; g.point = pos + 1; } return 0; case '-': n = consumenum(pt); if (n == 0) n = 1; while (n--) { pos = getlinebeg(); if (pos == 0) return "Not found"; g.point = pos - 1; g.point = getlinebeg(); } return 0; case '.': switch (**pt) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': pos = consumenum(pt); if (pos > g.end) return "Not found"; g.point = pos; break; case '-': (*pt)++; n = consumenum(pt); if (n == 0) n = 1; if (n > g.point) return "Not found"; g.point -= n; break; case '+': (*pt)++; n = consumenum(pt); if (n == 0) n = 1; if (n + g.point > g.end) return "Not found"; g.point += n; break; default: printf("%lu\n", g.point); } return 0; case '^': g.point = getlinebeg(); return 0; case '$': g.point = getlineend(); return 0; case '0': g.point = g.end; return 0; case '%': return gotobracket(); case 'i': arg = consumearg(pt); return insert(arg); /* I used the strangest memory management stategy that I've ever used, it's also the simplest. In short I don't reallocate when deleting or shrinking value, in all other cases I reallocate, strdup or malloc new value. I do that with t.text on "d" and with g.match in search(). Normally for dynamic values that can grow and shrink the dynamic array is used but it requires tracking of capacity and size independently. Initialization is often necessary and in most cases dynamic arrays never shrink. With my approach I do more allocations but I'm avoiding some of them on delete, I give memory back from time to time and I don't track anything. */ case 'd': if (g.point > g.mark) { pos = g.point; g.point = g.mark; g.mark = pos; } memmove(g.text + g.point, g.text + g.mark, g.end - g.mark + 1); g.end -= g.mark - g.point; g.mark = g.point; g.text[g.end] = 0; /* no need for realloc on delete */ g.dirty = 1; return 0; case 'y': beg = g.point < g.mark ? g.point : g.mark; end = g.point > g.mark ? g.point : g.mark; n = end - beg; g.clip = realloc(g.clip, n + 1); strncpy(g.clip, g.text + beg, n); return 0; case 'x': if (!g.clip || !*g.clip) return "Empty clipboard"; return insert(g.clip); case '/': case '?': arg = consumeregex(pt, cmd); return search(arg, cmd == '?'); case ',': g.mark = g.point; return 0; case ';': pos = g.point; g.point = g.mark; g.mark = pos; return 0; case 's': g.select = 1; return 0; case '!': arg = consumearg(pt); if (system(arg)) return "Shell command failed"; return 0; case '|': case '>': case '<': return "Not implemented"; } return "Unknown command"; } Err gotobracket() { Pos pos, a, b, c; int depth, direction; pos = g.point; a = g.text[g.point]; switch (a) { case '(': b = ')'; direction = 1; break; case ')': b = '('; direction = -1; break; case '[': b = ']'; direction = 1; break; case ']': b = '['; direction = -1; break; case '{': b = '}'; direction = 1; break; case '}': b = '{'; direction = -1; break; case '<': b = '>'; direction = 1; break; case '>': b = '<'; direction = -1; break; default: return "Not found"; } for (depth = 1; 1;) { pos += direction; if (pos >= g.end) break; c = g.text[pos]; /**/ if (c == a) depth++; else if (c == b) depth--; if (depth == 0) { /* found */ g.point = pos; return 0; } if (pos == 0) break; } return "Not found"; } Err search(char *phrase, int reverse) { static char err[4096]; Pos pos, n; int i, code, found; regex_t reg; regmatch_t match[LEN(g.match)]; if (*phrase) { g.search = realloc(g.search, strlen(phrase)); strcpy(g.search, phrase); } if (!g.search) return "No search phrase"; code = regcomp(®, g.search, REG_NEWLINE | REG_EXTENDED); if (code) { regerror(code, ®, err, sizeof err); regfree(®); return err; } if (reverse) { found = 0; pos = 0; while (1) { code = regexec(®, g.text + pos, LEN(match), match, 0); if (code || pos + match[0].rm_so >= g.point) break; pos += match[0].rm_so + 1; found = 1; } if (!found) return "Not found"; pos--; code = regexec(®, g.text + pos, LEN(match), match, 0); } else { pos = g.point; again: code = regexec(®, g.text + pos, LEN(match), match, 0); if (code) { regfree(®); return "Not found"; } /* try again when point is already on it search result */ if (match[0].rm_so == 0 && pos == g.point) { pos++; goto again; } } regfree(®); /* Make a copy of each capture group as using indexes to g.text from regmatch_t array is a terrible idea because g.text keep changing with each edit. */ for (i=0; i= sizeof buf - 1) return "Buffer size exceeded"; } text = buf; } g.dirty = 1; g.end += n; g.text = realloc(g.text, g.end + 1); memmove(g.text + g.point + n, g.text + g.point, g.end - n - g.point + 1); memcpy(g.text + g.point, text, n); g.point += n; return 0; } int main(int argc, char **argv) { int i; char buf[LEN(g.last)], *pt; while ((i = getopt(argc, argv, "hv")) != -1) switch (i) { case 'v': printf("ned "VERSION" by irek@gabr.pl GPLv2\n"); return 0; case 'h': default: printf("%s [-hv] [file]\n", argv[0]); return 0; } g.name = strdup(""); g.text = strdup(""); strcpy(g.last, "@20n+\n"); for (i=0; i 0) { snprintf(buf, sizeof buf, "e\"%s\"", argv[optind++]); pt = buf; runcmd(&pt); } while (fgets(buf, sizeof buf, stdin)) onprompt(buf); return 0; }