From 49265ae1b566cb3df63333450134b40c66258403 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Wed, 11 Oct 2023 13:32:48 -0600 Subject: [PATCH 01/10] refactor resolver to an object, update tests, remove runWithImports --- packages/squiggle-lang/.eslintrc.cjs | 1 + .../SqProject/SqProject_imports_test.ts | 56 +++-- .../__tests__/SqProject/SqProject_test.ts | 7 +- .../SqProject/SqProject_tutorial_1_test.ts | 161 ++++++------- .../SqProject_tutorial_2_multisource_test.ts | 151 ++++++------ .../SqProject_tutorial_3_imports_test.ts | 214 ++++++++++-------- ...roject_tutorial_4_injecting_user_values.ts | 33 --- packages/squiggle-lang/src/cli/utils.ts | 30 +-- .../src/public/SqProject/ProjectItem.ts | 2 +- .../src/public/SqProject/Resolver.ts | 5 +- .../src/public/SqProject/index.ts | 65 +++--- 11 files changed, 369 insertions(+), 356 deletions(-) delete mode 100644 packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_4_injecting_user_values.ts diff --git a/packages/squiggle-lang/.eslintrc.cjs b/packages/squiggle-lang/.eslintrc.cjs index 90eaa61a07..281d2e6059 100644 --- a/packages/squiggle-lang/.eslintrc.cjs +++ b/packages/squiggle-lang/.eslintrc.cjs @@ -4,6 +4,7 @@ module.exports = { plugins: ["@typescript-eslint"], rules: { "no-constant-condition": ["error", { checkLoops: false }], + // "multiline-comment-style": "warn", "@typescript-eslint/no-unused-vars": ["warn", { args: "none" }], "@typescript-eslint/no-empty-function": [ "error", diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts index 948eeb0695..69b2e4903e 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts @@ -1,7 +1,21 @@ import { SqProject } from "../../src/index.js"; +import { Resolver } from "../../src/public/SqProject/Resolver.js"; + +const buildResolver = (sources?: { [k: string]: string }) => { + const resolver: Resolver = { + resolve: (name) => name, + loadSource: async (id) => { + if (sources && id in sources) { + return sources[id]; + } + throw new Error(`Unknown id ${id}`); + }, + }; + return resolver; +}; describe("Parse imports", () => { - const project = SqProject.create({ resolver: (name) => name }); + const project = SqProject.create({ resolver: buildResolver() }); project.setSource( "main", ` @@ -40,8 +54,8 @@ x=1` }); describe("Unknown imports", () => { - test("run", async () => { - const project = SqProject.create({ resolver: (name) => name }); + test("without resolver", async () => { + const project = SqProject.create(); project.setSource( "main", ` @@ -54,8 +68,24 @@ import './lib' as lib expect(project.getResult("main").ok).toEqual(false); }); - test("runWithImports", () => { - const project = SqProject.create({ resolver: (name) => name }); + test("unknown import", () => { + const project = SqProject.create({ resolver: buildResolver() }); + project.setSource( + "main", + ` +import './lib' as lib +lib.x` + ); + + expect(project.run("main")).rejects.toThrow(); + }); + + test("known import", async () => { + const project = SqProject.create({ + resolver: buildResolver({ + "./lib": "x = 5", + }), + }); project.setSource( "main", ` @@ -63,19 +93,9 @@ import './lib' as lib lib.x` ); - expect( - project.runWithImports("main", async (id) => { - throw new Error(`Unknown id ${id}`); - }) - ).rejects.toThrow(); + expect(project.run("main")).resolves.toBe(undefined); - expect( - project.runWithImports("main", async (id) => { - if (id === "./lib") { - return "x = 5"; - } - throw new Error(`Unknown id ${id}`); - }) - ).resolves.toBe(undefined); + await project.run("main"); + expect(project.getResult("main").ok).toEqual(true); }); }); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts index 3b7457660e..7d492ab20e 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts @@ -143,7 +143,12 @@ describe("removing sources", () => { describe("project with import", () => { const project = SqProject.create({ - resolver: (name) => name, + resolver: { + resolve: (name) => name, + loadSource: () => { + throw new Error("Loading not supported"); + }, + }, }); project.setSource( diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts index 5eda268353..e7ed3eb3d5 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts @@ -1,96 +1,97 @@ -import { defaultEnvironment } from "../../src/index.js"; -import { SqProject, evaluate } from "../../src/public/SqProject/index.js"; +import { defaultEnvironment, run } from "../../src/index.js"; +import { SqProject } from "../../src/public/SqProject/index.js"; import { toStringResult } from "../../src/public/SqValue/index.js"; import "../helpers/toBeOkOutput.js"; -describe("SqProject Tutorial", () => { - describe("Single source", () => { - test("run", async () => { - /* - Let's start with running a single source and getting Result as well as the Bindings - First you need to create a project. A project is a collection of sources. - Project takes care of the dependencies between the sources, correct compilation and run order. - You can run any source in the project. It will be compiled and run if it hasn't happened already; otherwise already existing results will be presented. - The dependencies will be automatically compiled and run. So you don't need to worry about that in a multi source project. - In summary you issue a run command on the whole project or on a specific source to ensure that there is a result for that source. - */ - const project = SqProject.create(); - /* Every source has a name. This is used for debugging, dependencies and error messages. */ - project.setSource("main", "1 + 2"); - /* Let's run "main" source. */ - await project.run("main"); - /* - Now you have a result for "main" source. - Running one by one is necessary for UI to navigate among the sources and to see the results by source. - And you're free to run any source you want. - You will look at the results of this source and you don't want to run the others if not required. - */ +describe("Single source SqProject", () => { + test("run", async () => { + /* + * Let's start with running a single source and getting Result as well as the Bindings. + * First you need to create a project. A project is a collection of sources. + * Project takes care of the dependencies between the sources, correct compilation and run order. + * You can run any source in the project. It will be compiled and run if it hasn't happened already; otherwise already existing results will be presented. + * The dependencies will be automatically compiled and run. So you don't need to worry about that in a multi source project. + * In summary you issue a run command on the whole project or on a specific source to ensure that there is a result for that source. + */ + const project = SqProject.create(); + /* Every source has a name. This is used for debugging, dependencies and error messages. */ + project.setSource("main", "1 + 2"); + /* Let's run "main" source. */ + await project.run("main"); + /* + * Now you have a result for "main" source. + * Running one by one is necessary for UI to navigate among the sources and to see the results by source. + * And you're free to run any source you want. + * You will look at the results of this source and you don't have to run the others if not required. + */ - /* - However, you could also run the whole project. - If you have all the sources, you can always run the whole project. - Dependencies and recompiling on demand will be taken care of by the project. - */ - await project.runAll(); + /* + * However, you could also run the whole project. + * If you have all the sources, you can always run the whole project. + * Dependencies and recompiling on demand will be taken care of by the project. + */ + await project.runAll(); - /* - Either with run or runAll you executed the project. - You can get the result of a specific source by calling getResult for that source. - You can get the bindings of a specific source by calling getBindings for that source. - To get both, call getOutput. - If there is any runtime error, these functions will return the error result. - */ - const output = project.getOutput("main"); + /* + * Either with `run` or `runAll` you executed the project. + * You can get the result of a specific source by calling `getResult` for that source. + * You can get the bindings of a specific source by calling `getBindings` for that source. + * To get both, call `getOutput`. + * If there is any runtime error, these functions will return the error result. + */ + const output = project.getOutput("main"); + /* Output is a `result<{ result: SqValue, bindings: SqDict }, SqError>` */ - /* Output is a result<{ result: SqValue, bindings: SqDict }, SqError> */ - - /* Let's display the result and bindings */ - expect(output).toBeOkOutput("3", "{}"); - /* You've got 3 with empty bindings. */ - }); + /* Let's display the result and bindings. */ + expect(output).toBeOkOutput("3", "{}"); + /* You've got 3 with empty bindings. */ + }); - test("run summary", async () => { - const project = SqProject.create(); - project.setSource("main", "1 + 2"); - await project.runAll(); - const output = project.getOutput("main"); - /* Now you have external bindings and external result. */ - expect(output).toBeOkOutput("3", "{}"); - }); + test("run summary", async () => { + const project = SqProject.create(); + project.setSource("main", "1 + 2"); + await project.runAll(); + const output = project.getOutput("main"); + /* Now you have external bindings and external result. */ + expect(output).toBeOkOutput("3", "{}"); + }); - test("run with an environment", async () => { - /* Running the source code like above allows you to set a custom environment */ - const project = SqProject.create(); + test("run with an environment", async () => { + /* Running the source code like above allows you to set a custom environment */ + const project = SqProject.create(); - /* Optional. Set your custom environment anytime before running */ - project.setEnvironment(defaultEnvironment); + /* Optional. Set your custom environment anytime before running */ + project.setEnvironment(defaultEnvironment); - project.setSource("main", "1 + 2"); - await project.runAll(); - const result = project.getResult("main"); - expect(toStringResult(result)).toBe("Ok(3)"); - }); + project.setSource("main", "1 + 2"); + await project.runAll(); + const result = project.getResult("main"); + expect(toStringResult(result)).toBe("Ok(3)"); + }); - test("shortcut", () => { - // If you are running single source without imports and you don't need a custom environment, you can use the shortcut. - // Examples above was to prepare you for the multi source tutorial. - const outputR = evaluate("1+2"); - expect(outputR.ok).toBe(true); + test("shortcut", async () => { + /* + * If you are running a single source without imports and you don't need a custom environment, you can use the shortcut. + * Examples above were to prepare you for the multi source tutorial. + */ + const outputR = await run("1+2"); + expect(outputR.ok).toBe(true); - if (!outputR.ok) { - throw new Error("failed"); - } + if (!outputR.ok) { + throw new Error("failed"); + } - expect(outputR).toBeOkOutput("3", "{}"); - }); + expect(outputR).toBeOkOutput("3", "{}"); }); }); -//TODO: Multiple sources with multi level imports. Cycle detection. -//TODO: Implement a runOrder consideration - clean results based on run order. -//TODO: runOrder vs setSource/touchSource. -//TODO: Advanced details: (below). -//TODO: runOrder. imports vs continues. Run order based reexecution. -//TODO: Dependents and reexecution. -//TODO: Dependencies and reexecution. -//TODO: cleanAll, clean. +/* + * TODO: Multiple sources with multi level imports. Cycle detection. + * TODO: Implement a runOrder consideration - clean results based on run order. + * TODO: runOrder vs setSource/touchSource. + * TODO: Advanced details: (below). + * TODO: runOrder. imports vs continues. Run order based reexecution. + * TODO: Dependents and reexecution. + * TODO: Dependencies and reexecution. + * TODO: cleanAll, clean. + */ diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts index 212822245f..cbd769bb5c 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts @@ -1,89 +1,96 @@ import { SqProject } from "../../src/index.js"; import "../helpers/toBeOkOutput.js"; -describe("SqProject Tutorial", () => { - describe("Multi source", () => { - /** - * Running multiple sources. - * This approach uses `setContinues`, which is useful in Observable and other notebook-like environments. - */ - test("Chaining", async () => { - const project = SqProject.create(); - /* This time let's add 3 sources and chain them together */ - project.setSource("source1", "x=1"); - - project.setSource("source2", "y=x+1"); - /* To run, source2 depends on source1 */ - project.setContinues("source2", ["source1"]); - - project.setSource("source3", "z=y+1"); - /* To run, source3 depends on source2 */ - project.setContinues("source3", ["source2"]); - - /* Now we can run the project */ - await project.runAll(); +describe("Multi source SqProject", () => { + /** + * Running multiple sources. + * This approach uses `setContinues`, which is useful in Observable and other notebook-like environments, + * where the code is divided into multiple cells, but there are no explicit `import` statements. + */ + test("Chaining", async () => { + const project = SqProject.create(); + + /* This time let's add 3 sources and chain them together */ + project.setSource("source1", "x=1"); + + project.setSource("source2", "y=x+1"); + /* To run, source2 depends on source1 */ + project.setContinues("source2", ["source1"]); + + project.setSource("source3", "z=y+1"); + /* To run, source3 depends on source2 */ + project.setContinues("source3", ["source2"]); + + /* Now we can run the project */ + await project.runAll(); + + /* And let's check the result and bindings of source3 */ + const output = project.getOutput("source3"); + expect(output).toBeOkOutput("()", "{z: 3}"); + }); - /* And let's check the result and bindings of source3 */ - const output = project.getOutput("source3"); - expect(output).toBeOkOutput("()", "{z: 3}"); - }); + test("Depending", async () => { + /* Instead of chaining the sources, we could have a dependency tree. */ + /* The point here is that any source can depend on multiple sources. */ + const project = SqProject.create(); - test("Depending", async () => { - /* Instead of chaining the sources, we could have a dependency tree. */ - /* The point here is that any source can depend on multiple sources. */ - const project = SqProject.create(); + /* This time source1 and source2 are not depending on anything */ + project.setSource("source1", "x=1"); + project.setSource("source2", "y=2"); - /* This time source1 and source2 are not depending on anything */ - project.setSource("source1", "x=1"); - project.setSource("source2", "y=2"); + project.setSource("source3", "z=x+y"); + /* To run, source3 depends on source1 and source3 together */ + project.setContinues("source3", ["source1", "source2"]); - project.setSource("source3", "z=x+y"); - /* To run, source3 depends on source1 and source3 together */ - project.setContinues("source3", ["source1", "source2"]); + /* Now we can run the project */ + await project.runAll(); - /* Now we can run the project */ - await project.runAll(); + /* And let's check the result and bindings of source3 */ + const output = project.getOutput("source3"); + expect(output).toBeOkOutput("()", "{z: 3}"); + }); - /* And let's check the result and bindings of source3 */ - const output = project.getOutput("source3"); - expect(output).toBeOkOutput("()", "{z: 3}"); + test("Intro to imports", async () => { + /** + * Let's write the same project above with imports. + * You will see that parsing imports is setting the dependencies the same way as before. + */ + const project = SqProject.create({ + resolver: { + resolve: (name) => name, + loadSource: () => { + throw new Error("Loading not supported"); + }, + }, }); - test("Intro to imports", async () => { - /** - * Let's write the same project above with imports. - * You will see that parsing imports is setting the dependencies the same way as before. - */ - const project = SqProject.create({ resolver: (name) => name }); + /* This time source1 and source2 are not depending on anything */ + project.setSource("source1", "x=1"); - /* This time source1 and source2 are not depending on anything */ - project.setSource("source1", "x=1"); - project.setSource("source2", "y=2"); - - project.setSource( - "source3", - ` + project.setSource( + "source3", + ` import "source1" as s1 import "source2" as s2 z=s1.x+s2.y` - ); - /* We need to parse the imports to set the dependencies */ - project.parseImports("source3"); - - /* Now we can run the project */ - await project.runAll(); - - /* And let's check the result and bindings of source3 */ - const output = project.getOutput("source3"); - - expect(output).toBeOkOutput("()", "{z: 3}"); - /* - Dealing with imports needs more. - - There are parse errors - - There are cyclic imports - - And the depended source1 and source2 is not already there in the project - - If you knew the imports before hand there would not be point of the imports directive. - More on those on the next section. */ - }); + ); + /* We're creating source1, source2, source3 in a weird order to check that `runAll` loads imports on demand */ + project.setSource("source2", "y=2"); + + /* Now we can run the project */ + await project.runAll(); + + /* And let's check the result and bindings of source3 */ + const output = project.getOutput("source3"); + + expect(output).toBeOkOutput("()", "{z: 3}"); + /* + * Dealing with imports needs more. + * - There are parse errors + * - There are cyclic imports + * - And the depended source1 and source2 is not already there in the project + * - If you knew the imports before hand there would not be point of the imports directive. + * More on those on the next section. + */ }); }); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts index ef49f9508b..00ce8077a5 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts @@ -1,19 +1,114 @@ import { SqProject } from "../../src/index.js"; +import { Resolver } from "../../src/public/SqProject/Resolver.js"; import "../helpers/toBeOkOutput.js"; -/** - * Case: Imports. - * In the previous tutorial we have set the similarity between setContinues and parseImports. - * Here we will finally proceed to a real life scenario +/* + * Now let's look at explicit imports, possibly recursive and cyclic. + * In the previous tutorial we've used `setContinues` manually; now we'll rely on import statements in Squiggle to load the dependencies. */ +describe("SqProject with imports", () => { + /* + * Let's make a simple resolver. Resolvers are responsible for two things: + * 1. Converting a string name in `import "name"` to the source id (this is useful in some cases, e.g. for normalizing "../dir/file.squiggle" paths). + * 2. Loading a source by its id. + */ + const resolver: Resolver = { + resolve: (name) => name, + loadSource: async (sourceName) => { + switch (sourceName) { + case "source1": + return "x=1"; + case "source2": + return ` + import "source1" as s1 + y=2`; + case "source3": + return ` + import "source2" as s2 + z=3`; + default: + throw new Error(`source ${sourceName} not found`); + } + }, + }; + + const mainSource = ` + import "source1" as s1 + import "source2" as s2 + import "source3" as s3 + a = s1.x + s2.y + s3.z + b = doubleX // available through continues + a + `; + + const doubleXSource = ` + import "source1" as s1 + doubleX = s1.x * 2 + `; + + /* Basic approach is to call `run`; it's async and will load everything implicitly through the resolver. */ + test("run", async () => { + const project = SqProject.create({ resolver }); + + project.setSource("main", mainSource); + + /* + * Let's salt it more. Let's have another source in the project which also has imports. + * "doubleX" imports "source1" which is eventually imported by "main" as well. + */ + project.setSource("doubleX", doubleXSource); + + /* + * As doubleX is not imported by main, it is not loaded recursively. + * So we link it to the project as a dependency. + */ + project.setContinues("main", ["doubleX"]); + + /* Let's run the project; this method will load imports recursively for you. */ + await project.run("main"); + + const output = project.getOutput("main"); + + expect(output).toBeOkOutput("6", "{a: 6,b: 2}"); + }); + + test("explicit loadImportsRecursively", async () => { + const project = SqProject.create({ resolver }); + + project.setSource("main", mainSource); + /* + * Imports will be processed on the first attempt to run, but you can also pre-process them manually without running the source. + * Notice that `doubleX` in "main" source is not available yet, but that's fine because `loadImportsRecursively` only looks at import statements. + * Source code must be syntactically correct, though. + */ + await project.loadImportsRecursively("main"); + + project.setSource("doubleX", doubleXSource); + await project.loadImportsRecursively("doubleX"); + + project.setContinues("main", ["doubleX"]); + + /* Let's run the project */ + await project.runAll(); + const output = project.getOutput("main"); + + /* And see the result and bindings.. */ + expect(output).toBeOkOutput("6", "{a: 6,b: 2}"); + /* Everything as expected */ + }); +}); describe("parseImports", () => { /** - * Here we investigate the details about parseImports, before setting up a real life scenario in the next section. - * Everything happens inside a project, so let's have a project. + * Let's look at the details of how you can analyze the imports of each source. */ const project = SqProject.create({ - resolver: (name) => name, + resolver: { + resolve: (name) => name, + loadSource: () => { + throw new Error("loading not implemented"); + }, + }, }); project.setSource( "main", @@ -22,14 +117,18 @@ describe("parseImports", () => { x=1 ` ); - /* We need to parse imports after changing the source */ + + /* + * Parse import statements - this method is also used by `loadSourcesRecursively`, + * so if you've ever called or or ran your source, imports should be already parsed. + */ project.parseImports("main"); test("getDependencies", () => { - /* Parse imports has set the dependencies */ + /* Parse imports has set the dependencies. */ expect(project.getDependencies("main")).toEqual(["./common"]); - /** - * If there were no imports than there would be no dependencies. + /* + * If there were no imports then there would be no dependencies. * However if there was a syntax error at imports then would be no dependencies also. * Therefore looking at dependencies is not the right way to load imports. * `getDependencies` does not distinguish between `setContinues` and explicit import statements. @@ -53,95 +152,12 @@ describe("parseImports", () => { }); test("getDependents", () => { - /* For any reason, you are able to query what other sources - import or depend on the current source. - But you don't need to use this to execute the projects. - It is provided for completeness of information. */ + /* + * For any reason, you are able to query what other sources import or depend on the current source. + * But you don't need to use this to execute the projects. + * It is provided for completeness of information. + */ expect(project.getDependents("main")).toEqual([]); /* Nothing is depending on or including main */ }); }); - -/* - * Now let's look at recursive and possibly cyclic imports. - * There is no function provided to load the import files. - * Because we have no idea if will it be an ordinary function or will it use promises. - * Therefore one has to provide a function to load sources. - */ -describe("Recursive imports", () => { - /* Let's make a dummy loader */ - const loadSource = async (sourceName: string): Promise => { - switch (sourceName) { - case "source1": - return "x=1"; - case "source2": - return ` - import "source1" as s1 - y=2`; - case "source3": - return ` - import "source2" as s2 - z=3`; - default: - throw new Error(`source ${sourceName} not found`); - } - }; - - const mainSource = ` - import "source1" as s1 - import "source2" as s2 - import "source3" as s3 - a = s1.x + s2.y + s3.z - b = doubleX - a - `; - - const doubleXSource = ` - import "source1" as s1 - doubleX = s1.x * 2 - `; - - /* First possible approach is to pre-load imports with loadImportsRecursively, and then use project.run() */ - test("explicit loadImportsRecursively", async () => { - const project = SqProject.create({ resolver: (name) => name }); - - project.setSource("main", mainSource); - /* Setting source requires parsing and loading the imports recursively */ - await project.loadImportsRecursively("main", loadSource); // Not visited yet - - /* Let's salt it more. Let's have another source in the project which also has imports */ - /* doubleX imports source1 which is eventually imported by main as well */ - project.setSource("doubleX", doubleXSource); - await project.loadImportsRecursively("doubleX", loadSource); - /* Remember, any time you set a source, you need to load imports recursively */ - - /* As doubleX is not imported by main, it is not loaded recursively. - So we link it to the project as a dependency */ - project.setContinues("main", ["doubleX"]); - - /* Let's run the project */ - await project.runAll(); - const output = project.getOutput("main"); - - /* And see the result and bindings.. */ - expect(output).toBeOkOutput("6", "{a: 6,b: 2}"); - /* Everything as expected */ - }); - - /* Second approach is to use async runWithImports method; this is recommended */ - test("runWithImports", async () => { - const project = SqProject.create({ resolver: (name) => name }); - - project.setSource("main", mainSource); - project.setSource("doubleX", doubleXSource); - - project.setContinues("main", ["doubleX"]); - - /* Let's run the project; this method will load imports recursively for you */ - await project.runWithImports("main", loadSource); - - const output = project.getOutput("main"); - - expect(output).toBeOkOutput("6", "{a: 6,b: 2}"); - }); -}); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_4_injecting_user_values.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_4_injecting_user_values.ts deleted file mode 100644 index 4a7a99b042..0000000000 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_4_injecting_user_values.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SqProject } from "../../src/index.js"; -import { toStringResult } from "../../src/public/SqValue/index.js"; - -describe("SqProject Tutorial", () => { - /* Let's build a project that depends on values from the UI */ - const project = SqProject.create(); - project.setSource("main", "x+y+z"); - /* x, y and z is not defined in the project but they has to come from the user */ - test("Injecting user values", async () => { - /* User has input the values */ - const x = 1; - const y = 2; - const z = 3; - /* Then we construct a source code to define those values */ - const userCode = ` - x = ${x} - y = ${y} - z = ${z} - `; - /* We inject the user code into the project */ - project.setSource("userCode", userCode); - /* "main" is depending on the user code */ - project.setContinues("main", ["userCode"]); - /* We can now run the project */ - await project.runAll(); - const result = project.getResult("main"); - expect(toStringResult(result)).toBe("Ok(6)"); - }); -}); - -/* Note that this is not final version of the project */ -/* In the future, for safety, we will provide a way to inject values instead of a source code */ -/* But time is limited for now... */ diff --git a/packages/squiggle-lang/src/cli/utils.ts b/packages/squiggle-lang/src/cli/utils.ts index 7efba144fa..c01d0b9cff 100644 --- a/packages/squiggle-lang/src/cli/utils.ts +++ b/packages/squiggle-lang/src/cli/utils.ts @@ -5,6 +5,7 @@ import isFinite from "lodash/isFinite.js"; import { Env } from "../dist/env.js"; import { SqProject } from "../public/SqProject/index.js"; import { bold, red } from "./colors.js"; +import { Resolver } from "../public/SqProject/Resolver.js"; export async function measure(callback: () => Promise) { const t1 = new Date(); @@ -24,33 +25,32 @@ export type RunArgs = { sampleCount?: string | number; }; +const resolver: Resolver = { + resolve: (name, fromId) => { + if (!name.startsWith("./") && !name.startsWith("../")) { + throw new Error("Only relative paths in imports are allowed"); + } + return path.resolve(path.dirname(fromId), name); + }, + loadSource: async (importId: string) => { + return await fs.readFile(importId, "utf-8"); + }, +}; + async function _run(args: { src: string; filename?: string; environment?: Env; }) { - const project = SqProject.create({ - resolver: (name, fromId) => { - if (!name.startsWith("./") && !name.startsWith("../")) { - throw new Error("Only relative paths in imports are allowed"); - } - return path.resolve(path.dirname(fromId), name); - }, - }); + const project = SqProject.create({ resolver }); if (args.environment) { project.setEnvironment(args.environment); } const filename = path.resolve(args.filename || "./__anonymous__"); - const loader = async (importId: string) => { - return await fs.readFile(importId, "utf-8"); - }; - project.setSource(filename, args.src); - const time = await measure( - async () => await project.runWithImports(filename, loader) - ); + const time = await measure(async () => await project.run(filename)); const output = project.getOutput(filename); return { output, time }; diff --git a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts index 6153f34d6e..c8c0721f15 100644 --- a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts +++ b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts @@ -120,7 +120,7 @@ export class ProjectItem { const resolvedImports: ImportBinding[] = program.imports.map( ([file, variable]) => ({ variable: variable.value, - sourceId: resolver(file.value, this.sourceId), + sourceId: resolver.resolve(file.value, this.sourceId), }) ); diff --git a/packages/squiggle-lang/src/public/SqProject/Resolver.ts b/packages/squiggle-lang/src/public/SqProject/Resolver.ts index 8403e53de1..6101858ce2 100644 --- a/packages/squiggle-lang/src/public/SqProject/Resolver.ts +++ b/packages/squiggle-lang/src/public/SqProject/Resolver.ts @@ -1 +1,4 @@ -export type Resolver = (name: string, fromId: string) => string; +export type Resolver = { + resolve: (name: string, fromId: string) => string; + loadSource: (sourceId: string) => Promise; +}; diff --git a/packages/squiggle-lang/src/public/SqProject/index.ts b/packages/squiggle-lang/src/public/SqProject/index.ts index 00229bc394..e74898b8f5 100644 --- a/packages/squiggle-lang/src/public/SqProject/index.ts +++ b/packages/squiggle-lang/src/public/SqProject/index.ts @@ -48,14 +48,14 @@ export class SqProject { return new SqProject(options); } - setEnvironment(environment: Env) { - this.environment = environment; - } - getEnvironment(): Env { return this.environment; } + setEnvironment(environment: Env) { + this.environment = environment; + } + getStdLib(): Bindings { return this.stdLib; } @@ -323,19 +323,30 @@ export class SqProject { } async runAll() { + // preload all imports + if (this.resolver) { + for (const id of this.getSourceIds()) { + await this.loadImportsRecursively(id); + } + } + + // we intentionally call `getRunOrder` again because the order could've changed after we analyzed imports await this.runIds(this.getRunOrder()); } - // Deprecated; this method won't handle imports correctly. - // Use `runWithImports` instead. async run(sourceId: string) { - await this.runIds(Topology.getRunOrderFor(this, sourceId)); + if (this.resolver) { + await this.loadImportsRecursively(sourceId); + } + await this.runIds(this.getRunOrderFor(sourceId)); } - async loadImportsRecursively( - initialSourceName: string, - loadSource: (sourceId: string) => Promise - ) { + async loadImportsRecursively(initialSourceName: string) { + const resolver = this.resolver; + if (!resolver) { + return; + } + const visited = new Set(); const inner = async (sourceName: string) => { if (visited.has(sourceName)) { @@ -352,28 +363,21 @@ export class SqProject { } for (const newImportId of rImportIds.value) { - // We have got one of the new imports. - // Let's load it and add it to the project. - const newSource = await loadSource(newImportId); - this.setSource(newImportId, newSource); + if (this.getSource(newImportId) === undefined) { + // We have got one of the new imports. + // Let's load it and add it to the project. + const newSource = await resolver.loadSource(newImportId); + this.setSource(newImportId, newSource); + } // The new source is loaded and added to the project. // Of course the new source might have imports too. // Let's recursively load them. - await this.loadImportsRecursively(newImportId, loadSource); + await this.loadImportsRecursively(newImportId); } }; await inner(initialSourceName); } - async runWithImports( - sourceId: string, - loadSource: (sourceId: string) => Promise - ) { - await this.loadImportsRecursively(sourceId, loadSource); - - await this.run(sourceId); - } - findValuePathByOffset( sourceId: string, offset: number @@ -395,14 +399,3 @@ export class SqProject { return Result.Ok(found); } } - -// ------------------------------------------------------------------------------------ - -// Shortcut for running a single piece of code without creating a project -export function evaluate(sourceCode: string): SqOutputResult { - const project = SqProject.create(); - project.setSource("main", sourceCode); - project.runAll(); - - return project.getOutput("main"); -} From 6c021fa101199394905e642cb06c53cee5e284a3 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Wed, 11 Oct 2023 13:39:28 -0600 Subject: [PATCH 02/10] rename resolver -> linker --- .../SqProject/SqProject_imports_test.ts | 16 ++++++------- .../__tests__/SqProject/SqProject_test.ts | 2 +- .../SqProject_tutorial_2_multisource_test.ts | 2 +- .../SqProject_tutorial_3_imports_test.ts | 14 +++++------ packages/squiggle-lang/src/cli/utils.ts | 6 ++--- packages/squiggle-lang/src/index.ts | 1 + .../{SqProject/Resolver.ts => SqLinker.ts} | 2 +- .../src/public/SqProject/ProjectItem.ts | 10 ++++---- .../src/public/SqProject/index.ts | 24 +++++++++---------- 9 files changed, 39 insertions(+), 38 deletions(-) rename packages/squiggle-lang/src/public/{SqProject/Resolver.ts => SqLinker.ts} (81%) diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts index 69b2e4903e..7593a45f0a 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts @@ -1,8 +1,8 @@ import { SqProject } from "../../src/index.js"; -import { Resolver } from "../../src/public/SqProject/Resolver.js"; +import { SqLinker } from "../../src/public/SqLinker.js"; -const buildResolver = (sources?: { [k: string]: string }) => { - const resolver: Resolver = { +const buildLinker = (sources?: { [k: string]: string }) => { + const linker: SqLinker = { resolve: (name) => name, loadSource: async (id) => { if (sources && id in sources) { @@ -11,11 +11,11 @@ const buildResolver = (sources?: { [k: string]: string }) => { throw new Error(`Unknown id ${id}`); }, }; - return resolver; + return linker; }; describe("Parse imports", () => { - const project = SqProject.create({ resolver: buildResolver() }); + const project = SqProject.create({ linker: buildLinker() }); project.setSource( "main", ` @@ -54,7 +54,7 @@ x=1` }); describe("Unknown imports", () => { - test("without resolver", async () => { + test("without linker", async () => { const project = SqProject.create(); project.setSource( "main", @@ -69,7 +69,7 @@ import './lib' as lib }); test("unknown import", () => { - const project = SqProject.create({ resolver: buildResolver() }); + const project = SqProject.create({ linker: buildLinker() }); project.setSource( "main", ` @@ -82,7 +82,7 @@ lib.x` test("known import", async () => { const project = SqProject.create({ - resolver: buildResolver({ + linker: buildLinker({ "./lib": "x = 5", }), }); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts index 7d492ab20e..84989c6b22 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts @@ -143,7 +143,7 @@ describe("removing sources", () => { describe("project with import", () => { const project = SqProject.create({ - resolver: { + linker: { resolve: (name) => name, loadSource: () => { throw new Error("Loading not supported"); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts index cbd769bb5c..774ece21c5 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts @@ -56,7 +56,7 @@ describe("Multi source SqProject", () => { * You will see that parsing imports is setting the dependencies the same way as before. */ const project = SqProject.create({ - resolver: { + linker: { resolve: (name) => name, loadSource: () => { throw new Error("Loading not supported"); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts index 00ce8077a5..740eb3ae31 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts @@ -1,5 +1,5 @@ import { SqProject } from "../../src/index.js"; -import { Resolver } from "../../src/public/SqProject/Resolver.js"; +import { SqLinker } from "../../src/public/SqLinker.js"; import "../helpers/toBeOkOutput.js"; /* @@ -8,11 +8,11 @@ import "../helpers/toBeOkOutput.js"; */ describe("SqProject with imports", () => { /* - * Let's make a simple resolver. Resolvers are responsible for two things: + * Let's make a simple linker. Linkers are responsible for two things: * 1. Converting a string name in `import "name"` to the source id (this is useful in some cases, e.g. for normalizing "../dir/file.squiggle" paths). * 2. Loading a source by its id. */ - const resolver: Resolver = { + const linker: SqLinker = { resolve: (name) => name, loadSource: async (sourceName) => { switch (sourceName) { @@ -46,9 +46,9 @@ describe("SqProject with imports", () => { doubleX = s1.x * 2 `; - /* Basic approach is to call `run`; it's async and will load everything implicitly through the resolver. */ + /* Basic approach is to call `run`; it's async and will load everything implicitly through the linker. */ test("run", async () => { - const project = SqProject.create({ resolver }); + const project = SqProject.create({ linker }); project.setSource("main", mainSource); @@ -73,7 +73,7 @@ describe("SqProject with imports", () => { }); test("explicit loadImportsRecursively", async () => { - const project = SqProject.create({ resolver }); + const project = SqProject.create({ linker }); project.setSource("main", mainSource); /* @@ -103,7 +103,7 @@ describe("parseImports", () => { * Let's look at the details of how you can analyze the imports of each source. */ const project = SqProject.create({ - resolver: { + linker: { resolve: (name) => name, loadSource: () => { throw new Error("loading not implemented"); diff --git a/packages/squiggle-lang/src/cli/utils.ts b/packages/squiggle-lang/src/cli/utils.ts index c01d0b9cff..71c9647e91 100644 --- a/packages/squiggle-lang/src/cli/utils.ts +++ b/packages/squiggle-lang/src/cli/utils.ts @@ -5,7 +5,7 @@ import isFinite from "lodash/isFinite.js"; import { Env } from "../dist/env.js"; import { SqProject } from "../public/SqProject/index.js"; import { bold, red } from "./colors.js"; -import { Resolver } from "../public/SqProject/Resolver.js"; +import { SqLinker } from "../public/SqLinker.js"; export async function measure(callback: () => Promise) { const t1 = new Date(); @@ -25,7 +25,7 @@ export type RunArgs = { sampleCount?: string | number; }; -const resolver: Resolver = { +const linker: SqLinker = { resolve: (name, fromId) => { if (!name.startsWith("./") && !name.startsWith("../")) { throw new Error("Only relative paths in imports are allowed"); @@ -42,7 +42,7 @@ async function _run(args: { filename?: string; environment?: Env; }) { - const project = SqProject.create({ resolver }); + const project = SqProject.create({ linker }); if (args.environment) { project.setEnvironment(args.environment); } diff --git a/packages/squiggle-lang/src/index.ts b/packages/squiggle-lang/src/index.ts index a2509a7fd5..df622750d4 100644 --- a/packages/squiggle-lang/src/index.ts +++ b/packages/squiggle-lang/src/index.ts @@ -65,6 +65,7 @@ export { export { type AST, type ASTNode } from "./ast/parse.js"; export { type ASTCommentNode } from "./ast/peggyHelpers.js"; +export { type SqLinker } from "./public/SqLinker.js"; export async function run( code: string, diff --git a/packages/squiggle-lang/src/public/SqProject/Resolver.ts b/packages/squiggle-lang/src/public/SqLinker.ts similarity index 81% rename from packages/squiggle-lang/src/public/SqProject/Resolver.ts rename to packages/squiggle-lang/src/public/SqLinker.ts index 6101858ce2..bb4549978e 100644 --- a/packages/squiggle-lang/src/public/SqProject/Resolver.ts +++ b/packages/squiggle-lang/src/public/SqLinker.ts @@ -1,4 +1,4 @@ -export type Resolver = { +export type SqLinker = { resolve: (name: string, fromId: string) => string; loadSource: (sourceId: string) => Promise; }; diff --git a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts index c8c0721f15..31df84a915 100644 --- a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts +++ b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts @@ -14,7 +14,7 @@ import { SqOtherError, SqRuntimeError, } from "../SqError.js"; -import { Resolver } from "./Resolver.js"; +import { SqLinker } from "../SqLinker.js"; // source -> ast -> imports -> bindings & result @@ -85,7 +85,7 @@ export class ProjectItem { this.clean(); } - parseImports(resolver: Resolver | undefined): void { + parseImports(linker: SqLinker | undefined): void { if (this.imports) { return; } @@ -108,10 +108,10 @@ export class ProjectItem { return; } - if (!resolver) { + if (!linker) { this.setImports( Result.Err( - new SqOtherError("Can't use imports when resolver is not configured") + new SqOtherError("Can't use imports when linker is not configured") ) ); return; @@ -120,7 +120,7 @@ export class ProjectItem { const resolvedImports: ImportBinding[] = program.imports.map( ([file, variable]) => ({ variable: variable.value, - sourceId: resolver.resolve(file.value, this.sourceId), + sourceId: linker.resolve(file.value, this.sourceId), }) ); diff --git a/packages/squiggle-lang/src/public/SqProject/index.ts b/packages/squiggle-lang/src/public/SqProject/index.ts index e74898b8f5..790e98d0ab 100644 --- a/packages/squiggle-lang/src/public/SqProject/index.ts +++ b/packages/squiggle-lang/src/public/SqProject/index.ts @@ -14,7 +14,7 @@ import { SqValuePath } from "../SqValuePath.js"; import { SqValueContext } from "../SqValueContext.js"; import { ImportBinding, ProjectItem, RunOutput } from "./ProjectItem.js"; -import { Resolver } from "./Resolver.js"; +import { SqLinker } from "../SqLinker.js"; import * as Topology from "./Topology.js"; import { SqOutputResult } from "../types.js"; @@ -28,23 +28,23 @@ function getMissingDependencyError(id: string) { } type Options = { - resolver?: Resolver; + linker?: SqLinker; }; export class SqProject { private readonly items: Map; private stdLib: Bindings; private environment: Env; - private resolver?: Resolver; // if not present, imports are forbidden + private linker?: SqLinker; // if not present, imports are forbidden constructor(options?: Options) { this.items = new Map(); this.stdLib = Library.getStdLib(); this.environment = defaultEnv; - this.resolver = options?.resolver; + this.linker = options?.linker; } - static create(options?: { resolver: Resolver }) { + static create(options?: { linker: SqLinker }) { return new SqProject(options); } @@ -157,8 +157,8 @@ export class SqProject { } parseImports(sourceId: string): void { - // resolver can be undefined; in this case parseImports will fail if there are any imports - this.getItem(sourceId).parseImports(this.resolver); + // linker can be undefined; in this case parseImports will fail if there are any imports + this.getItem(sourceId).parseImports(this.linker); } getOutput(sourceId: string): SqOutputResult { @@ -324,7 +324,7 @@ export class SqProject { async runAll() { // preload all imports - if (this.resolver) { + if (this.linker) { for (const id of this.getSourceIds()) { await this.loadImportsRecursively(id); } @@ -335,15 +335,15 @@ export class SqProject { } async run(sourceId: string) { - if (this.resolver) { + if (this.linker) { await this.loadImportsRecursively(sourceId); } await this.runIds(this.getRunOrderFor(sourceId)); } async loadImportsRecursively(initialSourceName: string) { - const resolver = this.resolver; - if (!resolver) { + const linker = this.linker; + if (!linker) { return; } @@ -366,7 +366,7 @@ export class SqProject { if (this.getSource(newImportId) === undefined) { // We have got one of the new imports. // Let's load it and add it to the project. - const newSource = await resolver.loadSource(newImportId); + const newSource = await linker.loadSource(newImportId); this.setSource(newImportId, newSource); } // The new source is loaded and added to the project. From c9d74a0c13bba85a6781ea066f0c10d9bfc3037a Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Wed, 11 Oct 2023 13:53:59 -0600 Subject: [PATCH 03/10] remove unused `onChange useSquiggle arg --- packages/components/src/lib/hooks/useSquiggle.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/components/src/lib/hooks/useSquiggle.ts b/packages/components/src/lib/hooks/useSquiggle.ts index 2cc08e927b..291023047b 100644 --- a/packages/components/src/lib/hooks/useSquiggle.ts +++ b/packages/components/src/lib/hooks/useSquiggle.ts @@ -28,7 +28,6 @@ export type ProjectExecutionProps = { export type SquiggleArgs = { code: string; executionId?: number; - onChange?: (expr: SqValue | undefined, sourceId: string) => void; } & (StandaloneExecutionProps | ProjectExecutionProps); export type SquiggleOutput = { @@ -84,7 +83,7 @@ export function useSquiggle(args: SquiggleArgs): UseSquiggleOutput { SquiggleOutput | undefined >(undefined); - const { executionId = 1, onChange } = args; + const { executionId = 1 } = args; useEffect( () => { @@ -124,16 +123,6 @@ export function useSquiggle(args: SquiggleArgs): UseSquiggleOutput { [args.code, executionId, sourceId, continues, project] ); - useEffect(() => { - if (!squiggleOutput || isRunning) { - return; - } - onChange?.( - squiggleOutput.output.ok ? squiggleOutput.output.value.result : undefined, - sourceId - ); - }, [squiggleOutput, isRunning, onChange, sourceId]); - useEffect(() => { return () => { project.removeSource(sourceId); @@ -144,7 +133,7 @@ export function useSquiggle(args: SquiggleArgs): UseSquiggleOutput { squiggleOutput, { project, - isRunning: executionId !== squiggleOutput?.executionId, + isRunning, sourceId, }, ]; From 6bdcd28db9abcf62c3fc9e33b8bf095b48061ccf Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Wed, 11 Oct 2023 15:31:30 -0600 Subject: [PATCH 04/10] minimal linker support in components and hub --- .../LeftPlaygroundPanel/index.tsx | 15 +++- .../components/SquigglePlayground/index.tsx | 41 ++++++++--- .../[slug]/EditSquiggleSnippetModel.tsx | 2 + .../hub/src/squiggle/components/linker.ts | 68 +++++++++++++++++++ .../src/VersionedSquigglePlayground.tsx | 16 ++++- 5 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 packages/hub/src/squiggle/components/linker.ts diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx index 1dc7349859..69cd1d1ce7 100644 --- a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx +++ b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx @@ -7,7 +7,7 @@ import { useRef, } from "react"; -import { SqValuePath } from "@quri/squiggle-lang"; +import { SqProject, SqValuePath } from "@quri/squiggle-lang"; import { Bars3CenterLeftIcon } from "@quri/ui"; import { @@ -31,6 +31,7 @@ export type RenderExtraControls = (props: { }) => ReactNode; type Props = { + project: SqProject; defaultCode?: string; onCodeChange?(code: string): void; settings: PlaygroundSettings; @@ -49,6 +50,8 @@ type Props = { export type LeftPlaygroundPanelHandle = { getEditor(): CodeEditorHandle | null; // used by "find in editor" feature getLeftPanelElement(): HTMLDivElement | null; // used by local settings modal window positioning + run(): void; // force re-run + invalidate(): void; // mark output as stale but don't re-run if autorun is disabled; useful on environment changes, triggered in code }; export const LeftPlaygroundPanel = forwardRef( @@ -62,8 +65,8 @@ export const LeftPlaygroundPanel = forwardRef( const [squiggleOutput, { project, isRunning, sourceId }] = useSquiggle({ code: runnerState.renderedCode, + project: props.project, executionId: runnerState.executionId, - environment: props.settings.environment, }); const { onOutputChange } = props; @@ -87,6 +90,12 @@ export const LeftPlaygroundPanel = forwardRef( useImperativeHandle(ref, () => ({ getEditor: () => editorRef.current, getLeftPanelElement: () => containerRef.current, + run: () => runnerState.run(), + invalidate: () => { + if (runnerState.autorunMode) { + runnerState.run(); + } + }, })); const renderToolbar = ({ @@ -117,7 +126,7 @@ export const LeftPlaygroundPanel = forwardRef( // see https://github.com/quantified-uncertainty/squiggle/issues/1952 defaultValue={code} errors={errors} - height={"100%"} + height="100%" project={project} sourceId={sourceId} showGutter={true} diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index e5ecd7363d..367d08e781 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -1,6 +1,13 @@ import merge from "lodash/merge.js"; -import React, { CSSProperties, useCallback, useRef, useState } from "react"; +import React, { + CSSProperties, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { SqLinker, SqProject } from "@quri/squiggle-lang"; import { SquiggleOutput } from "../../lib/hooks/useSquiggle.js"; import { DynamicSquiggleViewer } from "../DynamicSquiggleViewer.js"; import { @@ -15,12 +22,19 @@ import { } from "./LeftPlaygroundPanel/index.js"; import { ResizableTwoPanelLayout } from "./ResizableTwoPanelLayout.js"; +/* + * We don't support `project` or `continues` in the playground. + * First, because playground will support multi-file mode by itself. + * Second, because environment is configurable through playground settings and it should match the project.getEnvironment(), so this component owns the project to guarantee that. + */ type PlaygroundProps = { - /* We don't support `project` or `continues` in the playground. - * First, because playground will support multi-file mode by itself. - * Second, because environment is configurable through playground settings and it won't be possible with an external project. + /* + * Playground code is not reactive, because Codemirror editor is stateful and it would be hard/impossible to support code updates. + * For example, it's not clear what we could do with the cursor position or selection if this prop is changed. + * So updates to it are completely ignored. */ defaultCode?: string; + linker?: SqLinker; onCodeChange?(code: string): void; /* When settings change */ onSettingsChange?(settings: PlaygroundSettings): void; @@ -43,6 +57,7 @@ export const PlaygroundContext = React.createContext({ export const SquigglePlayground: React.FC = (props) => { const { defaultCode, + linker, onCodeChange, onSettingsChange, renderExtraControls, @@ -72,14 +87,23 @@ export const SquigglePlayground: React.FC = (props) => { [onSettingsChange] ); + const [project] = useState(() => { + // not reactive on `linker` changes; TODO? + return new SqProject({ linker }); + }); + + useEffect(() => { + project.setEnvironment(settings.environment); + leftPanelRef.current?.invalidate(); + }, [project, settings.environment]); + const [output, setOutput] = useState<{ output: SquiggleOutput | undefined; isRunning: boolean; }>({ output: undefined, isRunning: false }); - const viewerRef = useRef(null); - const leftPanelRef = useRef(null); + const rightPanelRef = useRef(null); const getLeftPanelElement = useCallback( () => leftPanelRef.current?.getLeftPanelElement() ?? undefined, @@ -88,6 +112,7 @@ export const SquigglePlayground: React.FC = (props) => { const renderLeft = () => ( = (props) => { onOutputChange={setOutput} renderExtraControls={renderExtraControls} renderExtraModal={renderExtraModal} - onViewValuePath={(path) => viewerRef.current?.viewValuePath(path)} + onViewValuePath={(path) => rightPanelRef.current?.viewValuePath(path)} ref={leftPanelRef} /> ); @@ -106,7 +131,7 @@ export const SquigglePlayground: React.FC = (props) => { isRunning={output.isRunning} // FIXME - this will cause viewer to be rendered twice on initial render editor={leftPanelRef.current?.getEditor() ?? undefined} - ref={viewerRef} + ref={rightPanelRef} localSettingsEnabled={true} {...settings} /> diff --git a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index 73046257a2..a2bb50b2dd 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx @@ -20,6 +20,7 @@ import { EditModelExports } from "@/components/exports/EditModelExports"; import { useAvailableHeight } from "@/hooks/useAvailableHeight"; import { useMutationForm } from "@/hooks/useMutationForm"; import { extractFromGraphqlErrorUnion } from "@/lib/graphqlHelpers"; +import { squiggleHubLinker } from "@/squiggle/components/linker"; type FormShape = { code: string; @@ -160,6 +161,7 @@ export const EditSquiggleSnippetModel: FC = ({ modelRef }) => {
( + environment, + graphql` + query linkerQuery($input: QueryModelInput!) { + model(input: $input) { + __typename + ... on Model { + id + currentRevision { + content { + __typename + ... on SquiggleSnippet { + code + } + } + } + } + } + } + `, + { + input: { + owner: ownerSlug, + slug: modelSlug, + }, + } + // toPromise is discouraged by Relay docs, but should be fine if we don't do any streaming + ).toPromise(); + + if (!result || result.model.__typename !== "Model") { + throw new Error(`Failed to fetch sources for ${sourceId}`); + } + + const content = result.model.currentRevision.content; + if (content.__typename !== "SquiggleSnippet") { + throw new Error(`${sourceId} is not a SquiggleSnippet`); + } + + return content.code; + }, +}; diff --git a/packages/versioned-playground/src/VersionedSquigglePlayground.tsx b/packages/versioned-playground/src/VersionedSquigglePlayground.tsx index 0c77c99950..a0a2dfaba3 100644 --- a/packages/versioned-playground/src/VersionedSquigglePlayground.tsx +++ b/packages/versioned-playground/src/VersionedSquigglePlayground.tsx @@ -42,10 +42,19 @@ type CommonProps = { height?: string | number; }; -type Props = CommonProps & { - version: string; // not SquiggleVersion, because it's easier to validate the version inside this component +// supported only in modern playgrounds +type LinkerProps = { + linker?: { + resolve: (name: string, fromId: string) => string; + loadSource: (sourceId: string) => Promise; + }; }; +type Props = CommonProps & + LinkerProps & { + version: string; // not SquiggleVersion, because it's easier to validate the version inside this component + }; + export const VersionedSquigglePlayground: FC = ({ version, ...props @@ -79,6 +88,9 @@ export const VersionedSquigglePlayground: FC = ({ onCodeChange={props.onCodeChange} onSettingsChange={props.onSettingsChange} height={props.height} + // older playgrounds don't support this, it'll be ignored, that's fine + // (TODO: why TypeScript doesn't error on this, if `linker` prop doesn't exist in 0.8.5? no idea) + linker={props.linker} /> ); From fcc481243316e2e2d74f788616df611e84586f43 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Sun, 15 Oct 2023 11:21:49 -0600 Subject: [PATCH 05/10] refactor SqProject, remove topology, maintain inv graph on each operation --- .../SqProject/SqProject_Topology_test.ts | 97 ------ .../SqProject/SqProject_continues_test.ts | 79 +++++ .../SqProject/SqProject_imports_test.ts | 236 ++++++++++---- .../__tests__/SqProject/SqProject_test.ts | 224 +++---------- .../SqProject/SqProject_tutorial_1_test.ts | 37 +-- .../SqProject_tutorial_2_imports_test.ts | 132 ++++++++ .../SqProject_tutorial_2_multisource_test.ts | 96 ------ .../SqProject_tutorial_3_continues_test.ts | 52 +++ .../SqProject_tutorial_3_imports_test.ts | 163 --------- .../__tests__/helpers/projectHelpers.ts | 31 ++ packages/squiggle-lang/src/ast/parse.ts | 5 +- .../src/public/SqProject/ProjectItem.ts | 111 ++++--- .../src/public/SqProject/Topology.ts | 103 ------ .../src/public/SqProject/index.ts | 308 +++++++++--------- 14 files changed, 763 insertions(+), 911 deletions(-) delete mode 100644 packages/squiggle-lang/__tests__/SqProject/SqProject_Topology_test.ts create mode 100644 packages/squiggle-lang/__tests__/SqProject/SqProject_continues_test.ts create mode 100644 packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts delete mode 100644 packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts create mode 100644 packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_continues_test.ts delete mode 100644 packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts create mode 100644 packages/squiggle-lang/__tests__/helpers/projectHelpers.ts delete mode 100644 packages/squiggle-lang/src/public/SqProject/Topology.ts diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_Topology_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_Topology_test.ts deleted file mode 100644 index af4fa2115b..0000000000 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_Topology_test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import * as Topology from "../../src/public/SqProject/Topology.js"; -import { SqProject } from "../../src/public/SqProject/index.js"; - -function buildProject(graph: [string, string[]][]) { - const project = SqProject.create(); - - for (const [node, deps] of graph) { - project.setSource(node, ""); - project.setContinues(node, deps); - for (const dep of deps) { - // might create source multiple times, but it shouldn't matter and this helps to define the graph without listing all deps - project.setSource(dep, ""); - } - } - return project; -} - -describe("simple", () => { - const project = buildProject([["main", ["dep1", "dep2"]]]); - - test("getRunOrder", () => { - expect(Topology.getRunOrder(project)).toEqual(["dep1", "dep2", "main"]); - }); - - test("getRunOrderFor", () => { - expect(Topology.getRunOrderFor(project, "main")).toEqual([ - "dep1", - "dep2", - "main", - ]); - expect(Topology.getRunOrderFor(project, "dep1")).toEqual(["dep1"]); - expect(Topology.getRunOrderFor(project, "dep2")).toEqual(["dep2"]); - }); - - test("getDependents", () => { - expect(Topology.getDependents(project, "dep1")).toEqual(["main"]); - expect(Topology.getDependents(project, "main")).toEqual([]); - }); -}); - -describe("triangle", () => { - const project = buildProject([ - ["main", ["dep1", "dep2"]], - ["dep1", ["dep2"]], - ]); - - test("getRunOrder", () => { - expect(Topology.getRunOrder(project)).toEqual(["dep2", "dep1", "main"]); - }); - - test("getRunOrderFor", () => { - expect(Topology.getRunOrderFor(project, "main")).toEqual([ - "dep2", - "dep1", - "main", - ]); - expect(Topology.getRunOrderFor(project, "dep1")).toEqual(["dep2", "dep1"]); - expect(Topology.getRunOrderFor(project, "dep2")).toEqual(["dep2"]); - }); - - test("getDependents", () => { - expect(Topology.getDependents(project, "dep2")).toEqual(["main", "dep1"]); - expect(Topology.getDependents(project, "dep1")).toEqual(["main"]); - expect(Topology.getDependents(project, "main")).toEqual([]); - }); -}); - -describe("nested", () => { - const project = buildProject([ - ["main", ["a", "b"]], - ["a", ["c", "d"]], - ["d", ["e"]], - ["b", ["c", "a"]], - ]); - - test("getRunOrder", () => { - expect(Topology.getRunOrder(project)).toEqual([ - "c", - "e", - "d", - "a", - "b", - "main", - ]); - }); - - test("getDependents", () => { - expect(Topology.getDependents(project, "a")).toEqual(["main", "b"]); - }); - - test("traverseDependents", () => { - const order: string[] = []; - Topology.traverseDependents(project, "d", (id) => order.push(id)); - // depth-first order (shouldn't matter, we use traverseDependents only for cleaning) - expect(order).toEqual(["main", "b", "a"]); - }); -}); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_continues_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_continues_test.ts new file mode 100644 index 0000000000..9c262ecb00 --- /dev/null +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_continues_test.ts @@ -0,0 +1,79 @@ +import { SqProject } from "../../src/index.js"; +import { + buildNaiveLinker, + runFetchBindings, + runFetchResult, +} from "../helpers/projectHelpers.js"; + +describe("continues", () => { + test("order 1", async () => { + const project = SqProject.create(); + project.setSource("first", "x = 1"); + project.setSource("main", "x + 1"); + project.setContinues("main", ["first"]); + + expect(await runFetchResult(project, "main")).toBe("Ok(2)"); + }); + + test("order 2", async () => { + const project = SqProject.create(); + project.setSource("main", "x + 1"); + project.setSource("first", "x = 1"); + project.setContinues("main", ["first"]); + + expect(await runFetchResult(project, "main")).toBe("Ok(2)"); + }); + + test("getContinues", () => { + const project = SqProject.create(); + project.setSource("first", "x=1"); + project.setSource("main", "x + 1"); + project.setContinues("main", ["first"]); + expect(project.getContinues("main")).toEqual(["first"]); + expect(project.getContinues("first")).toEqual([]); + }); +}); + +describe("dependencies and dependents", () => { + const project = SqProject.create(); + project.setSource("first", "x=1"); + project.setSource("second", "y=2"); + project.setSource("main", "z=3;y"); + project.setContinues("main", ["second"]); + project.setContinues("second", ["first"]); + + test("dependents first", () => { + expect(project.getDependents("first")).toEqual(["second"]); + }); + test("dependents second", () => { + expect(project.getDependents("second")).toEqual(["main"]); + }); + test("dependents main", () => { + expect(project.getDependents("main")).toEqual([]); + }); + test("dependencies main", () => { + expect(project.getDependencies("main")).toEqual(["second"]); + }); + test("test result", async () => { + expect(await runFetchResult(project, "main")).toBe("Ok(2)"); + }); + test("test bindings", async () => { + // bindings from continues are not exposed! + expect(await runFetchBindings(project, "main")).toBe("{z: 3}"); + }); +}); + +describe("dynamic loading", () => { + const project = SqProject.create({ + linker: buildNaiveLinker({ + first: "x=1", + second: "y=2", + }), + }); + project.setSource("main", "x+y"); + project.setContinues("main", ["first", "second"]); + + test("test result", async () => { + expect(await runFetchResult(project, "main")).toBe("Ok(3)"); + }); +}); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts index 7593a45f0a..902eeebf39 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts @@ -1,66 +1,66 @@ import { SqProject } from "../../src/index.js"; -import { SqLinker } from "../../src/public/SqLinker.js"; - -const buildLinker = (sources?: { [k: string]: string }) => { - const linker: SqLinker = { - resolve: (name) => name, - loadSource: async (id) => { - if (sources && id in sources) { - return sources[id]; - } - throw new Error(`Unknown id ${id}`); - }, - }; - return linker; -}; - -describe("Parse imports", () => { - const project = SqProject.create({ linker: buildLinker() }); - project.setSource( - "main", +import { + buildNaiveLinker, + runFetchBindings, + runFetchResult, +} from "../helpers/projectHelpers.js"; + +describe("Imports tests", () => { + describe("Parse imports", () => { + const project = SqProject.create({ linker: buildNaiveLinker() }); + project.setSource( + "main", + ` + import './common' as common + import "./myModule" as myVariable + x = 1 ` -import './common' as common -import "./myModule" as myVariable -x=1` - ); - project.parseImports("main"); - - test("dependencies", () => { - expect(project.getDependencies("main")).toEqual(["./common", "./myModule"]); - }); + ); - test("getImportIds", () => { - const mainImportIds = project.getImportIds("main"); - if (mainImportIds.ok) { - expect(mainImportIds.value).toEqual(["./common", "./myModule"]); - } else { - throw new Error(mainImportIds.value.toString()); - } - }); + test("getDependencies", () => { + // `getDependencies` always parses the source + // (Also note how imported sources are missing but that's ok because they're not needed yet) + expect(project.getDependencies("main")).toEqual([ + "./common", + "./myModule", + ]); + }); - test("getImports", () => { - expect(project.getImports("main")).toEqual({ - ok: true, - value: [ - { variable: "common", sourceId: "./common" }, - { variable: "myVariable", sourceId: "./myModule" }, - ], + test("getDependents", () => { + expect(project.getDependents("./common")).toEqual(["main"]); + }); + test("getImportIds", () => { + const mainImportIds = project.getImportIds("main"); + if (mainImportIds.ok) { + expect(mainImportIds.value).toEqual(["./common", "./myModule"]); + } else { + throw new Error(mainImportIds.value.toString()); + } + }); + + test("getImports", () => { + expect(project.getImports("main")).toEqual({ + ok: true, + value: [ + { type: "named", variable: "common", sourceId: "./common" }, + { type: "named", variable: "myVariable", sourceId: "./myModule" }, + ], + }); }); - }); - test("continues", () => { - expect(project.getContinues("main")).toEqual([]); + test("continues", () => { + expect(project.getContinues("main")).toEqual([]); + }); }); -}); -describe("Unknown imports", () => { - test("without linker", async () => { + test("Without linker", async () => { const project = SqProject.create(); project.setSource( "main", ` -import './lib' as lib -123` + import './lib' as lib + 123 + ` ); await project.run("main"); @@ -68,8 +68,8 @@ import './lib' as lib expect(project.getResult("main").ok).toEqual(false); }); - test("unknown import", () => { - const project = SqProject.create({ linker: buildLinker() }); + test("Unknown import", async () => { + const project = SqProject.create({ linker: buildNaiveLinker() }); project.setSource( "main", ` @@ -77,12 +77,16 @@ import './lib' as lib lib.x` ); - expect(project.run("main")).rejects.toThrow(); + await project.run("main"); + expect(project.getResult("main").ok).toEqual(false); + expect(project.getResult("main").value.toString()).toEqual( + "Failed to load import ./lib" + ); }); - test("known import", async () => { + test("Known import", async () => { const project = SqProject.create({ - linker: buildLinker({ + linker: buildNaiveLinker({ "./lib": "x = 5", }), }); @@ -93,9 +97,125 @@ import './lib' as lib lib.x` ); - expect(project.run("main")).resolves.toBe(undefined); - await project.run("main"); + expect(project.getResult("main").ok).toEqual(true); + expect(project.getResult("main").value.toString()).toEqual("5"); + }); + + describe("Another import test", () => { + const project = SqProject.create({ + linker: buildNaiveLinker(), + }); + + project.setSource( + "first", + ` + import 'common' as common + x = 1 + ` + ); + expect(project.getDependencies("first")).toEqual(["common"]); + + project.setSource("common", "common = 0"); + project.setSource( + "second", + ` + import 'common' as common + y = 2 + ` + ); + project.setContinues("second", ["first"]); + expect(project.getDependencies("second")).toEqual(["first", "common"]); + + project.setSource("main", "z=3; y"); + project.setContinues("main", ["second"]); + + test("test result", async () => { + expect(await runFetchResult(project, "main")).toBe("Ok(2)"); + }); + test("test bindings", async () => { + // bindings from continues are not exposed! + expect(await runFetchBindings(project, "main")).toBe("{z: 3}"); + }); + }); + + test("Cyclic imports", async () => { + const project = SqProject.create({ + linker: buildNaiveLinker({ + bar: ` + import "foo" as foo + y = 6 + `, + foo: ` + import "bar" as bar + x = 5 + `, + }), + }); + project.setSource( + "main", + ` + import "foo" as foo + foo.x + ` + ); + + await project.run("main"); + + expect(project.getResult("main").ok).toEqual(false); + expect(project.getResult("main").value.toString()).toEqual( + "Cyclic import foo" + ); + }); + + test("Self-import", async () => { + const project = SqProject.create({ + linker: buildNaiveLinker(), + }); + project.setSource( + "main", + ` + import "main" as self + self + ` + ); + + await project.run("main"); + + expect(project.getResult("main").ok).toEqual(false); + expect(project.getResult("main").value.toString()).toEqual( + "Cyclic import main" + ); + }); + + test("Diamond shape", async () => { + const project = SqProject.create({ + linker: buildNaiveLinker({ + common: ` + x = 10 + `, + bar: ` + import "common" as common + x = common.x * 2 + `, + foo: ` + import "common" as common + x = common.x * 3 + `, + }), + }); + project.setSource( + "main", + ` + import "foo" as foo + import "bar" as bar + foo.x + bar.x + ` + ); + + await project.run("main"); + expect(project.getResult("main").ok).toBe(true); + expect(project.getResult("main").value.toString()).toBe("50"); }); }); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts index 84989c6b22..4ae1547b81 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts @@ -1,20 +1,5 @@ import { SqProject } from "../../src/public/SqProject/index.js"; -import { toStringResult } from "../../src/public/SqValue/index.js"; - -const runFetchResult = async (project: SqProject, sourceId: string) => { - await project.run(sourceId); - const result = project.getResult(sourceId); - return toStringResult(result); -}; - -const runFetchFlatBindings = async (project: SqProject, sourceId: string) => { - await project.run(sourceId); - const bindingsR = project.getBindings(sourceId); - if (!bindingsR.ok) { - return `Error(${bindingsR.value})`; - } - return bindingsR.value.toString(); -}; +import { runFetchBindings, runFetchResult } from "../helpers/projectHelpers.js"; test("test result true", async () => { const project = SqProject.create(); @@ -37,188 +22,79 @@ test("test library", async () => { test("test bindings", async () => { const project = SqProject.create(); project.setSource("variables", "myVariable=666"); - expect(await runFetchFlatBindings(project, "variables")).toBe( + expect(await runFetchBindings(project, "variables")).toBe( "{myVariable: 666}" ); }); -describe("project1", () => { - const project = SqProject.create(); - project.setSource("first", "x=1"); - project.setSource("main", "x"); - project.setContinues("main", ["first"]); +describe("removing sources", () => { + const getCommonProject = () => { + const project = SqProject.create(); + project.setSource("A", "x=1"); - test("runOrder", () => { - expect(project.getRunOrder()).toEqual(["first", "main"]); - }); - test("dependents first", () => { - expect(project.getDependents("first")).toEqual(["main"]); - }); - test("dependencies first", () => { - expect(project.getDependencies("first")).toEqual([]); - }); - test("dependents main", () => { - expect(project.getDependents("main")).toEqual([]); - }); - test("dependencies main", () => { - expect(project.getDependencies("main")).toEqual(["first"]); - }); + project.setSource("B", "y=2"); + project.setContinues("B", ["A"]); - test("continues first", () => { - expect(project.getContinues("first")).toEqual([]); - }); - test("continues main", () => { - expect(project.getContinues("main")).toEqual(["first"]); - }); + project.setSource("C", "y"); + project.setContinues("C", ["B"]); - test("test result", async () => { - expect(await runFetchResult(project, "main")).toBe("Ok(1)"); - }); - test("test bindings", async () => { - expect(await runFetchFlatBindings(project, "first")).toBe("{x: 1}"); - }); -}); + return project; + }; -describe("project2", () => { - const project = SqProject.create(); - project.setSource("first", "x=1"); - project.setSource("second", "y=2"); - project.setSource("main", "z=3;y"); - project.setContinues("main", ["second"]); - project.setContinues("second", ["first"]); - - test("runOrder", () => { - expect(project.getRunOrder()).toEqual(["first", "second", "main"]); - }); - test("runOrderFor", () => { - expect(project.getRunOrderFor("first")).toEqual(["first"]); - }); - test("runOrderFor", () => { - expect(project.getRunOrderFor("main")).toEqual(["first", "second", "main"]); - }); - test("dependencies first", () => { - expect(project.getDependencies("first")).toEqual([]); - }); - test("dependents first", () => { - expect(project.getDependents("first")).toEqual(["second"]); - }); - test("dependents second", () => { - expect(project.getDependents("second")).toEqual(["main"]); - }); - test("dependents main", () => { - expect(project.getDependents("main")).toEqual([]); - }); - test("dependencies main", () => { - expect(project.getDependencies("main")).toEqual(["second"]); - }); - test("test result", async () => { - expect(await runFetchResult(project, "main")).toBe("Ok(2)"); - }); - test("test bindings", async () => { - // bindings from continues are not exposed! - expect(await runFetchFlatBindings(project, "main")).toBe("{z: 3}"); - }); -}); + test("leaf", () => { + const project = getCommonProject(); -describe("removing sources", () => { - const project = SqProject.create(); - project.setSource("first", "x=1"); + expect(project.getSourceIds()).toEqual(["A", "B", "C"]); - project.setSource("second", "y=2"); - project.setContinues("second", ["first"]); + expect(project.getDependents("C")).toEqual([]); + expect(project.getDependencies("C")).toEqual(["B"]); - project.setSource("main", "y"); - project.setContinues("main", ["second"]); + project.removeSource("C"); + expect(project.getSourceIds()).toEqual(["A", "B"]); - project.removeSource("main"); + expect(project.getSource("C")).toBe(undefined); - test("project doesn't have source", () => { - expect(project.getSource("main")).toBe(undefined); + expect(project.getDependents("C")).toEqual([]); + expect(() => project.getDependencies("C")).toThrow(); }); - test("dependents get updated", () => { - expect(project.getDependents("second")).toEqual([]); - }); -}); + test("intermediate", () => { + const project = getCommonProject(); -describe("project with import", () => { - const project = SqProject.create({ - linker: { - resolve: (name) => name, - loadSource: () => { - throw new Error("Loading not supported"); - }, - }, - }); + expect(project.getSourceIds()).toEqual(["A", "B", "C"]); - project.setSource( - "first", - ` - import 'common' as common - x=1` - ); - project.parseImports("first"); //The only way of setting imports - //Don't forget to parse imports after changing the source - - project.setSource("common", "common=0"); - project.setSource( - "second", - ` - import 'common' as common - y=2` - ); - project.setContinues("second", ["first"]); - project.parseImports("second"); //The only way of setting imports - - project.setSource("main", "z=3; y"); - project.setContinues("main", ["second"]); - - test("runOrder", () => { - expect(project.getRunOrder()).toEqual([ - "common", - "first", - "second", - "main", - ]); - }); + expect(project.getDependents("B")).toEqual(["C"]); + expect(project.getDependencies("B")).toEqual(["A"]); - test("runOrderFor", () => { - expect(project.getRunOrderFor("first")).toEqual(["common", "first"]); - }); + project.removeSource("B"); + expect(project.getSourceIds()).toEqual(["A", "C"]); - test("dependencies first", () => { - expect(project.getDependencies("first")).toEqual(["common"]); - }); - test("dependents first", () => { - expect(project.getDependents("first")).toEqual(["second"]); - }); - test("dependents common", () => { - expect(project.getDependents("common")).toEqual(["first", "second"]); - }); - test("dependents main", () => { - expect(project.getDependents("main")).toEqual([]); - }); - test("dependencies main", () => { - expect(project.getDependencies("main")).toEqual(["second"]); - }); - test("test result", async () => { - expect(await runFetchResult(project, "main")).toBe("Ok(2)"); - }); - test("test bindings", async () => { - // bindings from continues are not exposed! - expect(await runFetchFlatBindings(project, "main")).toBe("{z: 3}"); + expect(project.getSource("B")).toBe(undefined); + + // the dependency is still there, but evaluating "C" will fail because "B" got removed + expect(project.getDependents("B")).toEqual(["C"]); + expect(() => project.getDependencies("B")).toThrow(); }); }); describe("project with independent sources", () => { - const project = SqProject.create(); - project.setSource("first", "1"); - project.setSource("second", "2"); + test("run first", async () => { + const project = SqProject.create(); + project.setSource("first", "1"); + project.setSource("second", "2"); - test("run order of first", () => { - expect(project.getRunOrderFor("first")).toEqual(["first"]); + expect(await runFetchResult(project, "first")).toBe("Ok(1)"); + expect(project.getOutput("second").ok).toBe(false); + expect(project.getOutput("second").value.toString()).toMatch("Need to run"); }); - test("run order of second", () => { - expect(project.getRunOrderFor("second")).toEqual(["second"]); + + test("run second", async () => { + const project = SqProject.create(); + project.setSource("first", "1"); + project.setSource("second", "2"); + + expect(await runFetchResult(project, "second")).toBe("Ok(2)"); + expect(project.getOutput("first").ok).toBe(false); + expect(project.getOutput("first").value.toString()).toMatch("Need to run"); }); }); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts index e7ed3eb3d5..cf73153bcb 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_1_test.ts @@ -16,6 +16,7 @@ describe("Single source SqProject", () => { const project = SqProject.create(); /* Every source has a name. This is used for debugging, dependencies and error messages. */ project.setSource("main", "1 + 2"); + /* Let's run "main" source. */ await project.run("main"); /* @@ -26,14 +27,7 @@ describe("Single source SqProject", () => { */ /* - * However, you could also run the whole project. - * If you have all the sources, you can always run the whole project. - * Dependencies and recompiling on demand will be taken care of by the project. - */ - await project.runAll(); - - /* - * Either with `run` or `runAll` you executed the project. + * With `run` you executed a source. * You can get the result of a specific source by calling `getResult` for that source. * You can get the bindings of a specific source by calling `getBindings` for that source. * To get both, call `getOutput`. @@ -47,31 +41,24 @@ describe("Single source SqProject", () => { /* You've got 3 with empty bindings. */ }); - test("run summary", async () => { - const project = SqProject.create(); - project.setSource("main", "1 + 2"); - await project.runAll(); - const output = project.getOutput("main"); - /* Now you have external bindings and external result. */ - expect(output).toBeOkOutput("3", "{}"); - }); - test("run with an environment", async () => { /* Running the source code like above allows you to set a custom environment */ - const project = SqProject.create(); + const project = SqProject.create({ + environment: { + ...defaultEnvironment, + sampleCount: 50, + }, + }); - /* Optional. Set your custom environment anytime before running */ - project.setEnvironment(defaultEnvironment); - - project.setSource("main", "1 + 2"); - await project.runAll(); + project.setSource("main", "(2 to 5) -> SampleSet.toList -> List.length"); + await project.run("main"); const result = project.getResult("main"); - expect(toStringResult(result)).toBe("Ok(3)"); + expect(toStringResult(result)).toBe("Ok(50)"); }); test("shortcut", async () => { /* - * If you are running a single source without imports and you don't need a custom environment, you can use the shortcut. + * If you are running a single source without imports, you can use the shortcut. * Examples above were to prepare you for the multi source tutorial. */ const outputR = await run("1+2"); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts new file mode 100644 index 0000000000..4f44d179c9 --- /dev/null +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts @@ -0,0 +1,132 @@ +import { SqProject } from "../../src/index.js"; +import { SqLinker } from "../../src/public/SqLinker.js"; +import "../helpers/toBeOkOutput.js"; + +/* + * Now let's look at explicit imports, possibly recursive and cyclic. + */ +describe("SqProject with imports", () => { + test("Preloaded sources", async () => { + /* + * Any project that uses sources with import statements needs a basic linker. + * Linkers are responsible for two things: + * 1. Converting a string name in `import "name"` to the source id. + * 2. Loading a source by its id. + */ + const project = SqProject.create({ + linker: { + /* + * Basic import syntax: `import "foo" as foo`. + * This method is responsible for resolving "foo" string to the source id. + * That's important, for example, for relative imports: "./foo" from root of the project and "../foo" from "./subfolder" should resolve to the same id. + */ + resolve: (name) => name, // we don't care for relative imports yet + loadSource: () => { + throw new Error("Loading not supported"); // this is ok because we're going to set all sources explicitly + }, + }, + }); + + /* This time source1 and source2 are not depending on anything */ + project.setSource("source1", "x=1"); + + project.setSource( + "source3", + ` + import "source1" as s1 + import "source2" as s2 + z=s1.x+s2.y` + ); + /* We're creating source1, source2, source3 in a weird order to check that `run` loads imports on demand */ + project.setSource("source2", "y=2"); + + /* Now we can run the project */ + await project.run("source3"); + + /* And let's check the result and bindings of source3 */ + const output = project.getOutput("source3"); + + expect(output).toBeOkOutput("()", "{z: 3}"); + }); + + test("Loading sources on demand", async () => { + const linker: SqLinker = { + resolve: (name) => name, + loadSource: async (sourceName) => { + // Note how this function is async and can load sources remotely on demand. + switch (sourceName) { + case "source1": + return "x=1"; + case "source2": + return ` + import "source1" as s1 + y=2`; + case "source3": + return ` + import "source2" as s2 + z=3`; + default: + throw new Error(`source ${sourceName} not found`); + } + }, + }; + + const project = SqProject.create({ linker }); + + project.setSource( + "main", + ` + import "source1" as s1 + import "source2" as s2 + import "source3" as s3 + a = s1.x + s2.y + s3.z + b = doubleX // available through continues + a + ` + ); + + /* + * Let's salt it more. Let's have another source in the project which also has imports. + * "doubleX" imports "source1" which is eventually imported by "main" as well. + */ + project.setSource( + "doubleX", + ` + import "source1" as s1 + doubleX = s1.x * 2 + ` + ); + + /* + * As doubleX is not imported by main, it is not loaded recursively. + * So we link it to the project as a dependency. + */ + project.setContinues("main", ["doubleX"]); + + /* Let's run the project; this method will load imports recursively for you. */ + await project.run("main"); + + const output = project.getOutput("main"); + + expect(output).toBeOkOutput("6", "{a: 6,b: 2}"); + + /* `getDependencies` returns the list of all dependency ids for a given source id, both continues and imports. */ + expect(project.getDependencies("main")).toEqual([ + "doubleX", + "source1", + "source2", + "source3", + ]); + + /* + * For any reason, you are able to query what other sources import or depend on the current source. + * But you don't need to use this to execute the projects. + * It is provided for completeness of information. + */ + expect(project.getDependents("source1")).toEqual([ + "main", + "doubleX", + "source2", + ]); + }); +}); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts deleted file mode 100644 index 774ece21c5..0000000000 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_multisource_test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { SqProject } from "../../src/index.js"; -import "../helpers/toBeOkOutput.js"; - -describe("Multi source SqProject", () => { - /** - * Running multiple sources. - * This approach uses `setContinues`, which is useful in Observable and other notebook-like environments, - * where the code is divided into multiple cells, but there are no explicit `import` statements. - */ - test("Chaining", async () => { - const project = SqProject.create(); - - /* This time let's add 3 sources and chain them together */ - project.setSource("source1", "x=1"); - - project.setSource("source2", "y=x+1"); - /* To run, source2 depends on source1 */ - project.setContinues("source2", ["source1"]); - - project.setSource("source3", "z=y+1"); - /* To run, source3 depends on source2 */ - project.setContinues("source3", ["source2"]); - - /* Now we can run the project */ - await project.runAll(); - - /* And let's check the result and bindings of source3 */ - const output = project.getOutput("source3"); - expect(output).toBeOkOutput("()", "{z: 3}"); - }); - - test("Depending", async () => { - /* Instead of chaining the sources, we could have a dependency tree. */ - /* The point here is that any source can depend on multiple sources. */ - const project = SqProject.create(); - - /* This time source1 and source2 are not depending on anything */ - project.setSource("source1", "x=1"); - project.setSource("source2", "y=2"); - - project.setSource("source3", "z=x+y"); - /* To run, source3 depends on source1 and source3 together */ - project.setContinues("source3", ["source1", "source2"]); - - /* Now we can run the project */ - await project.runAll(); - - /* And let's check the result and bindings of source3 */ - const output = project.getOutput("source3"); - expect(output).toBeOkOutput("()", "{z: 3}"); - }); - - test("Intro to imports", async () => { - /** - * Let's write the same project above with imports. - * You will see that parsing imports is setting the dependencies the same way as before. - */ - const project = SqProject.create({ - linker: { - resolve: (name) => name, - loadSource: () => { - throw new Error("Loading not supported"); - }, - }, - }); - - /* This time source1 and source2 are not depending on anything */ - project.setSource("source1", "x=1"); - - project.setSource( - "source3", - ` - import "source1" as s1 - import "source2" as s2 - z=s1.x+s2.y` - ); - /* We're creating source1, source2, source3 in a weird order to check that `runAll` loads imports on demand */ - project.setSource("source2", "y=2"); - - /* Now we can run the project */ - await project.runAll(); - - /* And let's check the result and bindings of source3 */ - const output = project.getOutput("source3"); - - expect(output).toBeOkOutput("()", "{z: 3}"); - /* - * Dealing with imports needs more. - * - There are parse errors - * - There are cyclic imports - * - And the depended source1 and source2 is not already there in the project - * - If you knew the imports before hand there would not be point of the imports directive. - * More on those on the next section. - */ - }); -}); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_continues_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_continues_test.ts new file mode 100644 index 0000000000..7366875f4d --- /dev/null +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_continues_test.ts @@ -0,0 +1,52 @@ +import { SqProject } from "../../src/index.js"; +import "../helpers/toBeOkOutput.js"; + +/* + * In the previous tutorial we've used explicit import statements; now we'll try implicit imports with `setContinues`, + * This approach uses `setContinues`, which is useful in Observable and other notebook-like environments, + * where the code is divided into multiple cells, but there are no explicit `import` statements. + */ +describe("SqProject with continues", () => { + test("Chaining", async () => { + const project = SqProject.create(); + + /* This time let's add 3 sources and chain them together */ + project.setSource("source1", "x=1"); + + project.setSource("source2", "y=x+1"); + /* To run, source2 depends on source1 */ + project.setContinues("source2", ["source1"]); + + project.setSource("source3", "z=y+1"); + /* To run, source3 depends on source2 */ + project.setContinues("source3", ["source2"]); + + /* Now we can run any source */ + await project.run("source3"); + + /* And let's check the result and bindings */ + const output = project.getOutput("source3"); + expect(output).toBeOkOutput("()", "{z: 3}"); + }); + + test("Depending", async () => { + /* Instead of chaining the sources, we could have a dependency tree. */ + /* The point here is that any source can depend on multiple sources. */ + const project = SqProject.create(); + + /* This time source1 and source2 are not depending on anything */ + project.setSource("source1", "x=1"); + project.setSource("source2", "y=2"); + + project.setSource("source3", "z=x+y"); + /* To run, source3 depends on source1 and source3 together */ + project.setContinues("source3", ["source1", "source2"]); + + /* Now we can run the project */ + await project.run("source3"); + + /* And let's check the result and bindings */ + const output = project.getOutput("source3"); + expect(output).toBeOkOutput("()", "{z: 3}"); + }); +}); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts deleted file mode 100644 index 740eb3ae31..0000000000 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_3_imports_test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { SqProject } from "../../src/index.js"; -import { SqLinker } from "../../src/public/SqLinker.js"; -import "../helpers/toBeOkOutput.js"; - -/* - * Now let's look at explicit imports, possibly recursive and cyclic. - * In the previous tutorial we've used `setContinues` manually; now we'll rely on import statements in Squiggle to load the dependencies. - */ -describe("SqProject with imports", () => { - /* - * Let's make a simple linker. Linkers are responsible for two things: - * 1. Converting a string name in `import "name"` to the source id (this is useful in some cases, e.g. for normalizing "../dir/file.squiggle" paths). - * 2. Loading a source by its id. - */ - const linker: SqLinker = { - resolve: (name) => name, - loadSource: async (sourceName) => { - switch (sourceName) { - case "source1": - return "x=1"; - case "source2": - return ` - import "source1" as s1 - y=2`; - case "source3": - return ` - import "source2" as s2 - z=3`; - default: - throw new Error(`source ${sourceName} not found`); - } - }, - }; - - const mainSource = ` - import "source1" as s1 - import "source2" as s2 - import "source3" as s3 - a = s1.x + s2.y + s3.z - b = doubleX // available through continues - a - `; - - const doubleXSource = ` - import "source1" as s1 - doubleX = s1.x * 2 - `; - - /* Basic approach is to call `run`; it's async and will load everything implicitly through the linker. */ - test("run", async () => { - const project = SqProject.create({ linker }); - - project.setSource("main", mainSource); - - /* - * Let's salt it more. Let's have another source in the project which also has imports. - * "doubleX" imports "source1" which is eventually imported by "main" as well. - */ - project.setSource("doubleX", doubleXSource); - - /* - * As doubleX is not imported by main, it is not loaded recursively. - * So we link it to the project as a dependency. - */ - project.setContinues("main", ["doubleX"]); - - /* Let's run the project; this method will load imports recursively for you. */ - await project.run("main"); - - const output = project.getOutput("main"); - - expect(output).toBeOkOutput("6", "{a: 6,b: 2}"); - }); - - test("explicit loadImportsRecursively", async () => { - const project = SqProject.create({ linker }); - - project.setSource("main", mainSource); - /* - * Imports will be processed on the first attempt to run, but you can also pre-process them manually without running the source. - * Notice that `doubleX` in "main" source is not available yet, but that's fine because `loadImportsRecursively` only looks at import statements. - * Source code must be syntactically correct, though. - */ - await project.loadImportsRecursively("main"); - - project.setSource("doubleX", doubleXSource); - await project.loadImportsRecursively("doubleX"); - - project.setContinues("main", ["doubleX"]); - - /* Let's run the project */ - await project.runAll(); - const output = project.getOutput("main"); - - /* And see the result and bindings.. */ - expect(output).toBeOkOutput("6", "{a: 6,b: 2}"); - /* Everything as expected */ - }); -}); - -describe("parseImports", () => { - /** - * Let's look at the details of how you can analyze the imports of each source. - */ - const project = SqProject.create({ - linker: { - resolve: (name) => name, - loadSource: () => { - throw new Error("loading not implemented"); - }, - }, - }); - project.setSource( - "main", - ` - import "./common" as common - x=1 - ` - ); - - /* - * Parse import statements - this method is also used by `loadSourcesRecursively`, - * so if you've ever called or or ran your source, imports should be already parsed. - */ - project.parseImports("main"); - - test("getDependencies", () => { - /* Parse imports has set the dependencies. */ - expect(project.getDependencies("main")).toEqual(["./common"]); - /* - * If there were no imports then there would be no dependencies. - * However if there was a syntax error at imports then would be no dependencies also. - * Therefore looking at dependencies is not the right way to load imports. - * `getDependencies` does not distinguish between `setContinues` and explicit import statements. - */ - }); - - test("getImports", () => { - /* Parse imports has set the imports */ - const importIds = project.getImportIds("main"); - if (importIds.ok) { - expect(importIds.value).toEqual(["./common"]); - } else { - throw new Error(importIds.value.toString()); - } - /** - * If the imports cannot be parsed then you get a syntax error. - * Otherwise you get the imports. - * If there is no syntax error then you can load that file and use setSource to add it to the project. - * And so on recursively... - */ - }); - - test("getDependents", () => { - /* - * For any reason, you are able to query what other sources import or depend on the current source. - * But you don't need to use this to execute the projects. - * It is provided for completeness of information. - */ - expect(project.getDependents("main")).toEqual([]); - /* Nothing is depending on or including main */ - }); -}); diff --git a/packages/squiggle-lang/__tests__/helpers/projectHelpers.ts b/packages/squiggle-lang/__tests__/helpers/projectHelpers.ts new file mode 100644 index 0000000000..5b908ba75f --- /dev/null +++ b/packages/squiggle-lang/__tests__/helpers/projectHelpers.ts @@ -0,0 +1,31 @@ +import { SqLinker } from "../../src/index.js"; +import { SqProject } from "../../src/public/SqProject/index.js"; +import { toStringResult } from "../../src/public/SqValue/index.js"; + +export async function runFetchResult(project: SqProject, sourceId: string) { + await project.run(sourceId); + const result = project.getResult(sourceId); + return toStringResult(result); +} + +export async function runFetchBindings(project: SqProject, sourceId: string) { + await project.run(sourceId); + const bindingsR = project.getBindings(sourceId); + if (!bindingsR.ok) { + return `Error(${bindingsR.value})`; + } + return bindingsR.value.toString(); +} + +export function buildNaiveLinker(sources?: { [k: string]: string }) { + const linker: SqLinker = { + resolve: (name) => name, + loadSource: async (id) => { + if (sources && id in sources) { + return sources[id]; + } + throw new Error(`Unknown id ${id}`); + }, + }; + return linker; +} diff --git a/packages/squiggle-lang/src/ast/parse.ts b/packages/squiggle-lang/src/ast/parse.ts index 46f0234775..ad294fa7a2 100644 --- a/packages/squiggle-lang/src/ast/parse.ts +++ b/packages/squiggle-lang/src/ast/parse.ts @@ -17,7 +17,7 @@ export type ParseError = { message: string; }; -export type AST = ASTNode & { +export type AST = Extract & { comments: ASTCommentNode[]; }; @@ -30,6 +30,9 @@ export function parse(expr: string, source: string): ParseResult { grammarSource: source, comments, }); + if (parsed.type !== "Program") { + throw new Error("Expected parse to result in a Program node"); + } parsed.comments = comments; return Result.Ok(parsed); } catch (e) { diff --git a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts index 31df84a915..ff4d7385b1 100644 --- a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts +++ b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts @@ -1,5 +1,5 @@ import { AST, parse } from "../../ast/parse.js"; -import { IRuntimeError } from "../../errors/IError.js"; +import { ICompileError, IRuntimeError } from "../../errors/IError.js"; import { compileAst } from "../../expression/compile.js"; import { ReducerContext } from "../../reducer/context.js"; import { ReducerFn, evaluate } from "../../reducer/index.js"; @@ -8,12 +8,7 @@ import { ImmutableMap } from "../../utility/immutableMap.js"; import * as Result from "../../utility/result.js"; import { Ok, result } from "../../utility/result.js"; import { Value } from "../../value/index.js"; -import { - SqCompileError, - SqError, - SqOtherError, - SqRuntimeError, -} from "../SqError.js"; +import { SqCompileError, SqError, SqRuntimeError } from "../SqError.js"; import { SqLinker } from "../SqLinker.js"; // source -> ast -> imports -> bindings & result @@ -23,17 +18,23 @@ export type RunOutput = { bindings: Bindings; }; -export type ImportBinding = { - sourceId: string; - variable: string; -}; +export type Import = + | { + type: "flat"; // for now, only `continues` can be flattened, but this might change in the future + sourceId: string; + } + | { + type: "named"; + sourceId: string; + variable: string; + }; export class ProjectItem { private readonly sourceId: string; source: string; continues: string[]; ast?: result; - imports?: result; + imports?: result; output?: result; constructor(props: { sourceId: string; source: string }) { @@ -60,7 +61,7 @@ export class ProjectItem { this.output = undefined; } - private setImports(imports: result): void { + private setImports(imports: result): void { this.imports = imports; this.output = undefined; @@ -70,14 +71,28 @@ export class ProjectItem { this.output = undefined; } + // Get the list of all imports and continues ids. getDependencies(): string[] { if (!this.imports?.ok) { // Evaluation will fail later in buildInitialBindings, so it's ok. // It would be better if we parsed imports recursively directly during the run, - // but it's complicated because of asyncs and separation of concerns between this module, SqProject and Topology. + // but it's complicated because of asyncs and separation of concerns between this module and SqProject. return this.continues; } - return [...this.imports.value.map((i) => i.sourceId), ...this.continues]; + return [...this.continues, ...this.imports.value.map((i) => i.sourceId)]; + } + + // Same as `continues`, but recoded to the common format. + // Naming conventions are a bit messy, should we rename `continues` to `implicitImports` everywhere? + getImplicitImports(): Import[] { + const implicitImports: Import[] = []; + for (const continueId of this.continues) { + implicitImports.push({ + type: "flat", + sourceId: continueId, + }); + } + return implicitImports; } setContinues(continues: string[]) { @@ -99,31 +114,51 @@ export class ProjectItem { } const program = this.ast.value; - if (program.type !== "Program") { - throw new Error("Expected Program as top-level AST type"); - } - if (!program.imports.length) { - this.setImports(Ok([])); - return; - } - - if (!linker) { - this.setImports( - Result.Err( - new SqOtherError("Can't use imports when linker is not configured") - ) - ); - return; + const resolvedImports: Import[] = []; + + for (const [file, variable] of program.imports) { + // TODO - this is used for errors, but we should use the entire import statement; + // To fix this, we need to represent each import statement as an AST node. + const location = file.location; + + if (!linker) { + this.setImports( + Result.Err( + new SqCompileError( + new ICompileError( + "Can't use imports when linker is not configured", + location + ) + ) + ) + ); + return; + } + + try { + const sourceId = linker.resolve(file.value, this.sourceId); + resolvedImports.push({ + type: "named", + variable: variable.value, + sourceId, + }); + } catch (e) { + // linker.resolve has failed, that's fatal + this.setImports( + Result.Err( + new SqCompileError( + new ICompileError( + `SqLinker.resolve has failed to resolve ${file.value}`, + location + ) + ) + ) + ); + return; + } } - const resolvedImports: ImportBinding[] = program.imports.map( - ([file, variable]) => ({ - variable: variable.value, - sourceId: linker.resolve(file.value, this.sourceId), - }) - ); - this.setImports(Ok(resolvedImports)); } @@ -171,6 +206,8 @@ export class ProjectItem { try { const wrappedEvaluate = context.evaluate; const asyncEvaluate: ReducerFn = (expression, context) => { + // For now, runs are sync, so this doesn't do anything, but this might change in the future. + // For example, if we decide to yield after each statement. return wrappedEvaluate(expression, context); }; diff --git a/packages/squiggle-lang/src/public/SqProject/Topology.ts b/packages/squiggle-lang/src/public/SqProject/Topology.ts deleted file mode 100644 index db1c3befba..0000000000 --- a/packages/squiggle-lang/src/public/SqProject/Topology.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { SqProject } from "./index.js"; - -// TODO - we should keep the persistent graph and reverse graph of dependencies for better performance. - -// Depth-first search. -function dfs({ - getEdges, - visited = new Set(), - from, - act, -}: { - getEdges: (id: string) => string[]; - visited?: Set; - from: string; - act: (id: string) => void; -}) { - const _dfs = (id: string) => { - if (visited.has(id)) return; - visited.add(id); - for (const dependencyId of getEdges(id)) { - if (visited.has(dependencyId)) continue; - _dfs(dependencyId); - } - act(id); - }; - _dfs(from); -} - -export function getRunOrder(project: SqProject): string[] { - const visited = new Set(); - const runOrder: string[] = []; - for (const sourceId of project.getSourceIds()) { - dfs({ - getEdges: (id) => project.getDependencies(id), - visited, - from: sourceId, - act: (id) => runOrder.push(id), - }); - } - return runOrder; -} - -export function getRunOrderFor(project: SqProject, sourceId: string): string[] { - const result: string[] = []; - dfs({ - getEdges: (id) => project.getDependencies(id), - from: sourceId, - act: (id) => result.push(id), - }); - return result; -} - -// unused -export function getDeepDependencies( - project: SqProject, - sourceId: string -): string[] { - const runOrder = getRunOrderFor(project, sourceId); - - // `sourceId` should be the last item of runOrder, but I didn't want to add an assertion, - // to protect against weird bugs. - return runOrder.filter((id) => id !== sourceId); -} - -function getInverseGraph(project: SqProject) { - const graph = new Map(); - for (const id of project.getSourceIds()) { - const dependencies = project.getDependencies(id); - for (const dependencyId of dependencies) { - const edges = graph.get(dependencyId) ?? []; - edges.push(id); - graph.set(dependencyId, edges); - } - } - return graph; -} - -export function traverseDependents( - project: SqProject, - sourceId: string, - act: (id: string) => void -): void { - // We'll need the inverse graph for this. - const graph = getInverseGraph(project); - - // TODO - it would be more appropriate to do bfs, but dfs+reverse allows to reuse the existing code - dfs({ - getEdges: (id) => graph.get(id) ?? [], - from: sourceId, - act: (id) => { - if (id === sourceId) { - return; - } - act(id); - }, - }); -} - -export function getDependents(project: SqProject, sourceId: string): string[] { - const graph = getInverseGraph(project); - - return graph.get(sourceId) ?? []; -} diff --git a/packages/squiggle-lang/src/public/SqProject/index.ts b/packages/squiggle-lang/src/public/SqProject/index.ts index 790e98d0ab..0d453cdb14 100644 --- a/packages/squiggle-lang/src/public/SqProject/index.ts +++ b/packages/squiggle-lang/src/public/SqProject/index.ts @@ -10,25 +10,21 @@ import { Value, vDict } from "../../value/index.js"; import { SqError, SqOtherError } from "../SqError.js"; import { SqDict } from "../SqValue/SqDict.js"; import { SqValue, wrapValue } from "../SqValue/index.js"; +import { SqValueContext } from "../SqValueContext.js"; import { SqValuePath } from "../SqValuePath.js"; -import { SqValueContext } from "../SqValueContext.js"; -import { ImportBinding, ProjectItem, RunOutput } from "./ProjectItem.js"; import { SqLinker } from "../SqLinker.js"; -import * as Topology from "./Topology.js"; import { SqOutputResult } from "../types.js"; +import { Import, ProjectItem, RunOutput } from "./ProjectItem.js"; function getNeedToRunError() { return new SqOtherError("Need to run"); } -// TODO - pass the the id from which the dependency was imported/continued too -function getMissingDependencyError(id: string) { - return new SqOtherError(`Dependency ${id} is missing`); -} - type Options = { linker?: SqLinker; + stdLib?: Bindings; + environment?: Env; }; export class SqProject { @@ -37,14 +33,23 @@ export class SqProject { private environment: Env; private linker?: SqLinker; // if not present, imports are forbidden + // Direct graph of dependencies is maintained inside each ProjectItem, + // while the inverse one is stored in this variable. + // We need to update it every time we update the list of direct dependencies: + // - when sources are deleted + // - on `setContinues` + // - on `parseImports` + // (this list might be incomplete) + private inverseGraph: Map> = new Map(); + constructor(options?: Options) { this.items = new Map(); - this.stdLib = Library.getStdLib(); - this.environment = defaultEnv; + this.stdLib = options?.stdLib ?? Library.getStdLib(); + this.environment = options?.environment ?? defaultEnv; this.linker = options?.linker; } - static create(options?: { linker: SqLinker }) { + static create(options?: Options) { return new SqProject(options); } @@ -53,6 +58,7 @@ export class SqProject { } setEnvironment(environment: Env) { + // TODO - should we invalidate all outputs? this.environment = environment; } @@ -60,10 +66,6 @@ export class SqProject { return this.stdLib; } - setStdLib(value: Bindings) { - this.stdLib = value; - } - getSourceIds(): string[] { return Array.from(this.items.keys()); } @@ -76,42 +78,64 @@ export class SqProject { return item; } - private touchDependents(sourceId: string) { - Topology.traverseDependents(this, sourceId, (id) => this.clean(id)); - } - - getDependencies(sourceId: string): string[] { - return this.getItem(sourceId).getDependencies(); + private cleanDependents(initialSourceId: string) { + // Traverse dependents recursively and call "clean" on each. + const visited = new Set(); + const inner = (currentSourceId: string) => { + visited.add(currentSourceId); + if (currentSourceId !== initialSourceId) { + this.clean(currentSourceId); + } + for (const sourceId of this.getDependents(currentSourceId)) { + if (visited.has(sourceId)) { + continue; + } + inner(sourceId); + } + }; + inner(initialSourceId); } - getRunOrder(): string[] { - return Topology.getRunOrder(this); + getDependents(sourceId: string): string[] { + return [...(this.inverseGraph.get(sourceId)?.values() ?? [])]; } - getRunOrderFor(sourceId: string): string[] { - return Topology.getRunOrderFor(this, sourceId); + getDependencies(sourceId: string): string[] { + this.parseImports(sourceId); + return this.getItem(sourceId).getDependencies(); } - getDependents(sourceId: string) { - return Topology.getDependents(this, sourceId); + // Removes only explicit imports (not continues). + // Useful on source changes. + private removeImportEdges(fromSourceId: string) { + const item = this.getItem(fromSourceId); + if (item.imports?.ok) { + for (const importData of item.imports.value) { + this.inverseGraph.get(importData.sourceId)?.delete(fromSourceId); + } + } } touchSource(sourceId: string) { + this.removeImportEdges(sourceId); this.getItem(sourceId).touchSource(); - this.touchDependents(sourceId); + this.cleanDependents(sourceId); } setSource(sourceId: string, value: string) { if (this.items.has(sourceId)) { + this.removeImportEdges(sourceId); this.getItem(sourceId).setSource(value); + this.cleanDependents(sourceId); } else { this.items.set(sourceId, new ProjectItem({ sourceId, source: value })); } - this.touchDependents(sourceId); } removeSource(sourceId: string) { - this.touchDependents(sourceId); + this.cleanDependents(sourceId); + this.removeImportEdges(sourceId); + this.setContinues(sourceId, []); this.items.delete(sourceId); } @@ -135,9 +159,7 @@ export class SqProject { return Result.fmap(imports, (imports) => imports.map((i) => i.sourceId)); } - getImports( - sourceId: string - ): Result.result | undefined { + getImports(sourceId: string): Result.result | undefined { return this.getItem(sourceId).imports; } @@ -146,8 +168,17 @@ export class SqProject { } setContinues(sourceId: string, continues: string[]): void { + for (const continueId of this.getContinues(sourceId)) { + this.inverseGraph.get(continueId)?.delete(sourceId); + } + for (const continueId of continues) { + if (!this.inverseGraph.has(continueId)) { + this.inverseGraph.set(continueId, new Set()); + } + this.inverseGraph.get(continueId)?.add(sourceId); + } this.getItem(sourceId).setContinues(continues); - this.touchDependents(sourceId); + this.cleanDependents(sourceId); } private getInternalOutput( @@ -156,9 +187,21 @@ export class SqProject { return this.getItem(sourceId).output ?? Result.Err(getNeedToRunError()); } - parseImports(sourceId: string): void { + private parseImports(sourceId: string): void { // linker can be undefined; in this case parseImports will fail if there are any imports - this.getItem(sourceId).parseImports(this.linker); + const item = this.getItem(sourceId); + if (item.imports) { + // already set, shortcut so that we don't have to update `inverseGraph` + return; + } + + item.parseImports(this.linker); + for (const dependencyId of item.getDependencies()) { + if (!this.inverseGraph.has(dependencyId)) { + this.inverseGraph.set(dependencyId, new Set()); + } + this.inverseGraph.get(dependencyId)?.add(sourceId); + } } getOutput(sourceId: string): SqOutputResult { @@ -181,8 +224,7 @@ export class SqProject { } const ast = astR.value; - const lastStatement = - ast.type === "Program" ? ast.statements.at(-1) : undefined; + const lastStatement = ast.statements.at(-1); const hasEndExpression = !!lastStatement && !isBindingStatement(lastStatement); @@ -230,154 +272,106 @@ export class SqProject { return Result.fmap(this.getOutput(sourceId), ({ bindings }) => bindings); } - private buildExternals(sourceId: string): Result.result { - const continues = this.getContinues(sourceId); - - // We start from stdLib and add more bindings on top of it. - const namespacesToMerge = [this.getStdLib()]; - - // First, merge continues. - for (const continueId of continues) { - if (!this.items.has(continueId)) { - return Result.Err(getMissingDependencyError(continueId)); - } - const outputR = this.getInternalOutput(continueId); - if (!outputR.ok) { - return outputR; - } - - namespacesToMerge.push(outputR.value.bindings); - } - let externals: Bindings = ImmutableMap().merge( - ...namespacesToMerge - ); - - // Second, merge imports. + private async buildExternals( + sourceId: string, + pendingIds: Set + ): Promise> { this.parseImports(sourceId); const rImports = this.getImports(sourceId); if (!rImports) { // Shouldn't happen, we just called parseImports. - return Result.Err(new SqOtherError("Internal logic error")); + throw new Error("Internal logic error"); } if (!rImports.ok) { - // Shouldn't happen, getImports fail only if parse failed. + // There's something wrong with imports, that's fatal. return rImports; } - for (const importBinding of rImports.value) { - if (!this.items.has(importBinding.sourceId)) { - return Result.Err(getMissingDependencyError(importBinding.sourceId)); - } - const importOutputR = this.getItem(importBinding.sourceId).output; - if (!importOutputR) { - return Result.Err(getNeedToRunError()); - } - if (!importOutputR.ok) { - return importOutputR; - } - - // TODO - check for collisions? - externals = externals.set( - importBinding.variable, - vDict(importOutputR.value.bindings) - ); - } - return Result.Ok(externals); - } - private async doLinkAndRun(sourceId: string): Promise { - const rExternals = this.buildExternals(sourceId); - - if (rExternals.ok) { - const context = createContext(this.getEnvironment()); + // We start from stdLib and add imports on top of it. + let externals: Bindings = ImmutableMap().merge( + this.getStdLib() + ); - await this.getItem(sourceId).run(context, rExternals.value); - } else { - this.getItem(sourceId).failRun(rExternals.value); - } - } + // Now, let's process everything and populate our externals bindings. + for (const importBinding of [ + ...this.getItem(sourceId).getImplicitImports(), // first, inject all variables from `continues` + ...rImports.value, // then bind all explicit imports + ]) { + if (!this.items.has(importBinding.sourceId)) { + if (!this.linker) { + throw new Error( + `Can't load source for ${importBinding.sourceId}, linker is missing` + ); + } - private async runIds(sourceIds: string[]) { - let error: SqError | undefined; - for (const sourceId of sourceIds) { - const cachedOutput = this.getItem(sourceId).output; - if (cachedOutput) { - // already ran - if (!cachedOutput.ok) { - error = cachedOutput.value; + // We have got one of the new imports. + // Let's load it and add it to the project. + let newSource: string; + try { + newSource = await this.linker.loadSource(importBinding.sourceId); + } catch (e) { + return Result.Err( + new SqOtherError(`Failed to load import ${importBinding.sourceId}`) + ); } - continue; + this.setSource(importBinding.sourceId, newSource); } - if (error) { - this.getItem(sourceId).failRun(error); - continue; + if (pendingIds.has(importBinding.sourceId)) { + // Oh we have already visited this source. There is an import cycle. + return Result.Err( + new SqOtherError(`Cyclic import ${importBinding.sourceId}`) + ); } - await this.doLinkAndRun(sourceId); - const output = this.getItem(sourceId).output; - if (output && !output.ok) { - error = output.value; + await this.innerRun(importBinding.sourceId, pendingIds); + const outputR = this.getInternalOutput(importBinding.sourceId); + if (!outputR.ok) { + return outputR; } - } - } - - async runAll() { - // preload all imports - if (this.linker) { - for (const id of this.getSourceIds()) { - await this.loadImportsRecursively(id); + const bindings = outputR.value.bindings; + + // TODO - check for name collisions? + switch (importBinding.type) { + case "flat": + externals = externals.merge(bindings); + break; + case "named": + externals = externals.set(importBinding.variable, vDict(bindings)); + break; + // exhaustiveness check for TypeScript + default: + throw new Error(`Internal error, ${importBinding satisfies never}`); } } - // we intentionally call `getRunOrder` again because the order could've changed after we analyzed imports - await this.runIds(this.getRunOrder()); + return Result.Ok(externals); } - async run(sourceId: string) { - if (this.linker) { - await this.loadImportsRecursively(sourceId); - } - await this.runIds(this.getRunOrderFor(sourceId)); - } + private async innerRun(sourceId: string, pendingIds: Set) { + pendingIds.add(sourceId); - async loadImportsRecursively(initialSourceName: string) { - const linker = this.linker; - if (!linker) { - return; - } + const cachedOutput = this.getItem(sourceId).output; + if (!cachedOutput) { + const rExternals = await this.buildExternals(sourceId, pendingIds); - const visited = new Set(); - const inner = async (sourceName: string) => { - if (visited.has(sourceName)) { - // Oh we have already visited this source. There is an import cycle. - throw new Error(`Cyclic import ${sourceName}`); - } - visited.add(sourceName); - // Let's parse the imports and dive into them. - this.parseImports(sourceName); - const rImportIds = this.getImportIds(sourceName); - if (!rImportIds.ok) { - // Maybe there is an import syntax error. - throw new Error(rImportIds.value.toString()); + if (!rExternals.ok) { + this.getItem(sourceId).failRun(rExternals.value); + } else { + const context = createContext(this.getEnvironment()); + await this.getItem(sourceId).run(context, rExternals.value); } + } - for (const newImportId of rImportIds.value) { - if (this.getSource(newImportId) === undefined) { - // We have got one of the new imports. - // Let's load it and add it to the project. - const newSource = await linker.loadSource(newImportId); - this.setSource(newImportId, newSource); - } - // The new source is loaded and added to the project. - // Of course the new source might have imports too. - // Let's recursively load them. - await this.loadImportsRecursively(newImportId); - } - }; - await inner(initialSourceName); + pendingIds.delete(sourceId); + } + + async run(sourceId: string) { + await this.innerRun(sourceId, new Set()); } + // Helper method for "Find in Editor" feature findValuePathByOffset( sourceId: string, offset: number From c5dad02a6902138fdda1bba88e38693bfbaf5651 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Sun, 15 Oct 2023 13:50:40 -0600 Subject: [PATCH 06/10] update triangle icon; menu dropdown in playground --- .../LeftPlaygroundPanel/index.tsx | 42 ++++++++++++++++++- .../components/SquigglePlayground/index.tsx | 4 +- .../components/SquiggleViewer/VariableBox.tsx | 16 +++---- .../ui/PanelWithToolbar/ToolbarItem.tsx | 7 ++-- .../components/src/lib/hooks/useSquiggle.ts | 5 ++- .../[slug]/EditSquiggleSnippetModel.tsx | 25 +++++++---- .../layout/RootLayout/DropdownWithArrow.tsx | 2 +- .../src/public/SqProject/index.ts | 3 ++ packages/ui/src/components/TextTooltip.tsx | 37 ++++++++-------- packages/ui/src/icons/TriangleIcon.tsx | 3 +- .../src/VersionedSquigglePlayground.tsx | 5 +++ 11 files changed, 106 insertions(+), 43 deletions(-) diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx index 69cd1d1ce7..c2a7f717f8 100644 --- a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx +++ b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx @@ -8,7 +8,15 @@ import { } from "react"; import { SqProject, SqValuePath } from "@quri/squiggle-lang"; -import { Bars3CenterLeftIcon } from "@quri/ui"; +import { + AdjustmentsVerticalIcon, + Bars3CenterLeftIcon, + Dropdown, + DropdownMenu, + DropdownMenuActionItem, + TextTooltip, + TriangleIcon, +} from "@quri/ui"; import { SquiggleOutput, @@ -42,6 +50,8 @@ type Props = { }): void; /* Allows to inject extra buttons to the left panel's menu, e.g. share button on the website, or save button in Squiggle Hub. */ renderExtraControls?: RenderExtraControls; + /* Allows to inject extra items to the left panel's dropdown menu. */ + renderExtraDropdownItems?: RenderExtraControls; renderExtraModal?: Parameters[0]["renderModal"]; onViewValuePath?: (path: SqValuePath) => void; }; @@ -111,7 +121,35 @@ export const LeftPlaygroundPanel = forwardRef( icon={Bars3CenterLeftIcon} onClick={editorRef.current?.format} /> - openModal("settings")} /> + ( + + +
+ editorRef.current?.format} + /> +
+
+ openModal("settings")} + /> + {props.renderExtraDropdownItems?.({ openModal })} +
+ )} + > + + Menu + +
{props.renderExtraControls?.({ openModal })}
diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index 367d08e781..9bdb7bbb7c 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -42,7 +42,7 @@ type PlaygroundProps = { height?: CSSProperties["height"]; } & Pick< Parameters[0], - "renderExtraControls" | "renderExtraModal" + "renderExtraControls" | "renderExtraDropdownItems" | "renderExtraModal" > & PartialPlaygroundSettings; @@ -61,6 +61,7 @@ export const SquigglePlayground: React.FC = (props) => { onCodeChange, onSettingsChange, renderExtraControls, + renderExtraDropdownItems, renderExtraModal, height = 500, ...defaultSettings @@ -119,6 +120,7 @@ export const SquigglePlayground: React.FC = (props) => { onSettingsChange={handleSettingsChange} onOutputChange={setOutput} renderExtraControls={renderExtraControls} + renderExtraDropdownItems={renderExtraDropdownItems} renderExtraModal={renderExtraModal} onViewValuePath={(path) => rightPanelRef.current?.viewValuePath(path)} ref={leftPanelRef} diff --git a/packages/components/src/components/SquiggleViewer/VariableBox.tsx b/packages/components/src/components/SquiggleViewer/VariableBox.tsx index f3fed5ab81..9e0769f99a 100644 --- a/packages/components/src/components/SquiggleViewer/VariableBox.tsx +++ b/packages/components/src/components/SquiggleViewer/VariableBox.tsx @@ -131,10 +131,10 @@ export const VariableBox: FC = ({ const triangleToggle = () => (
- +
); @@ -185,11 +185,11 @@ export const VariableBox: FC = ({ renderSettingsMenu?.({ onChange: forceUpdate }); const leftCollapseBorder = () => ( -
-
-
+
+
); @@ -203,7 +203,7 @@ export const VariableBox: FC = ({ diff --git a/packages/components/src/components/ui/PanelWithToolbar/ToolbarItem.tsx b/packages/components/src/components/ui/PanelWithToolbar/ToolbarItem.tsx index f6f489defc..15a7997101 100644 --- a/packages/components/src/components/ui/PanelWithToolbar/ToolbarItem.tsx +++ b/packages/components/src/components/ui/PanelWithToolbar/ToolbarItem.tsx @@ -7,7 +7,6 @@ type Props = { icon?: ComponentType<{ className?: string }>; children?: ReactNode; iconClasses?: string; - iconColorClasses?: string; iconSpin?: boolean; onClick?: () => void; tooltipText?: string; @@ -16,7 +15,7 @@ type Props = { export const ToolbarItem: FC = ({ icon: Icon, - iconColorClasses, + iconClasses, iconSpin, className, onClick, @@ -41,8 +40,8 @@ export const ToolbarItem: FC = ({ diff --git a/packages/components/src/lib/hooks/useSquiggle.ts b/packages/components/src/lib/hooks/useSquiggle.ts index 291023047b..e292c0118c 100644 --- a/packages/components/src/lib/hooks/useSquiggle.ts +++ b/packages/components/src/lib/hooks/useSquiggle.ts @@ -27,6 +27,7 @@ export type ProjectExecutionProps = { export type SquiggleArgs = { code: string; + sourceId?: string; executionId?: number; } & (StandaloneExecutionProps | ProjectExecutionProps); @@ -58,7 +59,9 @@ const defaultContinues: string[] = []; export function useSquiggle(args: SquiggleArgs): UseSquiggleOutput { // random; https://stackoverflow.com/a/12502559 // TODO - React.useId? - const sourceId = useMemo(() => Math.random().toString(36).slice(2), []); + const sourceId = useMemo(() => { + return args.sourceId ?? Math.random().toString(36).slice(2); + }, [args.sourceId]); const projectArg = "project" in args ? args.project : undefined; const environment = "environment" in args ? args.environment : undefined; diff --git a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index a2bb50b2dd..173216d609 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx @@ -3,7 +3,13 @@ import { FormProvider, useFieldArray } from "react-hook-form"; import { graphql, useFragment } from "react-relay"; import { PlaygroundToolbarItem } from "@quri/squiggle-components"; -import { Button, LinkIcon, TextTooltip } from "@quri/ui"; +import { + Button, + DropdownMenuActionItem, + DropdownMenuHeader, + LinkIcon, + TextTooltip, +} from "@quri/ui"; import { SquigglePlaygroundVersionPicker, SquiggleVersionShower, @@ -165,15 +171,20 @@ export const EditSquiggleSnippetModel: FC = ({ modelRef }) => { height={height ?? "100vh"} onCodeChange={onCodeChange} defaultCode={defaultCode} - renderExtraControls={({ openModal }) => ( -
- {model.isEditable && ( - + model.isEditable ? ( + <> + Experimental + openModal("exports")} /> - )} + + ) : null + } + renderExtraControls={({ openModal }) => ( +
{model.isEditable ? ( = ({ text }) => (
{text} - +
); diff --git a/packages/squiggle-lang/src/public/SqProject/index.ts b/packages/squiggle-lang/src/public/SqProject/index.ts index 0d453cdb14..6d46b8a6ad 100644 --- a/packages/squiggle-lang/src/public/SqProject/index.ts +++ b/packages/squiggle-lang/src/public/SqProject/index.ts @@ -133,6 +133,9 @@ export class SqProject { } removeSource(sourceId: string) { + if (!this.items.has(sourceId)) { + return; + } this.cleanDependents(sourceId); this.removeImportEdges(sourceId); this.setContinues(sourceId, []); diff --git a/packages/ui/src/components/TextTooltip.tsx b/packages/ui/src/components/TextTooltip.tsx index b9987c6c9b..5c97c594f3 100644 --- a/packages/ui/src/components/TextTooltip.tsx +++ b/packages/ui/src/components/TextTooltip.tsx @@ -11,6 +11,7 @@ import { useInteractions, useRole, Placement, + FloatingPortal, } from "@floating-ui/react"; type Props = { @@ -49,23 +50,25 @@ export const TextTooltip: FC = ({ )} {isOpen && ( - -
{text}
-
+ + +
{text}
+
+
)}
diff --git a/packages/ui/src/icons/TriangleIcon.tsx b/packages/ui/src/icons/TriangleIcon.tsx index bebc56f084..c6fdca5a6e 100644 --- a/packages/ui/src/icons/TriangleIcon.tsx +++ b/packages/ui/src/icons/TriangleIcon.tsx @@ -1,9 +1,8 @@ import { FC } from "react"; import { Icon, IconProps } from "./Icon.js"; -// From heroicons export const TriangleIcon: FC = (props) => ( - + ); diff --git a/packages/versioned-playground/src/VersionedSquigglePlayground.tsx b/packages/versioned-playground/src/VersionedSquigglePlayground.tsx index a0a2dfaba3..99a18116ca 100644 --- a/packages/versioned-playground/src/VersionedSquigglePlayground.tsx +++ b/packages/versioned-playground/src/VersionedSquigglePlayground.tsx @@ -26,6 +26,10 @@ const playgroundByVersion = { type CommonProps = { defaultCode?: string; distributionChartSettings?: { showSummary?: boolean }; // simplified + // available since 0.8.6 + renderExtraDropdownItems?: (options: { + openModal: (name: string) => void; + }) => ReactNode; renderExtraControls?: (options: { openModal: (name: string) => void; }) => ReactNode; @@ -83,6 +87,7 @@ export const VersionedSquigglePlayground: FC = ({ // Playground props shape can change in the future and this allows us to catch those cases early. defaultCode={props.defaultCode} distributionChartSettings={props.distributionChartSettings} + renderExtraDropdownItems={props.renderExtraDropdownItems} renderExtraControls={props.renderExtraControls} renderExtraModal={props.renderExtraModal} onCodeChange={props.onCodeChange} From 837c676534deafae504ec9d23276a413689c261d Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Sun, 15 Oct 2023 14:27:49 -0600 Subject: [PATCH 07/10] mock project info page --- .../components/src/components/CodeEditor.tsx | 37 +++++++-------- .../LeftPlaygroundPanel/ProjectInfoModal.tsx | 18 +++++++ .../LeftPlaygroundPanel/SettingsMenuItem.tsx | 17 ------- .../LeftPlaygroundPanel/index.tsx | 47 ++++++++++++++----- .../components/SquigglePlayground/index.tsx | 3 ++ .../[slug]/EditSquiggleSnippetModel.tsx | 3 +- .../src/VersionedSquigglePlayground.tsx | 16 ++++--- 7 files changed, 85 insertions(+), 56 deletions(-) create mode 100644 packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx delete mode 100644 packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/SettingsMenuItem.tsx diff --git a/packages/components/src/components/CodeEditor.tsx b/packages/components/src/components/CodeEditor.tsx index 3160e98f3a..715abae0c5 100644 --- a/packages/components/src/components/CodeEditor.tsx +++ b/packages/components/src/components/CodeEditor.tsx @@ -67,6 +67,7 @@ interface CodeEditorProps { export type CodeEditorHandle = { format(): void; scrollTo(position: number): void; + viewCurrentPosition(): void; }; const compTheme = new Compartment(); @@ -183,7 +184,21 @@ export const CodeEditor = forwardRef( view?.focus(); }; - useImperativeHandle(ref, () => ({ format, scrollTo })); + const viewCurrentPosition = useCallback(() => { + if (!onViewValuePath || !view || !sourceId) { + return; + } + const offset = view.state.selection.main.to; + if (offset === undefined) { + return; + } + const valuePathResult = project.findValuePathByOffset(sourceId, offset); + if (valuePathResult.ok) { + onViewValuePath(valuePathResult.value); + } + }, [onViewValuePath, project, sourceId, view]); + + useImperativeHandle(ref, () => ({ format, scrollTo, viewCurrentPosition })); useEffect(() => { view?.dispatch({ @@ -284,30 +299,14 @@ export const CodeEditor = forwardRef( { key: "Alt-Shift-v", run: () => { - if (!onViewValuePath) { - return true; - } - const offset = view.state.selection.main.to; - if (offset === undefined) { - return true; - } - if (sourceId === undefined) { - return true; - } - const valuePathResult = project.findValuePathByOffset( - sourceId, - offset - ); - if (valuePathResult.ok) { - onViewValuePath(valuePathResult.value); - } + viewCurrentPosition(); return true; }, }, ]) ), }); - }, [onViewValuePath, project, sourceId, view]); + }, [view, viewCurrentPosition]); useEffect(() => { if (!view) return; diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx new file mode 100644 index 0000000000..379dae841e --- /dev/null +++ b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx @@ -0,0 +1,18 @@ +import { FC } from "react"; + +import { SqProject } from "@quri/squiggle-lang"; + +// Passing settings as prop sacrifices react-hook-form performance optimizations, but that's not very important. +export const ProjectInfoModal: FC<{ + project: SqProject; +}> = ({ project }) => { + return ( +
+
    + {project.getSourceIds().map((sourceId) => ( +
  • {sourceId}
  • + ))} +
+
+ ); +}; diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/SettingsMenuItem.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/SettingsMenuItem.tsx deleted file mode 100644 index 0780d9f8da..0000000000 --- a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/SettingsMenuItem.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { FC } from "react"; - -import { AdjustmentsVerticalIcon } from "@quri/ui"; - -import { ToolbarItem } from "../../ui/PanelWithToolbar/ToolbarItem.js"; - -export const SetttingsMenuItem: FC<{ - onClick(): void; -}> = ({ onClick }) => { - return ( - - ); -}; diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx index c2a7f717f8..19cc735eed 100644 --- a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx +++ b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx @@ -32,7 +32,7 @@ import { PanelWithToolbar } from "../../ui/PanelWithToolbar/index.js"; import { AutorunnerMenuItem } from "./AutorunnerMenuItem.js"; import { GlobalSettingsModal } from "./GlobalSettingsModal.js"; import { RunMenuItem } from "./RunMenuItem.js"; -import { SetttingsMenuItem } from "./SettingsMenuItem.js"; +import { ProjectInfoModal } from "./ProjectInfoModal.js"; export type RenderExtraControls = (props: { openModal: (name: string) => void; @@ -41,6 +41,7 @@ export type RenderExtraControls = (props: { type Props = { project: SqProject; defaultCode?: string; + sourceId?: string; onCodeChange?(code: string): void; settings: PlaygroundSettings; onSettingsChange(settings: PlaygroundSettings): void; @@ -76,6 +77,7 @@ export const LeftPlaygroundPanel = forwardRef( const [squiggleOutput, { project, isRunning, sourceId }] = useSquiggle({ code: runnerState.renderedCode, project: props.project, + sourceId: props.sourceId, executionId: runnerState.executionId, }); @@ -142,6 +144,20 @@ export const LeftPlaygroundPanel = forwardRef( icon={AdjustmentsVerticalIcon} onClick={() => openModal("settings")} /> + + { + // experimental, won't always work, so disabled for now + /* editorRef.current?.viewCurrentPosition()} + /> */ + } + openModal("project-info")} + /> {props.renderExtraDropdownItems?.({ openModal })} )} @@ -177,18 +193,25 @@ export const LeftPlaygroundPanel = forwardRef( ); const renderModal = (modalName: string) => { - if (modalName === "settings") { - return { - title: "Configuration", - body: ( - - ), - }; + switch (modalName) { + case "settings": + return { + title: "Configuration", + body: ( + + ), + }; + case "project-info": + return { + title: "Project Info", + body: , + }; + default: + return props.renderExtraModal?.(modalName); } - return props.renderExtraModal?.(modalName); }; return ( diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index 9bdb7bbb7c..7b0a462817 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -34,6 +34,7 @@ type PlaygroundProps = { * So updates to it are completely ignored. */ defaultCode?: string; + sourceId?: string; linker?: SqLinker; onCodeChange?(code: string): void; /* When settings change */ @@ -64,6 +65,7 @@ export const SquigglePlayground: React.FC = (props) => { renderExtraDropdownItems, renderExtraModal, height = 500, + sourceId, ...defaultSettings } = props; @@ -115,6 +117,7 @@ export const SquigglePlayground: React.FC = (props) => { = ({ modelRef }) => {
= ({ modelRef }) => { ) : null } - renderExtraControls={({ openModal }) => ( + renderExtraControls={() => (
{model.isEditable ? ( void; - }) => ReactNode; renderExtraControls?: (options: { openModal: (name: string) => void; }) => ReactNode; @@ -44,6 +40,11 @@ type CommonProps = { distributionChartSettings: { showSummary: boolean }; }) => void; height?: string | number; + // available since 0.8.6 + sourceId?: string; + renderExtraDropdownItems?: (options: { + openModal: (name: string) => void; + }) => ReactNode; }; // supported only in modern playgrounds @@ -87,15 +88,16 @@ export const VersionedSquigglePlayground: FC = ({ // Playground props shape can change in the future and this allows us to catch those cases early. defaultCode={props.defaultCode} distributionChartSettings={props.distributionChartSettings} - renderExtraDropdownItems={props.renderExtraDropdownItems} renderExtraControls={props.renderExtraControls} renderExtraModal={props.renderExtraModal} onCodeChange={props.onCodeChange} onSettingsChange={props.onSettingsChange} height={props.height} - // older playgrounds don't support this, it'll be ignored, that's fine - // (TODO: why TypeScript doesn't error on this, if `linker` prop doesn't exist in 0.8.5? no idea) + // older playgrounds don't support these, it'll be ignored, that's fine + // (why TypeScript doesn't error on this, these props didn't exist in 0.8.5? no idea) linker={props.linker} + renderExtraDropdownItems={props.renderExtraDropdownItems} + sourceId={props.sourceId} /> ); From 44fcb7e6e481d51c8d6353b6938aee5ab821dbe4 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Sun, 15 Oct 2023 15:11:16 -0600 Subject: [PATCH 08/10] dependency graph modal --- packages/components/package.json | 1 + .../AutorunnerMenuItem.tsx | 5 ++-- .../DependencyGraphModal.tsx | 25 ++++++++++++++++ .../LeftPlaygroundPanel/ProjectInfoModal.tsx | 18 ------------ .../LeftPlaygroundPanel/index.tsx | 29 +++++-------------- .../components/src/components/ui/Mermaid.tsx | 26 +++++++++++++++++ packages/ui/src/icons/HeroIcons.tsx | 10 +++++++ packages/ui/src/index.ts | 1 + pnpm-lock.yaml | 3 ++ 9 files changed, 77 insertions(+), 41 deletions(-) create mode 100644 packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/DependencyGraphModal.tsx delete mode 100644 packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx create mode 100644 packages/components/src/components/ui/Mermaid.tsx diff --git a/packages/components/package.json b/packages/components/package.json index ee8e7605b8..2aff93b425 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -32,6 +32,7 @@ "d3": "^7.8.5", "framer-motion": "^10.16.4", "lodash": "^4.17.21", + "mermaid": "^10.5.0", "prettier": "^3.0.3", "react": "^18.2.0", "react-error-boundary": "^4.0.11", diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/AutorunnerMenuItem.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/AutorunnerMenuItem.tsx index 8e1ac780be..c915ed8f45 100644 --- a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/AutorunnerMenuItem.tsx +++ b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/AutorunnerMenuItem.tsx @@ -1,4 +1,5 @@ import React from "react"; +import clsx from "clsx"; import { BoltIcon, PauseIcon } from "@quri/ui"; @@ -15,10 +16,10 @@ export const AutorunnerMenuItem: React.FC = ({ aria-checked={autorunMode} > setAutorunMode(!autorunMode)} - className={!autorunMode ? "opacity-60" : ""} > Autorun diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/DependencyGraphModal.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/DependencyGraphModal.tsx new file mode 100644 index 0000000000..c4e64d44df --- /dev/null +++ b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/DependencyGraphModal.tsx @@ -0,0 +1,25 @@ +import { FC, lazy } from "react"; + +import { SqProject } from "@quri/squiggle-lang"; + +const Mermaid = lazy(() => import("../../ui/Mermaid.js")); + +export const DependencyGraphModal: FC<{ + project: SqProject; +}> = ({ project }) => { + const sourceIds = project.getSourceIds(); + + let diagram = "graph TD\n"; + for (const sourceId of sourceIds) { + diagram += `${sourceId}\n`; // for sources that don't have any dependencies + for (const dependencyId of project.getDependencies(sourceId)) { + diagram += `${sourceId} --> ${dependencyId}\n`; + } + } + + return ( +
+ {diagram} +
+ ); +}; diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx deleted file mode 100644 index 379dae841e..0000000000 --- a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/ProjectInfoModal.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { FC } from "react"; - -import { SqProject } from "@quri/squiggle-lang"; - -// Passing settings as prop sacrifices react-hook-form performance optimizations, but that's not very important. -export const ProjectInfoModal: FC<{ - project: SqProject; -}> = ({ project }) => { - return ( -
-
    - {project.getSourceIds().map((sourceId) => ( -
  • {sourceId}
  • - ))} -
-
- ); -}; diff --git a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx index 19cc735eed..fc9eef89e2 100644 --- a/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx +++ b/packages/components/src/components/SquigglePlayground/LeftPlaygroundPanel/index.tsx @@ -14,7 +14,7 @@ import { Dropdown, DropdownMenu, DropdownMenuActionItem, - TextTooltip, + PuzzleIcon, TriangleIcon, } from "@quri/ui"; @@ -30,9 +30,9 @@ import { PlaygroundSettings } from "../../PlaygroundSettings.js"; import { ToolbarItem } from "../../ui/PanelWithToolbar/ToolbarItem.js"; import { PanelWithToolbar } from "../../ui/PanelWithToolbar/index.js"; import { AutorunnerMenuItem } from "./AutorunnerMenuItem.js"; +import { DependencyGraphModal } from "./DependencyGraphModal.js"; import { GlobalSettingsModal } from "./GlobalSettingsModal.js"; import { RunMenuItem } from "./RunMenuItem.js"; -import { ProjectInfoModal } from "./ProjectInfoModal.js"; export type RenderExtraControls = (props: { openModal: (name: string) => void; @@ -126,19 +126,6 @@ export const LeftPlaygroundPanel = forwardRef( ( - -
- editorRef.current?.format} - /> -
-
( /> */ } openModal("project-info")} + title="Dependency Graph" + icon={PuzzleIcon} + onClick={() => openModal("dependency-graph")} /> {props.renderExtraDropdownItems?.({ openModal })}
@@ -204,10 +191,10 @@ export const LeftPlaygroundPanel = forwardRef( /> ), }; - case "project-info": + case "dependency-graph": return { - title: "Project Info", - body: , + title: "Dependency Graph", + body: , }; default: return props.renderExtraModal?.(modalName); diff --git a/packages/components/src/components/ui/Mermaid.tsx b/packages/components/src/components/ui/Mermaid.tsx new file mode 100644 index 0000000000..341b7373a3 --- /dev/null +++ b/packages/components/src/components/ui/Mermaid.tsx @@ -0,0 +1,26 @@ +import { FC, useEffect, useRef } from "react"; +import mermaid from "mermaid"; + +type Props = { + children: string; +}; + +const Mermaid: FC = ({ children }) => { + const ref = useRef(null); + useEffect(() => { + if (!ref.current) { + return; + } + mermaid.run({ + nodes: [ref.current], + }); + }, []); + + return ( +
+ {children} +
+ ); +}; + +export default Mermaid; // default export, because this component is lazy-loaded diff --git a/packages/ui/src/icons/HeroIcons.tsx b/packages/ui/src/icons/HeroIcons.tsx index 400e9a38ce..59b82864b4 100644 --- a/packages/ui/src/icons/HeroIcons.tsx +++ b/packages/ui/src/icons/HeroIcons.tsx @@ -358,3 +358,13 @@ export const HelpIcon: FC = (props) => ( /> ); + +export const PuzzleIcon: FC = (props) => ( + + + +); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d543528f00..fb6df45750 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -81,6 +81,7 @@ export { UserIcon, WrenchIcon, HelpIcon, + PuzzleIcon, } from "./icons/HeroIcons.js"; export type { IconProps } from "./icons/Icon.js"; export { RefreshIcon } from "./icons/RefreshIcon.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7c8be8016..ce43b9c3c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + mermaid: + specifier: ^10.5.0 + version: 10.5.0 prettier: specifier: ^3.0.3 version: 3.0.3 From 4ec44a26b9c7a4f5d5dbc3fec6fb8dde85289036 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Sun, 22 Oct 2023 17:07:38 -0600 Subject: [PATCH 09/10] export keyword support --- .../src/languageSupport/squiggle.grammar | 5 ++- .../src/languageSupport/squiggle.ts | 1 + packages/prettier-plugin/src/printer.ts | 2 + .../SqProject/SqProject_imports_test.ts | 10 ++--- .../__tests__/SqProject/SqProject_test.ts | 12 +++++- .../SqProject_tutorial_2_imports_test.ts | 16 ++++---- .../squiggle-lang/__tests__/ast/parse_test.ts | 5 +++ packages/squiggle-lang/__tests__/cli_test.ts | 4 +- .../__tests__/fixtures/imports/lib.squiggle | 2 +- .../__tests__/fixtures/imports/lib2.squiggle | 2 +- .../fixtures/relative-imports/common.squiggle | 2 +- .../relative-imports/dir1/common.squiggle | 2 +- .../relative-imports/dir1/lib.squiggle | 2 +- .../relative-imports/dir2/common.squiggle | 2 +- .../relative-imports/dir2/lib.squiggle | 2 +- .../__tests__/helpers/projectHelpers.ts | 9 ++++ .../__tests__/reducer/exports_test.ts | 9 ++++ packages/squiggle-lang/src/ast/parse.ts | 4 +- .../squiggle-lang/src/ast/peggyHelpers.ts | 19 +++++++-- .../squiggle-lang/src/ast/peggyParser.peggy | 10 ++--- .../squiggle-lang/src/expression/compile.ts | 20 ++++++++- .../squiggle-lang/src/expression/index.ts | 32 ++++++++++++--- .../src/public/SqProject/ProjectItem.ts | 20 +++++++-- .../src/public/SqProject/index.ts | 41 +++++++++++-------- packages/squiggle-lang/src/public/types.ts | 1 + packages/squiggle-lang/src/reducer/index.ts | 6 +-- packages/textmate-grammar/package.json | 20 ++++++++- .../src/squiggle.tmLanguage.yaml | 4 +- packages/textmate-grammar/tests/main.squiggle | 9 ++++ packages/versioned-playground/tsconfig.json | 1 + pnpm-lock.yaml | 25 ++++++++++- 31 files changed, 232 insertions(+), 67 deletions(-) create mode 100644 packages/squiggle-lang/__tests__/reducer/exports_test.ts create mode 100644 packages/textmate-grammar/tests/main.squiggle diff --git a/packages/components/src/languageSupport/squiggle.grammar b/packages/components/src/languageSupport/squiggle.grammar index f807e05729..d1495eb682 100644 --- a/packages/components/src/languageSupport/squiggle.grammar +++ b/packages/components/src/languageSupport/squiggle.grammar @@ -40,7 +40,7 @@ commaSep { // when trailing comma is allowed commaSep1 { "" | content ("," content?)* } -Binding { VariableName { identifier } "=" expression } +Binding { export? VariableName { identifier } "=" expression } LambdaParameter { LambdaParameterName { identifier } (":" expression)? @@ -50,7 +50,7 @@ LambdaArgs { () | LambdaParameter ("," LambdaParameter)* } -FunDeclaration { FunctionName { identifier } ~callOrDeclaration "(" LambdaArgs ")" "=" expression } +FunDeclaration { export? FunctionName { identifier } ~callOrDeclaration "(" LambdaArgs ")" "=" expression } statement[@isGroup="Statement"] { Binding @@ -111,6 +111,7 @@ if { kw<"if"> } then { kw<"then"> } else { kw<"else"> } import { kw<"import"> } +export { kw<"export"> } as { kw<"as"> } @skip { spaces | newline | Comment | BlockComment } diff --git a/packages/components/src/languageSupport/squiggle.ts b/packages/components/src/languageSupport/squiggle.ts index e9861a8074..d1f1cf205b 100644 --- a/packages/components/src/languageSupport/squiggle.ts +++ b/packages/components/src/languageSupport/squiggle.ts @@ -78,6 +78,7 @@ export function squiggleLanguageSupport(project: SqProject) { then: t.keyword, else: t.keyword, import: t.keyword, + export: t.keyword, as: t.keyword, Equals: t.definitionOperator, diff --git a/packages/prettier-plugin/src/printer.ts b/packages/prettier-plugin/src/printer.ts index 0033c28c54..828e787707 100644 --- a/packages/prettier-plugin/src/printer.ts +++ b/packages/prettier-plugin/src/printer.ts @@ -145,12 +145,14 @@ export function createSquigglePrinter( } case "LetStatement": return group([ + node.exported ? "export " : "", node.variable.value, " = ", typedPath(node).call(print, "value"), ]); case "DefunStatement": return group([ + node.exported ? "export " : "", node.variable.value, group([ "(", diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts index 902eeebf39..f34367fa8d 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_imports_test.ts @@ -87,7 +87,7 @@ lib.x` test("Known import", async () => { const project = SqProject.create({ linker: buildNaiveLinker({ - "./lib": "x = 5", + "./lib": "export x = 5", }), }); project.setSource( @@ -117,7 +117,7 @@ lib.x` ); expect(project.getDependencies("first")).toEqual(["common"]); - project.setSource("common", "common = 0"); + project.setSource("common", "export common = 0"); project.setSource( "second", ` @@ -193,15 +193,15 @@ lib.x` const project = SqProject.create({ linker: buildNaiveLinker({ common: ` - x = 10 + export x = 10 `, bar: ` import "common" as common - x = common.x * 2 + export x = common.x * 2 `, foo: ` import "common" as common - x = common.x * 3 + export x = common.x * 3 `, }), }); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts index 4ae1547b81..3713bfb6c3 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_test.ts @@ -1,5 +1,9 @@ import { SqProject } from "../../src/public/SqProject/index.js"; -import { runFetchBindings, runFetchResult } from "../helpers/projectHelpers.js"; +import { + runFetchBindings, + runFetchExports, + runFetchResult, +} from "../helpers/projectHelpers.js"; test("test result true", async () => { const project = SqProject.create(); @@ -27,6 +31,12 @@ test("test bindings", async () => { ); }); +test("test exports", async () => { + const project = SqProject.create(); + project.setSource("main", "x = 5; export y = 6; z = 7; export t = 8"); + expect(await runFetchExports(project, "main")).toBe("{y: 6,t: 8}"); +}); + describe("removing sources", () => { const getCommonProject = () => { const project = SqProject.create(); diff --git a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts index 4f44d179c9..b4ad4f65fa 100644 --- a/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts +++ b/packages/squiggle-lang/__tests__/SqProject/SqProject_tutorial_2_imports_test.ts @@ -28,7 +28,7 @@ describe("SqProject with imports", () => { }); /* This time source1 and source2 are not depending on anything */ - project.setSource("source1", "x=1"); + project.setSource("source1", "export x=1"); project.setSource( "source3", @@ -38,7 +38,7 @@ describe("SqProject with imports", () => { z=s1.x+s2.y` ); /* We're creating source1, source2, source3 in a weird order to check that `run` loads imports on demand */ - project.setSource("source2", "y=2"); + project.setSource("source2", "export y=2"); /* Now we can run the project */ await project.run("source3"); @@ -56,15 +56,17 @@ describe("SqProject with imports", () => { // Note how this function is async and can load sources remotely on demand. switch (sourceName) { case "source1": - return "x=1"; + return "export x=1"; case "source2": return ` - import "source1" as s1 - y=2`; + import "source1" as s1 + export y=2 + `; case "source3": return ` - import "source2" as s2 - z=3`; + import "source2" as s2 + export z=3 + `; default: throw new Error(`source ${sourceName} not found`); } diff --git a/packages/squiggle-lang/__tests__/ast/parse_test.ts b/packages/squiggle-lang/__tests__/ast/parse_test.ts index a1eec9a475..40e15f94f0 100644 --- a/packages/squiggle-lang/__tests__/ast/parse_test.ts +++ b/packages/squiggle-lang/__tests__/ast/parse_test.ts @@ -473,6 +473,11 @@ describe("Peggy parse", () => { testParse("x", "(Program :x)"); testParse("Math.pi", "(Program :Math.pi)"); }); + + describe("Exports", () => { + testParse("export x = 5", "(Program (LetStatement export :x (Block 5)))"); + testParse("exportx = 5", "(Program (LetStatement :exportx (Block 5)))"); + }); }); describe("parsing new line", () => { diff --git a/packages/squiggle-lang/__tests__/cli_test.ts b/packages/squiggle-lang/__tests__/cli_test.ts index 95d78dcdee..84a803c90c 100644 --- a/packages/squiggle-lang/__tests__/cli_test.ts +++ b/packages/squiggle-lang/__tests__/cli_test.ts @@ -11,7 +11,7 @@ afterEach(() => { jest.restoreAllMocks(); }); -const runCLI = async (args: string[]) => { +async function runCLI(args: string[]) { let stdout = ""; let stderr = ""; @@ -47,7 +47,7 @@ const runCLI = async (args: string[]) => { jest.restoreAllMocks(); return { stdout, stderr, exitCode }; -}; +} it("Usage output", async () => { const result = await runCLI([]); diff --git a/packages/squiggle-lang/__tests__/fixtures/imports/lib.squiggle b/packages/squiggle-lang/__tests__/fixtures/imports/lib.squiggle index fed79d05f4..91a18a1f72 100644 --- a/packages/squiggle-lang/__tests__/fixtures/imports/lib.squiggle +++ b/packages/squiggle-lang/__tests__/fixtures/imports/lib.squiggle @@ -1 +1 @@ -x = 5 +export x = 5 diff --git a/packages/squiggle-lang/__tests__/fixtures/imports/lib2.squiggle b/packages/squiggle-lang/__tests__/fixtures/imports/lib2.squiggle index 47643d4d30..9b0bfb4ee3 100644 --- a/packages/squiggle-lang/__tests__/fixtures/imports/lib2.squiggle +++ b/packages/squiggle-lang/__tests__/fixtures/imports/lib2.squiggle @@ -1 +1 @@ -y = 2 +export y = 2 diff --git a/packages/squiggle-lang/__tests__/fixtures/relative-imports/common.squiggle b/packages/squiggle-lang/__tests__/fixtures/relative-imports/common.squiggle index 7d4290a117..34ef1002f0 100644 --- a/packages/squiggle-lang/__tests__/fixtures/relative-imports/common.squiggle +++ b/packages/squiggle-lang/__tests__/fixtures/relative-imports/common.squiggle @@ -1 +1 @@ -x = 1 +export x = 1 diff --git a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/common.squiggle b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/common.squiggle index c43fd369c6..1474c5d3a3 100644 --- a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/common.squiggle +++ b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/common.squiggle @@ -1 +1 @@ -x = 10 +export x = 10 diff --git a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/lib.squiggle b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/lib.squiggle index 8f54586887..b0de21041f 100644 --- a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/lib.squiggle +++ b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir1/lib.squiggle @@ -1,3 +1,3 @@ import "./common.squiggle" as localCommon import "../common.squiggle" as common -x = localCommon.x + common.x +export x = localCommon.x + common.x diff --git a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/common.squiggle b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/common.squiggle index b48cfaa424..32cd8e35fe 100644 --- a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/common.squiggle +++ b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/common.squiggle @@ -1 +1 @@ -x = 100 +export x = 100 diff --git a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/lib.squiggle b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/lib.squiggle index 8f54586887..b0de21041f 100644 --- a/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/lib.squiggle +++ b/packages/squiggle-lang/__tests__/fixtures/relative-imports/dir2/lib.squiggle @@ -1,3 +1,3 @@ import "./common.squiggle" as localCommon import "../common.squiggle" as common -x = localCommon.x + common.x +export x = localCommon.x + common.x diff --git a/packages/squiggle-lang/__tests__/helpers/projectHelpers.ts b/packages/squiggle-lang/__tests__/helpers/projectHelpers.ts index 5b908ba75f..61faccd89f 100644 --- a/packages/squiggle-lang/__tests__/helpers/projectHelpers.ts +++ b/packages/squiggle-lang/__tests__/helpers/projectHelpers.ts @@ -17,6 +17,15 @@ export async function runFetchBindings(project: SqProject, sourceId: string) { return bindingsR.value.toString(); } +export async function runFetchExports(project: SqProject, sourceId: string) { + await project.run(sourceId); + const outputR = project.getOutput(sourceId); + if (!outputR.ok) { + return `Error(${outputR.value})`; + } + return outputR.value.exports.toString(); +} + export function buildNaiveLinker(sources?: { [k: string]: string }) { const linker: SqLinker = { resolve: (name) => name, diff --git a/packages/squiggle-lang/__tests__/reducer/exports_test.ts b/packages/squiggle-lang/__tests__/reducer/exports_test.ts new file mode 100644 index 0000000000..985b94e3e1 --- /dev/null +++ b/packages/squiggle-lang/__tests__/reducer/exports_test.ts @@ -0,0 +1,9 @@ +import { testEvalToBe, testEvalToMatch } from "../helpers/reducerHelpers.js"; + +describe("Exports Array", () => { + testEvalToMatch( + "x = { export y = 1; 2 }", + "Exports aren't allowed in blocks" + ); + testEvalToBe("export x = 5; y = 6; x + y", "11"); +}); diff --git a/packages/squiggle-lang/src/ast/parse.ts b/packages/squiggle-lang/src/ast/parse.ts index ad294fa7a2..71e5b39ab3 100644 --- a/packages/squiggle-lang/src/ast/parse.ts +++ b/packages/squiggle-lang/src/ast/parse.ts @@ -96,7 +96,9 @@ function nodeToString(node: ASTNode): string { case "Lambda": return sExpr([...node.args, node.body]); case "LetStatement": - return sExpr([node.variable, node.value]); + return node.exported + ? sExpr(["export", node.variable, node.value]) + : sExpr([node.variable, node.value]); case "DefunStatement": return sExpr([node.variable, node.value]); case "String": diff --git a/packages/squiggle-lang/src/ast/peggyHelpers.ts b/packages/squiggle-lang/src/ast/peggyHelpers.ts index c87db788e9..9a62342b24 100644 --- a/packages/squiggle-lang/src/ast/peggyHelpers.ts +++ b/packages/squiggle-lang/src/ast/peggyHelpers.ts @@ -117,7 +117,11 @@ type NodeIdentifier = N<"Identifier", { value: string }>; type NodeLetStatement = N< "LetStatement", - { variable: NodeIdentifier; value: ASTNode } + { + variable: NodeIdentifier; + value: ASTNode; + exported: boolean; + } >; type NodeLambda = N< @@ -137,6 +141,7 @@ type NodeDefunStatement = N< { variable: NodeIdentifier; value: NamedNodeLambda; + exported: boolean; } >; @@ -349,18 +354,26 @@ export function nodeLambda( export function nodeLetStatement( variable: NodeIdentifier, value: ASTNode, + exported: boolean, location: LocationRange ): NodeLetStatement { const patchedValue = value.type === "Lambda" ? { ...value, name: variable.value } : value; - return { type: "LetStatement", variable, value: patchedValue, location }; + return { + type: "LetStatement", + variable, + value: patchedValue, + exported, + location, + }; } export function nodeDefunStatement( variable: NodeIdentifier, value: NamedNodeLambda, + exported: boolean, location: LocationRange ): NodeDefunStatement { - return { type: "DefunStatement", variable, value, location }; + return { type: "DefunStatement", variable, value, exported, location }; } export function nodeString(value: string, location: LocationRange): NodeString { return { type: "String", value, location }; diff --git a/packages/squiggle-lang/src/ast/peggyParser.peggy b/packages/squiggle-lang/src/ast/peggyParser.peggy index 1b021c8ac9..08e99b3baa 100644 --- a/packages/squiggle-lang/src/ast/peggyParser.peggy +++ b/packages/squiggle-lang/src/ast/peggyParser.peggy @@ -61,18 +61,18 @@ voidStatement = "call" _nl value:zeroOMoreArgumentsBlockOrExpression { const variable = h.nodeIdentifier("_", location()); - return h.nodeLetStatement(variable, value, location()); + return h.nodeLetStatement(variable, value, false, location()); } letStatement - = variable:variable _ assignmentOp _nl value:zeroOMoreArgumentsBlockOrExpression - { return h.nodeLetStatement(variable, value, location()); } + = exported:("export" __nl)? variable:variable _ assignmentOp _nl value:zeroOMoreArgumentsBlockOrExpression + { return h.nodeLetStatement(variable, value, Boolean(exported), location()); } defunStatement - = variable:variable '(' _nl args:functionParameters _nl ')' _ assignmentOp _nl body:innerBlockOrExpression + = exported:("export" __nl)? variable:variable '(' _nl args:functionParameters _nl ')' _ assignmentOp _nl body:innerBlockOrExpression { const value = h.nodeLambda(args, body, location(), variable); - return h.nodeDefunStatement(variable, value, location()); + return h.nodeDefunStatement(variable, value, Boolean(exported), location()); } assignmentOp "assignment" = '=' diff --git a/packages/squiggle-lang/src/expression/compile.ts b/packages/squiggle-lang/src/expression/compile.ts index 57ce4a7314..de3314ad7d 100644 --- a/packages/squiggle-lang/src/expression/compile.ts +++ b/packages/squiggle-lang/src/expression/compile.ts @@ -65,6 +65,16 @@ function compileToContent( }; const statements: expression.Expression[] = []; for (const astStatement of ast.statements) { + if ( + (astStatement.type === "LetStatement" || + astStatement.type === "DefunStatement") && + astStatement.exported + ) { + throw new ICompileError( + "Exports aren't allowed in blocks", + astStatement.location + ); + } const [statement, newContext] = innerCompileAst( astStatement, currentContext @@ -82,15 +92,23 @@ function compileToContent( size: context.size, }; const statements: expression.Expression[] = []; + const exports: string[] = []; for (const astStatement of ast.statements) { const [statement, newContext] = innerCompileAst( astStatement, currentContext ); statements.push(statement); + if ( + (astStatement.type === "LetStatement" || + astStatement.type === "DefunStatement") && + astStatement.exported + ) { + exports.push(astStatement.variable.value); + } currentContext = newContext; } - return [expression.eProgram(statements), currentContext]; + return [expression.eProgram(statements, exports), currentContext]; } case "DefunStatement": case "LetStatement": { diff --git a/packages/squiggle-lang/src/expression/index.ts b/packages/squiggle-lang/src/expression/index.ts index bdb5776268..db1bbd0b45 100644 --- a/packages/squiggle-lang/src/expression/index.ts +++ b/packages/squiggle-lang/src/expression/index.ts @@ -17,9 +17,13 @@ export type ExpressionContent = value: Expression[]; } | { - // programs are similar to blocks, but don't create an inner scope. there can be only one program at the top level of the expression. + // Programs are similar to blocks, but they can export things for other modules to use. + // There can be only one program at the top level of the expression. type: "Program"; - value: Expression[]; + value: { + statements: Expression[]; + exports: string[]; + }; } | { type: "Array"; @@ -128,9 +132,15 @@ export const eBlock = (exprs: Expression[]): ExpressionContent => ({ value: exprs, }); -export const eProgram = (statements: Expression[]): ExpressionContent => ({ +export const eProgram = ( + statements: Expression[], + exports: string[] +): ExpressionContent => ({ type: "Program", - value: statements, + value: { + statements, + exports, + }, }); export const eLetStatement = ( @@ -167,8 +177,18 @@ export function expressionToString(expression: Expression): string { switch (expression.type) { case "Block": return `{${expression.value.map(expressionToString).join("; ")}}`; - case "Program": - return expression.value.map(expressionToString).join("; "); + case "Program": { + const exports = new Set(expression.value.exports); + return expression.value.statements + .map((statement) => { + const statementString = expressionToString(statement); + return statement.type === "Assign" && + exports.has(statement.value.left) + ? `export ${statementString}` + : statementString; + }) + .join("; "); + } case "Array": return `[${expression.value.map(expressionToString).join(", ")}]`; case "Dict": diff --git a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts index ff4d7385b1..3472339814 100644 --- a/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts +++ b/packages/squiggle-lang/src/public/SqProject/ProjectItem.ts @@ -11,16 +11,20 @@ import { Value } from "../../value/index.js"; import { SqCompileError, SqError, SqRuntimeError } from "../SqError.js"; import { SqLinker } from "../SqLinker.js"; -// source -> ast -> imports -> bindings & result +// source -> ast -> imports -> result/bindings/exports export type RunOutput = { result: Value; bindings: Bindings; + exports: Bindings; }; export type Import = | { - type: "flat"; // for now, only `continues` can be flattened, but this might change in the future + // For now, only `continues` can be flattened, but this might change in the future. + // Also, because of this, flat imports import _all_ bindings, not just exports. In the future, we might prefer to change that, + // and allow flat imports of exports only (for `import "foo" as *` syntax). + type: "flat"; sourceId: string; } | { @@ -211,13 +215,23 @@ export class ProjectItem { return wrappedEvaluate(expression, context); }; + if (expression.value.type !== "Program") { + // mostly for TypeScript, so that we could access `expression.value.exports` + throw new Error("Expected Program expression"); + } + const [result, contextAfterEvaluation] = evaluate(expression.value, { ...context, evaluate: asyncEvaluate, }); + + const bindings = contextAfterEvaluation.stack.asBindings(); + const exportNames = new Set(expression.value.value.exports); + const exports = bindings.filter((_, key) => exportNames.has(key)); this.output = Ok({ result, - bindings: contextAfterEvaluation.stack.asBindings(), + bindings, + exports, }); } catch (e: unknown) { this.failRun(new SqRuntimeError(IRuntimeError.fromException(e))); diff --git a/packages/squiggle-lang/src/public/SqProject/index.ts b/packages/squiggle-lang/src/public/SqProject/index.ts index 6d46b8a6ad..f3b6b64761 100644 --- a/packages/squiggle-lang/src/public/SqProject/index.ts +++ b/packages/squiggle-lang/src/public/SqProject/index.ts @@ -248,23 +248,26 @@ export class SqProject { }) ); - const bindings = new SqDict( - internalOutputR.value.bindings, - new SqValueContext({ - project: this, - sourceId, - source, - ast: astR.value, - valueAst: astR.value, - valueAstIsPrecise: true, - path: new SqValuePath({ - root: "bindings", - items: [], - }), - }) + const [bindings, exports] = (["bindings", "exports"] as const).map( + (field) => + new SqDict( + internalOutputR.value[field], + new SqValueContext({ + project: this, + sourceId, + source, + ast: astR.value, + valueAst: astR.value, + valueAstIsPrecise: true, + path: new SqValuePath({ + root: "bindings", + items: [], + }), + }) + ) ); - return Result.Ok({ result, bindings }); + return Result.Ok({ result, bindings, exports }); } getResult(sourceId: string): Result.result { @@ -333,15 +336,17 @@ export class SqProject { if (!outputR.ok) { return outputR; } - const bindings = outputR.value.bindings; // TODO - check for name collisions? switch (importBinding.type) { case "flat": - externals = externals.merge(bindings); + externals = externals.merge(outputR.value.bindings); break; case "named": - externals = externals.set(importBinding.variable, vDict(bindings)); + externals = externals.set( + importBinding.variable, + vDict(outputR.value.exports) + ); break; // exhaustiveness check for TypeScript default: diff --git a/packages/squiggle-lang/src/public/types.ts b/packages/squiggle-lang/src/public/types.ts index a3dd3736e9..0a933d2d5f 100644 --- a/packages/squiggle-lang/src/public/types.ts +++ b/packages/squiggle-lang/src/public/types.ts @@ -6,6 +6,7 @@ import { SqDict } from "./SqValue/SqDict.js"; export type SqOutput = { result: SqValue; bindings: SqDict; + exports: SqDict; }; export type SqOutputResult = result; diff --git a/packages/squiggle-lang/src/reducer/index.ts b/packages/squiggle-lang/src/reducer/index.ts index 673f687a15..24d5f2a8d3 100644 --- a/packages/squiggle-lang/src/reducer/index.ts +++ b/packages/squiggle-lang/src/reducer/index.ts @@ -107,12 +107,12 @@ const evaluateBlock: SubReducerFn<"Block"> = (statements, context) => { return [currentValue, context]; // throw away block's context }; -const evaluateProgram: SubReducerFn<"Program"> = (statements, context) => { - // Same as Block, but doesn't drop the context, so that we could return bindings from it. +const evaluateProgram: SubReducerFn<"Program"> = (expressionValue, context) => { + // Same as Block, but doesn't drop the context, so that we could return bindings and exports from it. let currentContext = context; let currentValue: Value = vVoid(); - for (const statement of statements) { + for (const statement of expressionValue.statements) { [currentValue, currentContext] = context.evaluate( statement, currentContext diff --git a/packages/textmate-grammar/package.json b/packages/textmate-grammar/package.json index 894d565320..b12218f1f3 100644 --- a/packages/textmate-grammar/package.json +++ b/packages/textmate-grammar/package.json @@ -8,6 +8,24 @@ }, "devDependencies": { "js-yaml": "^4.1.0", - "prettier": "^3.0.3" + "prettier": "^3.0.3", + "vscode-tmgrammar-test": "^0.1.2" + }, + "contributes": { + "languages": [ + { + "id": "squiggle", + "extensions": [ + ".squiggle" + ] + } + ], + "grammars": [ + { + "language": "squiggle", + "scopeName": "source.squiggle", + "path": "./dist/squiggle.tmLanguage.json" + } + ] } } diff --git a/packages/textmate-grammar/src/squiggle.tmLanguage.yaml b/packages/textmate-grammar/src/squiggle.tmLanguage.yaml index af24805be8..ca145dd4f5 100644 --- a/packages/textmate-grammar/src/squiggle.tmLanguage.yaml +++ b/packages/textmate-grammar/src/squiggle.tmLanguage.yaml @@ -30,9 +30,11 @@ repository: "4": name: variable.other.squiggle let: - match: ^\s*(\w+)\s*= + match: ^\s*(?:(export)\s+)?(\w+)\s*= captures: "1": + name: keyword.control.squiggle + "2": name: variable.other.squiggle function-call: begin: (\w+)\s*(\() diff --git a/packages/textmate-grammar/tests/main.squiggle b/packages/textmate-grammar/tests/main.squiggle new file mode 100644 index 0000000000..6479f4f9c0 --- /dev/null +++ b/packages/textmate-grammar/tests/main.squiggle @@ -0,0 +1,9 @@ +// SYNTAX TEST "source.squiggle" "main testcase" +import "mod" as mod +// <------ keyword.control.squiggle +foo = 5 +// <--- variable.other.squiggle +// ^ constant.numeric.integer.squiggle +export bar = 6 +// <------ keyword.control.squiggle +// ^^^ variable.other.squiggle diff --git a/packages/versioned-playground/tsconfig.json b/packages/versioned-playground/tsconfig.json index e95bda51de..33df7e9c16 100644 --- a/packages/versioned-playground/tsconfig.json +++ b/packages/versioned-playground/tsconfig.json @@ -17,6 +17,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true // default forced by Next.js, https://github.com/vercel/next.js/pull/51564 }, + "references": [{ "path": "../components" }], "include": ["src/**/*"], "exclude": ["node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce43b9c3c7..568ee3bafd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -623,6 +623,9 @@ importers: prettier: specifier: ^3.0.3 version: 3.0.3 + vscode-tmgrammar-test: + specifier: ^0.1.2 + version: 0.1.2 packages/ui: dependencies: @@ -8825,6 +8828,11 @@ packages: engines: {node: '>= 12'} dev: false + /commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + dev: true + /common-tags@1.8.2: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} @@ -18752,12 +18760,27 @@ packages: /vscode-oniguruma@1.7.0: resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} - dev: false + + /vscode-textmate@7.0.4: + resolution: {integrity: sha512-9hJp0xL7HW1Q5OgGe03NACo7yiCTMEk3WU/rtKXUbncLtdg6rVVNJnHwD88UhbIYU2KoxY0Dih0x+kIsmUKn2A==} + dev: true /vscode-textmate@8.0.0: resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} dev: false + /vscode-tmgrammar-test@0.1.2: + resolution: {integrity: sha512-tLJZMAP/NWeRwlpHzjSXx+HvjJzFltndoyR6AK9fJRGjALJX6Pg7EOiYyznpJ4TNC2eMmF3IRbcZBWzJwN+n5g==} + hasBin: true + dependencies: + chalk: 2.4.2 + commander: 9.5.0 + diff: 4.0.2 + glob: 7.2.3 + vscode-oniguruma: 1.7.0 + vscode-textmate: 7.0.4 + dev: true + /vscode-uri@3.0.7: resolution: {integrity: sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==} dev: false From 1d7834cd07d882933c9bf2060070ff6649392fef Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Wed, 1 Nov 2023 15:22:41 -0600 Subject: [PATCH 10/10] fix dashes in import slugs, address some review comments --- .../[slug]/EditSquiggleSnippetModel.tsx | 10 +++- .../hub/src/squiggle/components/linker.ts | 47 ++++++++++++------- .../SqProject/SqProject_imports_test.ts | 22 ++++----- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index 7a04ce00dd..fb617a6bda 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx @@ -26,7 +26,10 @@ import { EditModelExports } from "@/components/exports/EditModelExports"; import { useAvailableHeight } from "@/hooks/useAvailableHeight"; import { useMutationForm } from "@/hooks/useMutationForm"; import { extractFromGraphqlErrorUnion } from "@/lib/graphqlHelpers"; -import { squiggleHubLinker } from "@/squiggle/components/linker"; +import { + serializeSourceId, + squiggleHubLinker, +} from "@/squiggle/components/linker"; import { Draft, SquiggleSnippetDraftDialog, @@ -206,7 +209,10 @@ export const EditSquiggleSnippetModel: FC = ({ modelRef }) => { { +describe("Imports", () => { describe("Parse imports", () => { const project = SqProject.create({ linker: buildNaiveLinker() }); project.setSource( @@ -103,7 +103,7 @@ lib.x` expect(project.getResult("main").value.toString()).toEqual("5"); }); - describe("Another import test", () => { + describe("Mix imports and continues", () => { const project = SqProject.create({ linker: buildNaiveLinker(), }); @@ -192,24 +192,24 @@ lib.x` test("Diamond shape", async () => { const project = SqProject.create({ linker: buildNaiveLinker({ - common: ` + root: ` export x = 10 `, - bar: ` - import "common" as common - export x = common.x * 2 + left: ` + import "root" as root + export x = root.x * 2 `, - foo: ` - import "common" as common - export x = common.x * 3 + right: ` + import "root" as root + export x = root.x * 3 `, }), }); project.setSource( "main", ` - import "foo" as foo - import "bar" as bar + import "left" as foo + import "right" as bar foo.x + bar.x ` );