Skip to content

List Set Operations — PHP source

Compute the union, intersection, difference, or symmetric difference of two newline-separated lists. Deduped results with optional trim and case-insensitive matching. Runs entirely in your browser, with a shareable link.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * list-set-ops — union / intersection / difference / symmetric difference of
 * two newline-separated lists.
 *
 * Language: PHP (8.1+, standard library only — mbstring for safe multibyte
 *           lowercasing, which is effectively universal in modern PHP)
 * Source:   CosmoDev polyglot showcase port of the List Set Operations tool,
 *           ported from src/lib/list-set-ops.ts (the canonical TypeScript
 *           implementation) and cli/list-set-ops/list-set-ops.go (the Go twin).
 * License:  display source — part of CosmoDev's polyglot tool pages.
 *
 * Design goals:
 *   - Pure + deterministic; never throws.
 *   - Functionally equivalent to the TS/Go reference: same inputs -> same outputs.
 *   - Self-contained: stdlib only (no Composer packages).
 *
 * Algorithm: each list is parsed (optionally trimmed + deduped by first-seen
 * casing), then compared by exact string equality unless `caseInsensitive`
 * folds the comparison key (the original first-seen casing is still emitted).
 * Results are joined with "\n".
 */

declare(strict_types=1);

/**
 * The set operation to compute over two lists. Matches the TS `SetOp` union
 * and the Go `Op` constants literal-for-literal. (PHP has no string-union
 * type, so these consts are the contract.)
 */
const SET_OP_UNION = 'union';
const SET_OP_INTERSECT = 'intersect';
const SET_OP_DIFFERENCE = 'difference';
const SET_OP_SYMMETRIC_DIFFERENCE = 'symmetricDifference';

/**
 * Default options for set_ops(). A missing/null key means "use the default".
 *
 *   - caseInsensitive: bool   default false   fold case for the comparison
 *   - trim:            ?bool  default null    null -> true (trim on); an
 *                                             explicit false disables trimming
 */
function set_ops_default_options(): array
{
    return [
        'caseInsensitive' => false,
        'trim'            => null,
    ];
}

/**
 * Split a newline list into a deduped array of items.
 *
 * Empty input yields no items (not a single empty item). When trimming is
 * enabled (default) items are whitespace-trimmed and empty lines dropped.
 * Duplicates within the list are removed keeping the first-seen original
 * casing.
 *
 * @return list<string>
 */
function set_ops_parse_list(string $input, array $options): array
{
    if ($input === '') {
        return []; // empty input = no items
    }
    $trim = $options['trim'] ?? true; // null/missing -> true (default)
    $ci = !empty($options['caseInsensitive']);

    $items = explode("\n", $input);
    if ($trim) {
        // trim() is the ASCII whitespace trim here; the tool's inputs are
        // plain lines so mbstring isn't needed for the trim step.
        $items = array_values(array_filter(
            array_map('trim', $items),
            fn ($s) => $s !== ''
        ));
    }

    $seen = [];
    $out = [];
    foreach ($items as $item) {
        $key = $ci ? mb_strtolower($item, 'UTF-8') : $item;
        if (isset($seen[$key])) {
            continue;
        }
        $seen[$key] = true;
        $out[] = $item;
    }
    return $out;
}

/**
 * Compute a set operation over two newline-separated lists. Never throws.
 *
 * Items are compared by exact string equality unless `caseInsensitive` is set,
 * in which case the comparison is case-folded but the first-seen original
 * casing is emitted. The result is joined with "\n".
 *
 *   - union               -> every unique item, a then b (first-seen casing)
 *   - intersect           -> items present in both lists (a's order/casing)
 *   - difference          -> items in a not present in b
 *   - symmetricDifference -> items in exactly one list (a-only then b-only)
 *
 * An unknown op yields the empty string (no default branch).
 */
function set_ops(string $a, string $b, string $op, array $options = []): string
{
    $options = array_merge(set_ops_default_options(), $options);
    $ci = !empty($options['caseInsensitive']);
    $key_of = fn (string $s): string => $ci ? mb_strtolower($s, 'UTF-8') : $s;

    $listA = set_ops_parse_list($a, $options);
    $listB = set_ops_parse_list($b, $options);

    // Build the key sets for both lists. Array keys give O(1) isset() lookup.
    $keysA = [];
    foreach ($listA as $i) {
        $keysA[$key_of($i)] = true;
    }
    $keysB = [];
    foreach ($listB as $i) {
        $keysB[$key_of($i)] = true;
    }

    $out = [];
    switch ($op) {
        case SET_OP_UNION:
            $seen = [];
            foreach ($listA as $item) {
                $seen[$key_of($item)] = true;
                $out[] = $item; // listA is already deduped by parse_list
            }
            foreach ($listB as $item) {
                $k = $key_of($item);
                if (isset($seen[$k])) {
                    continue;
                }
                $seen[$k] = true;
                $out[] = $item;
            }
            break;
        case SET_OP_INTERSECT:
            foreach ($listA as $item) {
                if (isset($keysB[$key_of($item)])) {
                    $out[] = $item;
                }
            }
            break;
        case SET_OP_DIFFERENCE:
            foreach ($listA as $item) {
                if (!isset($keysB[$key_of($item)])) {
                    $out[] = $item;
                }
            }
            break;
        case SET_OP_SYMMETRIC_DIFFERENCE:
            foreach ($listA as $item) {
                if (!isset($keysB[$key_of($item)])) {
                    $out[] = $item;
                }
            }
            foreach ($listB as $item) {
                if (!isset($keysA[$key_of($item)])) {
                    $out[] = $item;
                }
            }
            break;
    }

    return implode("\n", $out);
}

// ---------- showcase (the canonical suite lives in src/lib) ----------
// Runs only when this file is executed directly from the CLI, so including it
// from elsewhere has no side effects.
$is_main = PHP_SAPI === 'cli'
    && !empty($argv[0])
    && realpath($argv[0]) === __FILE__;
if ($is_main) {
    $A = "apple\nbanana\ncherry";
    $B = "banana\ncherry\ndate";
    $ci = ['caseInsensitive' => true];

    assert(set_ops($A, $B, SET_OP_UNION) === "apple\nbanana\ncherry\ndate");
    assert(set_ops($A, $B, SET_OP_INTERSECT) === "banana\ncherry");
    assert(set_ops($A, $B, SET_OP_DIFFERENCE) === "apple");
    assert(set_ops($A, $B, SET_OP_SYMMETRIC_DIFFERENCE) === "apple\ndate");
    // case-folding compares keys, but the first-seen original casing is emitted.
    assert(set_ops('HELLO', 'hello', SET_OP_INTERSECT, $ci) === 'HELLO');
    assert(set_ops("X\nApple", "x\nAPPLE\nmango", SET_OP_UNION, $ci) === "X\nApple\nmango");
    echo "ok\n";
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →