Skip to content

Commit a687dbd

Browse files
committed
feat(core): native javascript interoperability parsing and fmu bundling
1 parent 839e68e commit a687dbd

7 files changed

Lines changed: 299 additions & 2 deletions

File tree

packages/core/src/compiler/modelica/dae.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export class ModelicaDAE {
3535
functions: ModelicaDAE[] = [];
3636
/** External function declaration text (e.g. `external "C" ...`). */
3737
externalDecl: string | null = null;
38+
/** JavaScript source code if this function was parsed from JS/TS */
39+
jsSource?: string;
40+
jsPath?: string;
3841
/** Extracted annotation(Library="...") references. */
3942
externalLibraries: string[] = [];
4043
/** Extracted annotation(Include="...") references. */

packages/core/src/compiler/modelica/flattener.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4838,6 +4838,15 @@ class ModelicaSyntaxFlattener extends ModelicaSyntaxVisitor<ModelicaExpression,
48384838

48394839
// Register the function definition early to prevent infinite recursion when
48404840
// the function body references itself (directly or via name resolution).
4841+
if (
4842+
resolved.parent &&
4843+
"jsSource" in resolved.parent &&
4844+
typeof (resolved.parent as { jsSource?: unknown }).jsSource === "string"
4845+
) {
4846+
fnDae.jsSource = (resolved.parent as { jsSource: string }).jsSource;
4847+
const jsP = (resolved.parent as { jsPath?: string }).jsPath;
4848+
if (typeof jsP === "string") fnDae.jsPath = jsP;
4849+
}
48414850
targetDae.functions.push(fnDae);
48424851

48434852
// Flatten algorithm and equation sections (these still use the standard path)

packages/core/src/compiler/modelica/fmu-archive.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ export function buildFmuArchive(
103103
}
104104
}
105105

106+
// ── Inject JS/TS Dependencies ──
107+
const crawledJsPaths = new Set<string>();
108+
const crawlDaeForJs = (d: ModelicaDAE) => {
109+
if (d.jsSource && d.jsPath && !crawledJsPaths.has(d.jsPath)) {
110+
crawledJsPaths.add(d.jsPath);
111+
const basename = d.jsPath.split(/[/\\]/).pop() ?? "dependency.js";
112+
files.set(`resources/${basename}`, encoder.encode(d.jsSource));
113+
}
114+
for (const fn of d.functions) crawlDaeForJs(fn);
115+
};
116+
crawlDaeForJs(dae);
117+
106118
// ── buildDescription.xml (FMI 3.0 §2.5) ──
107119
const buildDescLines = [
108120
'<?xml version="1.0" encoding="UTF-8"?>',

packages/core/src/compiler/modelica/interpreter.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,8 +1596,55 @@ export class ModelicaInterpreter extends ModelicaSyntaxVisitor<ModelicaExpressio
15961596
clonedFunction = functionInstance.clone(modification);
15971597
}
15981598

