#!/usr/bin/env python3 """ Generate BoxLang completion JSON files from the boxlang-docs repository. Usage: python3 scripts/generate_completions.py python3 scripts/generate_completions.py --docs-path /path/to/boxlang-docs python3 scripts/generate_completions.py --update # git pull all docs repos first python3 scripts/generate_completions.py --clone # clone missing repos Outputs (written to src/plugins_/basecompletions/json/): boxlang_functions.json - BIF name → [description, [req_snippet, full_snippet]] boxlang_tags.json - tag name → {attributes: [[req], [opt]], attribute_values: {}} boxlang_member_functions.json - type → {name → [description, [req_snippet, full_snippet]]} boxlang_function_params.json - BIF name → {description, params: [{name,type,required,description,default}], url_path, category} Covers: - Core BIFs and components (boxlang-language/reference/) - Module BIFs and components (boxlang-framework/modularity/ and boxlang-framework/boxlang-plus/modules/) - Extra module repos (e.g. bx-ai-docs at ai.ortusbooks.com) """ import argparse import html import json import os import re import subprocess import sys DOCS_REPO_URL = "https://github.com/ortus-boxlang/boxlang-docs" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.dirname(SCRIPT_DIR) JSON_DIR = os.path.join(REPO_ROOT, "src", "plugins_", "basecompletions", "json") BIF_ROOT = "boxlang-language/reference/built-in-functions" COMPONENT_ROOT = "boxlang-language/reference/components" TYPES_ROOT = "boxlang-language/reference/types" # Directories to scan for module BIFs and components within the main docs repo MODULE_ROOTS = [ "boxlang-framework/modularity", "boxlang-framework/boxlang-plus/modules", ] # Additional standalone docs repos for BoxLang modules. # Each entry: # clone_url - GitHub URL to clone from # default_path - default local path (relative to the parent of this repo) # url_base - live docs base URL (used to build the href for popup links) # bif_dirs - list of dirs (relative to repo root) containing BIF .md files # component_dirs - list of dirs containing component .md files # module_name - display name used as the category label EXTRA_DOCS_REPOS = [ { "clone_url": "https://github.com/ortus-boxlang/bx-ai-docs", "default_path": os.path.join(os.path.dirname(REPO_ROOT), "boxlang-modules", "bx-ai-docs"), "url_base": "https://ai.ortusbooks.com", "bif_dirs": ["advanced/reference/built-in-functions"], "component_dirs": [], "module_name": "BoxLang AI", }, ] # Member function type files → boxlang type name MEMBER_TYPE_FILES = { "array.md": "array", "string.md": "string", "struct.md": "struct", "query.md": "query", "date.md": "date", "datetime.md": "datetime", "list.md": "list", "numeric.md": "numeric", "xml.md": "xml", } # --------------------------------------------------------------------------- # Markdown parsing helpers # --------------------------------------------------------------------------- def strip_html(text): """Remove HTML tags and decode entities, collapsing whitespace.""" text = re.sub(r"", " ", text, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", "", text) text = html.unescape(text) text = re.sub(r"\s+", " ", text) return text.strip() def parse_default(raw): """Extract the default value from a table cell like '`false`' or ''.""" raw = raw.strip() if not raw: return None m = re.match(r"^`([^`]*)`$", raw) return m.group(1) if m else raw def parse_table_rows(lines, start): """ Parse a markdown pipe table starting at or after `start` index. Skips blank lines before the table begins. Returns list of dicts keyed by lowercased column header. Stops at first non-table line after the table has started. """ headers = [] rows = [] i = start table_started = False while i < len(lines): line = lines[i].strip() if not table_started: if not line: i += 1 continue # skip blank lines before table if not line.startswith("|"): break # no table found if not line.startswith("|"): break # end of table table_started = True cells = [c.strip() for c in line.split("|")[1:-1]] if not headers: headers = [c.lower().strip("`* ") for c in cells] elif re.match(r"^[-| :]+$", line): pass # separator row else: row = {} for j, h in enumerate(headers): row[h] = cells[j] if j < len(cells) else "" rows.append(row) i += 1 return rows def parse_bif_file(path): """ Parse a BIF markdown file. Handles two formats: 1. Core format: # Function: `Name` with ### Arguments 2. Module format: # Name with ## Arguments (column: Name or Argument) Returns: {name, description, params: [{name, type, required, description, default}]} or None on failure. """ try: with open(path, encoding="utf-8") as f: content = f.read() except OSError: return None lines = content.splitlines() # Name: try "# Function: `Name`" first, fall back to plain H1 heading name_match = re.search(r"^#\s+Function:\s+`([^`]+)`", content, re.MULTILINE) if name_match: name = name_match.group(1) name_prefix = "Function:" else: # Plain H1: "# SomeName" or "# Name1 / Name2" (aliases — take first) # Allows lower or uppercase start (e.g. createDate, couchbaseGetBucket) name_match = re.search(r"^#\s+([A-Za-z][A-Za-z0-9_]+)", content, re.MULTILINE) if not name_match: return None name = name_match.group(1) name_prefix = None # Description: first non-comment, non-empty, non-heading line after the name heading description = "" past_name = False for line in lines: if name_prefix and re.match(r"^#\s+Function:", line): past_name = True continue elif not name_prefix and re.match(r"^#\s+" + re.escape(name), line): past_name = True continue if past_name: stripped = line.strip() if stripped and not stripped.startswith("#") and not stripped.startswith("[comment]") \ and not stripped.startswith("{%") and not stripped.startswith("```"): description = strip_html(stripped) break # Arguments / Parameters table — handles ### Arguments, ## Arguments, ## Parameters params = [] for i, line in enumerate(lines): if re.match(r"^#{2,3}\s+(Arguments|Parameters)\s*$", line.strip()): rows = parse_table_rows(lines, i + 1) for row in rows: arg_name = row.get("argument", row.get("parameter", row.get("name", row.get("atrribute", row.get("attribute", ""))))) arg_name = re.sub(r"[`*]", "", arg_name).strip() if not arg_name: continue required_val = row.get("required", "false").strip().strip("`").lower() params.append({ "name": arg_name, "type": re.sub(r"[`*]", "", row.get("type", "any")).strip(), "required": required_val in ("true", "yes"), "description": strip_html(row.get("description", "")), "default": parse_default(row.get("default", "")), }) break return {"name": name, "description": description, "params": params} def parse_component_file(path): """ Parse a component/tag markdown file. Handles multiple formats: 1. Standard: # Component: `Name` with ### Attributes 2. bx: prefix: # bx:name with ## Attributes (and optional subsections) 3. Plain name: # Name with ## Attributes Returns: {name, description, attributes: [{name, type, required, description, default}]} or None. """ try: with open(path, encoding="utf-8") as f: content = f.read() except OSError: return None lines = content.splitlines() # Name: try several heading patterns in order name = None name_heading_line = None # 1. "# Component: `Name`" m = re.search(r"^#\s+Component:\s+`([^`]+)`", content, re.MULTILINE) if m: name = m.group(1) name_heading_line = m.group(0) else: # 2. "# bx:name" — strip bx: prefix m = re.search(r"^#\s+bx:([a-zA-Z][a-zA-Z0-9_-]*)\s*$", content, re.MULTILINE) if m: # Convert kebab-case to PascalCase for the tag name stored in JSON raw = m.group(1) name = "".join(w.capitalize() for w in raw.replace("-", " ").split()) name_heading_line = m.group(0) else: # 3. "# SomeName" or "# SomeName Component" — plain H1 m = re.search(r"^#\s+([A-Z][A-Za-z0-9_]+)(?:\s+Component)?\s*$", content, re.MULTILINE) if m: name = m.group(1) name_heading_line = m.group(0) if not name: return None description = "" past_name = False for line in lines: stripped_line = line.strip() if name_heading_line and stripped_line == name_heading_line.strip(): past_name = True continue if past_name: stripped = stripped_line if stripped and not stripped.startswith("#") and not stripped.startswith("{%") \ and not stripped.startswith("```") and not stripped.startswith("##"): description = strip_html(stripped) break # Attributes: collect all rows from all attribute table sections (## or ### level) # This handles charts which has multiple subsections under ## Attributes attributes = [] seen_names = set() for i, line in enumerate(lines): if re.match(r"^#{2,3}\s+(?:Core\s+|Responsive\s+|Styling\s+|Data\s+|Advanced\s+)?Attributes", line.strip()): rows = parse_table_rows(lines, i + 1) for row in rows: attr_name = row.get("atrribute", row.get("attribute", row.get("name", row.get("argument", "")))) attr_name = re.sub(r"[`*]", "", attr_name).strip() if not attr_name or attr_name.lower() in seen_names: continue seen_names.add(attr_name.lower()) required_val = row.get("required", "false").strip().strip("`").lower() attributes.append({ "name": attr_name, "type": re.sub(r"[`*]", "", row.get("type", "any")).strip(), "required": required_val in ("true", "yes"), "description": strip_html(row.get("description", "")), "default": parse_default(row.get("default", "")), }) return {"name": name, "description": description, "attributes": attributes} def parse_member_type_file(path, type_name): """ Parse a type reference file (array.md, string.md, etc.). Returns list of {name, description, params} dicts. """ try: with open(path, encoding="utf-8") as f: content = f.read() except OSError: return [] methods = [] # Split on
blocks detail_blocks = re.split(r"
", content, flags=re.IGNORECASE) for block in detail_blocks[1:]: # skip text before first
# Extract signature from ... sig_match = re.search(r"(.*?)", block, re.DOTALL | re.IGNORECASE) if not sig_match: continue signature = sig_match.group(1).strip() # method name is before the first `(` method_name_match = re.match(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", signature) if not method_name_match: continue method_name = method_name_match.group(1) # Description: first non-empty text after after_summary = re.sub(r".*?", "", block, flags=re.DOTALL | re.IGNORECASE, count=1) description = "" for line in after_summary.splitlines(): stripped = line.strip() if stripped and not stripped.startswith("|") and not stripped.startswith("Arguments") \ and not stripped.startswith("<") and not stripped.startswith("#"): description = strip_html(stripped) break # Arguments table (may not exist for no-arg methods) params = [] lines = after_summary.splitlines() for i, line in enumerate(lines): if re.match(r"^\|\s*(Argument|argument)", line): rows = parse_table_rows(lines, i) for row in rows: arg_name = row.get("argument", "") arg_name = re.sub(r"[`*]", "", arg_name).strip() if not arg_name: continue params.append({ "name": arg_name, "type": re.sub(r"[`*]", "", row.get("type", "any")).strip(), "required": row.get("required", "false").strip().strip("`") == "true", "description": "", "default": parse_default(row.get("default", "")), }) break methods.append({"name": method_name, "description": description, "params": params}) return methods # --------------------------------------------------------------------------- # Snippet builders # --------------------------------------------------------------------------- def build_snippets(params): """ Build (required_snippet, full_snippet) tuple from a params list. Required snippet: only required params. Full snippet: all params. """ required = [p for p in params if p["required"]] all_params = params def make_snippet(param_list): if not param_list: return "($0)" parts = [] for i, p in enumerate(param_list, 1): parts.append("${" + str(i) + ":" + p["name"] + "}") return "(" + ", ".join(parts) + "$0)" return (make_snippet(required), make_snippet(all_params)) # --------------------------------------------------------------------------- # Module directory discovery # --------------------------------------------------------------------------- def find_module_reference_dirs(docs_path, ref_type): """ Find all `reference/` directories under MODULE_ROOTS. ref_type is 'built-in-functions' or 'components'. Returns list of (module_name, dir_path) tuples. """ found = [] for module_root in MODULE_ROOTS: root_path = os.path.join(docs_path, module_root) if not os.path.isdir(root_path): continue for module_name in sorted(os.listdir(root_path)): module_path = os.path.join(root_path, module_name) if not os.path.isdir(module_path): continue ref_path = os.path.join(module_path, "reference", ref_type) if os.path.isdir(ref_path): found.append((module_name, ref_path)) return found def walk_md_files(directory): """Yield all .md files (excluding README.md) under directory, recursively.""" for root, dirs, files in os.walk(directory): dirs.sort() for fname in sorted(files): if fname.endswith(".md") and fname != "README.md": yield os.path.join(root, fname) # --------------------------------------------------------------------------- # Main generators # --------------------------------------------------------------------------- def generate_bif_data(docs_path): """Walk all BIF markdown files (core + modules) and return parsed data.""" results = {} missing = [] # Core BIFs: organized in category subdirectories bif_dir = os.path.join(docs_path, BIF_ROOT) for fpath in walk_md_files(bif_dir): data = parse_bif_file(fpath) if data: rel = os.path.relpath(fpath, docs_path).replace(os.sep, '/').lower() data['url_path'] = rel[:-3] if rel.endswith('.md') else rel data['category'] = os.path.basename(os.path.dirname(fpath)) results[data["name"]] = data else: missing.append(fpath) core_count = len(results) # Module BIFs: flat files or category subdirectories module_dirs = find_module_reference_dirs(docs_path, "built-in-functions") for module_name, ref_dir in module_dirs: before = len(results) for fpath in walk_md_files(ref_dir): data = parse_bif_file(fpath) if data: rel = os.path.relpath(fpath, docs_path).replace(os.sep, '/').lower() data['url_path'] = rel[:-3] if rel.endswith('.md') else rel data['category'] = module_name results[data["name"]] = data else: missing.append(fpath) added = len(results) - before if added: print(f" {module_name}: +{added} BIFs") # Extra standalone module docs repos for repo in EXTRA_DOCS_REPOS: repo_path = repo["default_path"] if not os.path.isdir(repo_path): print(f" [skip] {repo['module_name']} docs not found at {repo_path}", file=sys.stderr) continue url_base = repo["url_base"].rstrip("/") module_name = repo["module_name"] before = len(results) for bif_dir_rel in repo.get("bif_dirs", []): bif_dir = os.path.join(repo_path, bif_dir_rel) if not os.path.isdir(bif_dir): continue for fpath in walk_md_files(bif_dir): data = parse_bif_file(fpath) if data: rel = os.path.relpath(fpath, repo_path).replace(os.sep, '/').lower() url_path_part = rel[:-3] if rel.endswith('.md') else rel data['url_path'] = url_base + '/' + url_path_part data['category'] = module_name results[data["name"]] = data else: missing.append(fpath) added = len(results) - before if added: print(f" {module_name}: +{added} BIFs") if missing: print(f" [warn] Could not parse {len(missing)} BIF files", file=sys.stderr) return results, core_count def generate_component_data(docs_path): """Walk all component markdown files (core + modules) and return parsed data.""" results = {} # Core components comp_dir = os.path.join(docs_path, COMPONENT_ROOT) for fpath in walk_md_files(comp_dir): data = parse_component_file(fpath) if data: results[data["name"]] = data core_count = len(results) # Module components module_dirs = find_module_reference_dirs(docs_path, "components") for module_name, ref_dir in module_dirs: before = len(results) for fpath in walk_md_files(ref_dir): data = parse_component_file(fpath) if data: results[data["name"]] = data added = len(results) - before if added: print(f" {module_name}: +{added} components") # Extra standalone module docs repos for repo in EXTRA_DOCS_REPOS: repo_path = repo["default_path"] if not os.path.isdir(repo_path): continue module_name = repo["module_name"] before = len(results) for comp_dir_rel in repo.get("component_dirs", []): comp_dir = os.path.join(repo_path, comp_dir_rel) if not os.path.isdir(comp_dir): continue for fpath in walk_md_files(comp_dir): data = parse_component_file(fpath) if data: results[data["name"]] = data added = len(results) - before if added: print(f" {module_name}: +{added} components") return results, core_count def generate_member_data(docs_path): """Parse all type member function files.""" types_dir = os.path.join(docs_path, TYPES_ROOT) results = {} for fname, type_name in MEMBER_TYPE_FILES.items(): fpath = os.path.join(types_dir, fname) if not os.path.exists(fpath): print(f" [warn] Missing type file: {fpath}", file=sys.stderr) continue methods = parse_member_type_file(fpath, type_name) if methods: results[type_name] = {m["name"]: m for m in methods} return results # --------------------------------------------------------------------------- # JSON serializers (match existing format expected by basecompletions/__init__.py) # --------------------------------------------------------------------------- def build_functions_json(bif_data): """ {FuncName: [description, [required_snippet, full_snippet]]} """ out = {} for name, data in sorted(bif_data.items()): req_snip, full_snip = build_snippets(data["params"]) out[name] = [data["description"], [req_snip, full_snip]] return out def build_tags_json(component_data): """ {tagName: {attributes: [[required_names], [optional_names]], attribute_values: {}}} """ out = {} for name, data in sorted(component_data.items()): required = [a["name"] for a in data["attributes"] if a["required"]] optional = [a["name"] for a in data["attributes"] if not a["required"]] out[name] = {"attributes": [required, optional], "attribute_values": {}} return out def build_member_functions_json(member_data): """ {typeName: {funcName: [description, [required_snippet, full_snippet]]}} Merges with any existing JSON so that types under-documented in the docs repo (e.g. string has only 3 detail blocks) retain their prior entries. """ # Load existing data to use as fallback existing = {} existing_path = os.path.join(JSON_DIR, "boxlang_member_functions.json") if os.path.exists(existing_path): try: with open(existing_path, encoding="utf-8") as f: existing = json.load(f) except (json.JSONDecodeError, OSError): pass out = {} all_types = set(member_data.keys()) | set(existing.keys()) for type_name in sorted(all_types): docs_methods = member_data.get(type_name, {}) existing_methods = existing.get(type_name, {}) # Start from existing, then overlay with fresh docs data merged = dict(existing_methods) for func_name, data in docs_methods.items(): req_snip, full_snip = build_snippets(data["params"]) merged[func_name] = [data["description"], [req_snip, full_snip]] if merged: out[type_name] = {k: merged[k] for k in sorted(merged)} return out def build_function_params_json(bif_data): """ Full parameter data for the documentation popup. {FuncName: {description, params: [{name, type, required, description, default}], url_path, category}} """ out = {} for name, data in sorted(bif_data.items()): out[name] = { "description": data["description"], "params": data["params"], "url_path": data.get("url_path", ""), "category": data.get("category", ""), } return out # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description="Generate BoxLang completion JSON from boxlang-docs") parser.add_argument( "--docs-path", default=os.path.join(os.path.dirname(REPO_ROOT), "boxlang-docs"), help="Path to the boxlang-docs repo (default: ../boxlang-docs)", ) parser.add_argument( "--update", action="store_true", help="Run 'git pull' in the docs repo before generating", ) parser.add_argument( "--clone", action="store_true", help="Clone the docs repo to --docs-path if it doesn't exist", ) args = parser.parse_args() docs_path = os.path.abspath(args.docs_path) # Clone / update the main docs repo if not os.path.isdir(docs_path): if args.clone: print(f"Cloning {DOCS_REPO_URL} → {docs_path}") subprocess.run(["git", "clone", "--depth=1", DOCS_REPO_URL, docs_path], check=True) else: print(f"Error: docs path not found: {docs_path}", file=sys.stderr) print("Use --clone to clone it automatically, or --docs-path to specify a custom path.", file=sys.stderr) sys.exit(1) elif args.update: print(f"Updating {docs_path}...") subprocess.run(["git", "-C", docs_path, "pull", "--ff-only"], check=True) # Clone / update extra module repos for repo in EXTRA_DOCS_REPOS: repo_path = repo["default_path"] if not os.path.isdir(repo_path): if args.clone: print(f"Cloning {repo['clone_url']} → {repo_path}") os.makedirs(os.path.dirname(repo_path), exist_ok=True) subprocess.run(["git", "clone", "--depth=1", repo["clone_url"], repo_path], check=True) else: print(f" [skip] {repo['module_name']} docs not found at {repo_path} (use --clone)", file=sys.stderr) elif args.update: print(f"Updating {repo['module_name']} docs at {repo_path}...") subprocess.run(["git", "-C", repo_path, "pull", "--ff-only"], check=True) print(f"Reading docs from: {docs_path}") # Parse print("Parsing BIF files...") bif_data, core_bif_count = generate_bif_data(docs_path) print(f" Found {len(bif_data)} BIFs total ({core_bif_count} core + {len(bif_data) - core_bif_count} module)") print("Parsing component files...") component_data, core_comp_count = generate_component_data(docs_path) print(f" Found {len(component_data)} components total ({core_comp_count} core + {len(component_data) - core_comp_count} module)") print("Parsing member function files...") member_data = generate_member_data(docs_path) total_members = sum(len(v) for v in member_data.values()) print(f" Found {total_members} member functions across {len(member_data)} types") # Build JSON payloads functions_json = build_functions_json(bif_data) tags_json = build_tags_json(component_data) member_json = build_member_functions_json(member_data) params_json = build_function_params_json(bif_data) # Write os.makedirs(JSON_DIR, exist_ok=True) outputs = [ ("boxlang_functions.json", functions_json), ("boxlang_tags.json", tags_json), ("boxlang_member_functions.json", member_json), ("boxlang_function_params.json", params_json), ] for fname, data in outputs: fpath = os.path.join(JSON_DIR, fname) with open(fpath, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) print(f"Wrote {fpath} ({len(data)} entries)") print("\nDone. JSON files updated.") if __name__ == "__main__": main()