/* NED text editor =============== NED (NotED text editor) is an ED-like [1] text editor that instead of being line oriented is region based. Any chunk of text can be selected and edited. Chain single letter commands in any order to produce custom complex actions. Integration with shell 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 Commands -------- q Quit e[ x ] Edit, open file x or reopen current w[ x ] Write to file x or current = Print file name with '*' as dirty indicator p Print region n Print region with line numbers h Print last error % Mark all [-+]N Mark N line, or move by N lines m Mark matching bracket with region first char /[x/] Mark next regex x ?[x?] Mark prev regex x I Make next regex case insensitive . Set mode to affect only region beg , Set mode to affect only region end ; Set mode to affect whole region ^ Colapse region at it's beg $ Colapse region at it's end kx Store region in x (a-z) 'x Restore region x (a-z) i["x"] Insert x before region a["x"] Append x after region c["x"] Change region with x d Delete region y Yank (copy) region to clipboard x Paste clipboard after region s[/x/y] Substitute regex x with str y in region ![x!] Run x shell cmd >[x!] Like ! but pipe region as stdin <[x!] Like ! but replace region with stdout |[x!] Like > and < combined 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.8" #include #include #include #include #include #include /* Content of open file is stored in g.text of g.end size. Because of regex.h this string is also null-terminated. Only regex search rely on that last null, all other commands work with g.end. Insert, delete and edit commands maintain that null. */ #define LEN(arr) (int)((sizeof (arr)) / (sizeof (arr)[0])) typedef const char* Err; /* error message */ typedef size_t Pos; /* position in text */ typedef struct range Range; struct range { Pos a, b; /* from-to range (a <= b) */ }; static struct { Err error; /* last error */ int dirty; /* changes count since write */ int icase; /* ignore case in next regex */ char mode; /* movement mode */ char input[4096]; /* last input */ char search[4096]; /* last search phrase */ char* match[10]; /* last search capture groups */ char subst[4096]; /* last substitution */ char shell[4096]; /* last shell command */ char name[4096]; /* file name */ char* clip; /* clipboard */ char* text; /* file content */ Pos end; /* text size, last byte pos */ Range dot; /* active selection */ Range keep[26]; /* stored ranges a-z */ } g = {0}; static Pos eatnum (char**); static char* eatarg (char**, char delim); static Err runcmd (char**); static Err search (char*, Range*); static void insert (char*); static void delete (); Pos eatnum(char **str) { Pos n=0; int skip=0; sscanf(*str, "%lu%n", &n, &skip); *str += skip; return n; } char* eatarg(char **str, char delim) { static char buf[4096]; Pos len, si=0, bi=0; char *src, *match; src = *str; while (bi < LEN(buf)-1) { if (src[si] == '\\') { 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']; if (!match) break; len = strlen(match); if (len > LEN(buf) - bi - 1) len = LEN(buf) - bi - 1; memcpy(buf + bi, match, len); bi += len; break; case '\\':buf[bi++] = '\\'; break; case 't': buf[bi++] = '\t'; break; case 'n': buf[bi++] = '\n'; break; case 'r': buf[bi++] = '\r'; break; default: if (src[si] != delim) buf[bi++] = '\\'; if (bi < LEN(buf) - 1) buf[bi++] = src[si]; } si++; continue; } if (!src[si] || src[si] == '\n') break; if (src[si] == delim) { si++; break; } buf[bi++] = src[si++]; } buf[bi] = 0; (*str) += si; return buf; } Err runcmd(char **cmd) { Err err; Pos p, n, a, b, c; Range r; int i, delta; char *pt, delim, *arg="", buf[4096]; FILE *fp; pt = *cmd; /* parse navigatoin commands first */ switch (*pt) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': n = eatnum(&pt); if (n == 0) { g.dot.a = 0; g.dot.b = 0; break; } b = g.mode == '.' ? g.dot.b : g.end; for (n--, p=0; n && p 0) return "Not found"; /* TODO(irek): This label and related GOTOs can be avoided */ select: if (g.mode != ',') g.dot.a = p; if (g.mode != '.') { while (p < g.end && g.text[p] != '\n') p++; g.dot.b = p < g.end ? p + 1 : g.end; } break; case '+': pt++; a = g.mode == '.' ? g.dot.a : g.dot.b; b = g.mode == '.' ? g.dot.b : g.end; if (a == b) return "Reached region boundary"; n = eatnum(&pt); if (n == 0) n = 1; if (g.mode != '.') n--; for (p = a; n && p < b; p++) if (g.text[p] == '\n') n--; goto select; case '-': pt++; a = g.mode == ',' ? g.dot.b : g.dot.a; b = g.mode == ',' ? g.dot.a : 0; if (a == 0) return "Reached region boundary"; n = eatnum(&pt); if (n == 0) n = 1; if (g.mode == ',') n++; for (n++, p = a; n && p-- > b;) if (g.text[p] == '\n') n--; p = p == b ? b : p + 1; goto select; case '/': pt++; r.a = g.mode == '.' ? g.dot.a : g.dot.b; r.b = g.mode == '.' ? g.dot.b : g.end; arg = eatarg(&pt, **cmd); err = search(arg, &r); if (err) return err; switch (g.mode) { case '.': g.dot.a = r.b; break; case ',': g.dot.b = r.b; break; default: g.dot.a = r.a; g.dot.b = r.b; } break; case '?': pt++; r.a = g.mode == ',' ? g.dot.b : g.dot.a; r.b = g.mode == ',' ? g.dot.a : 0; arg = eatarg(&pt, **cmd); err = search(arg, &r); if (err) return err; switch (g.mode) { case '.': g.dot.a = r.a; break; case ',': g.dot.b = r.a; break; default: g.dot.a = r.a; g.dot.b = r.b; } break; case 'm': pt++; p = g.dot.a; a = g.text[p]; switch (a) { case '(': b = ')'; delta = 1; break; case ')': b = '('; delta = -1; break; case '[': b = ']'; delta = 1; break; case ']': b = '['; delta = -1; break; case '{': b = '}'; delta = 1; break; case '}': b = '{'; delta = -1; break; case '<': b = '>'; delta = 1; break; case '>': b = '<'; delta = -1; break; default: return "Not found"; } for (i = 1; 1;) { p += delta; if (p >= g.end) break; c = g.text[p]; /**/ if (c == a) i++; else if (c == b) i--; if (i == 0) { /* found */ g.dot.b = delta > 0 ? p + 1 : g.dot.a + 1; g.dot.a = delta > 0 ? g.dot.a : p; break; } if (p == 0) return "Not found"; } break; } if (*cmd != pt) { /* navigated, region changes */ if (*pt == '\n') { /* print region on last command */ fwrite(g.text + g.dot.a, 1, g.dot.b - g.dot.a, stdout); if (g.dot.b && g.text[g.dot.b-1] != '\n') putchar('\n'); } *cmd = pt; return 0; } switch (*pt++) { case 'p': fwrite(g.text + g.dot.a, 1, g.dot.b - g.dot.a, stdout); if (g.dot.b && g.text[g.dot.b-1] != '\n') putchar('\n'); break; case 'n': a = g.dot.a; b = g.dot.b; for (n=1, p=0; p ' ') { pt++; arg = eatarg(&pt, delim); if (*arg) strncpy(g.search, arg, sizeof g.search); arg = eatarg(&pt, delim); strncpy(g.subst, arg, sizeof g.subst); } if (!*g.search) return "No search phrase"; a = r.a = g.dot.a; b = r.b = g.dot.b; c = (Pos)-1; /* end of previous match */ i = g.icase; err = search(g.search, &r); if (err) return err; while (!err) { g.icase = i; if (r.a == r.b && r.a == c) { if (r.a >= b) break; r.a++; r.b = b; err = search(g.search, &r); continue; } g.dot.a = r.a; g.dot.b = r.b; delete(); insert(g.subst); b = b - (r.b - r.a) + (g.dot.b - g.dot.a); c = g.dot.b; /* empty match makes no progress, step over */ r.a = r.a == r.b ? g.dot.b + 1 : g.dot.b; if (r.a > b) break; r.b = b; if (r.a >= r.b) break; err = search(g.search, &r); } g.dot.a = a; g.dot.b = b; break; case '%': g.dot.a = 0; g.dot.b = g.end; break; case 'k': if (*pt < 'a' || *pt > 'z') return "Invalid region name"; i = *pt - 'a'; pt++; g.keep[i].a = g.dot.a; g.keep[i].b = g.dot.b; break; case '\'': if (*pt < 'a' || *pt > 'z') return "Invalid region name"; i = *pt - 'a'; pt++; g.dot.a = g.keep[i].a; g.dot.b = g.keep[i].b; break; case 'c': delete(); /* fallthrough */ case 'a': g.dot.a = g.dot.b; /* fallthrough */ case 'i': if (*pt == '"') { pt++; insert(eatarg(&pt, '"')); break; } g.dot.b = g.dot.a; a = g.dot.a; while (fgets(buf, sizeof buf, stdin)) { if (buf[0] == '.' && buf[1] == '\n') break; insert(buf); g.dot.a = g.dot.b; } g.dot.a = a; break; case 'd': delete(); break; case 'y': n = g.dot.b - g.dot.a; g.clip = realloc(g.clip, n + 1); memcpy(g.clip, g.text + g.dot.a, n); g.clip[n] = 0; break; case 'x': g.dot.a = g.dot.b; insert(g.clip); break; case '.': case ',': case ';': g.mode = **cmd; break; case '^': g.dot.b = g.dot.a; break; case '$': g.dot.a = g.dot.b; break; case '!': arg = eatarg(&pt, '!'); if (*arg) strncpy(g.shell, arg, sizeof g.shell); if (system(g.shell)) return "Shell command failed"; puts("!"); break; case '>': case '<': case '|': return "Not implemented"; default: return "Unknown command"; } *cmd = pt; return 0; } Err search(char *phrase, Range *range) { static char err[4096]; Pos pos, n; int i, code, found, flags; regex_t reg; regmatch_t match[10]; if (*phrase) strncpy(g.search, phrase, sizeof g.search); if (!*g.search) return "No search phrase"; flags = REG_NEWLINE | REG_EXTENDED; if (g.icase) flags |= REG_ICASE; g.icase = 0; code = regcomp(®, g.search, flags); if (code) { regerror(code, ®, err, sizeof err); regfree(®); return err; } if (range->a > range->b) { /* reverse search */ found = 0; pos = range->b; while (1) { flags = 0; if (pos && g.text[pos-1] != '\n') flags |= REG_NOTBOL; code = regexec(®, g.text + pos, LEN(match), match, flags); if (code || pos + match[0].rm_so >= range->a) break; pos += match[0].rm_so + 1; found = 1; } if (!found) return "Not found"; pos--; regexec(®, g.text + pos, LEN(match), match, 0); } else { pos = range->a; flags = 0; if (pos && g.text[pos-1] != '\n') flags |= REG_NOTBOL; code = regexec(®, g.text + pos, LEN(match), match, flags); if (code || pos + match[0].rm_so > range->b) { regfree(®); return "Not found"; } } 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<10; i++) { if (match[i].rm_so == -1) { if (g.match[i]) g.match[i][0] = 0; continue; } n = match[i].rm_eo - match[i].rm_so; g.match[i] = realloc(g.match[i], n + 1); memcpy(g.match[i], g.text + pos + match[i].rm_so, n); g.match[i][n] = 0; } range->a = pos + match[0].rm_so; range->b = pos + match[0].rm_eo; return 0; } void insert(char *text) { Pos n; int i; n = strlen(text); g.dirty++; g.end += n; g.text = realloc(g.text, g.end + 1); memmove(g.text + g.dot.a + n, g.text + g.dot.a, g.end - n - g.dot.a + 1); memcpy(g.text + g.dot.a, text, n); g.dot.b = g.dot.a + n; for (i=0; i g.dot.a && g.keep[i].a <= g.dot.b) g.keep[i].a = g.dot.a; if (g.keep[i].b > g.dot.a && g.keep[i].b <= g.dot.b) g.keep[i].b = g.dot.a; if (g.keep[i].a >= g.dot.b) g.keep[i].a -= n; if (g.keep[i].b >= g.dot.b) g.keep[i].b -= n; } g.dot.b = g.dot.a; g.text[g.end] = 0; /* no need for realloc on delete */ g.dirty++; } int main(int argc, char **argv) { int i; char buf[4096], *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; } strncpy(g.input, "+,+20n", sizeof g.input); strncpy(g.name, "tmp", sizeof g.name); g.clip = strdup(""); g.text = strdup(""); if (argc - optind > 0) { snprintf(buf, sizeof buf, "e %s\n", argv[optind++]); pt = buf; runcmd(&pt); } while (fgets(buf, sizeof buf, stdin)) { if (*buf != '\n') strncpy(g.input, buf, sizeof g.input); pt = g.input; g.mode = ';'; g.icase = 0; while (1) { while (*pt && *pt <= ' ') pt++; if (!*pt) break; g.error = runcmd(&pt); if (g.error) { puts("?"); break; } } } return 0; }