Skip to content

Commit 3e7c2b8

Browse files
Use typst-gather analyze subcommand for init-config and gather subcommand for gathering
Replace the TypeScript regex-based import scanning with a call to `typst-gather analyze`, which uses Typst's own parser for robust import discovery including nested imports and transitive @Local dependencies. - Remove parseSimpleToml(), discoverImportsFromFiles(), and related types - Add runAnalyze() that pipes TOML config to `typst-gather analyze -` on stdin - Rewrite generateConfigFromAnalysis() to consume structured JSON output - Pipe config on stdin for auto-detected gather (no temp files) - Use `gather` subcommand for all gather invocations - Add QUARTO_TYPST_GATHER env var override to call version - Extract typstGatherBinaryPath() shared helper - Add 12 unit tests for generateConfigFromAnalysis()
1 parent 8faf262 commit 3e7c2b8

2 files changed

Lines changed: 359 additions & 164 deletions

File tree

src/command/call/typst-gather/cmd.ts

Lines changed: 103 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,11 @@ async function resolveConfig(
116116
const configPath = join(cwd, "typst-gather.toml");
117117
if (existsSync(configPath)) {
118118
info(`Using config: ${configPath}`);
119-
// Return the config file path - rust will parse it directly
120-
// We still parse minimally to validate and show info
121-
const content = Deno.readTextFileSync(configPath);
122-
const config = parseSimpleToml(content);
123-
config.configFile = configPath;
124-
return config;
119+
return {
120+
configFile: configPath,
121+
destination: "",
122+
discover: [],
123+
};
125124
}
126125

127126
// No config file - try to auto-detect from _extension.yml
@@ -164,105 +163,58 @@ async function resolveConfig(
164163
};
165164
}
166165

