Files
windmill/frontend/src/lib/infer.ts
T
Ruben FiszelandClaude Opus 5 f081fb1070 feat: recognize // volume: mounts in PHP scripts (#11018)
* feat: recognize `// volume:` mounts in PHP scripts

Volume annotations were parsed for every language but PHP, so a PHP script
could not mount a workspace volume. Two things stood in the way: PHP had no
entry in the comment-prefix maps, and a PHP script opens with `<?php`, which
ends the leading comment block the parsers scan before any annotation is read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3FR7iS9nRhpFt615cnuQ7

* fix: tolerate a PHP opener that carries code, drop the inert CLI hunk

The open-tag skip matched `<?php` exactly, so `<?php declare(strict_types=1);`
still ended the leading comment block and every annotation below it was silently
ignored. Match the tag as a case-insensitive prefix and skip the whole line.

The CLI local-graph hunk could never fire: PHP has no wasm asset parser, so
`fallbackParse` handles it, and its own header scan stops at `<?php` — the script
is dropped as a non-pipeline-member before any volume asset is read. Making only
the CLI PHP-aware would also put the local graph out of parity with the deployed
one, whose `parse_pipeline_annotations` stops there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3FR7iS9nRhpFt615cnuQ7

* docs: correct the CLI mirror comment, state the own-line annotation rule

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3FR7iS9nRhpFt615cnuQ7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 13:23:35 +02:00

782 lines
25 KiB
TypeScript

