UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

126 lines • 5.09 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { fileURLToPath } from "node:url"; import { parse } from "@ast-grep/napi"; import { ELIXIR_TEMPLATE_EXTENSIONS } from "../constants.js"; import { logger } from "./logger.js"; const parsers = new Map(); let initialization = null; export function isElixirTemplateExtension(ext) { return ELIXIR_TEMPLATE_EXTENSIONS.has(ext.toLowerCase()); } /** Load the official HEEx/EEx grammars once. Failure keeps the safe line/leaf fallback. */ export function ensureElixirTemplateParsers() { if (initialization) return initialization; initialization = (async () => { try { const [{ Language, Parser }, { default: heexLanguage }, { default: eexLanguage }] = await Promise.all([ import("web-tree-sitter"), import("@lumis-sh/wasm-heex"), import("@lumis-sh/wasm-eex"), ]); await Parser.init(); for (const [name, language] of [["heex", heexLanguage], ["eex", eexLanguage]]) { const parser = new Parser(); parser.setLanguage(await Language.load(fileURLToPath(language.wasm))); parsers.set(name, parser); } return true; } catch (err) { logger.warn("HEEx/EEx grammar loading failed; templates will use safe line/leaf fallback", { error: err instanceof Error ? err.message : String(err), }); return false; } })(); return initialization; } function parserFor(ext) { return parsers.get(ext.toLowerCase() === ".heex" ? "heex" : "eex") ?? null; } /** ast-grep exposes Tree-sitter MISSING tokens as zero-width, empty leaf nodes. */ function hasMissingNode(node) { return node.isLeaf() ? node.text() === "" : node.children().some(hasMissingNode); } function maskEmbeddedElixir(source, nodes) { if (nodes.length === 0) return ""; const masked = Array.from({ length: source.length }, (_, i) => source[i] === "\n" || source[i] === "\r" ? source[i] : " "); const ranges = nodes .map((node) => ({ start: node.startIndex, end: node.endIndex })) .filter((range) => range.end > range.start) .sort((a, b) => a.start - b.start) .filter((range, index, all) => index === 0 || range.start >= all[index - 1].end); let previousEnd = -1; for (const range of ranges) { if (previousEnd >= 0 && !source.slice(previousEnd, range.start).includes("\n")) { for (let i = range.start - 1; i >= previousEnd; i--) { if (masked[i] === " ") { masked[i] = ";"; // separate independent same-line expressions break; } } } for (let i = range.start; i < range.end; i++) masked[i] = source[i]; previousEnd = range.end; } const elixirSource = masked.join(""); try { const root = parse("elixir", elixirSource).root(); if (root.findAll({ rule: { kind: "ERROR" } }).length > 0 || hasMissingNode(root)) return null; } catch { return null; } return elixirSource; } /** Parse one template. Invalid template ASTs return null rather than guessed data. */ export function analyzeElixirTemplate(source, ext) { const parser = parserFor(ext); if (!parser) return null; const tree = parser.parse(source); if (!tree) return null; try { const root = tree.rootNode; if (root.hasError) return null; const heex = ext.toLowerCase() === ".heex"; const expressionNodes = root.descendantsOfType(heex ? ["expression_value", "partial_expression_value", "ending_expression_value"] : ["expression", "partial_expression"]).filter((node) => { let parent = node.parent; while (parent && parent.type !== "directive") parent = parent.parent; return !parent?.text.startsWith("<%%"); }); const componentNodes = heex ? root.descendantsOfType(["start_component", "self_closing_component"]) : []; const moduleReferences = [...new Set(componentNodes.flatMap((node) => { const name = node.namedChildren.find((child) => child.type === "component_name"); const module = name?.namedChildren.find((child) => child.type === "module")?.text; return module ? [module] : []; }))]; const regions = root.namedChildren .filter((node) => node.type !== "text" && node.type !== "comment") .map((node) => ({ startLine: node.startPosition.row, endLine: node.endPosition.row + 1, })); return { elixirSource: maskEmbeddedElixir(source, expressionNodes), moduleReferences, regions, }; } finally { tree.delete(); } } //# sourceMappingURL=elixir-templates.js.map