From 572725e61a00b9deda7407971dc9290477170515 Mon Sep 17 00:00:00 2001 From: Rares Popa <2606875+rarepops@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:00:57 +0200 Subject: [PATCH] fix(extraction): treat trailing blanks at EOF as an absent newline, not a miss A file whose final line lacks its newline leaves the grammar's mandatory terminator MISSING at EOF. #1610 suppressed that phantom parse_partial on the grounds that the node is ZERO-WIDTH: the parser consumed no source for it, so by construction nothing was dropped. That suppression tested `end == source_len` exactly. Trailing blanks are extras owned by no node, so `ENTRYPOINT ["a"] ` + EOF parks the zero-width terminator one byte short of source_len and the check missed it. This is why neither a trailing blank nor an absent final newline flagged on its own -- only the pair did, exactly as the reporter's byte-exact control matrix showed. Treat "at EOF" as EOF modulo a trailing blank run. Every blank except newline qualifies, deliberately not a hand-picked subset: a form feed is no more content than a space, and #1610 exists precisely because whether a file got flagged used to hinge on such incidentals. Newline stays excluded because a terminated final line produces no MISSING terminator at all. The rule remains ZERO-WIDTH ONLY. A width-bearing MISSING/ERROR at EOF is a genuine loss and is still flagged (a Makefile's unterminated final recipe really does vanish), as is any failure earlier in the file; both are pinned by guard tests alongside the existing #1610 guards. Verified end to end: the issue's own `cli index_repository` reproduction goes from parse_partial_count 1 to 0 with node count unchanged, so the phantom flag is gone without anything dropping out of the graph. Fixes #1746 Signed-off-by: Rares Popa <2606875+rarepops@users.noreply.github.com> --- internal/cbm/cbm.c | 12 ++++-- tests/test_parse_coverage.c | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index da6e720a2..cac199d2b 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -817,9 +817,13 @@ static void cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) { * untouched. * * #1746: the Dockerfile grammar places that zero-width missing newline before - * trailing horizontal whitespace rather than at raw EOF. Preserve the broad - * exact-EOF rule above; only extend it past spaces/tabs when the missing token - * is specifically a newline. */ + * trailing whitespace rather than at raw EOF. Preserve the broad exact-EOF + * rule above; only extend it past blanks when the missing token is specifically + * a newline. */ +static bool cbm_is_blank_not_newline(char c) { + return c == ' ' || c == '\t' || c == '\v' || c == '\f' || c == '\r'; +} + static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_len) { if (!ts_node_is_missing(n) || source_len < 0) { return false; @@ -836,7 +840,7 @@ static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_ return false; } for (uint32_t i = end; i < (uint32_t)source_len; i++) { - if (source[i] != ' ' && source[i] != '\t') { + if (!cbm_is_blank_not_newline(source[i])) { return false; } } diff --git a/tests/test_parse_coverage.c b/tests/test_parse_coverage.c index ba741531d..e044418fb 100644 --- a/tests/test_parse_coverage.c +++ b/tests/test_parse_coverage.c @@ -470,6 +470,77 @@ TEST(perl_malformed_source_remains_partial_issue1838) { PASS(); } +/* ── #1746: trailing blanks before EOF are still just an absent newline ─────── + * + * #1610 suppressed the zero-width MISSING terminator but tested for it with + * `end == source_len`. Trailing blanks are extras owned by no node, so + * `ENTRYPOINT ["a"] ` + EOF parks it at [29,29) while source_len is 30. + * + * The reporter's byte-exact controls pin the trigger to the PAIR: `] ` + EOF + * flags, `] ` + newline is clean, `]` + EOF is clean. */ +TEST(dockerfile_trailing_blank_at_eof_not_flagged_issue1746) { + const char *cases[] = { + "FROM scratch\nENTRYPOINT [\"a\"] ", /* space + EOF — the report */ + "FROM scratch\nENTRYPOINT [\"a\"]\t", /* tab + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"]\v", /* vertical tab + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"]\f", /* form feed + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"] \t ", /* run of blanks + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"] \r", /* CRLF file truncated to CR */ + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + CBMFileResult *r = do_extract(cases[i], CBM_LANG_DOCKERFILE, "Dockerfile"); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + if (flagged) { + fprintf(stderr, " case %zu flagged: ranges=%s\n", i, + r->error_ranges ? r->error_ranges : "(none)"); + } + cbm_free_result(r); + if (flagged) { + FAIL("trailing blanks must not turn an absent final newline into parse_partial"); + } + } + PASS(); +} + +/* GUARD: widening the tail must not swallow a genuine mid-file failure just + * because the file happens to end in blanks. */ +TEST(real_error_before_eof_still_flagged_with_trailing_blank_issue1746) { + size_t n = strlen(C_IFDEF_SPLIT); + char *buf = (char *)malloc(n + 1); + ASSERT_NOT_NULL(buf); + memcpy(buf, C_IFDEF_SPLIT, n + 1); + buf[n - 1] = ' '; /* final newline becomes a blank */ + + CBMFileResult *r = do_extract(buf, CBM_LANG_C, "split.c"); + free(buf); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + bool has_ranges = r->error_ranges != NULL; + cbm_free_result(r); + if (!flagged) { + FAIL("a real mid-file parse failure must still be reported when the file ends in blanks"); + } + if (!has_ranges) { + FAIL("a reported failure must still name its line range"); + } + PASS(); +} + +/* GUARD: a WIDTH-BEARING loss at EOF stays honest with a blank tail too — the + * Makefile recipe really is dropped, and only zero-width nodes are excused. */ +TEST(width_bearing_error_at_eof_still_flagged_with_trailing_blank_issue1746) { + const char *src = "all:\n\techo hi "; + CBMFileResult *r = do_extract(src, CBM_LANG_MAKEFILE, "Makefile"); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + cbm_free_result(r); + if (!flagged) { + FAIL("a width-bearing parse failure at EOF must still be reported when the file ends in blanks"); + } + PASS(); +} + SUITE(parse_coverage) { RUN_TEST(c_ifdef_split_brace_sets_parse_incomplete); RUN_TEST(c_ifdef_split_brace_neighbors_still_extracted); @@ -491,4 +562,7 @@ SUITE(parse_coverage) { RUN_TEST(width_bearing_error_at_eof_still_flagged_issue1610); RUN_TEST(perl_format_followed_by_named_sub_is_complete_issue1838); RUN_TEST(perl_malformed_source_remains_partial_issue1838); + RUN_TEST(dockerfile_trailing_blank_at_eof_not_flagged_issue1746); + RUN_TEST(real_error_before_eof_still_flagged_with_trailing_blank_issue1746); + RUN_TEST(width_bearing_error_at_eof_still_flagged_with_trailing_blank_issue1746); }