import {
ScriptService,
type MainArgSignature,
FlowService,
type Script,
type AssetUsageAccessType
} from '$lib/gen'
import { get, writable } from 'svelte/store'
import type { Schema, SupportedLanguage } from './common.js'
import {
type DynamicInput,
emptySchema,
getHubFlowIdFromPath,
isHubFlowPath,
sortObject
} from './utils.js'
import { tick } from 'svelte'
import initTsParser, { parse_deno, parse_outputs } from 'windmill-parser-wasm-ts'
import initRegexParsers, {
parse_sql,
parse_mysql,
parse_oracledb,
parse_duckdb,
parse_bigquery,
parse_snowflake,
parse_graphql,
parse_mssql,
parse_db_resource,
parse_bash,
parse_powershell
} from 'windmill-parser-wasm-regex'
import initPythonParser, { parse_python } from 'windmill-parser-wasm-py'
import initAssetParser, {
parse_assets_ts,
parse_assets_py,
parse_assets_sql
} from 'windmill-parser-wasm-asset'
import initGoParser, { parse_go } from 'windmill-parser-wasm-go'
import initPhpParser, { parse_php } from 'windmill-parser-wasm-php'
import initRustParser, { parse_rust } from 'windmill-parser-wasm-rust'
import initYamlParser, {
parse_assets_ansible,
parse_ansible,
parse_ansible_delegate
} from 'windmill-parser-wasm-yaml'
// `parse_dbt` is newer than the published `windmill-parser-wasm-yaml`, and a
// NAMED import of an export a package does not have is a link-time failure —
// which takes this whole module down, and with it every language's inference.
// Reached through the namespace instead, so a package predating it degrades to
// "no schema inferred for dbt" (the deployed script's own, derived server-side
// by `dbt_arg_schema`, is unaffected).
import * as yamlParser from 'windmill-parser-wasm-yaml'
const parse_dbt: ((code: string) => string) | undefined = (yamlParser as any).parse_dbt
import initCSharpParser, { parse_csharp } from 'windmill-parser-wasm-csharp'
import initNuParser, { parse_nu } from 'windmill-parser-wasm-nu'
import initJavaParser, { parse_java } from 'windmill-parser-wasm-java'
import initRubyParser, { parse_ruby } from 'windmill-parser-wasm-ruby'
import initRParser, { parse_r } from 'windmill-parser-wasm-r'
import wasmUrlTs from 'windmill-parser-wasm-ts/windmill_parser_wasm_bg.wasm?url'
import wasmUrlRegex from 'windmill-parser-wasm-regex/windmill_parser_wasm_bg.wasm?url'
import wasmUrlPy from 'windmill-parser-wasm-py/windmill_parser_wasm_bg.wasm?url'
import wasmUrlGo from 'windmill-parser-wasm-go/windmill_parser_wasm_bg.wasm?url'
import wasmUrlPhp from 'windmill-parser-wasm-php/windmill_parser_wasm_bg.wasm?url'
import wasmUrlRust from 'windmill-parser-wasm-rust/windmill_parser_wasm_bg.wasm?url'
import wasmUrlYaml from 'windmill-parser-wasm-yaml/windmill_parser_wasm_bg.wasm?url'
import wasmUrlCSharp from 'windmill-parser-wasm-csharp/windmill_parser_wasm_bg.wasm?url'
import wasmUrlNu from 'windmill-parser-wasm-nu/windmill_parser_wasm_bg.wasm?url'
import wasmUrlJava from 'windmill-parser-wasm-java/windmill_parser_wasm_bg.wasm?url'
import wasmUrlRuby from 'windmill-parser-wasm-ruby/windmill_parser_wasm_bg.wasm?url'
import wasmUrlR from 'windmill-parser-wasm-r/windmill_parser_wasm_bg.wasm?url'
import wasmUrlAsset from 'windmill-parser-wasm-asset/windmill_parser_wasm_bg.wasm?url'
import initWacParser, { parse_workflow_as_code } from 'windmill-parser-wasm-wac'
import wasmUrlWac from 'windmill-parser-wasm-wac/windmill_parser_wasm_bg.wasm?url'
import { workspaceStore } from './stores.js'
import { argSigToJsonSchemaType } from 'windmill-utils-internal'
import { type AssetWithAccessType } from './components/assets/lib.js'
import { type ColumnLineage } from './components/assets/AssetGraph/parsePipelineAnnotations'
const loadSchemaLastRun = writable<
| [
string | undefined,
MainArgSignature | undefined,
string | undefined,
SupportedLanguage | 'bunnative' | undefined
]
| undefined
>(undefined)
// Memoize each WASM parser's init promise. Without this, concurrent callers
// (e.g. Promise.all in initFlowState across modules of the same language)
// would each invoke the initializer and race — if any one of them rejected
// transiently, the schema for that module would come back empty and the user
// had to modify the code to trigger a second inference.
function memoize(init: () => Promise<any>): () => Promise<any> {
let promise: Promise<any> | undefined
return () => {
if (promise == undefined) {
promise = init().catch((e) => {
// Allow a subsequent call to retry after a failed init
promise = undefined
throw e
})
}
return promise
}
}
export const initWasmTs = memoize(() => initTsParser(wasmUrlTs))
const initWasmRegex = memoize(() => initRegexParsers(wasmUrlRegex))
const initWasmPython = memoize(() => initPythonParser(wasmUrlPy))
const initWasmPhp = memoize(() => initPhpParser(wasmUrlPhp))
const initWasmRust = memoize(() => initRustParser(wasmUrlRust))
const initWasmGo = memoize(() => initGoParser(wasmUrlGo))
const initWasmYaml = memoize(() => initYamlParser(wasmUrlYaml))
const initWasmCSharp = memoize(() => initCSharpParser(wasmUrlCSharp))
const initWasmNu = memoize(() => initNuParser(wasmUrlNu))
const initWasmJava = memoize(() => initJavaParser(wasmUrlJava))
const initWasmRuby = memoize(() => initRubyParser(wasmUrlRuby))
const initWasmR = memoize(() => initRParser(wasmUrlR))
const initWasmAsset = memoize(() => initAssetParser(wasmUrlAsset))
const initWasmWac = memoize(() => initWacParser(wasmUrlWac))
export type WacDagNode = {
id: string
node_type:
| { type: 'Step'; name: string; script: string }
| { type: 'InlineStep'; name: string }
| { type: 'Sleep'; seconds: string }
| { type: 'WaitForApproval' }
| { type: 'Branch'; condition_source: string }
| { type: 'ParallelStart' }
| { type: 'ParallelEnd' }
| { type: 'LoopStart'; iter_source: string }
| { type: 'LoopEnd' }
| { type: 'Merge' }
| { type: 'Return' }
label: string
line: number
}
export type WacDagEdge = {
from: string
to: string
label?: string
}
export type WacWorkflowDag = {
nodes: WacDagNode[]
edges: WacDagEdge[]
params: { name: string; typ?: string }[]
source_hash: string
}
export async function parseWacDag(
code: string,
language: string
): Promise<WacWorkflowDag | { errors: { message: string; line: number }[] } | null> {
try {
await initWasmWac()
const raw = parse_workflow_as_code(code, language)
const result = JSON.parse(raw)
if (result.type === 'success') {
return result as WacWorkflowDag
} else if (result.type === 'error') {
return { errors: result.errors }
}
return null
} catch {
return null
}
}
type InferAssetsResult =
| {
status: 'ok'
assets: AssetWithAccessType[]
sql_queries?: InferAssetsSqlQueryDetails[]
columns?: Record<string, AssetUsageAccessType>
// Body-inferred column lineage (DuckDB SQL AST). Present once the
// `windmill-parser-wasm-asset` package is rebuilt with the inference;
// the spread below already forwards it from the parser output.
column_lineage?: ColumnLineage[]
}
| {
status: 'error'
error: string
assets?: undefined
sql_queries?: undefined
}
export type InferAssetsSqlQueryDetails = {
query_string: string // SQL query with $1 placeholders for interpolations
span: [number, number] // [start, end] byte positions in source code
source_kind: 'datatable' | 'ducklake' // AssetKind equivalent
source_name: string // e.g., "main", "dt"
source_schema?: string // e.g., "public", optional
has_raw_interpolation?: boolean // true if any ${sql.raw(...)} was used
prepared?: PreparedAssetsSqlQuery
}
export type PreparedAssetsSqlQuery =
| { columns: Record<string, string> } // e.g { id: "number", name: "text" }
| { error: string; columns?: undefined } // error message if preparation failed
// Scans the leading comment block, stopping at the first line that is neither blank
// nor a comment. A PHP script opens with `<?php`, which is not a comment, so that line
// is skipped like a blank one; otherwise the block would end before reaching any
// annotation. The whole line goes, since the tag may carry code (`<?php declare(…);`) —
// so an annotation must sit on its own line, below the opener.
function parseVolumeAnnotations(code: string, commentPrefix: string): AssetWithAccessType[] {
const volumes: AssetWithAccessType[] = []
for (const line of code.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.slice(0, 5).toLowerCase() === '<?php') continue
if (!trimmed.startsWith(commentPrefix)) break
const after = trimmed.slice(commentPrefix.length).trim()
const match = after.match(/^volume:\s*(\S+)/)
if (match) {
volumes.push({ kind: 'volume', path: match[1], access_type: 'rw' })
}
}
return volumes
}
function getCommentPrefix(language: SupportedLanguage | undefined): string | undefined {
switch (language) {
case 'python3':
case 'bash':
case 'powershell':
case 'ansible':
case 'ruby':
case 'rlang':
return '#'
case 'deno':
case 'bun':
case 'bunnative':
case 'nativets':
case 'go':
case 'php':
return '//'
default:
return undefined
}
}
export async function inferAssets(
language: SupportedLanguage | undefined,
code: string
): Promise<InferAssetsResult> {
function wrap(raw_result: string): InferAssetsResult {
if (raw_result.startsWith('err:')) {
return { status: 'error', error: raw_result.slice(4).trim() }
}
return { status: 'ok', ...JSON.parse(raw_result) }
}
let result: InferAssetsResult | undefined
try {
if (language === 'duckdb') {
await initWasmAsset()
result = wrap(parse_assets_sql(code))
} else if (language === 'deno' || language === 'nativets' || language === 'bun') {
await initWasmAsset()
result = wrap(parse_assets_ts(code))
} else if (language === 'python3') {
await initWasmAsset()
result = wrap(parse_assets_py(code))
} else if (language === 'ansible') {
await initWasmYaml()
result = wrap(parse_assets_ansible(code))
}
} catch (e) {
return { status: 'error', error: (e as Error)?.message || JSON.stringify(e) }
}
if (!result) {
result = { status: 'ok', assets: [] }
}
const prefix = getCommentPrefix(language)
if (prefix && result.status === 'ok') {
const volumeAssets = parseVolumeAnnotations(code, prefix)
if (volumeAssets.length > 0) {
result = { ...result, assets: [...result.assets, ...volumeAssets] }
}
}
return result
}
export async function inferAnsibleExecutionMode(code: string): Promise<any> {
try {
await initWasmYaml()
return JSON.parse(parse_ansible_delegate(code))
} catch (e) {
console.error('error parsing git repo for ansible', e)
return undefined
}
}
const SQL_LANGUAGES = [
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'oracledb',
'duckdb'
]
/**
* Returns the parameter names of `entrypoint` in `code` (or `main` if not given),
* or `undefined` if the function can't be found, the code can't be parsed, the
* language isn't supported here, or the signature contains rest/keyword args
* (in which case the callee should fall back to a conservative full comparison).
*
* Lighter than {@link inferArgs} — does not touch any schema.
*/
export async function parseEntrypointArgs(
language: SupportedLanguage | 'bunnative' | undefined,
code: string,
entrypoint?: string
): Promise<Set<string> | undefined> {
if (!code) return undefined
try {
let sig: MainArgSignature
if (language === 'python3') {
await initWasmPython()
sig = JSON.parse(parse_python(code, entrypoint))
} else if (
language === 'deno' ||
language === 'nativets' ||
language === 'bun' ||
language === 'bunnative'
) {
await initWasmTs()
sig = JSON.parse(parse_deno(code, entrypoint))
} else {
return undefined
}
if (sig.type === 'Invalid') return undefined
if (sig.star_args || sig.star_kwargs) return undefined
if (!Array.isArray(sig.args)) return undefined
// The parser sets auto_kind when no matching entrypoint function was
// found — empty args in that case means "unknown signature", not
// "function takes no params", so we fall back to a full comparison.
if (sig.args.length === 0 && sig.auto_kind != null) return undefined
return new Set(sig.args.map((a) => a.name))
} catch {
return undefined
}
}
const helperEntrypointCache = new Map<string, Set<string> | undefined>()
/**
* Resolves a {@link DynamicInput.HelperScript} to its entrypoint parameter
* names. For deployed helpers it fetches the script (or the flow's inline
* dyn-select code) once and caches the result per workspace+path+entrypoint.
*/
export async function getHelperEntrypointArgs(
helper: DynamicInput.HelperScript,
entrypoint?: string
): Promise<Set<string> | undefined> {
if (helper.source === 'inline') {
return parseEntrypointArgs(helper.lang, helper.code, entrypoint)
}
const workspace = get(workspaceStore)
if (!workspace) return undefined
const cacheKey = `${workspace}::${helper.runnable_kind}::${helper.path}::${entrypoint ?? ''}`
if (helperEntrypointCache.has(cacheKey)) return helperEntrypointCache.get(cacheKey)
let result: Set<string> | undefined
try {
if (helper.runnable_kind === 'script') {
const script = await ScriptService.getScriptByPath({ workspace, path: helper.path })
result = await parseEntrypointArgs(script.language, script.content ?? '', entrypoint)
} else {
const flow = await FlowService.getFlowByPath({ workspace, path: helper.path })
const schema = flow.schema as Record<string, unknown> | undefined
const code = schema?.['x-windmill-dyn-select-code']
const lang = schema?.['x-windmill-dyn-select-lang']
if (typeof code === 'string' && typeof lang === 'string') {
result = await parseEntrypointArgs(lang as SupportedLanguage, code, entrypoint)
}
}
} catch {
result = undefined
}
helperEntrypointCache.set(cacheKey, result)
return result
}
export async function inferArgs(
language: SupportedLanguage | 'bunnative' | undefined,
code: string,
schema: Schema,
mainOverride?: string
): Promise<{
auto_kind: string | null
has_preprocessor: boolean | null
} | null> {
const lastRun = get(loadSchemaLastRun)
let inferedSchema: MainArgSignature
if (
lastRun &&
code == lastRun[0] &&
lastRun[1] &&
lastRun[2] == mainOverride &&
lastRun[3] === language
) {
inferedSchema = lastRun[1]
} else {
if (code == '') {
code = ' '
}
let inlineDBResource: string | undefined = undefined
if (language && SQL_LANGUAGES.includes(language)) {
await initWasmRegex()
}
if (
['postgresql', 'mysql', 'bigquery', 'snowflake', 'mssql', 'oracledb'].includes(language ?? '')
) {
inlineDBResource = parse_db_resource(code)
}
if (language == 'python3') {
await initWasmPython()
inferedSchema = JSON.parse(parse_python(code, mainOverride))
} else if (language == 'deno') {
await initWasmTs()
inferedSchema = JSON.parse(parse_deno(code, mainOverride))
} else if (language == 'nativets') {
await initWasmTs()
inferedSchema = JSON.parse(parse_deno(code, mainOverride))
} else if (language == 'bun' || language == 'bunnative') {
await initWasmTs()
inferedSchema = JSON.parse(parse_deno(code, mainOverride))
} else if (language == 'postgresql') {
inferedSchema = JSON.parse(parse_sql(code))
if (inlineDBResource === undefined) {
inferedSchema.args = [
{
name: 'database',
typ: { resource: 'postgresql' }
},
...inferedSchema.args
]
}
} else if (language == 'mysql') {
inferedSchema = JSON.parse(parse_mysql(code))
if (inlineDBResource === undefined) {
inferedSchema.args = [
{ name: 'database', typ: { resource: 'mysql' } },
...inferedSchema.args
]
}
} else if (language == 'bigquery') {
inferedSchema = JSON.parse(parse_bigquery(code))
if (inlineDBResource === undefined) {
inferedSchema.args = [
{ name: 'database', typ: { resource: 'bigquery' } },
...inferedSchema.args
]
}
} else if (language == 'oracledb') {
inferedSchema = JSON.parse(parse_oracledb(code))
if (inlineDBResource === undefined) {
inferedSchema.args = [
{ name: 'database', typ: { resource: 'oracledb' } },
...inferedSchema.args
]
}
} else if (language == 'duckdb') {
inferedSchema = JSON.parse(parse_duckdb(code))
} else if (language == 'snowflake') {
inferedSchema = JSON.parse(parse_snowflake(code))
if (inlineDBResource === undefined) {
inferedSchema.args = [
{ name: 'database', typ: { resource: 'snowflake' } },
...inferedSchema.args
]
}
} else if (language == 'mssql') {
inferedSchema = JSON.parse(parse_mssql(code))
if (inlineDBResource === undefined) {
inferedSchema.args = [
{ name: 'database', typ: { resource: 'ms_sql_server' } },
...inferedSchema.args
]
}
} else if (language == 'graphql') {
await initWasmRegex()
inferedSchema = JSON.parse(parse_graphql(code))
inferedSchema.args = [{ name: 'api', typ: { resource: 'graphql' } }, ...inferedSchema.args]
} else if (language == 'go') {
await initWasmGo()
inferedSchema = JSON.parse(parse_go(code))
} else if (language == 'bash') {
await initWasmRegex()
inferedSchema = JSON.parse(parse_bash(code))
} else if (language == 'powershell') {
await initWasmRegex()
inferedSchema = JSON.parse(parse_powershell(code))
} else if (language == 'php') {
await initWasmPhp()
inferedSchema = JSON.parse(parse_php(code, mainOverride))
} else if (language == 'rust') {
await initWasmRust()
inferedSchema = JSON.parse(parse_rust(code))
} else if (language == 'ansible') {
await initWasmYaml()
inferedSchema = JSON.parse(parse_ansible(code))
} else if (language == 'csharp') {
await initWasmCSharp()
inferedSchema = JSON.parse(parse_csharp(code))
} else if (language == 'nu') {
await initWasmNu()
inferedSchema = JSON.parse(parse_nu(code))
} else if (language == 'java') {
await initWasmJava()
inferedSchema = JSON.parse(parse_java(code))
} else if (language == 'ruby') {
await initWasmRuby()
inferedSchema = JSON.parse(parse_ruby(code))
} else if (language == 'rlang') {
try {
await initWasmR()
inferedSchema = JSON.parse(parse_r(code))
} catch {
inferedSchema = parseRSignatureFallback(code)
}
} else if (language == 'dbt') {
// Absent on a parser package predating dbt: the editor keeps whatever
// schema it has rather than clearing the run form to nothing.
if (!parse_dbt) return null
await initWasmYaml()
inferedSchema = JSON.parse(parse_dbt(code))
// for related places search: ADD_NEW_LANG
} else {
return null
}
if (inferedSchema.type == 'Invalid') {
throw new Error(inferedSchema.error)
}
loadSchemaLastRun.set([code, inferedSchema, mainOverride, language])
}
schema.required = []
const oldProperties = JSON.parse(JSON.stringify(schema.properties))
schema.properties = {}
for (const arg of inferedSchema.args) {
if (!(arg.name in oldProperties)) {
schema.properties[arg.name] = { description: '', type: '' }
} else {
schema.properties[arg.name] = oldProperties[arg.name]
if (schema.properties[arg.name].oneOf && !('oneof' in arg))
delete schema.properties[arg.name].oneOf
}
schema.properties[arg.name] = sortObject(schema.properties[arg.name])
argSigToJsonSchemaType(arg.typ, schema.properties[arg.name])
// For T | T[] detection for debouncing arg accumulation
if ((arg as any).otyp && (arg as any).otyp.includes('[') && (arg as any).otyp.includes('|')) {
schema.properties[arg.name].originalType = (arg as any).otyp
}
schema.properties[arg.name].default = arg.default
if (!arg.has_default && !schema.required.includes(arg.name)) {
schema.required.push(arg.name)
}
}
// Store PowerShell CmdletBinding metadata as schema extensions
const psSchema = inferedSchema as MainArgSignature & {
has_cmd_binding?: boolean
supports_should_process?: boolean
}
if (language === 'powershell' && psSchema.has_cmd_binding) {
;(schema as any)['x-windmill-ps-cmd-binding'] = true
;(schema as any)['x-windmill-ps-supports-should-process'] =
psSchema.supports_should_process ?? false
} else {
delete (schema as any)['x-windmill-ps-cmd-binding']
delete (schema as any)['x-windmill-ps-supports-should-process']
}
await tick()
return {
auto_kind: inferedSchema.auto_kind,
has_preprocessor: inferedSchema.has_preprocessor
}
}
export async function loadSchemaFromPath(
path: string,
hash?: string,
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
workspace?: string
): Promise<Schema> {
if (path.startsWith('hub/')) {
const { content, language, schema } = await ScriptService.getHubScriptByPath({ path })
if (schema && typeof schema === 'object' && 'properties' in schema) {
return schema as any
} else {
const newSchema = emptySchema()
await inferArgs(language as SupportedLanguage, content ?? '', newSchema)
return newSchema
}
} else if (hash) {
const script = await ScriptService.getScriptByHash({
workspace: workspace ?? get(workspaceStore)!,
hash
})
return inferSchemaIfNecessary(script)
} else {
const script = await ScriptService.getScriptByPath({
workspace: workspace ?? get(workspaceStore)!,
path: path ?? ''
})
return inferSchemaIfNecessary(script)
}
}
async function inferSchemaIfNecessary(script: Script) {
if (script.schema) {
return script.schema as any
} else {
const newSchema = emptySchema()
await inferArgs(script.language, script.content ?? '', newSchema)
return newSchema
}
}
export async function loadSchema(
workspace: string,
path: string,
runType: 'script' | 'flow' | 'hubscript'
): Promise<{ schema: Schema; summary: string | undefined }> {
if (runType === 'script') {
const script = await ScriptService.getScriptByPath({
workspace,
path
})
return { schema: script.schema as any, summary: script.summary }
} else if (runType === 'flow') {
if (isHubFlowPath(path)) {
const hubFlowId = getHubFlowIdFromPath(path)
if (hubFlowId === undefined) {
throw new Error(`Invalid hub flow path: ${path}`)
}
const hub = await FlowService.getHubFlowById({ id: hubFlowId })
const flow = hub.flow
const schema =
flow?.schema && typeof flow.schema === 'object' && Object.keys(flow.schema).length > 0
? (flow.schema as any)
: emptySchema()
return { schema, summary: flow?.summary }
}
const flow = await FlowService.getFlowByPath({
workspace,
path
})
return { schema: flow.schema as any, summary: flow.summary }
} else {
const script = await ScriptService.getHubScriptByPath({
path
})
if (
script.schema == undefined ||
Object.keys(script.schema).length == 0 ||
typeof script.schema != 'object'
) {
script.schema = emptySchema()
}
await inferArgs(script.language as SupportedLanguage, script.content, script.schema as any)
return { schema: script.schema as any, summary: script.summary }
}
}
export async function parseOutputs(
code: string,
ignoreError
): Promise<[string, string][] | undefined> {
await initWasmTs()
const getOutputs = await parse_outputs(code)
const outputs = JSON.parse(getOutputs)
if (outputs.error) {
if (ignoreError) {
return undefined
}
throw new Error(outputs.error)
}
return outputs.error ? [] : outputs.outputs
}
/** JS fallback parser for R main() signatures when WASM parser is unavailable. */
function parseRSignatureFallback(code: string): MainArgSignature {
const result: MainArgSignature = {
type: 'Valid',
error: '',
star_args: false,
star_kwargs: false,
args: [],
has_preprocessor: null,
auto_kind: null
}
const mainMatch = code.match(/\bmain\s*(?:<-|=)\s*function\s*\(([^)]*)\)/)
if (!mainMatch) {
return result
}
const paramsStr = mainMatch[1].trim()
if (!paramsStr) return result
// Split params respecting nested parens
const params: string[] = []
let depth = 0
let current = ''
for (const ch of paramsStr) {
if ('([{'.includes(ch)) {
depth++
current += ch
} else if (')]}'.includes(ch)) {
depth--
current += ch
} else if (ch === ',' && depth === 0) {
params.push(current)
current = ''
} else {
current += ch
}
}
if (current.trim()) params.push(current)
for (const param of params) {
const trimmed = param.trim()
if (!trimmed) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) {
result.args.push({ name: trimmed, typ: 'unknown', has_default: false, default: undefined })
} else {
const name = trimmed.slice(0, eqIndex).trim()
const raw = trimmed.slice(eqIndex + 1).trim()
const parsed = parseRDefault(raw)
result.args.push({ name, typ: parsed.typ, has_default: true, default: parsed.value })
}
}
return result
}
function parseRDefault(raw: string): { value: unknown; typ: MainArgSignature['args'][0]['typ'] } {
if (raw === 'TRUE' || raw === 'true') return { value: true, typ: 'bool' }
if (raw === 'FALSE' || raw === 'false') return { value: false, typ: 'bool' }
if (raw === 'NULL') return { value: null, typ: 'unknown' }
if (/^-?\d+(\.\d+)?$/.test(raw)) {
const num = Number(raw)
if (Number.isInteger(num) && !raw.includes('.')) return { value: num, typ: 'int' }
return { value: num, typ: 'float' }
}
const strMatch = raw.match(/^"((?:[^"\\]|\\.)*)"$/) || raw.match(/^'((?:[^'\\]|\\.)*)'$/)
if (strMatch) return { value: strMatch[1], typ: { str: null } }
if (raw.startsWith('list(') || raw.startsWith('c(')) return { value: null, typ: { list: null } }
return { value: null, typ: 'unknown' }
}