1599-
// Execute algorithm statements to compute output values
1600-
if (this.#evaluateAlgorithms && this.#functionCallDepth < ModelicaInterpreter.MAX_FUNCTION_CALL_DEPTH) {
1599+
// Execute algorithm statements or JS source to compute output values
1600+
let jsExecuted: ModelicaExpression | null = null;
1601+
1602+
if (
1603+
this.#evaluateAlgorithms &&
1604+
clonedFunction.parent &&
1605+
"jsSource" in clonedFunction.parent &&
1606+
typeof (clonedFunction.parent as { jsSource?: unknown }).jsSource === "string"
1607+
) {
1608+
const jsSource = (clonedFunction.parent as { jsSource: string }).jsSource;
1609+
const transpiled = jsSource
1610+
.replace(/import\s+.*?from\s+['"].*?['"];?/g, "")
1611+
.replace(/export\s+function/g, "function")
1612+
.replace(/export\s+const/g, "const")
1613+
.replace(/export\s+\{([^}]+)\};?/g, "")
1614+
.replace(/export\s+default\s+([a-zA-Z_$][\w$]*);?/g, "");
1615+
1616+
const args: unknown[] = [];
1617+
for (const inputParameter of clonedFunction.inputParameters) {
1618+
const expr = ModelicaExpression.fromClassInstance(inputParameter.classInstance);
1619+
if (expr instanceof ModelicaRealLiteral || expr instanceof ModelicaIntegerLiteral) {
1620+
args.push(expr.value);
1621+
} else if (expr instanceof ModelicaBooleanLiteral) {
1622+
args.push(expr.value);
1623+
} else if (expr instanceof ModelicaStringLiteral) {
1624+
args.push(expr.value);
1625+
} else {
1626+
args.push(0);
1627+
}
1628+
}
1629+
1630+
try {
1631+
const executor = new Function(`
1632+
${transpiled}
1633+
return ${clonedFunction.name}(...arguments);
1634+
`);
1635+
const result = executor(...args);
1636+
1637+
if (typeof result === "number") {
1638+
jsExecuted = new ModelicaRealLiteral(result);
1639+
} else if (typeof result === "boolean") {
1640+
jsExecuted = new ModelicaBooleanLiteral(result);
1641+
} else if (typeof result === "string") {
1642+
jsExecuted = new ModelicaStringLiteral(result);
1643+
}
1644+
} catch (e) {
1645+
console.error(`[JS Interop] Error executing function ${clonedFunction.name}:`, e);
1646+
}
1647+
} else if (this.#evaluateAlgorithms && this.#functionCallDepth < ModelicaInterpreter.MAX_FUNCTION_CALL_DEPTH) {
16011648
this.#functionCallDepth++;
16021649
try {
16031650
for (const statement of clonedFunction.algorithms) {
@@ -1610,6 +1657,10 @@ export class ModelicaInterpreter extends ModelicaSyntaxVisitor<ModelicaExpressio
16101657
}
16111658
}
16121659

1660+
if (jsExecuted) {
1661+
return jsExecuted;
1662+
}
1663+
16131664
const outputExpressions: ModelicaExpression[] = [];
16141665
for (const outputParameter of clonedFunction.outputParameters) {
16151666
const outputExpression = ModelicaExpression.fromClassInstance(outputParameter.classInstance);
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
/**
4+
* Javascript interoperability. Parses JS/TS exports into Modelica components.
5+
*/
6+
7+
import type { Scope } from "../scope.js";
8+
import {
9+
ModelicaClassInstance,
10+
ModelicaComponentInstance,
11+
type ModelicaElement,
12+
type ModelicaNamedElement,
13+
} from "./model.js";
14+
import { ModelicaCausality, ModelicaClassKind, type ModelicaIdentifierSyntaxNode } from "./syntax.js";
15+
16+
/**
17+
* A Modelica class instance backed by a Javascript/Typescript file.
18+
*/
19+
export class ModelicaJavascriptEntity extends ModelicaClassInstance {
20+
jsPath: string;
21+
jsSource: string | null = null;
22+
#syntheticComponents: ModelicaComponentInstance[] = [];
23+
#loaded = false;
24+
25+
constructor(parent: Scope, path: string) {
26+
super(parent);
27+
this.jsPath = path;
28+
this.classKind = ModelicaClassKind.PACKAGE;
29+
}
30+
31+
override clone(): ModelicaClassInstance {
32+
if (!this.#loaded) this.load();
33+
const cloned = new ModelicaJavascriptEntity(this.parent ?? this, this.jsPath);
34+
cloned.name = this.name;
35+
cloned.jsSource = this.jsSource;
36+
cloned.#loaded = true;
37+
cloned.instantiate();
38+
return cloned;
39+
}
40+
41+
override get elements(): IterableIterator<ModelicaElement> {
42+
if (!this.instantiated && !this.instantiating) this.instantiate();
43+
const components = this.#syntheticComponents;
44+
return (function* () {
45+
yield* components;
46+
})();
47+
}
48+
49+
override resolveSimpleName(
50+
identifier: ModelicaIdentifierSyntaxNode | string | null | undefined,
51+
global = false,
52+
encapsulated = false,
53+
): ModelicaNamedElement | null {
54+
const simpleName = typeof identifier === "string" ? identifier : identifier?.text;
55+
if (!simpleName) return null;
56+
if (!this.instantiated && !this.instantiating) this.instantiate();
57+
58+
for (const comp of this.#syntheticComponents) {
59+
if (comp.name === simpleName) return comp;
60+
}
61+
62+
return super.resolveSimpleName(identifier, global, encapsulated);
63+
}
64+
65+
override instantiate(): void {
66+
if (this.instantiated) return;
67+
if (this.instantiating) return;
68+
this.instantiating = true;
69+
try {
70+
if (!this.#loaded) this.load();
71+
this.declaredElements = [];
72+
this.#syntheticComponents = [];
73+
74+
if (!this.jsSource) return;
75+
76+
const types = {
77+
number: this.root?.resolveSimpleName("Real") as ModelicaClassInstance | null,
78+
boolean: this.root?.resolveSimpleName("Boolean") as ModelicaClassInstance | null,
79+
string: this.root?.resolveSimpleName("String") as ModelicaClassInstance | null,
80+
};
81+
82+
// Extract functions
83+
// export function add(a, b) { ... }
84+
// export function multiply(a: number, b: number): number { ... }
85+
const funcRegex = /export\s+function\s+([a-zA-Z_$][\w$]*)\s*\(([^)]*)\)(?:\s*:\s*([a-zA-Z_$][\w$]*))?/g;
86+
let match;
87+
while ((match = funcRegex.exec(this.jsSource)) !== null) {
88+
const funcName = match[1];
89+
const argsStr = match[2] ?? "";
90+
const returnTypeStr = match[3] ?? "number";
91+
92+
if (!funcName) continue;
93+
94+
const funcClass = new ModelicaClassInstance(this);
95+
funcClass.name = funcName;
96+
funcClass.classKind = ModelicaClassKind.FUNCTION;
97+
funcClass.instantiated = true;
98+
funcClass.declaredElements = [];
99+
100+
// Parse arguments
101+
const args = argsStr
102+
.split(",")
103+
.map((s) => s.trim())
104+
.filter((s) => s.length > 0);
105+
for (const arg of args) {
106+
const parts = arg.split(":").map((s) => s.trim());
107+
const argName = parts[0];
108+
const argType = parts[1] ?? "number";
109+
110+
if (argName) {
111+
const inputComp = new ModelicaComponentInstance(funcClass, null);
112+
inputComp.name = argName;
113+
inputComp.causality = ModelicaCausality.INPUT;
114+
const baseType = types[argType as keyof typeof types] || types.number;
115+
if (baseType) inputComp.classInstance = baseType.clone();
116+
inputComp.instantiated = true;
117+
funcClass.declaredElements.push(inputComp);
118+
}
119+
}
120+
121+
// Return value
122+
const outputComp = new ModelicaComponentInstance(funcClass, null);
123+
outputComp.name = "result";
124+
outputComp.causality = ModelicaCausality.OUTPUT;
125+
const baseRetType = types[returnTypeStr as keyof typeof types] || types.number;
126+
if (baseRetType) outputComp.classInstance = baseRetType.clone();
127+
outputComp.instantiated = true;
128+
funcClass.declaredElements.push(outputComp);
129+
130+
const comp = new ModelicaComponentInstance(this, null);
131+
comp.name = funcName;
132+
comp.classInstance = funcClass;
133+
comp.instantiated = true;
134+
this.#syntheticComponents.push(comp);
135+
this.declaredElements.push(comp);
136+
}
137+
138+
// Allow parsing: export const FOO = 42;
139+
const constRegex = /export\s+const\s+([a-zA-Z_$][\w$]*)\s*=/g;
140+
while ((match = constRegex.exec(this.jsSource)) !== null) {
141+
const constName = match[1];
142+
if (!constName) continue;
143+
const comp = new ModelicaComponentInstance(this, null);
144+
comp.name = constName;
145+
const baseType = types.number;
146+
if (baseType) comp.classInstance = baseType.clone();
147+
comp.instantiated = true;
148+
this.#syntheticComponents.push(comp);
149+
this.declaredElements.push(comp);
150+
}
151+
152+
// Allow parsing export { a, b, c };
153+
const blockExportRegex = /export\s*\{([^}]*)\}/g;
154+
while ((match = blockExportRegex.exec(this.jsSource)) !== null) {
155+
const names = (match[1] || "")
156+
.split(",")
157+
.map((s) => s.trim())
158+
.filter((s) => s.length > 0);
159+
for (const name of names) {
160+
// just assume function/variable
161+
const comp = new ModelicaComponentInstance(this, null);
162+
comp.name = name;
163+
// By default, make it a function for simplicity
164+
const funcClass = new ModelicaClassInstance(this);
165+
funcClass.name = name;
166+
funcClass.classKind = ModelicaClassKind.FUNCTION;
167+
funcClass.instantiated = true;
168+
funcClass.declaredElements = [];
169+
comp.classInstance = funcClass;
170+
comp.instantiated = true;
171+
this.#syntheticComponents.push(comp);
172+
this.declaredElements.push(comp);
173+
}
174+
}
175+
176+
this.instantiated = true;
177+
} finally {
178+
this.instantiating = false;
179+
}
180+
}
181+
182+
load(): void {
183+
if (this.#loaded) return;
184+
this.#loaded = true;
185+
186+
if (this.jsSource !== null) return;
187+
188+
const context = this.context;
189+
if (context) {
190+
try {
191+
const data = context.fs.read(this.jsPath);
192+
this.jsSource = data;
193+
if (!this.name) {
194+
const basename = this.jsPath.split(/[/\\]/).pop() || this.jsPath;
195+
this.name = basename.replace(/\.[tj]s$/, "");
196+
}
197+
} catch {
198+
console.warn(`[ModelicaJavascriptEntity] Failed to read JS file: ${this.jsPath}`);
199+
}
200+
}
201+
}
202+
}

packages/core/src/compiler/modelica/model.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,6 +1798,22 @@ export class ModelicaEntity extends ModelicaClassInstance {
17981798
}
17991799
continue;
18001800
}
1801+
// Check for JS/TS files
1802+
if (ext === ".js" || ext === ".ts") {
1803+
const jsPath = context.fs.join(this.path, dirent.name);
1804+
try {
1805+
/* eslint-disable @typescript-eslint/no-require-imports */
1806+
const { ModelicaJavascriptEntity } =
1807+
require("./javascript-entity.js") as typeof import("./javascript-entity.js");
1808+
/* eslint-enable @typescript-eslint/no-require-imports */
1809+
const jsEntity = new ModelicaJavascriptEntity(this, jsPath);
1810+
jsEntity.name = dirent.name.replace(/\.[tj]s$/, "");
1811+
this.subEntities.push(jsEntity as unknown as ModelicaEntity);
1812+
} catch {
1813+
// Skip
1814+
}
1815+
continue;
1816+
}
18011817
if (ext !== ".mo") continue;
18021818
}
18031819
const subEntity = new ModelicaEntity(this, context.fs.join(this.path, dirent.name));

packages/core/tests/tsconfig.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
{
22
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "../",
5+
"noEmit": true
6+
},
37
"include": ["**/*.ts"]
48
}

0 commit comments

Comments
 (0)