@@ -2834,7 +2834,8 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
28342834// --- Class definition extraction ---
28352835
28362836// Push a simple class definition (used by config language extractors).
2837- static void push_simple_class_def (CBMExtractCtx * ctx , TSNode node , char * name , const char * label ) {
2837+ static void push_simple_class_def (CBMExtractCtx * ctx , TSNode node , char * name , const char * label ,
2838+ const char * docstring ) {
28382839 CBMArena * a = ctx -> arena ;
28392840 CBMDefinition def ;
28402841 memset (& def , 0 , sizeof (def ));
@@ -2845,6 +2846,7 @@ static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, c
28452846 def .start_line = ts_node_start_point (node ).row + TS_LINE_OFFSET ;
28462847 def .end_line = ts_node_end_point (node ).row + TS_LINE_OFFSET ;
28472848 def .is_exported = true;
2849+ def .docstring = docstring ; // section body for Markdown (#518); NULL otherwise
28482850 cbm_defs_push (& ctx -> result -> defs , a , def );
28492851}
28502852
@@ -2943,6 +2945,57 @@ static char *extract_markdown_heading_name(CBMArena *a, TSNode node, const char
29432945 return trim_heading_name (name );
29442946}
29452947
2948+ // Max bytes of Markdown section body captured for BM25 content search (#518).
2949+ enum { CBM_MD_SECTION_BODY_MAX = 500 };
2950+
2951+ // Capture the prose body beneath a Markdown heading so BM25 can search the
2952+ // content, not just the heading text (#518). In the tree-sitter-markdown grammar
2953+ // each heading lives inside a `section` node that also holds the body blocks and
2954+ // any nested subsections; the body is the source span between the heading and
2955+ // either the first nested subsection or the end of the section. Returns NULL when
2956+ // there is no enclosing section or no body text. The result is trimmed and capped
2957+ // at CBM_MD_SECTION_BODY_MAX bytes (without splitting a UTF-8 sequence).
2958+ static char * extract_markdown_section_body (CBMArena * a , TSNode heading , const char * source ) {
2959+ TSNode parent = ts_node_parent (heading );
2960+ if (ts_node_is_null (parent ) || strcmp (ts_node_type (parent ), "section" ) != 0 ) {
2961+ return NULL ;
2962+ }
2963+ uint32_t body_start = ts_node_end_byte (heading );
2964+ uint32_t body_end = ts_node_end_byte (parent );
2965+ // Stop at the first nested subsection — it gets its own Section node + body.
2966+ uint32_t cc = ts_node_child_count (parent );
2967+ for (uint32_t i = 0 ; i < cc ; i ++ ) {
2968+ TSNode ch = ts_node_child (parent , i );
2969+ if (ts_node_start_byte (ch ) >= body_start && strcmp (ts_node_type (ch ), "section" ) == 0 ) {
2970+ body_end = ts_node_start_byte (ch );
2971+ break ;
2972+ }
2973+ }
2974+ // Trim surrounding whitespace/newlines (UTF-8 lead/continuation bytes are all
2975+ // >= 0x80, so a byte-wise <= ' ' test never cuts a multi-byte character).
2976+ while (body_start < body_end && (unsigned char )source [body_start ] <= ' ' ) {
2977+ body_start ++ ;
2978+ }
2979+ while (body_end > body_start && (unsigned char )source [body_end - 1 ] <= ' ' ) {
2980+ body_end -- ;
2981+ }
2982+ if (body_end <= body_start ) {
2983+ return NULL ;
2984+ }
2985+ size_t len = (size_t )(body_end - body_start );
2986+ if (len > CBM_MD_SECTION_BODY_MAX ) {
2987+ len = CBM_MD_SECTION_BODY_MAX ;
2988+ // Back off so the cap never splits a UTF-8 multi-byte sequence.
2989+ while (len > 0 && ((unsigned char )source [body_start + len ] & 0xC0 ) == 0x80 ) {
2990+ len -- ;
2991+ }
2992+ if (len == 0 ) {
2993+ return NULL ;
2994+ }
2995+ }
2996+ return cbm_arena_strndup (a , source + body_start , len );
2997+ }
2998+
29462999// INI: extract section name from section node.
29473000static char * find_ini_section_name (CBMArena * a , TSNode node , const char * source ) {
29483001 uint32_t nc = ts_node_child_count (node );
@@ -2996,6 +3049,7 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char
29963049 CBMArena * a = ctx -> arena ;
29973050 char * name = NULL ;
29983051 const char * label = "Class" ;
3052+ const char * docstring = NULL ;
29993053
30003054 if (ctx -> language == CBM_LANG_TOML &&
30013055 (strcmp (kind , "table" ) == 0 || strcmp (kind , "table_array_element" ) == 0 )) {
@@ -3008,14 +3062,15 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char
30083062 (strcmp (kind , "atx_heading" ) == 0 || strcmp (kind , "setext_heading" ) == 0 )) {
30093063 name = extract_markdown_heading_name (a , node , kind , ctx -> source );
30103064 label = "Section" ;
3065+ docstring = extract_markdown_section_body (a , node , ctx -> source ); // #518
30113066 } else if (ctx -> language == CBM_LANG_HCL && strcmp (kind , "block" ) == 0 ) {
30123067 name = find_hcl_block_name (a , node , ctx -> source );
30133068 } else {
30143069 return false;
30153070 }
30163071
30173072 if (name && name [0 ]) {
3018- push_simple_class_def (ctx , node , name , label );
3073+ push_simple_class_def (ctx , node , name , label , docstring );
30193074 }
30203075 return true;
30213076}
@@ -5514,6 +5569,174 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
55145569 }
55155570}
55165571
5572+ // ── Config module description promotion (#519) ──────────────────────────────
5573+ // YAML/JSON metadata files (META.yaml, skill manifests, …) carry their most
5574+ // search-relevant text in a top-level `description`/`summary`/`purpose` value.
5575+ // That value is otherwise dropped — only the key becomes a Variable node — so
5576+ // BM25 can't find a module by its description. We promote the value onto the
5577+ // file's Module node docstring, which the FTS `body` column then indexes.
5578+
5579+ // Case-insensitive ASCII equality (keys are conventionally lowercase, but accept
5580+ // "Description"/"SUMMARY" too).
5581+ static bool cfg_ci_eq (const char * x , const char * y ) {
5582+ while (* x && * y ) {
5583+ char cx = * x ;
5584+ char cy = * y ;
5585+ if (cx >= 'A' && cx <= 'Z' ) {
5586+ cx = (char )(cx + ('a' - 'A' ));
5587+ }
5588+ if (cy >= 'A' && cy <= 'Z' ) {
5589+ cy = (char )(cy + ('a' - 'A' ));
5590+ }
5591+ if (cx != cy ) {
5592+ return false;
5593+ }
5594+ x ++ ;
5595+ y ++ ;
5596+ }
5597+ return * x == '\0' && * y == '\0' ;
5598+ }
5599+
5600+ static bool cfg_is_desc_key (const char * key ) {
5601+ return key && (cfg_ci_eq (key , "description" ) || cfg_ci_eq (key , "summary" ) ||
5602+ cfg_ci_eq (key , "purpose" ));
5603+ }
5604+
5605+ // Trim surrounding whitespace and strip one layer of matching quotes, in place.
5606+ static char * cfg_strip_quotes_trim (char * s ) {
5607+ if (!s ) {
5608+ return NULL ;
5609+ }
5610+ while (* s && (unsigned char )* s <= ' ' ) {
5611+ s ++ ;
5612+ }
5613+ size_t len = strlen (s );
5614+ while (len > 0 && (unsigned char )s [len - 1 ] <= ' ' ) {
5615+ s [-- len ] = '\0' ;
5616+ }
5617+ if (len >= 2 && ((s [0 ] == '"' && s [len - 1 ] == '"' ) || (s [0 ] == '\'' && s [len - 1 ] == '\'' ))) {
5618+ s [len - 1 ] = '\0' ;
5619+ s ++ ;
5620+ }
5621+ return s ;
5622+ }
5623+
5624+ // Copy at most `cap` bytes of `s`, never splitting a UTF-8 sequence; NULL if empty.
5625+ static char * cfg_arena_capped (CBMArena * a , const char * s , size_t cap ) {
5626+ if (!s || !s [0 ]) {
5627+ return NULL ;
5628+ }
5629+ size_t len = strlen (s );
5630+ if (len > cap ) {
5631+ len = cap ;
5632+ while (len > 0 && ((unsigned char )s [len ] & 0xC0 ) == 0x80 ) {
5633+ len -- ;
5634+ }
5635+ if (len == 0 ) {
5636+ return NULL ;
5637+ }
5638+ }
5639+ return cbm_arena_strndup (a , s , len );
5640+ }
5641+
5642+ // Descend stream/document/block_node to the top-level YAML block_mapping.
5643+ static TSNode cfg_find_yaml_mapping (TSNode root ) {
5644+ TSNode none = {0 };
5645+ TSNode cur = root ;
5646+ for (int depth = 0 ; depth < 8 ; depth ++ ) {
5647+ uint32_t n = ts_node_child_count (cur );
5648+ TSNode next = none ;
5649+ bool have_next = false;
5650+ for (uint32_t i = 0 ; i < n ; i ++ ) {
5651+ TSNode ch = ts_node_child (cur , i );
5652+ const char * ck = ts_node_type (ch );
5653+ if (strcmp (ck , "block_mapping" ) == 0 ) {
5654+ return ch ;
5655+ }
5656+ if (!have_next && (strcmp (ck , "stream" ) == 0 || strcmp (ck , "document" ) == 0 ||
5657+ strcmp (ck , "block_node" ) == 0 )) {
5658+ next = ch ;
5659+ have_next = true;
5660+ }
5661+ }
5662+ if (!have_next ) {
5663+ break ;
5664+ }
5665+ cur = next ;
5666+ }
5667+ return none ;
5668+ }
5669+
5670+ // Descend document wrappers to the top-level JSON object.
5671+ static TSNode cfg_find_json_object (TSNode root ) {
5672+ TSNode none = {0 };
5673+ TSNode cur = root ;
5674+ for (int depth = 0 ; depth < 6 ; depth ++ ) {
5675+ if (strcmp (ts_node_type (cur ), "object" ) == 0 ) {
5676+ return cur ;
5677+ }
5678+ uint32_t n = ts_node_child_count (cur );
5679+ TSNode next = none ;
5680+ bool have_next = false;
5681+ for (uint32_t i = 0 ; i < n ; i ++ ) {
5682+ TSNode ch = ts_node_child (cur , i );
5683+ const char * ck = ts_node_type (ch );
5684+ if (strcmp (ck , "object" ) == 0 ) {
5685+ return ch ;
5686+ }
5687+ if (!have_next && strcmp (ck , "document" ) == 0 ) {
5688+ next = ch ;
5689+ have_next = true;
5690+ }
5691+ }
5692+ if (!have_next ) {
5693+ break ;
5694+ }
5695+ cur = next ;
5696+ }
5697+ return none ;
5698+ }
5699+
5700+ // Find a top-level description/summary/purpose value in a YAML or JSON file and
5701+ // return it (trimmed, unquoted, capped) for promotion to the Module docstring.
5702+ static const char * extract_config_module_description (CBMExtractCtx * ctx ) {
5703+ if (ctx -> language != CBM_LANG_YAML && ctx -> language != CBM_LANG_JSON ) {
5704+ return NULL ;
5705+ }
5706+ CBMArena * a = ctx -> arena ;
5707+ TSNode mapping ;
5708+ const char * pair_kind ;
5709+ if (ctx -> language == CBM_LANG_YAML ) {
5710+ mapping = cfg_find_yaml_mapping (ctx -> root );
5711+ pair_kind = "block_mapping_pair" ;
5712+ } else {
5713+ mapping = cfg_find_json_object (ctx -> root );
5714+ pair_kind = "pair" ;
5715+ }
5716+ if (ts_node_is_null (mapping )) {
5717+ return NULL ;
5718+ }
5719+ uint32_t n = ts_node_named_child_count (mapping );
5720+ for (uint32_t i = 0 ; i < n ; i ++ ) {
5721+ TSNode pair = ts_node_named_child (mapping , i );
5722+ if (strcmp (ts_node_type (pair ), pair_kind ) != 0 ) {
5723+ continue ;
5724+ }
5725+ TSNode key = ts_node_child_by_field_name (pair , TS_FIELD ("key" ));
5726+ TSNode val = ts_node_child_by_field_name (pair , TS_FIELD ("value" ));
5727+ if (ts_node_is_null (key ) || ts_node_is_null (val )) {
5728+ continue ;
5729+ }
5730+ const char * key_txt = cfg_strip_quotes_trim (cbm_node_text (a , key , ctx -> source ));
5731+ if (!cfg_is_desc_key (key_txt )) {
5732+ continue ;
5733+ }
5734+ char * val_txt = cfg_strip_quotes_trim (cbm_node_text (a , val , ctx -> source ));
5735+ return cfg_arena_capped (a , val_txt , CBM_MD_SECTION_BODY_MAX );
5736+ }
5737+ return NULL ;
5738+ }
5739+
55175740void cbm_extract_definitions (CBMExtractCtx * ctx ) {
55185741 const CBMLangSpec * spec = cbm_lang_spec (ctx -> language );
55195742 if (!spec ) {
@@ -5533,6 +5756,8 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) {
55335756 mod .end_line = ts_node_end_point (ctx -> root ).row + TS_LINE_OFFSET ;
55345757 mod .is_exported = true;
55355758 mod .is_test = ctx -> result -> is_test_file ;
5759+ // Promote a YAML/JSON top-level description onto the Module for BM25 (#519).
5760+ mod .docstring = extract_config_module_description (ctx );
55365761 cbm_defs_push (& ctx -> result -> defs , a , mod );
55375762
55385763 // Walk AST for function/class definitions
0 commit comments