diff --git a/README.md b/README.md index d040f8c..298f42e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ hdi run Just run/start commands (aliases: start, r) hdi test Just test commands (alias: t) hdi deploy Just deploy/release commands and platform detection (alias: d) hdi all All sections (aliases: a) -hdi check Check if required tools are installed (alias: c) +hdi contrib Commands from contributor/development docs (alias: c) +hdi needs Check if required tools are installed (alias: n) hdi /path/to/project Scan a different directory hdi /path/to/file.md Parse a specific markdown file ``` @@ -78,7 +79,8 @@ hdi r Run/start commands hdi t Test commands hdi d Deploy/release commands hdi a All sections -hdi c Check required tools +hdi c Contributor/development docs +hdi n Check required tools ``` ### Flags diff --git a/build b/build index 8eeaff1..b4d4a71 100755 --- a/build +++ b/build @@ -16,8 +16,8 @@ sources=( src/display.sh src/render.sh src/picker.sh - src/check.sh src/platform.sh + src/needs.sh src/json.sh src/main.sh ) diff --git a/hdi b/hdi index 7e321d0..83e70dc 100755 --- a/hdi +++ b/hdi @@ -8,7 +8,8 @@ # hdi test Just test commands (alias: t) # hdi deploy Just deploy/release commands and platform detection (alias: d) # hdi all Show all matched sections (currently the default mode) -# hdi check Check if required tools are installed (experimental) +# hdi contrib Show commands from contributor/development docs (alias: c) +# hdi needs Check if required tools are installed (alias: n) # hdi [mode] --no-interactive Print commands without the picker (alias: --ni) # hdi [mode] --full Include prose around commands # hdi [mode] --raw Plain markdown output (no colour, good for piping) @@ -52,7 +53,8 @@ for arg in "$@"; do test|t) MODE="test" ;; deploy|d) MODE="deploy" ;; all|a) MODE="all" ;; - check|c) MODE="check" ;; + needs|n) MODE="needs" ;; + contrib|c) MODE="contrib" ;; --full|-f) FULL=true ;; --raw) RAW=true; INTERACTIVE="no" ;; --json) JSON=true; INTERACTIVE="no" ;; @@ -98,7 +100,8 @@ case "$MODE" in test) PATTERN="($KW_TEST)" ;; deploy) PATTERN="($KW_DEPLOY)" ;; all) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; - check) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + needs) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + contrib) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; default) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; esac @@ -125,16 +128,42 @@ else done fi -if [[ -z "$README" ]]; then +if [[ -z "$README" ]] && [[ "$MODE" != "contrib" ]]; then echo "${YELLOW}hdi: no README found in ${DIR}${RESET}" >&2 echo "${DIM}Looked for README.md, readme.md, Readme.md, README.rst${RESET}" >&2 echo "${DIM}Try: hdi --help${RESET}" >&2 exit 1 fi +# ── Discover contributor/development docs ─────────────────────────────────── +CONTRIB_FILES=() +if [[ -z "$FILE" ]]; then + for _cname in CONTRIBUTING.md contributing.md Contributing.md \ + DEVELOPMENT.md development.md Development.md \ + DEVELOPERS.md developers.md Developers.md \ + HACKING.md hacking.md; do + [[ -f "$DIR/$_cname" ]] || continue + # Deduplicate (case-insensitive filesystems may match multiple variants) + _cf_dup=false + _cf_real=$(cd "$DIR" && realpath "$_cname" 2>/dev/null || echo "$DIR/$_cname") + for _cf_existing in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + [[ "$_cf_existing" == "$_cf_real" ]] && _cf_dup=true && break + done + $_cf_dup || CONTRIB_FILES+=("$_cf_real") + done +fi + +if [[ "$MODE" == "contrib" ]] && (( ${#CONTRIB_FILES[@]} == 0 )); then + echo "${YELLOW}hdi: no contributor docs found in ${DIR}${RESET}" >&2 + echo "${DIM}Looked for CONTRIBUTING.md, DEVELOPMENT.md, DEVELOPERS.md, HACKING.md${RESET}" >&2 + exit 1 +fi + # ── Extract matching sections ──────────────────────────────────────────────── declare -a SECTION_TITLES=() declare -a SECTION_BODIES=() +declare -a SECTION_FILES=() +_PARSE_SOURCE="" parse_sections() { local in_section=false @@ -172,12 +201,14 @@ parse_sections() { if (( level <= section_level )); then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" elif [[ "$text" =~ $PATTERN ]]; then # Deeper child heading also matches - save parent body first SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" fi @@ -297,6 +328,7 @@ parse_sections() { if $in_section; then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") fi shopt -u nocasematch @@ -532,9 +564,19 @@ declare -a CMD_INDICES=() # indices into DISPLAY_LINES that are commands declare -a SECTION_FIRST_CMD=() # cursor indices (into CMD_INDICES) of first cmd per section build_display_list() { + local _prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local body="${SECTION_BODIES[$i]}" + local _source="${SECTION_FILES[$i]:-}" + + # File separator when source file changes + if [[ -n "$_prev_source" && -n "$_source" && "$_source" != "$_prev_source" ]]; then + DISPLAY_LINES+=("$(basename "$_source")") + LINE_TYPES+=("filesep") + LINE_CMDS+=("") + fi + _prev_source="$_source" # Section header DISPLAY_LINES+=("$title") @@ -643,6 +685,13 @@ render_static() { local type="${LINE_TYPES[$idx]}" case "$type" in + filesep) + if $RAW; then + printf "\n--- %s ---\n" "$line" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$line" "$RESET" + fi + ;; header) if $RAW; then printf "\n## %s\n" "$line" @@ -677,9 +726,21 @@ render_static() { # ── Full-prose render ──────────────────────────────────────────────────────── render_full() { + local _rf_prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local content="${SECTION_BODIES[$i]}" + local _rf_source="${SECTION_FILES[$i]:-}" + + # File separator when source changes + if [[ -n "$_rf_prev_source" && -n "$_rf_source" && "$_rf_source" != "$_rf_prev_source" ]]; then + if $RAW; then + printf "\n--- %s ---\n" "$(basename "$_rf_source")" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$(basename "$_rf_source")" "$RESET" + fi + fi + _rf_prev_source="$_rf_source" # Strip trailing blank lines (pure bash, no tail subprocess) while [[ "$content" == *$'\n' ]]; do @@ -810,7 +871,7 @@ _term_height() { # Headers/subheaders take 2 lines (blank + text), others take 1 _SL=1 _sl() { - case "${LINE_TYPES[$1]}" in header|subheader) _SL=2 ;; *) _SL=1 ;; esac + case "${LINE_TYPES[$1]}" in header|subheader|filesep) _SL=2 ;; *) _SL=1 ;; esac } # Adjust VIEWPORT_TOP so that the selected item is visible @@ -842,7 +903,7 @@ adjust_viewport() { # Walk back through consecutive headers/subheaders to show full context while (( VIEWPORT_TOP > 0 )); do local prev_type="${LINE_TYPES[$((VIEWPORT_TOP - 1))]}" - if [[ "$prev_type" == "header" || "$prev_type" == "subheader" ]]; then + if [[ "$prev_type" == "header" || "$prev_type" == "subheader" || "$prev_type" == "filesep" ]]; then (( VIEWPORT_TOP -= 1 )) else break @@ -943,6 +1004,7 @@ draw_picker() { fi ;; all) hdr+=" ${DIM}[all]${RESET}" ;; + contrib) hdr+=" ${DIM}[contrib]${RESET}" ;; esac _line "$hdr" local chrome=3 @@ -982,6 +1044,13 @@ draw_picker() { fi case "$type" in + filesep) + if (( idx != VIEWPORT_TOP )); then + _blank; (( rendered += 1 )) + fi + _line " ${DIM}── ${line} ──────────────────────────────${RESET}" + (( rendered += 1 )) + ;; header) if (( idx != VIEWPORT_TOP )); then _blank; (( rendered += 1 )) @@ -1247,105 +1316,6 @@ run_interactive() { done } -# ── Check mode: report which tools are installed ───────────────────────────── - -# Shell builtins and coreutils that are always available - not worth checking -_CHECK_SKIP="^(cd|cp|mv|rm|mkdir|echo|export|source|cat|chmod|chown|ln|touch|ls|printf|trap|pwd|set|unset|eval|exec|exit|return|read|test|true|false|tee|head|tail|wc|sort|grep|xargs|find|tar|gzip|gunzip|sed|awk|tr|cut|diff|date|sleep|kill|whoami|env|which|man|less|more)$" - -# Extract the tool name from a command string -# Strips leading env vars (FOO=bar) and sudo, returns the first word -# Sets _CT_RESULT or "" if nothing useful -_check_tool_name() { - local cmd="$1" - - # Strip leading env vars - while [[ "$cmd" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]* ]]; do - cmd="${cmd#"${BASH_REMATCH[0]}"}" - cmd="${cmd#"${cmd%%[![:space:]]*}"}" - done - - # Strip sudo - if [[ "$cmd" =~ ^sudo[[:space:]]+ ]]; then - cmd="${cmd#sudo}" - cmd="${cmd#"${cmd%%[![:space:]]*}"}" - fi - - # First word - local tool="${cmd%% *}" - - # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins - if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then - _CT_RESULT="" - return - fi - - _CT_RESULT="$tool" -} - -run_check() { - local -a tools=() - local tool - - # Collect unique tool names from all extracted commands - for idx in "${!DISPLAY_LINES[@]}"; do - [[ "${LINE_TYPES[$idx]}" != "command" ]] && continue - _check_tool_name "${LINE_CMDS[$idx]}" - [[ -z "$_CT_RESULT" ]] && continue - tool="$_CT_RESULT" - - # Deduplicate - local seen=false - for t in "${tools[@]+"${tools[@]}"}"; do - [[ "$t" == "$tool" ]] && seen=true && break - done - $seen && continue - tools+=("$tool") - done - - if (( ${#tools[@]} == 0 )); then - echo "${YELLOW}hdi: no tool references found in commands${RESET}" >&2 - echo "${DIM}Try: hdi all --full${RESET}" >&2 - exit 1 - fi - - # Header - printf "\n%s%s[hdi] %s%s %scheck (experimental)%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" - - local found=0 missing=0 - for tool in "${tools[@]}"; do - if command -v "$tool" >/dev/null 2>&1; then - # Try to extract a version number (some tools use -V instead of --version) - local ver="" raw="" - raw=$("$tool" --version 2>&1 | head -5) || true - if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then - ver="${BASH_REMATCH[0]}" - else - raw=$("$tool" -V 2>&1 | head -5) || true - if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then - ver="${BASH_REMATCH[0]}" - fi - fi - - if [[ -n "$ver" ]]; then - printf " %s✓%s %-14s %s(%s)%s\n" "$GREEN" "$RESET" "$tool" "$DIM" "$ver" "$RESET" - else - printf " %s✓%s %-14s\n" "$GREEN" "$RESET" "$tool" - fi - found=$((found + 1)) - else - printf " %s✗%s %-14s %snot found%s\n" "$YELLOW" "$RESET" "$tool" "$DIM" "$RESET" - missing=$((missing + 1)) - fi - done - - printf "\n" - if (( missing == 0 )); then - printf " %s✓ All %d tools found%s\n\n" "$DIM" "$found" "$RESET" - else - printf " %s%d found, %s%d not found%s\n\n" "$DIM" "$found" "$YELLOW" "$missing" "$RESET" - fi -} - # ── Platform detection ──────────────────────────────────────────────────────── # Detects deployment platforms from three sources: # 1. Config files in the project directory (high confidence) @@ -1484,6 +1454,105 @@ build_platform_display() { _PLATFORM_DISPLAY="$parts" } +# ── Needs mode: report which tools are installed ───────────────────────────── + +# Shell builtins and coreutils that are always available - not worth checking +_CHECK_SKIP="^(cd|cp|mv|rm|mkdir|echo|export|source|cat|chmod|chown|ln|touch|ls|printf|trap|pwd|set|unset|eval|exec|exit|return|read|test|true|false|tee|head|tail|wc|sort|grep|xargs|find|tar|gzip|gunzip|sed|awk|tr|cut|diff|date|sleep|kill|whoami|env|which|man|less|more)$" + +# Extract the tool name from a command string +# Strips leading env vars (FOO=bar) and sudo, returns the first word +# Sets _CT_RESULT or "" if nothing useful +_check_tool_name() { + local cmd="$1" + + # Strip leading env vars + while [[ "$cmd" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]* ]]; do + cmd="${cmd#"${BASH_REMATCH[0]}"}" + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + done + + # Strip sudo + if [[ "$cmd" =~ ^sudo[[:space:]]+ ]]; then + cmd="${cmd#sudo}" + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + fi + + # First word + local tool="${cmd%% *}" + + # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins + if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then + _CT_RESULT="" + return + fi + + _CT_RESULT="$tool" +} + +run_needs() { + local -a tools=() + local tool + + # Collect unique tool names from all extracted commands + for idx in "${!DISPLAY_LINES[@]}"; do + [[ "${LINE_TYPES[$idx]}" != "command" ]] && continue + _check_tool_name "${LINE_CMDS[$idx]}" + [[ -z "$_CT_RESULT" ]] && continue + tool="$_CT_RESULT" + + # Deduplicate + local seen=false + for t in "${tools[@]+"${tools[@]}"}"; do + [[ "$t" == "$tool" ]] && seen=true && break + done + $seen && continue + tools+=("$tool") + done + + if (( ${#tools[@]} == 0 )); then + echo "${YELLOW}hdi: no tool references found in commands${RESET}" >&2 + echo "${DIM}Try: hdi all --full${RESET}" >&2 + exit 1 + fi + + # Header + printf "\n%s%s[hdi] %s%s %sneeds%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" + + local found=0 missing=0 + for tool in "${tools[@]}"; do + if command -v "$tool" >/dev/null 2>&1; then + # Try to extract a version number (some tools use -V instead of --version) + local ver="" raw="" + raw=$("$tool" --version 2>&1 | head -5) || true + if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then + ver="${BASH_REMATCH[0]}" + else + raw=$("$tool" -V 2>&1 | head -5) || true + if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then + ver="${BASH_REMATCH[0]}" + fi + fi + + if [[ -n "$ver" ]]; then + printf " %s✓%s %-14s %s(%s)%s\n" "$GREEN" "$RESET" "$tool" "$DIM" "$ver" "$RESET" + else + printf " %s✓%s %-14s\n" "$GREEN" "$RESET" "$tool" + fi + found=$((found + 1)) + else + printf " %s✗%s %-14s %snot found%s\n" "$YELLOW" "$RESET" "$tool" "$DIM" "$RESET" + missing=$((missing + 1)) + fi + done + + printf "\n" + if (( missing == 0 )); then + printf " %s✓ All %d tools found%s\n\n" "$DIM" "$found" "$RESET" + else + printf " %s%d found, %s%d not found%s\n\n" "$DIM" "$found" "$YELLOW" "$missing" "$RESET" + fi +} + # ── JSON output ─────────────────────────────────────────────────────────────── # Escape a string for safe embedding in JSON @@ -1647,8 +1716,8 @@ _json_full_prose() { printf '\n ]' } -# Print the check array as JSON using the current DISPLAY_LINES -_json_check() { +# Print the needs array as JSON using the current DISPLAY_LINES +_json_needs() { local -a tools=() local tool @@ -1705,7 +1774,7 @@ _json_platforms() { fi } -# Main JSON renderer: outputs all modes, fullProse, and check +# Main JSON renderer: outputs all modes, fullProse, and needs render_json() { local _modes=("default" "install" "run" "test" "deploy" "all") local first @@ -1716,7 +1785,8 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list @@ -1729,19 +1799,21 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" _json_full_prose "$_m" done - # Re-parse with "all" pattern for check tool extraction - printf '\n },\n "check": ' + # Re-parse with "all" pattern for needs tool extraction + printf '\n },\n "needs": ' _json_set_pattern "all" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list - _json_check + _json_needs # Platform detection (uses deploy pattern) printf ',\n "platforms": ' @@ -1769,10 +1841,24 @@ if $JSON; then exit 0 fi -parse_sections < "$README" +# Parse README (unless contrib-only mode) +if [[ "$MODE" != "contrib" ]] && [[ -n "$README" ]]; then + _PARSE_SOURCE="$README" + parse_sections < "$README" +fi + +# Parse contributor docs +for _cf in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + _PARSE_SOURCE="$_cf" + parse_sections < "$_cf" +done if (( ${#SECTION_TITLES[@]} == 0 )); then - echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + if [[ "$MODE" == "contrib" ]]; then + echo "${YELLOW}hdi: no matching sections found in contributor docs${RESET}" >&2 + else + echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + fi echo "${DIM}Try: hdi all --full${RESET}" >&2 exit 1 fi @@ -1790,8 +1876,8 @@ if [[ "$MODE" == "deploy" ]]; then build_platform_display fi -if [[ "$MODE" == "check" ]]; then - run_check +if [[ "$MODE" == "needs" ]]; then + run_needs elif [[ "$INTERACTIVE" == "yes" ]] && ! $FULL; then run_interactive else @@ -1809,6 +1895,7 @@ else fi ;; all) printf " %s[all]%s" "$DIM" "$RESET" ;; + contrib) printf " %s[contrib]%s" "$DIM" "$RESET" ;; esac printf "\n" fi diff --git a/src/args.sh b/src/args.sh index 50d5a9d..7476416 100644 --- a/src/args.sh +++ b/src/args.sh @@ -22,7 +22,8 @@ for arg in "$@"; do test|t) MODE="test" ;; deploy|d) MODE="deploy" ;; all|a) MODE="all" ;; - check|c) MODE="check" ;; + needs|n) MODE="needs" ;; + contrib|c) MODE="contrib" ;; --full|-f) FULL=true ;; --raw) RAW=true; INTERACTIVE="no" ;; --json) JSON=true; INTERACTIVE="no" ;; @@ -68,6 +69,7 @@ case "$MODE" in test) PATTERN="($KW_TEST)" ;; deploy) PATTERN="($KW_DEPLOY)" ;; all) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; - check) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + needs) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + contrib) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; default) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; esac diff --git a/src/display.sh b/src/display.sh index c7e2518..fd1f44d 100644 --- a/src/display.sh +++ b/src/display.sh @@ -10,9 +10,19 @@ declare -a CMD_INDICES=() # indices into DISPLAY_LINES that are commands declare -a SECTION_FIRST_CMD=() # cursor indices (into CMD_INDICES) of first cmd per section build_display_list() { + local _prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local body="${SECTION_BODIES[$i]}" + local _source="${SECTION_FILES[$i]:-}" + + # File separator when source file changes + if [[ -n "$_prev_source" && -n "$_source" && "$_source" != "$_prev_source" ]]; then + DISPLAY_LINES+=("$(basename "$_source")") + LINE_TYPES+=("filesep") + LINE_CMDS+=("") + fi + _prev_source="$_source" # Section header DISPLAY_LINES+=("$title") diff --git a/src/header.sh b/src/header.sh index cc0b5d7..e44617f 100644 --- a/src/header.sh +++ b/src/header.sh @@ -8,7 +8,8 @@ # hdi test Just test commands (alias: t) # hdi deploy Just deploy/release commands and platform detection (alias: d) # hdi all Show all matched sections (currently the default mode) -# hdi check Check if required tools are installed (experimental) +# hdi contrib Show commands from contributor/development docs (alias: c) +# hdi needs Check if required tools are installed (alias: n) # hdi [mode] --no-interactive Print commands without the picker (alias: --ni) # hdi [mode] --full Include prose around commands # hdi [mode] --raw Plain markdown output (no colour, good for piping) diff --git a/src/json.sh b/src/json.sh index 469a1c3..312102d 100644 --- a/src/json.sh +++ b/src/json.sh @@ -161,8 +161,8 @@ _json_full_prose() { printf '\n ]' } -# Print the check array as JSON using the current DISPLAY_LINES -_json_check() { +# Print the needs array as JSON using the current DISPLAY_LINES +_json_needs() { local -a tools=() local tool @@ -219,7 +219,7 @@ _json_platforms() { fi } -# Main JSON renderer: outputs all modes, fullProse, and check +# Main JSON renderer: outputs all modes, fullProse, and needs render_json() { local _modes=("default" "install" "run" "test" "deploy" "all") local first @@ -230,7 +230,8 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list @@ -243,19 +244,21 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" _json_full_prose "$_m" done - # Re-parse with "all" pattern for check tool extraction - printf '\n },\n "check": ' + # Re-parse with "all" pattern for needs tool extraction + printf '\n },\n "needs": ' _json_set_pattern "all" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list - _json_check + _json_needs # Platform detection (uses deploy pattern) printf ',\n "platforms": ' diff --git a/src/main.sh b/src/main.sh index cb3e0b7..c4d65f0 100644 --- a/src/main.sh +++ b/src/main.sh @@ -5,10 +5,24 @@ if $JSON; then exit 0 fi -parse_sections < "$README" +# Parse README (unless contrib-only mode) +if [[ "$MODE" != "contrib" ]] && [[ -n "$README" ]]; then + _PARSE_SOURCE="$README" + parse_sections < "$README" +fi + +# Parse contributor docs +for _cf in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + _PARSE_SOURCE="$_cf" + parse_sections < "$_cf" +done if (( ${#SECTION_TITLES[@]} == 0 )); then - echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + if [[ "$MODE" == "contrib" ]]; then + echo "${YELLOW}hdi: no matching sections found in contributor docs${RESET}" >&2 + else + echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + fi echo "${DIM}Try: hdi all --full${RESET}" >&2 exit 1 fi @@ -26,8 +40,8 @@ if [[ "$MODE" == "deploy" ]]; then build_platform_display fi -if [[ "$MODE" == "check" ]]; then - run_check +if [[ "$MODE" == "needs" ]]; then + run_needs elif [[ "$INTERACTIVE" == "yes" ]] && ! $FULL; then run_interactive else @@ -45,6 +59,7 @@ else fi ;; all) printf " %s[all]%s" "$DIM" "$RESET" ;; + contrib) printf " %s[contrib]%s" "$DIM" "$RESET" ;; esac printf "\n" fi diff --git a/src/check.sh b/src/needs.sh similarity index 93% rename from src/check.sh rename to src/needs.sh index 48bcf0c..5fc4cc7 100644 --- a/src/check.sh +++ b/src/needs.sh @@ -1,4 +1,4 @@ -# ── Check mode: report which tools are installed ───────────────────────────── +# ── Needs mode: report which tools are installed ───────────────────────────── # Shell builtins and coreutils that are always available - not worth checking _CHECK_SKIP="^(cd|cp|mv|rm|mkdir|echo|export|source|cat|chmod|chown|ln|touch|ls|printf|trap|pwd|set|unset|eval|exec|exit|return|read|test|true|false|tee|head|tail|wc|sort|grep|xargs|find|tar|gzip|gunzip|sed|awk|tr|cut|diff|date|sleep|kill|whoami|env|which|man|less|more)$" @@ -33,7 +33,7 @@ _check_tool_name() { _CT_RESULT="$tool" } -run_check() { +run_needs() { local -a tools=() local tool @@ -60,7 +60,7 @@ run_check() { fi # Header - printf "\n%s%s[hdi] %s%s %scheck (experimental)%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" + printf "\n%s%s[hdi] %s%s %sneeds%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" local found=0 missing=0 for tool in "${tools[@]}"; do diff --git a/src/parse.sh b/src/parse.sh index f82b764..c442854 100644 --- a/src/parse.sh +++ b/src/parse.sh @@ -1,6 +1,8 @@ # ── Extract matching sections ──────────────────────────────────────────────── declare -a SECTION_TITLES=() declare -a SECTION_BODIES=() +declare -a SECTION_FILES=() +_PARSE_SOURCE="" parse_sections() { local in_section=false @@ -38,12 +40,14 @@ parse_sections() { if (( level <= section_level )); then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" elif [[ "$text" =~ $PATTERN ]]; then # Deeper child heading also matches - save parent body first SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" fi @@ -163,6 +167,7 @@ parse_sections() { if $in_section; then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") fi shopt -u nocasematch diff --git a/src/picker.sh b/src/picker.sh index d638257..171b3d4 100644 --- a/src/picker.sh +++ b/src/picker.sh @@ -23,7 +23,7 @@ _term_height() { # Headers/subheaders take 2 lines (blank + text), others take 1 _SL=1 _sl() { - case "${LINE_TYPES[$1]}" in header|subheader) _SL=2 ;; *) _SL=1 ;; esac + case "${LINE_TYPES[$1]}" in header|subheader|filesep) _SL=2 ;; *) _SL=1 ;; esac } # Adjust VIEWPORT_TOP so that the selected item is visible @@ -55,7 +55,7 @@ adjust_viewport() { # Walk back through consecutive headers/subheaders to show full context while (( VIEWPORT_TOP > 0 )); do local prev_type="${LINE_TYPES[$((VIEWPORT_TOP - 1))]}" - if [[ "$prev_type" == "header" || "$prev_type" == "subheader" ]]; then + if [[ "$prev_type" == "header" || "$prev_type" == "subheader" || "$prev_type" == "filesep" ]]; then (( VIEWPORT_TOP -= 1 )) else break @@ -156,6 +156,7 @@ draw_picker() { fi ;; all) hdr+=" ${DIM}[all]${RESET}" ;; + contrib) hdr+=" ${DIM}[contrib]${RESET}" ;; esac _line "$hdr" local chrome=3 @@ -195,6 +196,13 @@ draw_picker() { fi case "$type" in + filesep) + if (( idx != VIEWPORT_TOP )); then + _blank; (( rendered += 1 )) + fi + _line " ${DIM}── ${line} ──────────────────────────────${RESET}" + (( rendered += 1 )) + ;; header) if (( idx != VIEWPORT_TOP )); then _blank; (( rendered += 1 )) diff --git a/src/readme.sh b/src/readme.sh index 3767bee..3b8aac5 100644 --- a/src/readme.sh +++ b/src/readme.sh @@ -9,9 +9,33 @@ else done fi -if [[ -z "$README" ]]; then +if [[ -z "$README" ]] && [[ "$MODE" != "contrib" ]]; then echo "${YELLOW}hdi: no README found in ${DIR}${RESET}" >&2 echo "${DIM}Looked for README.md, readme.md, Readme.md, README.rst${RESET}" >&2 echo "${DIM}Try: hdi --help${RESET}" >&2 exit 1 fi + +# ── Discover contributor/development docs ─────────────────────────────────── +CONTRIB_FILES=() +if [[ -z "$FILE" ]]; then + for _cname in CONTRIBUTING.md contributing.md Contributing.md \ + DEVELOPMENT.md development.md Development.md \ + DEVELOPERS.md developers.md Developers.md \ + HACKING.md hacking.md; do + [[ -f "$DIR/$_cname" ]] || continue + # Deduplicate (case-insensitive filesystems may match multiple variants) + _cf_dup=false + _cf_real=$(cd "$DIR" && realpath "$_cname" 2>/dev/null || echo "$DIR/$_cname") + for _cf_existing in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + [[ "$_cf_existing" == "$_cf_real" ]] && _cf_dup=true && break + done + $_cf_dup || CONTRIB_FILES+=("$_cf_real") + done +fi + +if [[ "$MODE" == "contrib" ]] && (( ${#CONTRIB_FILES[@]} == 0 )); then + echo "${YELLOW}hdi: no contributor docs found in ${DIR}${RESET}" >&2 + echo "${DIM}Looked for CONTRIBUTING.md, DEVELOPMENT.md, DEVELOPERS.md, HACKING.md${RESET}" >&2 + exit 1 +fi diff --git a/src/render.sh b/src/render.sh index c05bbf6..bfa0aca 100644 --- a/src/render.sh +++ b/src/render.sh @@ -5,6 +5,13 @@ render_static() { local type="${LINE_TYPES[$idx]}" case "$type" in + filesep) + if $RAW; then + printf "\n--- %s ---\n" "$line" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$line" "$RESET" + fi + ;; header) if $RAW; then printf "\n## %s\n" "$line" @@ -39,9 +46,21 @@ render_static() { # ── Full-prose render ──────────────────────────────────────────────────────── render_full() { + local _rf_prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local content="${SECTION_BODIES[$i]}" + local _rf_source="${SECTION_FILES[$i]:-}" + + # File separator when source changes + if [[ -n "$_rf_prev_source" && -n "$_rf_source" && "$_rf_source" != "$_rf_prev_source" ]]; then + if $RAW; then + printf "\n--- %s ---\n" "$(basename "$_rf_source")" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$(basename "$_rf_source")" "$RESET" + fi + fi + _rf_prev_source="$_rf_source" # Strip trailing blank lines (pure bash, no tail subprocess) while [[ "$content" == *$'\n' ]]; do diff --git a/test/fixtures/node-express/CONTRIBUTING.md b/test/fixtures/node-express/CONTRIBUTING.md new file mode 100644 index 0000000..252497a --- /dev/null +++ b/test/fixtures/node-express/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing to express-api + +## Development Setup + +Fork the repo and install dependencies: + +```bash +npm install +cp .env.example .env.test +``` + +## Running Tests + +Run the full test suite with coverage: + +```bash +npm run test:coverage +``` + +Run integration tests only: + +```bash +npm run test:integration +``` + +## Code Style + +We use ESLint and Prettier. Run the linter before submitting a PR: + +```bash +npm run lint +npm run format +``` + +## Release Process + +```bash +npm version patch +npm publish +``` diff --git a/test/hdi.bats b/test/hdi.bats index f2e1dc7..8dc3b3b 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -1359,30 +1359,30 @@ else: [[ "$output" == *"npm run serve --env staging"* ]] } -# ── Check mode ────────────────────────────────────────────────────────────── +# ── Needs mode ────────────────────────────────────────────────────────────── -@test "check: reports installed tools" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: reports installed tools" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] [[ "$output" == *"npm"* ]] } -@test "check: marks missing tools" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: marks missing tools" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] [[ "$output" == *"nvm"* ]] [[ "$output" == *"not found"* ]] } -@test "check: skips shell builtins like cp" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: skips shell builtins like cp" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] - # cp is in the install section but should not appear in check output + # cp is in the install section but should not appear in needs output [[ "$output" != *" cp "* ]] } -@test "check: deduplicates tool names" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: deduplicates tool names" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] # npm appears in multiple commands but should only be listed once local count @@ -1390,22 +1390,22 @@ else: [ "$count" -eq 1 ] } -@test "check: scans all sections (install + run + test)" { - run "$HDI" check "$FIXTURES/react-nextjs" +@test "needs: scans all sections (install + run + test)" { + run "$HDI" needs "$FIXTURES/react-nextjs" [ "$status" -eq 0 ] [[ "$output" == *"npm"* ]] } -@test "check: skips path-like commands" { - run "$HDI" check "$FIXTURES/ruby-rails" +@test "needs: skips path-like commands" { + run "$HDI" needs "$FIXTURES/ruby-rails" [ "$status" -eq 0 ] [[ "$output" != *"bin/rails"* ]] [[ "$output" != *"bin/dev"* ]] } -@test "check: skips flags in code blocks" { +@test "needs: skips flags in code blocks" { # Flags like -h, --help, --raw should not appear as tools - run "$HDI" check "$BATS_TEST_DIRNAME/.." + run "$HDI" needs "$BATS_TEST_DIRNAME/.." [ "$status" -eq 0 ] [[ "$output" != *" -h,"* ]] [[ "$output" != *" -v,"* ]] @@ -1414,6 +1414,62 @@ else: [[ "$output" != *" --ni,"* ]] } +# ── Contrib mode ─────────────────────────────────────────────────────────── + +@test "contrib: shows commands from CONTRIBUTING.md" { + run "$HDI" contrib "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"npm run test:coverage"* ]] + [[ "$output" == *"npm version patch"* ]] +} + +@test "contrib: 'c' is an alias for contrib mode" { + run "$HDI" c "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"npm run test:coverage"* ]] +} + +@test "contrib: does not include README commands" { + run "$HDI" contrib "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" != *"npx prisma migrate dev"* ]] +} + +@test "contrib: error when no contributor docs found" { + run "$HDI" contrib "$FIXTURES/python-flask" + [ "$status" -eq 1 ] + [[ "$output" == *"no contributor docs found"* ]] +} + +@test "contrib: default mode includes contrib with separator" { + run "$HDI" --ni "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"CONTRIBUTING.md"* ]] + [[ "$output" == *"npm run test:coverage"* ]] + [[ "$output" == *"npm install"* ]] +} + +@test "contrib: --raw separator uses plain text" { + run "$HDI" --raw "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"--- CONTRIBUTING.md ---"* ]] +} + +@test "contrib: mode filter applies to contrib sections" { + run "$HDI" test "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"npm test"* ]] + [[ "$output" == *"npm run test:coverage"* ]] + # lint/format are not test commands + [[ "$output" != *"npm run lint"* ]] +} + +@test "contrib: no separator when no contrib files" { + run "$HDI" --ni "$FIXTURES/python-flask" + [ "$status" -eq 0 ] + [[ "$output" != *"CONTRIBUTING"* ]] +} + # ── JSON output ──────────────────────────────────────────────────────────── @test "json: produces valid JSON" { @@ -1438,10 +1494,10 @@ else: done } -@test "json: contains check array" { +@test "json: contains needs array" { run "$HDI" --json "$FIXTURES/node-express" [ "$status" -eq 0 ] - echo "$output" | python3 -c "import json,sys; d=json.load(sys.stdin); assert isinstance(d['check'], list)" + echo "$output" | python3 -c "import json,sys; d=json.load(sys.stdin); assert isinstance(d['needs'], list)" } @test "json: modes items have type and text fields" { @@ -1481,13 +1537,13 @@ assert 'header' in types, 'no header items' " } -@test "json: check items have tool and installed fields" { +@test "json: needs items have tool and installed fields" { run "$HDI" --json "$FIXTURES/node-express" [ "$status" -eq 0 ] echo "$output" | python3 -c " import json,sys d=json.load(sys.stdin) -for item in d['check']: +for item in d['needs']: assert 'tool' in item and 'installed' in item, f'missing fields in {item}' " }