Skip to main content
Glama
by microsoft
astgrep.ts11.9 kB
import { CancellationOptions, checkCancelled } from "./cancellation" import { CancelError, errorMessage } from "./error" import { resolveFileContent } from "./file" import { host } from "./host" import { uniq } from "es-toolkit" import { readText, writeText } from "./fs" import { extname } from "node:path" import { diffFindChunk, diffResolve } from "./diff" import { genaiscriptDebug } from "./debug" const dbg = genaiscriptDebug("astgrep") const dbgLang = dbg.extend("lang") class SgChangeSetImpl implements SgChangeSet { private pending: Record<string, { root: SgRoot; edits: SgEdit[] }> = {} toString() { return `changeset ${this.count} edits` } get count(): number { return Object.values(this.pending).reduce( (acc, { edits }) => acc + edits.length, 0 ) } replace(node: SgNode, text: string) { const edit = node.replace(text) const root = node.getRoot() let rootEdits = this.pending[root.filename()] if (rootEdits) { if (rootEdits.root !== root) { throw new Error( `node ${node} belongs to a different root ${root} than the pending edits ${rootEdits.root}` ) } } else rootEdits = this.pending[root.filename()] = { root, edits: [] } rootEdits.edits.push(edit) return edit } commit() { const files: WorkspaceFile[] = [] for (const { root, edits } of Object.values(this.pending)) { const filename = root.filename() const content = root.root().commitEdits(edits) files.push({ filename, content }) } return files } } /** * Creates an instance of a change set for managing and committing AST node edits. * * This function initializes an empty change set, which can be used for tracking edits * to AST nodes, associating them with their corresponding file roots, and committing * the changes back to files. * * @returns A new change set instance to handle AST edits. */ export function astGrepCreateChangeSet(): SgChangeSet { return new SgChangeSetImpl() } /** * Searches for files matching specific criteria based on file patterns and match rules, * and performs analysis or modifications on matched nodes in the files. * * @param lang - The language of the files to search, such as JavaScript or HTML. * @param glob - A single or array of glob patterns to match file paths. * @param matcher - The match criteria, either a string pattern or a specific matcher object. * @param options - Optional parameters, including cancellation options and options for file search. * - cancellationToken: A token to handle operation interruptions. * - diff: A diff object to filter files based on changes. * * @returns An object containing: * - `files`: The number of files scanned. * - `matches`: The list of matched nodes. * * @throws An error if `glob` or `matcher` is not provided. */ export async function astGrepFindFiles( lang: SgLang, glob: ElementOrArray<string>, matcher: string | SgMatcher, options?: SgSearchOptions & CancellationOptions ): ReturnType<Sg["search"]> { const { cancellationToken, diff } = options || {} if (!glob) { throw new Error("glob is required") } if (!matcher) { throw new Error("matcher is required") } const diffFiles = diffResolve(diff) dbg(`search %O`, matcher) if (diffFiles?.length) dbg(`diff files: ${diffFiles.length}`) const { findInFiles } = await import("@ast-grep/napi") checkCancelled(cancellationToken) let paths = await host.findFiles(glob, options) if (!paths?.length) { dbg(`no files found for glob`, glob) return { files: 0, matches: [], } } dbg(`found ${paths.length} files`, paths) if (diffFiles?.length) { const diffFilesSet = new Set( diffFiles.filter((f) => f.to).map((f) => f.to) ) paths = paths.filter((p) => diffFilesSet.has(p)) dbg(`filtered files by diff: ${paths.length}`) if (!paths?.length) { return { files: 0, matches: [], } } } let matches: SgNode[] = [] const p = new Promise<number>(async (resolve, reject) => { let i = 0 let n: number = undefined const sglang = await resolveLang(lang) n = await findInFiles( sglang, { paths, matcher: typeof matcher === "string" ? <SgMatcher>{ rule: { pattern: matcher } } : matcher, }, (err, nodes) => { if (err) { dbg(`error occurred: ${err}`) throw err } dbg(`nodes found: ${nodes.length}`) matches.push(...nodes) if (cancellationToken?.isCancellationRequested) { reject(new CancelError("cancelled")) } if (++i === n) { dbg(`resolving promise with count: ${n}`) resolve(n) } } ) if (n === i) { dbg("resolving promise as callbacks might be ahead") // we might be ahead of the callbacks resolve(n) } }) const scanned = await p dbg(`files scanned: ${scanned}, matches found: ${matches.length}`) checkCancelled(cancellationToken) // apply diff if (diffFiles?.length) { matches = matches.filter((m) => { const range: [number, number] = [ m.range().start.line, m.range().end.line, ] const { chunk } = diffFindChunk(m.getRoot().filename(), range, diffFiles) || {} if (chunk) dbg( `diff overlap at (${range[0]},${range[1]}) x (${chunk.newStart},${chunk.newStart + chunk.newLines})` ) return chunk }) dbg(`matches filtered by diff: ${matches.length}`) } return { files: scanned, matches } } /** * Writes edits to the roots of the provided nodes to their corresponding files. * * @param nodes - An array of AST nodes whose root edits need to be written. * @param options - Optional configuration for cancellation, containing a cancellation token to handle operation interruptions. * * The function iterates through the unique roots of the provided nodes, checks for file content differences, * and writes updated content to the respective files if changes are detected. If a file does not have a filename, it is skipped. */ export async function astGrepWriteRootEdits( nodes: SgNode[], options?: CancellationOptions ) { const { cancellationToken } = options || {} const roots = uniq(nodes.map((n) => n.getRoot())) dbg(`writing edits to roots: ${roots.length}`) for (const root of roots) { checkCancelled(cancellationToken) const filename = root.filename() if (!filename) continue const existing = await readText(filename) const updated = root.root().text() if (existing !== updated) { dbg(`writing changes to root: ${filename}`) await writeText(filename, updated) } } } /** * Parses a given file into an abstract syntax tree (AST) root node. * * @param file - The input file to parse. Must include filename, encoding, and content properties. * @param options - Optional parameters: * - lang: Specifies the programming or markup language for parsing. If not provided, attempts to infer from the file name. * - cancellationToken: Optional cancellation token to abort the operation if necessary. * * @returns The parsed AST root node. Returns undefined if the file is binary or language cannot be resolved. * * Notes: * - Skips binary files based on the `encoding` property. * - Automatically resolves file content before parsing. * - Uses the library "@ast-grep/napi" for parsing. */ export async function astGrepParse( file: WorkspaceFile, options?: { lang?: SgLang | Record<string, SgLang> } & CancellationOptions ): Promise<SgRoot> { const { cancellationToken } = options || {} if (file.encoding) { dbg("ignore binary file") return undefined } // binary file await resolveFileContent(file) checkCancelled(cancellationToken) const { filename, encoding, content } = file if (encoding) { dbg("ignore binary file") return undefined } // binary file dbg(`parsing file: ${filename}`) const { parseAsync } = await import("@ast-grep/napi") const lang = await resolveLang(options?.lang, filename) if (!lang) { return undefined } dbg("parsing file content") const root = await parseAsync(lang, content) checkCancelled(cancellationToken) return root } async function resolveLang( lang: SgLang | Record<string, SgLang>, filename?: string ) { const { Lang } = await import("@ast-grep/napi") const norm = (l: string) => l.toLowerCase().replace(/^\./, "") // pre-compiled with ast-grep const builtins: any = { html: Lang.Html, htm: Lang.Html, cjs: Lang.JavaScript, mjs: Lang.JavaScript, js: Lang.JavaScript, cts: Lang.TypeScript, mts: Lang.TypeScript, ts: Lang.TypeScript, typescript: Lang.TypeScript, javascript: Lang.JavaScript, jsx: Lang.Tsx, tsx: Lang.Tsx, css: Lang.Css, } const dynamics: any = { h: "c", c: "c", cpp: "cpp", hpp: "cpp", hxx: "cpp", cxx: "cpp", cs: "csharp", py: "python", sql: "sql", yml: "yaml", yaml: "yaml", } const forbidden = ["bin", "exe", "dll"] // user provided a string if (typeof lang === "string") { lang = norm(lang) dbgLang(`resolving language ${lang}`) const builtin = builtins[lang] if (builtin) return builtin else return await loadDynamicLanguage(lang) } if (!filename) { dbgLang(`filename not provided`) throw new Error("filename is required to resolve language") } if (filename) { const ext = norm(extname(filename)) dbgLang(`resolving language for ${ext}`) // known builtins const builtin = builtins[ext] if (builtin) return builtin // known dynamics const dynamic = dynamics[ext] if (dynamic) return await loadDynamicLanguage(dynamic) if (forbidden.includes(ext)) return undefined // try our luck return await loadDynamicLanguage(ext) } dbgLang(`language not resolved`, { lang, filename }) throw new Error("language not resolved") } const loadedDynamicLanguages = new Set<string>() async function loadDynamicLanguage(langName: string) { if (!loadedDynamicLanguages.has(langName)) { dbgLang(`loading language: ${langName}`) const { registerDynamicLanguage } = await import("@ast-grep/napi") try { const dynamicLang = (await import(`@ast-grep/lang-${langName}`)) .default registerDynamicLanguage({ [langName]: dynamicLang }) loadedDynamicLanguages.add(langName) dbgLang(`language ${langName} registered `) } catch (err) { dbgLang(`error loading language ${langName}: ${errorMessage(err)}`) throw Error( `@ast-grep/lang-${langName} package failed to load, please install it using 'npm install -D @ast-grep/lang-${langName}'` ) } } return langName }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/microsoft/genaiscript'

If you have feedback or need assistance with the MCP directory API, please join our Discord server