[PATCH v2] join: new applet
Ron Yorston via busybox <[email protected]> Mon, 06 Jul 2026 08:33:19 +0100
| Newsgroups | gmane.linux.busybox |
|---|---|
| Message-ID | <6a4b5a3f.0i+GiSDqwLjknHPS%[email protected]> |
function old new delta
join_main - 933 +933
readfields - 437 +437
printfields - 427 +427
packed_usage 35215 35518 +303
.rodata 102009 102250 +241
applet_names 2814 2819 +5
applet_main 1628 1632 +4
------------------------------------------------------------------------------
(add/remove: 4/0 grow/shrink: 4/0 up/down: 2350/0) Total: 2350 bytes
v2: Add a missing return statement which broke the '-t' option.
Thanks to Roberto A. Foglietta for pointing this out.
Signed-off-by: Morgan Bartlett <[email protected]>
Signed-off-by: Ron Yorston <[email protected]>
---
coreutils/join.c | 534 +++++++++++++++++++++++++++++++++++++++++++
testsuite/join.tests | 57 +++++
2 files changed, 591 insertions(+)
create mode 100644 coreutils/join.c
create mode 100755 testsuite/join.tests
diff --git a/coreutils/join.c b/coreutils/join.c
new file mode 100644
index 000000000..912c0687a
--- /dev/null
+++ b/coreutils/join.c
@@ -0,0 +1,534 @@
+/* vi: set sw=4 ts=4: */
+/*
+ * join -- equality join on two files
+ *
+ * Written by Morgan Bartlett
+ * Copyright (C) 2025 Morgan Bartlett
+ *
+ * Licensed under GPLv2 or later, see file LICENSE in this source tree.
+ */
+//config:config JOIN
+//config: bool "join (6.6 kb)"
+//config: default y
+//config: help
+//config: Equality join on two files
+
+//applet:IF_JOIN(APPLET_NOEXEC(join, join, BB_DIR_USR_BIN, BB_SUID_DROP, join))
+
+//kbuild:lib-$(CONFIG_JOIN) += join.o
+
+//usage:#define join_trivial_usage
+//usage: "[-a file_number | -v file_number] [-e empty] [-o list] [-t sep] [-1 field] [-2 field] file1 file2"
+//usage:#define join_full_usage "\n\n"
+//usage: "Perform a join on file1 and file2, writing to stdout\n"
+//usage: "\n -a FNUM Also write unpaired lines from file FNUM"
+//usage: "\n -v FNUM Only write unpaired lines from file FNUM"
+//usage: "\n -e STR Replace empty fields with STR"
+//usage: "\n -o LIST Write the fields in LIST"
+//usage: "\n -t SEP Use a different field separator"
+//usage: "\n -1 N Join on field N from file1"
+//usage: "\n -2 N Join on field N from file2"
+//usage: "\n"
+//usage: "\nLIST is a space or comma separated list of FNUM.FIELD or 0 (the join field)"
+
+#include "libbb.h"
+#include "unicode.h"
+
+/* This is a NOEXEC applet. Be very careful! */
+
+/* Must match getopt32 call */
+#define FLAG_UNPAIRED_ADD 1
+#define FLAG_UNPAIRED_ONLY 2
+#define FLAG_EMPTY_STRING 4
+#define FLAG_LIST_OUTPUT 8
+#define FLAG_SEP_USED 16
+#define FLAG_FIELD_1 32
+#define FLAG_FIELD_2 64
+
+typedef struct {
+ char *line;
+ char **fields;
+ int fieldcount;
+} LINE;
+
+typedef struct {
+ FILE *fp;
+ int idx; /* index of join field in fields array */
+ const char *field; /* pointer to lines[0].fields[idx] or "" */
+ LINE *lines;
+ LINE pushback;
+ int linecount; /* number of lines currently cached */
+ int linecap; /* current capacity of lines array */
+} FDAT;
+
+/* split s by sep, and put the results into *curr */
+static void field_split(char *s, char sep, LINE *curr)
+{
+ /* compare awk_split from editors/awk.c */
+ int n;
+ char *ps = s;
+ char *s1 = s;
+
+ char **sl;
+ size_t sn;
+
+ /* in worst case, every character is a separator */
+ curr->fields = sl = xzalloc(sizeof(char*) * (strlen(s) + 2));
+ curr->line = s;
+
+ n = 0;
+ if (sep != '\0') { /* single-character split */
+ while ((s1 = strchr(ps, sep)) != NULL) {
+ sl[n] = ps;
+ *s1 = '\0';
+ ps = s1 + 1;
+ n++;
+ }
+ /* Add the last field */
+ sl[n] = ps;
+ n++;
+ curr->fieldcount = n;
+ return;
+ }
+ /* default split: skip the initial whitespace and then any run
+ of non-whitespace characters is a field */
+ while (*s) {
+ /* blanks are space and tab in the POSIX locale */
+ while (*s == ' ' || *s == '\t')
+ s++;
+ if (!*s)
+ break;
+
+ s1 = strpbrk(s, " \t");
+ if (s1 == NULL) {
+ /* last field */
+ sn = strlen(s);
+ } else {
+ sn = s1 - s;
+ *s1 = '\0';
+ }
+
+ sl[n] = s;
+ n++;
+ s += sn + 1;
+ }
+ curr->fieldcount = n;
+}
+
+static inline void freefields(LINE *lp)
+{
+ free(lp->line);
+ free(lp->fields);
+ lp->line = NULL;
+ lp->fields = NULL;
+}
+
+static void freelines(FDAT *f)
+{
+ for (int n = 0; n < f->linecount; n ++) {
+ freefields(f->lines + n);
+ }
+ f->linecount = 0;
+ f->field = "";
+}
+
+static void readfields(char sep, FDAT *f)
+{
+ LINE curr = { .line = NULL, .fields = NULL, .fieldcount = 0 };
+ char *line;
+ const char *field2;
+ bool first = TRUE;
+
+ freelines(f);
+
+ while (true) {
+ if (first && f->pushback.fields) {
+ /* first check for a pushed back line */
+ curr = f->pushback;
+ f->pushback = (LINE) { .line = NULL, .fields = NULL, .fieldcount = 0 };
+ } else {
+ /* read a line and split */
+ line = xmalloc_fgetline(f->fp);
+ if (line == NULL) {
+ return;
+ }
+
+ field_split(line, sep, &curr);
+ }
+
+ if (f->idx >= curr.fieldcount)
+ field2 = "";
+ else
+ field2 = curr.fields[f->idx];
+
+ /* Ensure strcmp() matches on first pass through loop */
+ if (first)
+ f->field = field2;
+
+ if (strcmp(f->field, field2) == 0) {
+ /* add to the stack */
+ if (f->linecount == f->linecap) {
+ f->linecap = f->linecap ? f->linecap * 2 : 8;
+ f->lines = xrealloc(f->lines, sizeof(LINE) * f->linecap);
+ }
+ f->lines[f->linecount++] = curr;
+ } else {
+ f->pushback = curr;
+ return;
+ }
+ first = FALSE;
+ }
+}
+
+static inline const char *fieldorempty(const char *field, const char *empty_str)
+{
+ if (*field == '\0')
+ return empty_str;
+ else
+ return field;
+}
+
+static void printfields(int *format, const char *empty_str, char sep, FDAT *f1, FDAT *f2)
+{
+ const char *field = (f1 == NULL) ? f2->field : f1->field;
+
+ int linef1;
+ int linef2;
+ int fn;
+ int format_fl;
+ int format_idx;
+ int *formatcurr;
+ bool first;
+
+ LINE *l1;
+ LINE *l2;
+
+ if (sep == '\0')
+ sep = ' ';
+
+ for (linef1 = 0; linef1 < (f1 ? f1->linecount : 1); linef1 ++) {
+ l1 = f1 ? &f1->lines[linef1] : NULL;
+ for (linef2 = 0; linef2 < (f2 ? f2->linecount : 1); linef2 ++) {
+ l2 = f2 ? &f2->lines[linef2] : NULL;
+
+ if (format) {
+ /*
+ Format is a sort of null-terminated array:
+ They are indexed by [n] for file number and [n + 1] for field number.
+ If file number is 0 then that is the null-terminator.
+ If file number is neither 1 nor 2 then we have the join field.
+ */
+ first = true;
+ formatcurr = format;
+ while (formatcurr[0]) {
+ if (first)
+ first = false;
+ else
+ bb_putchar(sep);
+
+ format_fl = formatcurr[0];
+ format_idx = formatcurr[1];
+
+ if (format_fl == 1) {
+ if (l1 != NULL && l1->fieldcount > format_idx)
+ fputs_stdout(fieldorempty(l1->fields[format_idx], empty_str));
+ else
+ fputs_stdout(empty_str);
+ } else if (format_fl == 2) {
+ if (l2 != NULL && l2->fieldcount > format_idx)
+ fputs_stdout(fieldorempty(l2->fields[format_idx], empty_str));
+ else
+ fputs_stdout(empty_str);
+ } else
+ fputs_stdout(fieldorempty(field, empty_str));
+
+ formatcurr += 2;
+ }
+ } else {
+ fputs_stdout(fieldorempty(field, empty_str));
+
+ fn = 0;
+ if (l1 != NULL)
+ while (l1->fields[fn]) {
+ if (fn != f1->idx) {
+ bb_putchar(sep);
+ fputs_stdout(fieldorempty(l1->fields[fn], empty_str));
+ }
+ fn++;
+ }
+
+ fn = 0;
+ if (l2 != NULL)
+ while (l2->fields[fn]) {
+ if (fn != f2->idx) {
+ bb_putchar(sep);
+ fputs_stdout(fieldorempty(l2->fields[fn], empty_str));
+ }
+ fn++;
+ }
+ }
+ bb_putchar('\n');
+ }
+ }
+}
+
+static void parsejformat(int **format_p, const char *format_str)
+{
+ int *format;
+ int field_idx;
+
+ char scache[20] = { 0 };
+
+ int n = 0;
+ const char *s1 = format_str;
+
+ size_t sn;
+
+ /* The most formats we can have is if the format_str is noted as 0,0,0,0
+ which ends up being (strlen + 1) / 2
+ and then we need to have two for each entry plus one for the terminator */
+ *format_p = format = xzalloc(sizeof(int) * ((strlen(format_str) + 1) / 2) * 2 + 1);
+
+ /* default split: skip the initial whitespace and then any run
+ of non-whitespace characters is a field */
+ while (*format_str) {
+ /* blanks are space and tab in the POSIX locale */
+ while (*format_str == ' ' || *format_str == '\t' || *format_str == ',')
+ format_str++;
+ if (!*format_str)
+ break;
+
+ s1 = strpbrk(format_str, " \t,");
+ if (s1 == NULL)
+ /* last field */
+ sn = strlen(format_str);
+ else
+ sn = s1 - format_str;
+
+ if (sn == 1 && *format_str == '0') {
+ format[n * 2] = 3;
+ } else if (sn >= 3 && (*format_str == '1' || *format_str == '2') && *(format_str + 1) == '.') {
+ if (sn > 21) /* res_str > 19 */
+ bb_simple_error_msg_and_die("field specifier too large");
+ memcpy(scache, format_str + 2, sn - 2);
+ scache[sn - 2] = '\0';
+ field_idx = xatoi_positive(scache);
+ if (--field_idx < 0)
+ bb_simple_error_msg_and_die("field number can't be 0");
+ format[n * 2] = *format_str - '0';
+ format[n * 2 + 1] = field_idx;
+ } else {
+ bb_simple_error_msg_and_die("field specifier must be 0, 1.x or 2.x");
+ }
+
+ n++;
+ format_str += sn;
+ }
+}
+
+int join_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE;
+int join_main(int argc, char **argv)
+{
+ llist_t *unpaired_list = NULL;
+ const char *empty_str = "";
+ const char *format_str = NULL;
+ char *separator;
+ uint32_t opts;
+
+ bool print1unpaired = false;
+ bool print2unpaired = false;
+ bool printpaired = true;
+
+ int *format = NULL;
+
+ FDAT f1 = {
+ .fp = NULL,
+ .idx = 0,
+ .field = NULL,
+ .lines = NULL,
+ .pushback = { .line = NULL, .fields = NULL, .fieldcount = 0 },
+ .linecount = 0,
+ .linecap = 0,
+ };
+ FDAT f2 = {
+ .fp = NULL,
+ .idx = 0,
+ .field = NULL,
+ .lines = NULL,
+ .pushback = { .line = NULL, .fields = NULL, .fieldcount = 0 },
+ .linecount = 0,
+ .linecap = 0,
+ };
+
+ char *unpaired_str;
+
+ /* We can't use \0 as a real separator, so this stands in for the whitespace+ pattern */
+ char sep = 0;
+
+ init_unicode();
+
+ opts = getopt32(argv,
+ "^a:*v:*"
+ "e:"
+ "o:"
+ "t:"
+ "1:+2:+"
+ "\0""=2:a--v:v--a",
+ &unpaired_list,
+ &unpaired_list,
+ &empty_str,
+ &format_str,
+ &separator,
+ &f1.idx,
+ &f2.idx);
+ argc -= optind; argv += optind;
+
+ if ((opts & FLAG_UNPAIRED_ADD) || (opts & FLAG_UNPAIRED_ONLY)) {
+ printpaired = !(opts & FLAG_UNPAIRED_ONLY);
+ while ((unpaired_str = llist_pop(&unpaired_list)) != NULL) {
+ if (*unpaired_str == '1')
+ print1unpaired = true;
+ else if (*unpaired_str == '2')
+ print2unpaired = true;
+ else if (unpaired_str[1] != '\0')
+ bb_simple_error_msg_and_die("-a and -v take either 1 or 2");
+ }
+ }
+
+ if (((opts & FLAG_FIELD_1) && --f1.idx < 0) ||
+ ((opts & FLAG_FIELD_2) && --f2.idx < 0))
+ bb_simple_error_msg_and_die("field 0 doesn't exist");
+
+ if (opts & FLAG_SEP_USED) {
+ if (separator[1] != '\0')
+ bb_simple_error_msg_and_die("separators are single characters");
+
+ sep = *separator;
+ }
+
+ if (opts & FLAG_LIST_OUTPUT)
+ parsejformat(&format, format_str);
+
+ f1.fp = xfopen_stdin(argv[0]);
+ f2.fp = xfopen_stdin(argv[1]);
+ if (f1.fp == f2.fp)
+ bb_simple_error_msg_and_die("can't combine stdin with itself");
+
+ /*
+
+ Outline of the program:
+
+ - Read a line set from each file into cache
+
+ - While there are cached current lines from both files:
+ - If the two indexing fields are the same:
+ - If no unpaired_only options:
+ - Print joined line set formatted with fields
+ - Read line sets from both files
+ - Elseif indexing1 < indexing2 then
+ - If print1unpaired:
+ - Print line set from file 1
+ - Read another line set from file 1
+ - Elseif indexing1 > indexing2 then
+ - If print2unpaired:
+ - Print line set from file 2
+ - Read another line set from file 2
+
+ - If there are cached lines from file 1 AND print1unpaired:
+ - Print the rest of the lines from file 1
+ - If there are cached lines from file 2 AND print2unpaired:
+ - Print the rest of the lines from file 2
+
+ */
+
+ /*
+
+ Notes for POSIX:
+
+ https://pubs.opengroup.org/onlinepubs/9699919799/utilities/join.html
+
+ Multiple instances of the same key are meant to give combinatorial results.
+
+ E.g:
+ file1:
+ a 1
+ a 2
+
+ file2:
+ a A
+ a B
+ a C
+
+ result:
+ a 1 A
+ a 1 B
+ a 1 C
+ a 2 A
+ a 2 B
+ a 2 C
+
+ This is done in this implementation by reading all the lines that have the same key
+ and then iterating through the combinations when printing them.
+
+ (test with `./busybox join <(printf 'a 1\na 2\n';) <(printf 'a A\na B\na C\n')`)
+
+ */
+
+ readfields(sep, &f1);
+ readfields(sep, &f2);
+
+ while (f1.linecount && f2.linecount) {
+ int res = strcmp(f1.field, f2.field);
+
+ if (res == 0) {
+ if (printpaired)
+ printfields(format, empty_str, sep, &f1, &f2);
+
+ readfields(sep, &f1);
+ readfields(sep, &f2);
+ } else if (res < 0) {
+ if (print1unpaired)
+ printfields(format, empty_str, sep, &f1, NULL);
+
+ readfields(sep, &f1);
+ } else {
+ if (print2unpaired)
+ printfields(format, empty_str, sep, NULL, &f2);
+
+ readfields(sep, &f2);
+ }
+ }
+
+ if (f1.linecount && print1unpaired) {
+ do {
+ printfields(format, empty_str, sep, &f1, NULL);
+ readfields(sep, &f1);
+ } while (f1.linecount);
+ }
+
+ if (f2.linecount && print2unpaired) {
+ do {
+ printfields(format, empty_str, sep, NULL, &f2);
+ readfields(sep, &f2);
+ } while (f2.linecount);
+ }
+
+#if ENABLE_FEATURE_CLEAN_UP
+ if (f1.linecap) {
+ freelines(&f1);
+ free(f1.lines);
+ }
+
+ if (f2.linecap) {
+ freelines(&f2);
+ free(f2.lines);
+ }
+
+ free(format);
+
+ fclose_if_not_stdin(f1.fp);
+ fclose_if_not_stdin(f2.fp);
+#endif
+
+ fflush_stdout_and_exit_SUCCESS();
+}
diff --git a/testsuite/join.tests b/testsuite/join.tests
new file mode 100755
index 000000000..e356865b4
--- /dev/null
+++ b/testsuite/join.tests
@@ -0,0 +1,57 @@
+#!/bin/sh
+
+# Copyright 2026 by R M Yorston <[email protected]>
+# Licensed under GPLv2, see file LICENSE in this source tree.
+
+. ./testing.sh
+
+# testing "description" "command" "result" "infile" "stdin"
+
+testing "join common lines and unpaired from first file" \
+ "join -a 1 input -" \
+ "a 1 A\nb 2\n" "a 1\nb 2\n" "a A\nc C\n"
+
+testing "join common lines and unpaired from second file" \
+ "join -a 2 input -" \
+ "a 1 A\nc C\n" "a 1\nb 2\n" "a A\nc C\n"
+
+testing "join paired and unpaired lines from both files (union)" \
+ "join -a 1 -a 2 input -" \
+ "a 1 A\nb 2\nc C\n" "a 1\nb 2\n" "a A\nc C\n"
+
+testing "join unpaired lines from first file (difference)" \
+ "join -v 1 input -" \
+ "b 2\n" "a 1\nb 2\n" "a A\nc C\n"
+
+testing "join unpaired lines from second file (difference)" \
+ "join -v 2 input -" \
+ "c C\n" "a 1\nb 2\n" "a A\nc C\n"
+
+testing "join unpaired lines from both file (symmetric difference)" \
+ "join -v 1 -v 2 input -" \
+ "b 2\nc C\n" "a 1\nb 2\n" "a A\nc C\n"
+
+testing "join duplicate keys give combinatorial results" "join input -" \
+ "a b c w x\na b c y z\na b c o p\na d e w x\na d e y z\na d e o p\n" \
+ "a b c\na d e\n" \
+ "a w x\na y z\na o p\n"
+
+testing "join -o for fields to print, -e for empty fields" \
+ "join -a 1 -a 2 -e --- -o 0,1.2,2.2 input -" \
+ "a 123 abc\nb 456 ---\nc 789 def\nd --- ghi\n" \
+ "a 123\nb 456\nc 789\n" \
+ "a abc\nc def\nd ghi\n"
+
+testing "join -o works for combinatorial" \
+ "join -o 0,1.2,2.2 input -" \
+ "a 123 abc\na 123 def\na 456 abc\na 456 def\n" \
+ "a 123\na 456\n" \
+ "a abc\na def\n"
+
+testing "join -t works" \
+ "join -t A input -" \
+ "item 1A123Aabc\nitem 2A456Adef\n" \
+ "item 1A123\nitem 2A456\n" \
+ "item 1Aabc\nitem 2Adef\n"
+
+exit $FAILCOUNT
--
2.55.0