167-
function parseSimpleToml(content: string): TypstGatherConfig {
168-
const lines = content.split("\n");
169-
let rootdir: string | undefined;
170-
let destination = "";
171-
const discover: string[] = [];
172-
173-
for (const line of lines) {
174-
const trimmed = line.trim();
175-
176-
// Parse rootdir
177-
const rootdirMatch = trimmed.match(/^rootdir\s*=\s*"([^"]+)"/);
178-
if (rootdirMatch) {
179-
rootdir = rootdirMatch[1];
180-
continue;
181-
}
182-
183-
// Parse destination
184-
const destMatch = trimmed.match(/^destination\s*=\s*"([^"]+)"/);
185-
if (destMatch) {
186-
destination = destMatch[1];
187-
continue;
188-
}
189-
190-
// Parse discover as string
191-
const discoverStrMatch = trimmed.match(/^discover\s*=\s*"([^"]+)"/);
192-
if (discoverStrMatch) {
193-
discover.push(discoverStrMatch[1]);
194-
continue;
195-
}
196-
197-
// Parse discover as array (simple single-line parsing)
198-
const discoverArrMatch = trimmed.match(/^discover\s*=\s*\[([^\]]+)\]/);
199-
if (discoverArrMatch) {
200-
const items = discoverArrMatch[1].split(",");
201-
for (const item of items) {
202-
const match = item.trim().match(/"([^"]+)"/);
203-
if (match) {
204-
discover.push(match[1]);
205-
}
206-
}
207-
}
208-
}
209-
210-
return { rootdir, destination, discover };
211-
}
212-
213-
interface DiscoveredImport {
166+
export interface AnalyzeImport {
167+
namespace: string;
214168
name: string;
215169
version: string;
216-
sourceFile: string;
170+
source: string;
171+
direct: boolean;
217172
}
218173

219-
interface DiscoveryResult {
220-
preview: DiscoveredImport[];
221-
local: DiscoveredImport[];
222-
scannedFiles: string[];
174+
export interface AnalyzeResult {
175+
imports: AnalyzeImport[];
176+
files: string[];
223177
}
224178

225-
function discoverImportsFromFiles(files: string[]): DiscoveryResult {
226-
const result: DiscoveryResult = {
227-
preview: [],
228-
local: [],
229-
scannedFiles: [],
230-
};
179+
function typstGatherBinaryPath(): string {
180+
const binaryName = isWindows ? "typst-gather.exe" : "typst-gather";
181+
const binary = Deno.env.get("QUARTO_TYPST_GATHER") ||
182+
architectureToolsPath(binaryName);
231183

232-
// Regex to match @namespace/name:version imports
233-
// Note: #include is for files, not packages, so we only match #import
234-
const importRegex = /#import\s+"@(\w+)\/([^:]+):([^"]+)"/g;
184+
if (!existsSync(binary)) {
185+
throw new Error(
186+
`typst-gather binary not found.\n` +
187+
`Run ./configure.sh to build and install it.`,
188+
);
189+
}
235190

236-
for (const file of files) {
237-
if (!existsSync(file)) continue;
238-
if (!file.endsWith(".typ")) continue;
191+
return binary;
192+
}
239193

240-
const filename = file.split("/").pop() || file;
241-
result.scannedFiles.push(filename);
194+
async function runAnalyze(tomlConfig: string): Promise<AnalyzeResult> {
195+
const binary = typstGatherBinaryPath();
196+
197+
const result = await execProcess(
198+
{
199+
cmd: binary,
200+
args: ["analyze", "-"],
201+
stdout: "piped",
202+
stderr: "piped",
203+
},
204+
tomlConfig,
205+
);
242206

243-
try {
244-
const content = Deno.readTextFileSync(file);
245-
let match;
246-
while ((match = importRegex.exec(content)) !== null) {
247-
const [, namespace, name, version] = match;
248-
const entry = { name, version, sourceFile: filename };
249-
250-
if (namespace === "preview") {
251-
result.preview.push(entry);
252-
} else if (namespace === "local") {
253-
result.local.push(entry);
254-
}
255-
}
256-
} catch {
257-
// Skip files that can't be read
258-
}
207+
if (!result.success) {
208+
throw new Error(
209+
result.stderr || "typst-gather analyze failed",
210+
);
259211
}
260212

261-
return result;
213+
return JSON.parse(result.stdout!) as AnalyzeResult;
262214
}
263215

264-
function generateConfigContent(
265-
discovery: DiscoveryResult,
216+
export function generateConfigFromAnalysis(
217+
result: AnalyzeResult,
266218
rootdir?: string,
267219
): string {
268220
const lines: string[] = [];
@@ -278,31 +230,31 @@ function generateConfigContent(
278230
lines.push("");
279231

280232
// Discover section
281-
if (discovery.scannedFiles.length > 0) {
282-
if (discovery.scannedFiles.length === 1) {
283-
lines.push(`discover = "${toTomlPath(discovery.scannedFiles[0])}"`);
284-
} else {
285-
const files = discovery.scannedFiles.map((f) => `"${toTomlPath(f)}"`)
286-
.join(", ");
287-
lines.push(`discover = [${files}]`);
288-
}
233+
if (result.files.length === 1) {
234+
lines.push(`discover = "${toTomlPath(result.files[0])}"`);
235+
} else if (result.files.length > 1) {
236+
const files = result.files.map((f) => `"${toTomlPath(f)}"`).join(", ");
237+
lines.push(`discover = [${files}]`);
289238
} else {
290239
lines.push('# discover = "template.typ" # Add your .typ files here');
291240
}
292241

293242
lines.push("");
294243

295244
// Preview section (commented out - packages will be auto-discovered)
245+
const previewImports = result.imports.filter((i) =>
246+
i.namespace === "preview"
247+
);
296248
lines.push("# Preview packages are auto-discovered from imports.");
297249
lines.push("# Uncomment to pin specific versions:");
298250
lines.push("# [preview]");
299-
if (discovery.preview.length > 0) {
300-
// Deduplicate
251+
if (previewImports.length > 0) {
301252
const seen = new Set<string>();
302-
for (const { name, version } of discovery.preview) {
253+
for (const { name, version, direct, source } of previewImports) {
303254
if (!seen.has(name)) {
304255
seen.add(name);
305-
lines.push(`# ${name} = "${version}"`);
256+
const suffix = direct ? "" : ` # via ${source}`;
257+
lines.push(`# ${name} = "${version}"${suffix}`);
306258
}
307259
}
308260
} else {
@@ -312,21 +264,24 @@ function generateConfigContent(
312264
lines.push("");
313265

314266
// Local section
267+
const localImports = result.imports.filter(
268+
(i) => i.namespace === "local" && i.direct,
269+
);
315270
lines.push(
316271
"# Local packages (@local namespace) must be configured manually.",
317272
);
318-
if (discovery.local.length > 0) {
273+
if (localImports.length > 0) {
319274
lines.push("# Found @local imports:");
320275
const seen = new Set<string>();
321-
for (const { name, version, sourceFile } of discovery.local) {
276+
for (const { name, version, source } of localImports) {
322277
if (!seen.has(name)) {
323278
seen.add(name);
324-
lines.push(`# @local/${name}:${version} (in ${sourceFile})`);
279+
lines.push(`# @local/${name}:${version} (in ${source})`);
325280
}
326281
}
327282
lines.push("[local]");
328283
seen.clear();
329-
for (const { name } of discovery.local) {
284+
for (const { name } of localImports) {
330285
if (!seen.has(name)) {
331286
seen.add(name);
332287
lines.push(`${name} = "/path/to/${name}" # TODO: set correct path`);
@@ -373,14 +328,18 @@ async function initConfig(): Promise<void> {
373328
info(`Found extension: ${extensionDir}`);
374329
}
375330

376-
// Discover imports from the files
377-
const discovery = discoverImportsFromFiles(typFiles);
331+
// Build analyze config with discover paths
332+
const discoverArray = typFiles.map((f) => `"${toTomlPath(f)}"`).join(", ");
333+
const analyzeConfig = `discover = [${discoverArray}]\n`;
334+
335+
// Run typst-gather analyze to discover imports
336+
const analysis = await runAnalyze(analyzeConfig);
378337

379338
// Calculate relative path from cwd to extension dir for rootdir
380339
const rootdir = relative(Deno.cwd(), extensionDir);
381340

382-
// Generate config content
383-
const configContent = generateConfigContent(discovery, rootdir);
341+
// Generate config content from analysis
342+
const configContent = generateConfigFromAnalysis(analysis, rootdir);
384343

385344
// Write config file
386345
try {
@@ -390,23 +349,30 @@ async function initConfig(): Promise<void> {
390349
Deno.exit(1);
391350
}
392351

352+
const previewImports = analysis.imports.filter(
353+
(i) => i.namespace === "preview",
354+
);
355+
const localImports = analysis.imports.filter(
356+
(i) => i.namespace === "local" && i.direct,
357+
);
358+
393359
info("Created typst-gather.toml");
394-
if (discovery.scannedFiles.length > 0) {
395-
info(` Scanned: ${discovery.scannedFiles.join(", ")}`);
360+
if (analysis.files.length > 0) {
361+
info(` Scanned: ${analysis.files.join(", ")}`);
396362
}
397-
if (discovery.preview.length > 0) {
398-
info(` Found ${discovery.preview.length} @preview import(s)`);
363+
if (previewImports.length > 0) {
364+
info(` Found ${previewImports.length} @preview import(s)`);
399365
}
400-
if (discovery.local.length > 0) {
366+
if (localImports.length > 0) {
401367
info(
402-
` Found ${discovery.local.length} @local import(s) - configure paths in [local] section`,
368+
` Found ${localImports.length} @local import(s) - configure paths in [local] section`,
403369
);
404370
}
405371

406372
info("");
407373
info("Next steps:");
408374
info(" 1. Review and edit typst-gather.toml");
409-
if (discovery.local.length > 0) {
375+
if (localImports.length > 0) {
410376
info(" 2. Add paths for @local packages in [local] section");
411377
}
412378
info(" 3. Run: quarto call typst-gather");
@@ -442,37 +408,21 @@ export const typstGatherCommand = new Command()
442408
Deno.exit(1);
443409
}
444410

445-
if (!config.destination) {
446-
console.error("No destination specified in configuration.");
447-
Deno.exit(1);
448-
}
449-
450-
if (config.discover.length === 0) {
451-
console.error("No files to discover imports from.");
452-
Deno.exit(1);
453-
}
454-
455-
// Find typst-gather binary in standard tools location
456-
const binaryName = isWindows ? "typst-gather.exe" : "typst-gather";
457-
const typstGatherBinary = architectureToolsPath(binaryName);
458-
if (!existsSync(typstGatherBinary)) {
459-
console.error(
460-
`typst-gather binary not found.\n` +
461-
`Run ./configure.sh to build and install it.`,
462-
);
463-
Deno.exit(1);
464-
}
411+
const typstGatherBinary = typstGatherBinaryPath();
465412

466-
// Determine config file to use
467-
let configFileToUse: string;
468-
let tempConfig: string | null = null;
413+
info(`Running typst-gather...`);
469414

415+
// Run typst-gather gather
416+
let result;
470417
if (config.configFile) {
471-
// Use existing config file directly - rust will parse [local], [preview], etc.
472-
configFileToUse = config.configFile;
418+
// Existing config file — pass directly
419+
result = await execProcess({
420+
cmd: typstGatherBinary,
421+
args: ["gather", config.configFile],
422+
cwd: Deno.cwd(),
423+
});
473424
} else {
474-
// Create a temporary TOML config file for auto-detected config
475-
tempConfig = Deno.makeTempFileSync({ suffix: ".toml" });
425+
// Auto-detected — pipe config on stdin
476426
const discoverArray = config.discover.map((p) => `"${toTomlPath(p)}"`)
477427
.join(", ");
478428
let tomlContent = "";
@@ -481,26 +431,15 @@ export const typstGatherCommand = new Command()
481431
}
482432
tomlContent += `destination = "${toTomlPath(config.destination)}"\n`;
483433
tomlContent += `discover = [${discoverArray}]\n`;
484-
Deno.writeTextFileSync(tempConfig, tomlContent);
485-
configFileToUse = tempConfig;
486-
}
487-
488-
info(`Running typst-gather...`);
489434

490-
// Run typst-gather
491-
const result = await execProcess({
492-
cmd: typstGatherBinary,
493-
args: [configFileToUse],
494-
cwd: Deno.cwd(),
495-
});
496-
497-
// Clean up temp file if we created one
498-
if (tempConfig) {
499-
try {
500-
Deno.removeSync(tempConfig);
501-
} catch {
502-
// Ignore cleanup errors
503-
}
435+
result = await execProcess(
436+
{
437+
cmd: typstGatherBinary,
438+
args: ["gather", "-"],
439+
cwd: Deno.cwd(),
440+
},
441+
tomlContent,
442+
);
504443
}
505444

506445
if (!result.success) {

0 commit comments

Comments
 (0)