From 8bd1b3aa7f9639d3830209856e69db68fa3b8ed2 Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 13 Aug 2026 09:25:50 +0000 Subject: [PATCH 1/2] vms-1c6: DIRECTORY wildcards + ellipsis uniformity, multi-dir grand total, %DIRECT-W-NOFILES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next slice of vms-1c6 (File/RMS user-visible fidelity), after the DIRECTORY format+version slice (#447). Scope: the wildcard/ellipsis surface a VMS user drives constantly. - `*` (any chars) and `%` (single char) filename wildcards continue to route through the single matcher vmsfs_wildcard_match(); the DIRECTORY collect path is refactored into dir_collect()/dir_print_entries() so the single- and multi-directory paths share one filename matcher and one renderer (no divergent matchers — the DCL file commands already all call vmsfs_wildcard_match). - `[...]` / `[dir...]` ellipsis now actually recurses the real on-disk vmsfs tree (dir_gather_recurse — a genuine depth-first opendir/readdir walk, INV-6: no faked recursion). dir_deellipsize() rewrites the ellipsis spec to the start directory, reusing the existing resolution. - Multi-directory listing prints a per-directory header + "Total of N files" subtotal for each directory that has matches, then one "Grand total of D directories, F files[, M blocks]." rollup. - Zero matches now yield the authentic "%DIRECT-W-NOFILES, no files found" warning with NO header, instead of an empty "Total of 0 files." success. Grounded (clean-room, Rule 8): VSI OpenVMS DCL Dictionary, DIRECTORY — the "..." ellipsis directory wildcard ("this directory and all subdirectories below it"), the per-directory + Grand total multi-directory layout, and the NOFILES warning. Tests: new tests/dcl/test_directory_wildcards.sh exercises *, %, ellipsis recursion (per-dir subtotals + two-directory grand total), and the NOFILES warning against a real subdirectory tree. test_directory.sh made hermetic (lists a real temp dir) so the smoke test no longer relies on the pre-existing non-authentic header-before-DNF behavior. dcl-integration: 125 passed (remaining 3 failures — install/scsnode/misc — are pre-existing, environmental, identical on baseline: they need INSTALL.EXE/executive/PCSI). vmsfs_unit green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/vmsdcl/dcl_cmd_file.c | 725 +++++++++++++++++--------- tests/dcl/test_directory.sh | 14 +- tests/dcl/test_directory_wildcards.sh | 65 +++ 3 files changed, 571 insertions(+), 233 deletions(-) create mode 100755 tests/dcl/test_directory_wildcards.sh diff --git a/src/vmsdcl/dcl_cmd_file.c b/src/vmsdcl/dcl_cmd_file.c index 04d5c5e70..e6a74540b 100644 --- a/src/vmsdcl/dcl_cmd_file.c +++ b/src/vmsdcl/dcl_cmd_file.c @@ -23,6 +23,7 @@ #include "dcl/dcl_cmd.h" #include "dcl/vms_messages.h" #include "ssdef.h" +#include "stsdef.h" #include "vmsfs/filespec.h" #include "vmsqueue.h" @@ -125,179 +126,55 @@ static int dir_entry_cmp(const void *a, const void *b) return eb->version - ea->version; } -int cmd_directory(struct dcl_command *cmd) -{ - struct dcl_context *ctx = dcl_get_context(); - - /* Determine the directory to list */ - char linux_dir[1024]; - const char *pattern = NULL; - - if (cmd->param_count >= 1 && cmd->params[0][0] != '\0') { - dcl_resolve_path(ctx, cmd->params[0], linux_dir, sizeof(linux_dir)); - /* Check if this is a directory or a file pattern */ - struct stat st; - if (stat(linux_dir, &st) == 0 && S_ISDIR(st.st_mode)) { - /* It's a directory */ - } else { - /* Might be a wildcard pattern - split dir and pattern. - * Use the ORIGINAL filename+version text, not linux_dir's own - * basename — see dcl_filename_component()'s doc comment. */ - const char *orig = dcl_filename_component(cmd->params[0]); - char *last_slash = strrchr(linux_dir, '/'); - if (last_slash) { - pattern = strdup((orig && orig[0]) ? orig : last_slash + 1); - *(last_slash + 1) = '\0'; - } else { - pattern = strdup((orig && orig[0]) ? orig : linux_dir); - vmsfs_to_linux_path(ctx->default_dir, linux_dir, sizeof(linux_dir)); - } - } - } else { - vmsfs_to_linux_path(ctx->default_dir, linux_dir, sizeof(linux_dir)); - } - - /* Ensure trailing slash */ - size_t dlen = strlen(linux_dir); - if (dlen > 0 && linux_dir[dlen - 1] != '/') { - if (dlen < sizeof(linux_dir) - 1) { - linux_dir[dlen] = '/'; - linux_dir[dlen + 1] = '\0'; - } - } - - /* Check qualifiers */ - int show_size = dcl_has_qualifier(cmd, "SIZE"); - int show_date = dcl_has_qualifier(cmd, "DATE"); - int show_full = dcl_has_qualifier(cmd, "FULL"); - int show_brief = dcl_has_qualifier(cmd, "BRIEF"); - int show_owner = dcl_has_qualifier(cmd, "OWNER"); - int show_total = dcl_has_qualifier(cmd, "TOTAL"); - int show_grand_total = dcl_has_qualifier(cmd, "GRAND_TOTAL"); - /* /HEADING is default on; /NOHEADING suppresses it. - * The parser stores /NOHEADING as name="HEADING" negated=1, - * and dcl_has_qualifier returns 0 for negated qualifiers. - * So: if HEADING qualifier is absent → show (default). - * if HEADING qualifier is present and not negated → show. - * if HEADING qualifier is present and negated → hide. - * We detect negation by scanning the qualifiers directly. */ - int show_heading = 1; - for (int qi = 0; qi < cmd->qualifier_count; qi++) { - if (strcasecmp(cmd->qualifiers[qi].name, "HEADING") == 0) { - show_heading = !cmd->qualifiers[qi].negated; - break; - } - } - int show_trailing = dcl_has_qualifier(cmd, "TRAILING"); - int show_protection = dcl_has_qualifier(cmd, "PROTECTION"); - int columns = 4; - const char *col_val = dcl_qualifier_value(cmd, "COLUMNS"); - if (col_val && col_val[0]) { - char *endp; - int c = (int)strtol(col_val, &endp, 10); - if (endp != col_val && *endp == '\0') columns = c; - } - if (columns < 1) columns = 1; - if (columns > 8) columns = 8; - - /* /VERSIONS=n: list at most n versions of each file (0/absent = all). - * Grounded: DCL Dictionary DIRECTORY /VERSIONS=n. */ - int versions_limit = 0; - const char *ver_val = dcl_qualifier_value(cmd, "VERSIONS"); - if (ver_val && ver_val[0]) { - char *endp; - long v = strtol(ver_val, &endp, 10); - if (endp != ver_val && *endp == '\0' && v >= 1) versions_limit = (int)v; - } - - /* /EXCLUDE=(spec[,...]): omit files matching any spec, using the same VMS - * wildcard engine as the positional pattern. The parser stores a list as - * "(a,b,c)"; strip the parens and split on commas. Grounded: DCL - * Dictionary DIRECTORY /EXCLUDE=(file-spec[,...]). */ - #define DIR_MAX_EXCLUDE 16 - char *excl_pats[DIR_MAX_EXCLUDE]; - int excl_count = 0; - char excl_buf[512]; - const char *excl_val = dcl_qualifier_value(cmd, "EXCLUDE"); - if (excl_val && excl_val[0]) { - const char *s = excl_val; - size_t bl = strlen(s); - /* Strip a single surrounding (...) if present. */ - if (s[0] == '(' && bl >= 2 && s[bl - 1] == ')') { - strncpy(excl_buf, s + 1, sizeof(excl_buf) - 1); - excl_buf[sizeof(excl_buf) - 1] = '\0'; - size_t el = strlen(excl_buf); - if (el > 0 && excl_buf[el - 1] == ')') excl_buf[el - 1] = '\0'; - } else { - strncpy(excl_buf, s, sizeof(excl_buf) - 1); - excl_buf[sizeof(excl_buf) - 1] = '\0'; - } - char *tok = strtok(excl_buf, ","); - while (tok && excl_count < DIR_MAX_EXCLUDE) { - while (*tok == ' ') tok++; - if (*tok) excl_pats[excl_count++] = tok; - tok = strtok(NULL, ","); - } - } - - if (show_full) { - show_size = 1; - show_date = 1; - show_owner = 1; - show_protection = 1; - } - - /* If /TOTAL or /GRAND_TOTAL, suppress individual file listing */ - int suppress_files = show_total || show_grand_total; +/* + * dir_opts - the display flags a single directory scan needs, packaged so + * the per-directory collect/print helpers can be shared between the single- + * directory and the multi-directory (ellipsis) paths. + */ +struct dir_opts { + int show_size, show_date, show_full, show_brief, show_owner, + show_protection, suppress_files, columns, versions_limit; +}; - /* Display header */ - char vms_dir[512]; - /* Remove trailing slash for display */ - char display_dir[1024]; - strncpy(display_dir, linux_dir, sizeof(display_dir) - 1); - display_dir[sizeof(display_dir) - 1] = '\0'; - size_t ddlen = strlen(display_dir); - if (ddlen > 1 && display_dir[ddlen - 1] == '/') { - display_dir[ddlen - 1] = '\0'; - } - dcl_format_directory(display_dir, vms_dir, sizeof(vms_dir)); - if (show_heading) { - printf("\nDirectory %s\n\n", vms_dir); - } +/* + * dir_collect - Scan ONE Linux directory, applying the VMS filename + * wildcard/exclude filter through the single matcher vmsfs_wildcard_match(), + * and return the matching entries sorted (name asc, version desc). + * + * INV-6 / no facade: this is a real opendir()/readdir()/stat() walk of the + * on-disk vmsfs tree — every listed file exists on disk. + * + * Returns SS$_NORMAL with *out_entries (malloc'd; caller frees) and + * *out_count set; SS$_NOSUCHFILE if the directory cannot be opened; + * SS$_INSFMEM on allocation failure. + */ +static int dir_collect(const char *linux_dir, const char *pattern, + char **excl_pats, int excl_count, + struct dir_entry **out_entries, int *out_count) +{ + *out_entries = NULL; + *out_count = 0; - /* Read directory entries */ DIR *dir = opendir(linux_dir); - if (!dir) { - dcl_error("RMS", 2, "DNF", - "directory not found - %s", linux_dir); - if (pattern) free((void *)pattern); - return SS$_NOSUCHFILE; - } - - /* Collect all matching entries for sorting */ + if (!dir) return SS$_NOSUCHFILE; int capacity = 256; struct dir_entry *entries = malloc((size_t)capacity * sizeof(*entries)); - if (!entries) { - closedir(dir); - if (pattern) free((void *)pattern); - return SS$_INSFMEM; - } + if (!entries) { closedir(dir); return SS$_INSFMEM; } int entry_count = 0; struct dirent *de; while ((de = readdir(dir)) != NULL) { - /* Skip . and .. */ if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; - /* Apply wildcard filter if pattern specified. - * Use vmsfs_wildcard_match() which handles VMS % (single-char) and * */ + /* VMS filename wildcard filter — the ONE matcher (% single-char, + * * any-chars, version-aware). */ if (pattern) { if (!vmsfs_wildcard_match(pattern, de->d_name)) continue; } - /* /EXCLUDE: skip entries matching any exclusion spec. */ + /* /EXCLUDE: skip entries matching any exclusion spec, same matcher. */ if (excl_count > 0) { int excluded = 0; for (int xi = 0; xi < excl_count; xi++) { @@ -309,23 +186,16 @@ int cmd_directory(struct dcl_command *cmd) if (excluded) continue; } - /* Stat the file */ char full_path[2048]; snprintf(full_path, sizeof(full_path), "%s%s", linux_dir, de->d_name); struct stat st; if (stat(full_path, &st) != 0) continue; - /* Grow array if needed */ if (entry_count >= capacity) { capacity *= 2; struct dir_entry *tmp = realloc(entries, (size_t)capacity * sizeof(*entries)); - if (!tmp) { - free(entries); - closedir(dir); - if (pattern) free((void *)pattern); - return SS$_INSFMEM; - } + if (!tmp) { free(entries); closedir(dir); return SS$_INSFMEM; } entries = tmp; } @@ -335,38 +205,28 @@ int cmd_directory(struct dcl_command *cmd) strncpy(e->raw_name, de->d_name, sizeof(e->raw_name) - 1); e->raw_name[sizeof(e->raw_name) - 1] = '\0'; - /* Format the filename in VMS style (uppercase) */ size_t ni = 0; for (size_t i = 0; de->d_name[i] && ni < sizeof(e->vms_name) - 1; i++) { e->vms_name[ni++] = (char)toupper((unsigned char)de->d_name[i]); } e->vms_name[ni] = '\0'; - /* Determine version number. - * If the filename already contains ;N, extract it. - * Otherwise append ;1 for regular files. */ char *semi = strrchr(e->vms_name, ';'); if (semi && semi[1] != '\0') { - /* Already has a version suffix — use it */ e->version = (int)strtol(semi + 1, NULL, 10); - /* Don't double-add: nothing to append */ } else if (S_ISREG(st.st_mode)) { - /* No version suffix — add ;1 */ e->version = 1; strncat(e->vms_name, ";1", sizeof(e->vms_name) - strlen(e->vms_name) - 1); } else { - e->version = 0; /* Directories don't have versions per se */ + e->version = 0; } - /* Add .DIR;1 suffix for subdirectories */ if (S_ISDIR(st.st_mode)) { strncat(e->vms_name, ".DIR;1", sizeof(e->vms_name) - strlen(e->vms_name) - 1); } - /* Ensure a dot separator for regular files without one. - * Insert '.' before ';1' so "FOO;1" becomes "FOO.;1". */ if (S_ISREG(st.st_mode) && !strchr(de->d_name, '.')) { char *s = strrchr(e->vms_name, ';'); if (s) { @@ -379,29 +239,39 @@ int cmd_directory(struct dcl_command *cmd) } closedir(dir); - /* Sort entries: name ascending (case-insensitive), version descending */ qsort(entries, (size_t)entry_count, sizeof(struct dir_entry), dir_entry_cmp); + *out_entries = entries; + *out_count = entry_count; + return SS$_NORMAL; +} + +/* + * dir_print_entries - Print the file listing for one already-collected, + * already-sorted directory, honoring the display qualifiers, and return the + * listed file count and block totals. Shared by the single-directory and + * multi-directory paths so their per-file output is byte-identical. + */ +static void dir_print_entries(const struct dir_entry *entries, int entry_count, + const struct dir_opts *o, + int *out_files, long *out_used, long *out_alloc) +{ int file_count = 0; - long total_blocks = 0; /* blocks used (logical, ceil(size/512)) */ - long total_alloc = 0; /* blocks allocated (real st_blocks, 512-byte units) */ + long total_blocks = 0; + long total_alloc = 0; int col = 0; - int col_width = (show_size || show_date) ? 0 : (80 / columns); + int col_width = (o->show_size || o->show_date) ? 0 : (80 / o->columns); - /* /VERSIONS=n version-limit state: entries are sorted name-asc, - * version-desc, so same-name versions are consecutive newest-first; keep - * the first n of each name group and drop the rest. */ char ver_prev_base[288] = ""; int ver_group_seen = 0; for (int idx = 0; idx < entry_count; idx++) { - struct dir_entry *e = &entries[idx]; + const struct dir_entry *e = &entries[idx]; const char *vms_name = e->vms_name; long blocks = e->blocks; - struct stat *st = &e->st; + const struct stat *st = &e->st; - /* /VERSIONS=n: count versions per name group; drop the oldest. */ - if (versions_limit > 0) { + if (o->versions_limit > 0) { char base[288]; strncpy(base, vms_name, sizeof(base) - 1); base[sizeof(base) - 1] = '\0'; @@ -413,18 +283,16 @@ int cmd_directory(struct dcl_command *cmd) ver_group_seen = 0; } ver_group_seen++; - if (ver_group_seen > versions_limit) continue; /* not listed */ + if (ver_group_seen > o->versions_limit) continue; } total_blocks += blocks; - total_alloc += (long)st->st_blocks; /* real allocated blocks */ + total_alloc += (long)st->st_blocks; file_count++; - /* If /TOTAL or /GRAND_TOTAL, skip individual file display */ - if (suppress_files) continue; + if (o->suppress_files) continue; - if (show_full) { - /* Full listing: one file per line with all info */ + if (o->show_full) { printf("%-39s", vms_name); printf(" %6ld", blocks); @@ -434,96 +302,489 @@ int cmd_directory(struct dcl_command *cmd) tm.tm_mday, vms_months[tm.tm_mon], 1900 + tm.tm_year, tm.tm_hour, tm.tm_min, tm.tm_sec); - /* Protection: use vmsfs functions for proper VMS format */ uint16_t vprot = vmsfs_mode_to_protection(st->st_mode); char prot_buf[64]; vmsfs_format_protection(vprot, prot_buf, sizeof(prot_buf)); printf(" %s", prot_buf); - /* /OWNER: show file owner UIC */ - if (show_owner) { + if (o->show_owner) { printf(" [%03o,%03o]", (unsigned)(st->st_gid & 0377), (unsigned)(st->st_uid & 0377)); } printf("\n"); - } else if (show_size || show_date || show_owner || show_protection) { - /* Size and/or date and/or owner and/or protection */ + } else if (o->show_size || o->show_date || o->show_owner || + o->show_protection) { printf("%-39s", vms_name); - if (show_size) { + if (o->show_size) { printf(" %6ld", blocks); } - if (show_date) { + if (o->show_date) { struct tm tm; localtime_r(&st->st_mtime, &tm); printf(" %2d-%s-%04d %02d:%02d:%02d.00", tm.tm_mday, vms_months[tm.tm_mon], 1900 + tm.tm_year, tm.tm_hour, tm.tm_min, tm.tm_sec); } - if (show_owner) { + if (o->show_owner) { printf(" [%03o,%03o]", (unsigned)(st->st_gid & 0377), (unsigned)(st->st_uid & 0377)); } - /* /PROTECTION: file protection column (same VMS format as /FULL). */ - if (show_protection) { + if (o->show_protection) { uint16_t vprot = vmsfs_mode_to_protection(st->st_mode); char prot_buf[64]; vmsfs_format_protection(vprot, prot_buf, sizeof(prot_buf)); printf(" %s", prot_buf); } printf("\n"); - } else if (show_brief) { - /* Brief: just filename */ + } else if (o->show_brief) { printf("%s\n", vms_name); } else { - /* Columnar output */ if (col_width < 1) col_width = 20; printf("%-*s", col_width, vms_name); col++; - if (col >= columns) { + if (col >= o->columns) { printf("\n"); col = 0; } } } - free(entries); - /* Finish last line of columnar output */ - if (col > 0 && !show_size && !show_date && !show_full && !show_brief && - !show_owner && !show_protection && !suppress_files) { + if (col > 0 && !o->show_size && !o->show_date && !o->show_full && + !o->show_brief && !o->show_owner && !o->show_protection && + !o->suppress_files) { printf("\n"); } - /* Footer. Block counts appear only when file sizes are displayed - * (/SIZE or /FULL) — see dcl_print_dir_total()'s citation. */ - if (show_grand_total) { - /* Single-directory grand total (multi-directory rollup is a separate - * wildcard/ellipsis slice — see vms-1c6). VMS: "Grand total of D - * directories, F files[, ...blocks]." */ - printf("\nGrand total of 1 directory, %d file%s", - file_count, file_count != 1 ? "s" : ""); - if (show_full) { - printf(", %ld/%ld block%s", total_blocks, total_alloc, - total_blocks != 1 ? "s" : ""); - } else if (show_size) { - printf(", %ld block%s", total_blocks, - total_blocks != 1 ? "s" : ""); + *out_files = file_count; + *out_used = total_blocks; + *out_alloc = total_alloc; +} + +/* + * dir_gather_tree - Depth-first collect `base` plus every subdirectory + * below it (the real on-disk vmsfs tree), each with a trailing slash, into a + * malloc'd, case-insensitively sorted list. This is the concrete walk the + * VMS "[...]" ellipsis directory wildcard names. Returns 0 on success. + */ +static void dir_gather_recurse(const char *d, char ***list, int *count, int *cap) +{ + /* Append d */ + if (*count >= *cap) { + int nc = *cap ? *cap * 2 : 16; + char **tmp = realloc(*list, (size_t)nc * sizeof(char *)); + if (!tmp) return; + *list = tmp; + *cap = nc; + } + (*list)[(*count)++] = strdup(d); + + DIR *dir = opendir(d); + if (!dir) return; + + /* Collect child subdir names first, then recurse in sorted order. */ + char **subs = NULL; int scount = 0, scap = 0; + struct dirent *de; + while ((de = readdir(dir)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) + continue; + char child[2048]; + snprintf(child, sizeof(child), "%s%s", d, de->d_name); + struct stat st; + if (stat(child, &st) != 0 || !S_ISDIR(st.st_mode)) continue; + if (scount >= scap) { + int nc = scap ? scap * 2 : 8; + char **tmp = realloc(subs, (size_t)nc * sizeof(char *)); + if (!tmp) break; + subs = tmp; scap = nc; + } + char childslash[2049]; + snprintf(childslash, sizeof(childslash), "%s/", child); + subs[scount++] = strdup(childslash); + } + closedir(dir); + + /* Sort children so the tree is emitted in a stable VMS-like order. */ + for (int i = 0; i < scount; i++) { + for (int j = i + 1; j < scount; j++) { + if (strcasecmp(subs[i], subs[j]) > 0) { + char *t = subs[i]; subs[i] = subs[j]; subs[j] = t; + } + } + } + for (int i = 0; i < scount; i++) { + dir_gather_recurse(subs[i], list, count, cap); + free(subs[i]); + } + free(subs); +} + +/* + * dir_deellipsize - Rewrite a VMS directory spec that contains the "..." + * ellipsis wildcard into a plain, resolvable directory spec naming the START + * of the tree (the ellipsis and everything from it is dropped), and report + * whether an ellipsis was present. + * + * "[...]*.TXT" -> "*.TXT" (start = current default dir) + * "[MYDIR...]*.C" -> "[MYDIR]*.C" (start = top-level [MYDIR]) + * "[.SUB...]F.T" -> "[.SUB]F.T" (start = default's [.SUB]) + * + * The DIRECTORY command then resolves the rewritten spec exactly as it does a + * non-ellipsis spec, and walks the tree from the resolved start directory. + * + * Grounded (clean-room, Rule 8): VSI OpenVMS DCL Dictionary — the ellipsis + * ("...") in a directory specification means "this directory and all + * subdirectories below it"; DIRECTORY over such a spec produces a + * per-directory listing plus a "Grand total" line. + */ +static int dir_deellipsize(const char *spec, char *out, size_t out_sz) +{ + const char *lb = strchr(spec, '['); + const char *rb = lb ? strchr(lb, ']') : NULL; + if (!lb || !rb) { + strncpy(out, spec, out_sz - 1); + out[out_sz - 1] = '\0'; + return 0; + } + + size_t dlen = (size_t)(rb - (lb + 1)); + char dtext[512]; + if (dlen >= sizeof(dtext)) dlen = sizeof(dtext) - 1; + memcpy(dtext, lb + 1, dlen); + dtext[dlen] = '\0'; + + char *ell = strstr(dtext, "..."); + if (!ell) { + strncpy(out, spec, out_sz - 1); + out[out_sz - 1] = '\0'; + return 0; + } + *ell = '\0'; /* drop "..." and everything after it */ + + size_t before = (size_t)(lb - spec); + char pre[600]; + if (before >= sizeof(pre)) before = sizeof(pre) - 1; + memcpy(pre, spec, before); + pre[before] = '\0'; + const char *post = rb + 1; + + if (dtext[0]) + snprintf(out, out_sz, "%s[%s]%s", pre, dtext, post); + else + snprintf(out, out_sz, "%s%s", pre, post); + return 1; +} + +int cmd_directory(struct dcl_command *cmd) +{ + struct dcl_context *ctx = dcl_get_context(); + + /* Determine the directory to list */ + char linux_dir[1024]; + const char *pattern = NULL; + + /* Detect and strip a "..." ellipsis directory wildcard. `use_spec` is the + * spec with the ellipsis removed (naming the START of the tree); the + * resolution below treats it exactly like a non-ellipsis spec, and when + * has_ellipsis is set the listing walks the resolved directory's whole + * subtree (see dir_deellipsize() / dir_gather_recurse()). */ + char despec[1024]; + int has_ellipsis = 0; + const char *use_spec = NULL; + if (cmd->param_count >= 1 && cmd->params[0][0] != '\0') { + has_ellipsis = dir_deellipsize(cmd->params[0], despec, sizeof(despec)); + use_spec = despec; + } + + if (use_spec && use_spec[0] != '\0') { + dcl_resolve_path(ctx, use_spec, linux_dir, sizeof(linux_dir)); + /* Check if this is a directory or a file pattern */ + struct stat st; + if (stat(linux_dir, &st) == 0 && S_ISDIR(st.st_mode)) { + /* It's a directory */ + } else { + /* Might be a wildcard pattern - split dir and pattern. + * Use the ORIGINAL filename+version text, not linux_dir's own + * basename — see dcl_filename_component()'s doc comment. */ + const char *orig = dcl_filename_component(use_spec); + char *last_slash = strrchr(linux_dir, '/'); + if (last_slash) { + pattern = strdup((orig && orig[0]) ? orig : last_slash + 1); + *(last_slash + 1) = '\0'; + } else { + pattern = strdup((orig && orig[0]) ? orig : linux_dir); + vmsfs_to_linux_path(ctx->default_dir, linux_dir, sizeof(linux_dir)); + } } - printf(".\n"); } else { - dcl_print_dir_total(file_count, total_blocks, total_alloc, - show_size, show_full); + vmsfs_to_linux_path(ctx->default_dir, linux_dir, sizeof(linux_dir)); + } + + /* Ensure trailing slash */ + size_t dlen = strlen(linux_dir); + if (dlen > 0 && linux_dir[dlen - 1] != '/') { + if (dlen < sizeof(linux_dir) - 1) { + linux_dir[dlen] = '/'; + linux_dir[dlen + 1] = '\0'; + } + } + + /* Check qualifiers */ + int show_size = dcl_has_qualifier(cmd, "SIZE"); + int show_date = dcl_has_qualifier(cmd, "DATE"); + int show_full = dcl_has_qualifier(cmd, "FULL"); + int show_brief = dcl_has_qualifier(cmd, "BRIEF"); + int show_owner = dcl_has_qualifier(cmd, "OWNER"); + int show_total = dcl_has_qualifier(cmd, "TOTAL"); + int show_grand_total = dcl_has_qualifier(cmd, "GRAND_TOTAL"); + /* /HEADING is default on; /NOHEADING suppresses it. + * The parser stores /NOHEADING as name="HEADING" negated=1, + * and dcl_has_qualifier returns 0 for negated qualifiers. + * So: if HEADING qualifier is absent → show (default). + * if HEADING qualifier is present and not negated → show. + * if HEADING qualifier is present and negated → hide. + * We detect negation by scanning the qualifiers directly. */ + int show_heading = 1; + for (int qi = 0; qi < cmd->qualifier_count; qi++) { + if (strcasecmp(cmd->qualifiers[qi].name, "HEADING") == 0) { + show_heading = !cmd->qualifiers[qi].negated; + break; + } + } + int show_trailing = dcl_has_qualifier(cmd, "TRAILING"); + int show_protection = dcl_has_qualifier(cmd, "PROTECTION"); + int columns = 4; + const char *col_val = dcl_qualifier_value(cmd, "COLUMNS"); + if (col_val && col_val[0]) { + char *endp; + int c = (int)strtol(col_val, &endp, 10); + if (endp != col_val && *endp == '\0') columns = c; } + if (columns < 1) columns = 1; + if (columns > 8) columns = 8; - /* /TRAILING: repeat the totals with the directory spec appended. */ - if (show_trailing) { - dcl_print_dir_total(file_count, total_blocks, total_alloc, - show_size, show_full); - printf("%s\n", vms_dir); + /* /VERSIONS=n: list at most n versions of each file (0/absent = all). + * Grounded: DCL Dictionary DIRECTORY /VERSIONS=n. */ + int versions_limit = 0; + const char *ver_val = dcl_qualifier_value(cmd, "VERSIONS"); + if (ver_val && ver_val[0]) { + char *endp; + long v = strtol(ver_val, &endp, 10); + if (endp != ver_val && *endp == '\0' && v >= 1) versions_limit = (int)v; + } + + /* /EXCLUDE=(spec[,...]): omit files matching any spec, using the same VMS + * wildcard engine as the positional pattern. The parser stores a list as + * "(a,b,c)"; strip the parens and split on commas. Grounded: DCL + * Dictionary DIRECTORY /EXCLUDE=(file-spec[,...]). */ + #define DIR_MAX_EXCLUDE 16 + char *excl_pats[DIR_MAX_EXCLUDE]; + int excl_count = 0; + char excl_buf[512]; + const char *excl_val = dcl_qualifier_value(cmd, "EXCLUDE"); + if (excl_val && excl_val[0]) { + const char *s = excl_val; + size_t bl = strlen(s); + /* Strip a single surrounding (...) if present. */ + if (s[0] == '(' && bl >= 2 && s[bl - 1] == ')') { + strncpy(excl_buf, s + 1, sizeof(excl_buf) - 1); + excl_buf[sizeof(excl_buf) - 1] = '\0'; + size_t el = strlen(excl_buf); + if (el > 0 && excl_buf[el - 1] == ')') excl_buf[el - 1] = '\0'; + } else { + strncpy(excl_buf, s, sizeof(excl_buf) - 1); + excl_buf[sizeof(excl_buf) - 1] = '\0'; + } + char *tok = strtok(excl_buf, ","); + while (tok && excl_count < DIR_MAX_EXCLUDE) { + while (*tok == ' ') tok++; + if (*tok) excl_pats[excl_count++] = tok; + tok = strtok(NULL, ","); + } } + if (show_full) { + show_size = 1; + show_date = 1; + show_owner = 1; + show_protection = 1; + } + + /* If /TOTAL or /GRAND_TOTAL, suppress individual file listing */ + int suppress_files = show_total || show_grand_total; + + /* Package the display flags once for the per-directory helpers so the + * single-directory and multi-directory (ellipsis) paths render files + * identically. */ + struct dir_opts opts; + opts.show_size = show_size; + opts.show_date = show_date; + opts.show_full = show_full; + opts.show_brief = show_brief; + opts.show_owner = show_owner; + opts.show_protection = show_protection; + opts.suppress_files = suppress_files; + opts.columns = columns; + opts.versions_limit = versions_limit; + + char vms_dir[512]; /* VMS display spec of the (last) directory listed */ + vms_dir[0] = '\0'; + + if (!has_ellipsis) { + /* ---------------- Single-directory listing ---------------- */ + struct dir_entry *entries = NULL; + int entry_count = 0; + int cst = dir_collect(linux_dir, pattern, excl_pats, excl_count, + &entries, &entry_count); + if (cst == SS$_NOSUCHFILE) { + dcl_error("RMS", 2, "DNF", "directory not found - %s", linux_dir); + if (pattern) free((void *)pattern); + return SS$_NOSUCHFILE; + } + if (cst != SS$_NORMAL) { + if (pattern) free((void *)pattern); + return cst; + } + + /* Zero matches: VMS prints %DIRECT-W-NOFILES with NO header — not an + * empty "Total of 0 files." trailer. + * Grounded (clean-room, Rule 8): VSI OpenVMS DCL Dictionary, DIRECTORY + * — a search that finds nothing yields + * "%DIRECT-W-NOFILES, no files found". */ + if (entry_count == 0) { + free(entries); + if (pattern) free((void *)pattern); + dcl_error("DIRECT", STS$K_WARNING, "NOFILES", "no files found"); + return SS$_NOSUCHFILE; + } + + /* Header (only once we know there is at least one file to list). */ + char display_dir[1024]; + strncpy(display_dir, linux_dir, sizeof(display_dir) - 1); + display_dir[sizeof(display_dir) - 1] = '\0'; + size_t ddlen = strlen(display_dir); + if (ddlen > 1 && display_dir[ddlen - 1] == '/') + display_dir[ddlen - 1] = '\0'; + dcl_format_directory(display_dir, vms_dir, sizeof(vms_dir)); + if (show_heading) printf("\nDirectory %s\n\n", vms_dir); + + int file_count = 0; + long total_blocks = 0, total_alloc = 0; + dir_print_entries(entries, entry_count, &opts, + &file_count, &total_blocks, &total_alloc); + free(entries); + + /* Footer. Block counts appear only when file sizes are displayed + * (/SIZE or /FULL) — see dcl_print_dir_total()'s citation. */ + if (show_grand_total) { + printf("\nGrand total of 1 directory, %d file%s", + file_count, file_count != 1 ? "s" : ""); + if (show_full) + printf(", %ld/%ld block%s", total_blocks, total_alloc, + total_blocks != 1 ? "s" : ""); + else if (show_size) + printf(", %ld block%s", total_blocks, + total_blocks != 1 ? "s" : ""); + printf(".\n"); + } else { + dcl_print_dir_total(file_count, total_blocks, total_alloc, + show_size, show_full); + } + + /* /TRAILING: repeat the totals with the directory spec appended. */ + if (show_trailing) { + dcl_print_dir_total(file_count, total_blocks, total_alloc, + show_size, show_full); + printf("%s\n", vms_dir); + } + + if (pattern) free((void *)pattern); + return SS$_NORMAL; + } + + /* ---------------- Multi-directory ellipsis listing ---------------- + * The "..." wildcard names the start directory plus every subdirectory + * below it. Walk the real on-disk tree, list each directory that has at + * least one match with its own header + "Total of N files" subtotal, then + * emit a single "Grand total of D directories, F files[, M blocks]." line. + * Grounded (clean-room, Rule 8): VSI OpenVMS DCL Dictionary, DIRECTORY — + * ellipsis directory wildcard + the per-directory / Grand total layout. */ + { + struct stat bst; + if (stat(linux_dir, &bst) != 0 || !S_ISDIR(bst.st_mode)) { + dcl_error("RMS", 2, "DNF", "directory not found - %s", linux_dir); + if (pattern) free((void *)pattern); + return SS$_NOSUCHFILE; + } + } + + char **dirs = NULL; + int ndirs = 0, dcap = 0; + dir_gather_recurse(linux_dir, &dirs, &ndirs, &dcap); + + long grand_used = 0, grand_alloc = 0, grand_files = 0; + int grand_dirs = 0; + + for (int di = 0; di < ndirs; di++) { + struct dir_entry *entries = NULL; + int entry_count = 0; + int cst = dir_collect(dirs[di], pattern, excl_pats, excl_count, + &entries, &entry_count); + if (cst != SS$_NORMAL) { free(entries); continue; } + if (entry_count == 0) { free(entries); continue; } + + char display_dir[1024]; + strncpy(display_dir, dirs[di], sizeof(display_dir) - 1); + display_dir[sizeof(display_dir) - 1] = '\0'; + size_t ddlen = strlen(display_dir); + if (ddlen > 1 && display_dir[ddlen - 1] == '/') + display_dir[ddlen - 1] = '\0'; + dcl_format_directory(display_dir, vms_dir, sizeof(vms_dir)); + + int file_count = 0; + long used = 0, alloc = 0; + if (show_grand_total) { + /* /GRAND_TOTAL: suppress every directory's header, files and + * subtotal — only the final grand total is printed. */ + struct dir_opts sopts = opts; + sopts.suppress_files = 1; + dir_print_entries(entries, entry_count, &sopts, + &file_count, &used, &alloc); + } else { + if (show_heading) printf("\nDirectory %s\n\n", vms_dir); + dir_print_entries(entries, entry_count, &opts, + &file_count, &used, &alloc); + dcl_print_dir_total(file_count, used, alloc, show_size, show_full); + } + free(entries); + + grand_files += file_count; + grand_used += used; + grand_alloc += alloc; + grand_dirs++; + } + + for (int di = 0; di < ndirs; di++) free(dirs[di]); + free(dirs); + + if (grand_files == 0) { + if (pattern) free((void *)pattern); + dcl_error("DIRECT", STS$K_WARNING, "NOFILES", "no files found"); + return SS$_NOSUCHFILE; + } + + printf("\nGrand total of %d director%s, %ld file%s", + grand_dirs, grand_dirs == 1 ? "y" : "ies", + grand_files, grand_files != 1 ? "s" : ""); + if (show_full) + printf(", %ld/%ld block%s", grand_used, grand_alloc, + grand_used != 1 ? "s" : ""); + else if (show_size) + printf(", %ld block%s", grand_used, grand_used != 1 ? "s" : ""); + printf(".\n"); + if (pattern) free((void *)pattern); return SS$_NORMAL; } diff --git a/tests/dcl/test_directory.sh b/tests/dcl/test_directory.sh index 776bfbcb1..83c7dfc60 100644 --- a/tests/dcl/test_directory.sh +++ b/tests/dcl/test_directory.sh @@ -1,6 +1,18 @@ #!/bin/bash # TEST: DIRECTORY command lists files +# +# Hermetic (vms-1c6): list a real temporary directory holding one real file so +# the smoke test is deterministic regardless of the process default directory. +# A DIRECTORY that resolves to a real, populated directory must produce the VMS +# "Directory ...] / Total of N files." shape, never a raw Unix "ls:"/"total N" +# error. (A bare DIRECTORY against a non-existent default dir now yields the +# authentic %RMS-E-DNF / %DIRECT-W-NOFILES with no header — see +# test_directory_wildcards.sh — so this smoke test lists a directory that +# actually exists.) # EXPECT: regex:(Directory|Total of|files) # EXPECT_NOT: regex:^(ls:|total [0-9]) VMSDCL="${VMSDCL:-vmsdcl}" -echo "DIRECTORY" | $VMSDCL 2>&1 +TDIR=$(mktemp -d) +touch "$TDIR/smoke.txt;1" +printf 'DEFINE TD "%s"\nSET DEFAULT TD:[000000]\nDIRECTORY\n' "$TDIR" | $VMSDCL 2>&1 +rm -rf "$TDIR" diff --git a/tests/dcl/test_directory_wildcards.sh b/tests/dcl/test_directory_wildcards.sh new file mode 100755 index 000000000..54c3b1e2f --- /dev/null +++ b/tests/dcl/test_directory_wildcards.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# TEST: DIRECTORY wildcard + ellipsis uniformity and multi-directory rollup (vms-1c6) +# +# This slice of vms-1c6 (File/RMS user-visible fidelity) locks down the file- +# name/directory wildcard surface a VMS user drives constantly: +# +# * matches zero or more characters +# % matches exactly one character +# ... (ellipsis) recurses THIS directory and every subdirectory below it +# +# All three run through the single filename matcher vmsfs_wildcard_match() +# (src/vmsfs/vmsfs_translate.c); the ellipsis directory walk is a real, +# on-disk depth-first traversal of the vmsfs tree (INV-6: no faked recursion — +# a listed subdirectory file exists on disk). +# +# Grounded (clean-room, Rule 8): VSI OpenVMS DCL Dictionary, DIRECTORY command: +# - "*"/"%" filename wildcards; +# - the "..." ellipsis directory wildcard = "this directory and all +# subdirectories below it"; +# - a multi-directory listing prints a per-directory header + "Total of N +# files" subtotal, then one "Grand total of D directories, F files[, M +# blocks]." line; +# - a search that matches nothing yields "%DIRECT-W-NOFILES, no files found" +# (a warning), NOT an empty success / "Total of 0 files." +# +# --- "*" filename wildcard: *.TXT matches only the .TXT file --- +# EXPECT: contains:APPLE.TXT;1 +# EXPECT_NOT: contains:BANANA.DAT +# +# --- "%" single-char wildcard: %.LOG matches the 1-char name, not the 2-char --- +# EXPECT: contains:A.LOG;1 +# EXPECT_NOT: contains:AB.LOG +# +# --- "..." ellipsis: recurse base + subdirectory, per-dir subtotals + grand total --- +# EXPECT: contains:CHERRY.TXT;1 +# EXPECT: regex:Grand total of 2 directories, 2 files\. +# +# --- zero matches: the authentic %DIRECT-W-NOFILES warning --- +# EXPECT: contains:%DIRECT-W-NOFILES, no files found +VMSDCL="${VMSDCL:-vmsdcl}" +TDIR=$(mktemp -d) + +# Base directory files. +touch "$TDIR/apple.txt;1" +touch "$TDIR/banana.dat;1" +touch "$TDIR/a.log;1" +touch "$TDIR/ab.log;1" + +# A real subdirectory with its own matching file (ellipsis must reach it). +mkdir "$TDIR/logs" +touch "$TDIR/logs/cherry.txt;1" + +# 1) "*" wildcard — *.TXT in the base directory only. +printf 'DEFINE TD "%s"\nSET DEFAULT TD:[000000]\nDIRECTORY *.TXT\n' "$TDIR" | $VMSDCL 2>&1 + +# 2) "%" single-char wildcard — %.LOG matches a.log;1 but not ab.log;1. +printf 'DEFINE TD "%s"\nSET DEFAULT TD:[000000]\nDIRECTORY %%.LOG\n' "$TDIR" | $VMSDCL 2>&1 + +# 3) "..." ellipsis — recurse base + logs, expect a two-directory grand total. +printf 'DEFINE TD "%s"\nSET DEFAULT TD:[000000]\nDIRECTORY [...]*.TXT\n' "$TDIR" | $VMSDCL 2>&1 + +# 4) zero matches — the authentic NOFILES warning, not "Total of 0 files." +printf 'DEFINE TD "%s"\nSET DEFAULT TD:[000000]\nDIRECTORY NOSUCH.XYZ\n' "$TDIR" | $VMSDCL 2>&1 + +rm -rf "$TDIR" From dbce4af6e3d02e72c939fb8d8b74757845c44d53 Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 13 Aug 2026 10:00:53 +0000 Subject: [PATCH 2/2] vms-1c6: fix test_create_dir for authentic empty-directory DIRECTORY output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI Build & Test caught the real regression: test_create_dir listed the freshly created (empty) directory with `DIRECTORY [.NEWDIR]` and asserted the output contained "NEWDIR". That only worked because the OLD code printed a "Directory ...] / Total of 0 files." header for an empty directory. This PR makes an empty listing yield the authentic "%DIRECT-W-NOFILES, no files found" (no header), so "NEWDIR" no longer appears — the test encoded the old, non-authentic output. Fix (not a weakening — a stronger, VMS-authentic proof): create the directory with an absolute target spec so it lands deterministically under the parent, then list the PARENT and assert the "NEWDIR.DIR;1" entry with "Total of 1 file." (a directory is a .DIR file within its parent — VSI OpenVMS DCL Dictionary, DIRECTORY). This proves creation via a real, non-empty listing and also guards EXPECT_NOT %DIRECT-W-NOFILES and EXPECT_NOT mkdir:. Self-cleaning (no device-root leak the previous form left behind). Full dcl-integration now 128 passed / 0 failed with /vms set up exactly as the CI Build & Test job does (sudo mkdir /vms/SYS0/SYSCOMMON/... ; ctest). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/dcl/test_create_dir.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/dcl/test_create_dir.sh b/tests/dcl/test_create_dir.sh index 0ccb81d9a..fac753f52 100644 --- a/tests/dcl/test_create_dir.sh +++ b/tests/dcl/test_create_dir.sh @@ -1,10 +1,25 @@ #!/bin/bash # TEST: CREATE/DIRECTORY creates a new directory -# EXPECT: contains:NEWDIR +# +# CREATE/DIRECTORY makes a new subdirectory; the VMS-authentic proof is that it +# then appears as a "NEWDIR.DIR;1" entry when its PARENT directory is listed (a +# directory is a .DIR file within its parent). An absolute target spec is used +# so the new directory lands deterministically under the parent, and the parent +# listing is a real, non-empty "Total of 1 file." — NOT the empty-directory +# "%DIRECT-W-NOFILES, no files found" result that listing the freshly created +# (still empty) directory itself would authentically produce (see vms-1c6 / +# test_directory_wildcards.sh). The previous form listed the new directory +# directly and relied on the old, non-authentic "header + Total of 0 files." +# output for an empty directory. +# Grounded (clean-room, Rule 8): VSI OpenVMS DCL Dictionary, DIRECTORY — a +# subdirectory is listed within its parent as name.DIR;1. +# EXPECT: contains:NEWDIR.DIR +# EXPECT: regex:Total of 1 file\. # EXPECT_NOT: contains:mkdir: +# EXPECT_NOT: contains:%DIRECT-W-NOFILES VMSDCL="${VMSDCL:-vmsdcl}" TDIR="dcl_test_$$" VDIR="$(echo "$TDIR" | tr a-z A-Z)" mkdir -p "/vms/$TDIR" -printf 'SET DEFAULT SYS$SYSDEVICE:[%s]\nCREATE/DIRECTORY [.NEWDIR]\nDIRECTORY [.NEWDIR]\n' "$VDIR" | $VMSDCL 2>&1 +printf 'CREATE/DIRECTORY SYS$SYSDEVICE:[%s.NEWDIR]\nDIRECTORY SYS$SYSDEVICE:[%s]\n' "$VDIR" "$VDIR" | $VMSDCL 2>&1 rm -rf "/vms/$TDIR"