Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/discover/discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,11 @@ static const char *file_skip_reason(const char *entry_name, const char *rel_path
static CBMLanguage detect_file_language(const char *entry_name, const char *abs_path) {
CBMLanguage lang = cbm_language_for_filename(entry_name);
if (lang == CBM_LANG_COUNT) {
return CBM_LANG_COUNT;
/* Filename/extension detection failed: fall back to a conservative
* shebang probe so extensionless scripts get indexed (#1199). Filename
* detection stays authoritative — this runs only when it returns
* unknown. */
return cbm_language_from_shebang(abs_path);
}
/* Special: .m files need content-based disambiguation */
const char *dot = strrchr(entry_name, '.');
Expand Down
14 changes: 14 additions & 0 deletions src/discover/discover.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ CBMLanguage cbm_disambiguate_cls(const char *path);
* On read failure, defaults to CBM_LANG_BITBAKE. */
CBMLanguage cbm_disambiguate_inc(const char *path);

/* Detect a supported script language from a file's shebang (#!...) first line.
* Conservative fallback used only when filename/extension detection is unknown
* (see detect_file_language); it never overrides extension or special-filename
* matches. Opens the file read-only and reads only a bounded first line.
* Recognizes the interpreter *basename* (not the parent path):
* python / python2 / python3 / dotted versions (python3.12) -> CBM_LANG_PYTHON
* sh / bash / dash / ksh / zsh -> CBM_LANG_BASH
* node / nodejs -> CBM_LANG_JAVASCRIPT
* ruby -> RUBY, perl -> PERL, php -> PHP, lua -> LUA
* Handles direct paths, "env <interp>", "env -S <interp> <args>", and CRLF.
* Fails closed (returns CBM_LANG_COUNT) on read error, missing/malformed
* shebang, an embedded NUL in the first line, or an unknown interpreter. */
CBMLanguage cbm_language_from_shebang(const char *path);

/* ── Gitignore pattern matching ──────────────────────────────────── */

typedef struct cbm_gitignore cbm_gitignore_t;
Expand Down
166 changes: 166 additions & 0 deletions src/discover/language.c
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,172 @@ const char *cbm_language_name(CBMLanguage lang) {
return LANG_NAMES[lang] ? LANG_NAMES[lang] : "Unknown";
}

/* ── Shebang interpreter detection (extensionless scripts) ────────── */

/* Basename of an interpreter path: the segment after the last '/'. Shebangs
* are a POSIX convention, so only '/' is treated as a separator. */
static const char *interp_basename(const char *path) {
const char *slash = strrchr(path, '/');
return slash ? slash + SKIP_ONE : path;
}

/* "python" optionally followed by an explicit numeric version (digits and dots
* only, e.g. "python3", "python3.12"). Bounded and explicit so arbitrary
* suffixes like "python-wrapper" are rejected. */
static bool is_python_interp(const char *base) {
if (strncmp(base, "python", SLEN("python")) != 0) {
return false;
}
const char *version = base + SLEN("python");
if (*version == '\0') {
return true;
}

/* Each numeric component must contain at least one digit. */
bool need_digit = true;
for (const char *v = version; *v; v++) {
if (isdigit((unsigned char)*v)) {
need_digit = false;
} else if (*v == '.' && !need_digit) {
need_digit = true;
} else {
return false;
}
}
return !need_digit;
}

/* Map an interpreter basename to a language, or CBM_LANG_COUNT if unrecognized.
* Non-python interpreters are matched exactly (no prefix/suffix logic). */
static CBMLanguage lang_for_interpreter(const char *base) {
if (is_python_interp(base)) {
return CBM_LANG_PYTHON;
}
static const struct {
const char *name;
CBMLanguage lang;
} INTERP_TABLE[] = {
{"sh", CBM_LANG_BASH}, {"bash", CBM_LANG_BASH}, {"dash", CBM_LANG_BASH},
{"ksh", CBM_LANG_BASH}, {"zsh", CBM_LANG_BASH}, {"node", CBM_LANG_JAVASCRIPT},
{"nodejs", CBM_LANG_JAVASCRIPT}, {"ruby", CBM_LANG_RUBY}, {"perl", CBM_LANG_PERL},
{"php", CBM_LANG_PHP}, {"lua", CBM_LANG_LUA},
};
for (size_t i = 0; i < sizeof(INTERP_TABLE) / sizeof(INTERP_TABLE[0]); i++) {
if (strcmp(base, INTERP_TABLE[i].name) == 0) {
return INTERP_TABLE[i].lang;
}
}
return CBM_LANG_COUNT;
}

/* Advance *cursor past leading blanks and return the next whitespace-delimited
* token (NUL-terminated in place), or NULL when the line is exhausted. */
static char *shebang_next_token(char **cursor) {
char *p = *cursor;
while (*p == ' ' || *p == '\t') {
p++;
}
if (*p == '\0') {
*cursor = p;
return NULL;
}
char *start = p;
while (*p && *p != ' ' && *p != '\t') {
p++;
}
if (*p) {
*p = '\0';
p++;
}
*cursor = p;
return start;
}

CBMLanguage cbm_language_from_shebang(const char *path) {
if (!path) {
return CBM_LANG_COUNT;
}

FILE *f = cbm_fopen(path, "rb");
if (!f) {
return CBM_LANG_COUNT; /* fail closed on read error */
}

/* Read only a bounded first line. */
char buf[CBM_SZ_256];
size_t n = fread(buf, SKIP_ONE, sizeof(buf) - SKIP_ONE, f);

/* Fail closed on any read error rather than parsing a partial buffer. */
if (ferror(f)) {
(void)fclose(f);
return CBM_LANG_COUNT;
}

/* If the bounded buffer filled without containing a newline, the first
* line may extend past our bound. Probe a single extra byte to tell an
* exact EOF (the whole file is <= 255 bytes) from a truncated longer
* line: any surviving byte -- including a newline just beyond the bound --
* means the first line was cut off, so fail closed. A probe read error
* fails closed too. This keeps the read bounded (no unbounded line read
* or allocation). */
bool have_newline = (memchr(buf, '\n', n) != NULL);
if (!have_newline && n == sizeof(buf) - SKIP_ONE) {
int probe = fgetc(f);
if (probe != EOF || ferror(f)) {
(void)fclose(f);
return CBM_LANG_COUNT;
}
}
(void)fclose(f);

/* Must begin with "#!". */
if (n < PAIR_LEN || buf[0] != '#' || buf[1] != '!') {
return CBM_LANG_COUNT;
}

/* Isolate the first line; reject an embedded NUL before the newline. */
size_t line_len = 0;
while (line_len < n && buf[line_len] != '\n') {
if (buf[line_len] == '\0') {
return CBM_LANG_COUNT; /* embedded NUL — treat as binary */
}
line_len++;
}
/* Trim a trailing CR so CRLF first lines parse. */
if (line_len > 0 && buf[line_len - SKIP_ONE] == '\r') {
line_len--;
}
buf[line_len] = '\0';

/* First token after "#!" is the interpreter (or env). */
char *cursor = buf + PAIR_LEN;
char *interp = shebang_next_token(&cursor);
if (!interp) {
return CBM_LANG_COUNT;
}
const char *base = interp_basename(interp);

/* "env [-S] <interp> [args...]": the real interpreter is the next token.
* Only the plain "env <interp>" and "env -S/--split-string <interp> [args]"
* shapes are supported. After the optional -S, the interpreter token must
* be a real command, so reject option tokens (leading '-') and NAME=value
* assignments (containing '=') -- e.g. "env PYTHON=/usr/bin/python
* python-wrapper", where env would treat the first token as an env-var
* setting rather than the program to run. */
if (strcmp(base, "env") == 0) {
char *tok = shebang_next_token(&cursor);
if (tok && (strcmp(tok, "-S") == 0 || strcmp(tok, "--split-string") == 0)) {
tok = shebang_next_token(&cursor);
}
if (!tok || tok[0] == '-' || strchr(tok, '=') != NULL) {
return CBM_LANG_COUNT;
}
base = interp_basename(tok);
}

return lang_for_interpreter(base);
}

/* ── .m file disambiguation ──────────────────────────────────────── */

/* Simple substring search helper */
Expand Down
Loading
Loading