Compare commits

...

24 Commits

Author SHA1 Message Date
eeb223a9cb Remove useless 'optional: bool' field on CallExpression 2024-11-27 17:20:05 -06:00
46be4e7eef Log simple performance metrics (#4596)
Signed-off-by: Nick Cameron <nrc@ncameron.org>
2024-11-28 11:27:17 +13:00
412d1b7a99 Update KCL Types doc (#4591)
* use `=` instead of `:`, fix formatting

* remove `%` from `segEnd` and `segLen` calls
2024-11-27 11:14:59 -05:00
cfdd22af74 Add ability to immediately enter sketch mode by double-clicking an existing sketch (#4573)
* Implement the functionality

* Another fmt

* Fix handler to not rely on modelingMachine's context,
because that creates an implicit race

* Write an E2E test

* Fix tsc and fmt

* Use artifactGraph helpers for more concise code

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>

* Fix up imports and whatnot from commit 2bfc5f5c

* Make early return more clear with curly braces

* Whoops should have linted

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
2024-11-27 10:08:23 -05:00
68a11e7aa5 Remove the lexer from KCL's API (#4589)
Signed-off-by: Nick Cameron <nrc@ncameron.org>
2024-11-27 03:30:28 +00:00
3139e18dc7 Make = and => optional in function declarations (#4577)
* Make `=` and `=>` optional in function declarations

And requires `:` for return types

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Tests

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Format types in function decls

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Require  in anon function decls

Signed-off-by: Nick Cameron <nrc@ncameron.org>

---------

Signed-off-by: Nick Cameron <nrc@ncameron.org>
2024-11-27 15:46:58 +13:00
d461b09a4d KCL refactor: break typechecking into its own fn (#4587) 2024-11-26 16:02:31 -06:00
9c42c39ba9 Fix publish path in release bucket (#4585) 2024-11-26 15:11:26 -05:00
aa3f40e22c KCL: Two tiny refactors (#4580)
* Refactor: Combine two impl blocks

* Refactor: Constant for NO_META(data)
2024-11-26 12:27:09 -06:00
4423ae16dc Add offset plane point-and-click user flow (#4552)
* Add a code mod for offset plane

* Add support for default plane selections to our `otherSelections` object

* Make availableVars work without a selection range
(because default planes don't have one)

* Make default planes selectable in cmdbar even if AST is empty

* Add offset plane command and activate in toolbar

* Avoid unnecessary error when sketching on offset plane by returning early

* Add supporting test features for offset plane E2E test

* Add WIP E2E test for offset plane
Struggling to get local electron test suite running properly

* Typos

* Lints

* Fix test by making it a web-based one:
I couldn't use the cmdBar fixture with an electron test for some reason.

* Update src/lib/commandBarConfigs/modelingCommandConfig.ts

* Update src/machines/modelingMachine.ts

* Revert changes to `homePageFixture`, as they were unused

* @Irev-Dev feedback: convert action to actor, fix machine layout

* Update plane icon to be not dashed, follow conventions closer
2024-11-26 16:36:14 +00:00
1d45bed649 Bump react-router-dom from 6.27.0 to 6.28.0 (#4414)
* Bump react-router-dom from 6.27.0 to 6.28.0

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.27.0 to 6.28.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@6.28.0/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.28.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Force 6.28.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
2024-11-26 11:06:59 -05:00
64aac7bccc KCL refactor: Type alias for KCL object fields (#4579)
This way, if we want to change our key-value
representation later (e.g. using a tree map instead
of a hash map) we can, easily, in just one place.
2024-11-26 09:51:43 -06:00
002edeaf19 fix selection bugs found by QA-wolf (#4578)
* fix selection bugs found by QA-wolf

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* trigger ci

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-26 04:43:28 -05:00
5424252dac AST: Factor shebangs out of non-code metadata and into Program (#4557)
* AST: Factor shebangs out of non-code metadata and into Progam

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Empty commit to try to unstick CI

---------

Signed-off-by: Nick Cameron <nrc@ncameron.org>
2024-11-26 03:39:57 +00:00
30bc85add8 Add warnings for recently deprecated syntax (#4560)
Signed-off-by: Nick Cameron <nrc@ncameron.org>
2024-11-26 14:59:40 +13:00
39a2bd685b Bump tokio from 1.40.0 to 1.41.1 in /src/wasm-lib (#4426)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.40.0 to 1.41.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.40.0...tokio-1.41.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-25 18:45:39 -06:00
23a3e330f6 KCL tests: take fancier snapshots of KCL errors (#4574)
Right now our KCL tests output a debug representation of the KCLError
value. This works OK, but it's difficult to read an error like
"runtime error: SourceRange([44, 48])" because I don't fucking know what
the 44th character in my KCL program is.

In the modeling app, source ranges are turned into nice red squiggly
underlines in the editor. I want nice squiggly underline when I run the
Rust unit tests too, damnit. The JS world should NEVER have fancy toys
that I, a Rust programmer, cannot access. I deserve this. I need this.

So anyway instead of snapshotting debug repr, snapshot a fancy error
via the miette library.
2024-11-25 17:28:57 -06:00
99dd8b87dc Change to use Angle type's comparison (#4575) 2024-11-25 23:17:47 +00:00
5ff1d9e268 Open about section links externally in Settings (#4571)
* Open nightly download link externally

* Applied to other about links
2024-11-25 16:14:40 -05:00
ce1a37e0bc Upgrade to typescript 5.7.2 (#4569)
* Upgrade to typescript 5.7.2

* Fix tsc errors
2024-11-25 20:37:04 +00:00
ac605c2434 Fix yarn tsc in codemirror-lsp-client (#4568) 2024-11-25 15:12:17 -05:00
f6ecdfcb02 Bump kcmc, release kcl-lib (#4567) 2024-11-25 20:10:45 +00:00
28815eb2f1 KCL: Executor returns specific errors, not anyhow (#4566)
Some clients (e.g. the simulation tests) want to know why a KCL program failed. Was it because of a network error? Or because the client wasn't authorized properly? Or was it a KCL runtime type error?

It's difficult for clients to ask these questions, because the interpreter just returns Anyhow error, basically just a string.

Instead, we should return different variants of an Error enum for different kinds of errors.

This lets us render nice Cargo-style error messages that show the exact KCL line where an error occurred, instead of just printing off a source range. We have that in the CLI, but I'd like it for running normal KCL unit tests.

# Testing

This is a pure refactor, it doesn't change any behaviour, it just stops hiding the specific error types via anyhow. No testing needed. If it compiles, it works.
2024-11-25 15:06:23 -05:00
166fa71f7e Add nightly link in about section (#4548)
* Add nightly link in about sectoin

* Add nightly detection

* Lint

* Change APP_NAME to PACKAGE_NAME

* To be improved: working implementation on mac for click to download

* Revert "To be improved: working implementation on mac for click to download"

This reverts commit 7ced32a533.

* Nevermind, will process on the website
2024-11-25 14:20:50 -05:00
208 changed files with 8588 additions and 9839 deletions

View File

@ -123,7 +123,7 @@ jobs:
path: out
glob: '*'
parent: false
destination: 'dl.kittycad.io/releases/modeling-app/test/new-workflow'
destination: 'dl.kittycad.io/releases/modeling-app'
- name: Invalidate bucket cache on latest*.yml and last_download.json files
run: |

View File

@ -450,3 +450,9 @@ PS: for the debug panel, the following JSON is useful for snapping the camera
## KCL
For how to contribute to KCL, [see our KCL README](https://github.com/KittyCAD/modeling-app/tree/main/src/wasm-lib/kcl).
### Logging
To display logging (to the terminal or console) set `ZOO_LOG=1`. This will log some warnings and simple performance metrics. To view these in test runs, use `-- --nocapture`.
To enable memory metrics, build with `--features dhat-heap`.

View File

@ -58,7 +58,7 @@ mountingPlate = extrude(thickness, mountingPlateSketch)
```js
// Sketch on the face of a chamfer.
fn cube = (pos, scale) => {
fn cube(pos, scale) {
sg = startSketchOn('XY')
|> startProfileAt(pos, %)
|> line([0, scale], %)

File diff suppressed because one or more lines are too long

View File

@ -37,7 +37,7 @@ assertEqual(n, 3, 0.0001, "5/2 = 2.5, rounded up makes 3")
startSketchOn('XZ')
|> circle({ center = [0, 0], radius = 2 }, %)
|> extrude(5, %)
|> patternTransform(n, (id) => {
|> patternTransform(n, fn(id) {
return { translate = [4 * id, 0, 0] }
}, %)
```

View File

@ -29,7 +29,7 @@ map(array: [KclValue], map_fn: FunctionParam) -> [KclValue]
```js
r = 10 // radius
fn drawCircle = (id) => {
fn drawCircle(id) {
return startSketchOn("XY")
|> circle({ center = [id * 2 * r, 0], radius = r }, %)
}
@ -45,7 +45,7 @@ circles = map([1..3], drawCircle)
```js
r = 10 // radius
// Call `map`, using an anonymous function instead of a named one.
circles = map([1..3], (id) => {
circles = map([1..3], (id) {
return startSketchOn("XY")
|> circle({ center = [id * 2 * r, 0], radius = r }, %)
})

File diff suppressed because one or more lines are too long

View File

@ -30,7 +30,7 @@ patternTransform2d(total_instances: u32, transform_function: FunctionParam, soli
```js
// Each instance will be shifted along the X axis.
fn transform = (id) => {
fn transform(id) {
return { translate = [4 * id, 0] }
}

View File

@ -30,14 +30,14 @@ reduce(array: [KclValue], start: KclValue, reduce_fn: FunctionParam) -> KclValue
```js
// This function adds two numbers.
fn add = (a, b) => {
fn add(a, b) {
return a + b
}
// This function adds an array of numbers.
// It uses the `reduce` function, to call the `add` function on every
// element of the `arr` parameter. The starting value is 0.
fn sum = (arr) => {
fn sum(arr) {
return reduce(arr, 0, add)
}
@ -61,7 +61,7 @@ assertEqual(sum([1, 2, 3]), 6, 0.00001, "1 + 2 + 3 summed is 6")
// an anonymous `add` function as its parameter, instead of declaring a
// named function outside.
arr = [1, 2, 3]
sum = reduce(arr, 0, (i, result_so_far) => {
sum = reduce(arr, 0, (i, result_so_far) {
return i + result_so_far
})
@ -74,7 +74,7 @@ assertEqual(sum, 6, 0.00001, "1 + 2 + 3 summed is 6")
```js
// Declare a function that sketches a decagon.
fn decagon = (radius) => {
fn decagon(radius) {
// Each side of the decagon is turned this many degrees from the previous angle.
stepAngle = 1 / 10 * tau()
@ -84,7 +84,7 @@ fn decagon = (radius) => {
// Use a `reduce` to draw the remaining decagon sides.
// For each number in the array 1..10, run the given function,
// which takes a partially-sketched decagon and adds one more edge to it.
fullDecagon = reduce([1..10], startOfDecagonSketch, (i, partialDecagon) => {
fullDecagon = reduce([1..10], startOfDecagonSketch, (i, partialDecagon) {
// Draw one edge of the decagon.
x = cos(stepAngle * i) * radius
y = sin(stepAngle * i) * radius

View File

@ -36,7 +36,7 @@ cube = startSketchAt([0, 0])
|> close(%)
|> extrude(5, %)
fn cylinder = (radius, tag) => {
fn cylinder(radius, tag) {
return startSketchAt([0, 0])
|> circle({
radius = radius,

View File

@ -36,7 +36,7 @@ cube = startSketchAt([0, 0])
|> close(%)
|> extrude(5, %)
fn cylinder = (radius, tag) => {
fn cylinder(radius, tag) {
return startSketchAt([0, 0])
|> circle({
radius = radius,

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,7 @@ If you want to get a value from an array you can use the index like so:
An object is defined with `{}` braces. Here is an example object:
```
myObj = {a: 0, b: "thing"}
myObj = { a = 0, b = "thing" }
```
We support two different ways of getting properties from objects, you can call
@ -90,12 +90,12 @@ startSketchOn('XZ')
|> startProfileAt(origin, %)
|> angledLine([0, 191.26], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001, %) - 90,
segAng(rectangleSegmentA001) - 90,
196.99
], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001, %),
-segLen(rectangleSegmentA001, %)
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
@ -120,18 +120,18 @@ However if the code was written like this:
```
fn rect = (origin) => {
return startSketchOn('XZ')
|> startProfileAt(origin, %)
|> angledLine([0, 191.26], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001, %) - 90,
196.99
], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001, %),
-segLen(rectangleSegmentA001, %)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
|> startProfileAt(origin, %)
|> angledLine([0, 191.26], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001) - 90,
196.99
], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
}
rect([0, 0])
@ -148,26 +148,29 @@ For example the following code works.
```
fn rect = (origin) => {
return startSketchOn('XZ')
|> startProfileAt(origin, %)
|> angledLine([0, 191.26], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001, %) - 90,
196.99
], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001, %),
-segLen(rectangleSegmentA001, %)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
|> startProfileAt(origin, %)
|> angledLine([0, 191.26], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001) - 90,
196.99
], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
}
rect([0, 0])
myRect = rect([20, 0])
myRect
myRect
|> extrude(10, %)
|> fillet({radius: 0.5, tags: [myRect.tags.rectangleSegmentA001]}, %)
|> fillet({
radius = 0.5,
tags = [myRect.tags.rectangleSegmentA001]
}, %)
```
See how we use the tag `rectangleSegmentA001` in the `fillet` function outside

View File

@ -85,7 +85,6 @@ layout: manual
| `type` |enum: `CallExpression`| | No |
| `callee` |[`Identifier`](/docs/kcl/types/Identifier)| | No |
| `arguments` |`[` [`Expr`](/docs/kcl/types/Expr) `]`| | No |
| `optional` |`boolean`| | No |
| `digest` |`[, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`]`| | No |
| `start` |`integer`| | No |
| `end` |`integer`| | No |

View File

@ -125,7 +125,6 @@ An expression can be evaluated to yield a single KCL value.
| `type` |enum: `CallExpression`| | No |
| `callee` |[`Identifier`](/docs/kcl/types/Identifier)| An expression can be evaluated to yield a single KCL value. | No |
| `arguments` |`[` [`Expr`](/docs/kcl/types/Expr) `]`| | No |
| `optional` |`boolean`| | No |
| `digest` |`[, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`]`| | No |
| `start` |`integer`| | No |
| `end` |`integer`| | No |

View File

@ -11,23 +11,6 @@ layout: manual
**This schema accepts exactly one of the following:**
A shebang. This is a special type of comment that is at the top of the file. It looks like this: ```python,no_run #!/usr/bin/env python ```
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `shebang`| | No |
| `value` |`string`| | No |
----
An inline comment. Here are examples: `1 + 1 // This is an inline comment`. `1 + 1 /* Here's another */`.
**Type:** `object`

View File

@ -18,6 +18,7 @@ A KCL program top level, or function body.
|----------|------|-------------|----------|
| `body` |`[` [`BodyItem`](/docs/kcl/types/BodyItem) `]`| | No |
| `nonCodeMeta` |[`NonCodeMeta`](/docs/kcl/types/NonCodeMeta)| A KCL program top level, or function body. | No |
| `shebang` |[`Shebang`](/docs/kcl/types/Shebang)| | No |
| `digest` |`[, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`]`| | No |
| `start` |`integer`| | No |
| `end` |`integer`| | No |

23
docs/kcl/types/Shebang.md Normal file
View File

@ -0,0 +1,23 @@
---
title: "Shebang"
excerpt: "A shebang. This is a special type of comment that is at the top of the file. It looks like this: ```python,no_run #!/usr/bin/env python ```"
layout: manual
---
A shebang. This is a special type of comment that is at the top of the file. It looks like this: ```python,no_run #!/usr/bin/env python ```
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `content` |`string`| | No |
| `start` |`integer`| | No |
| `end` |`integer`| | No |

View File

@ -35,7 +35,7 @@ export class CmdBarFixture {
}
private _serialiseCmdBar = async (): Promise<CmdBarSerialised> => {
const reviewForm = await this.page.locator('#review-form')
const reviewForm = this.page.locator('#review-form')
const getHeaderArgs = async () => {
const inputs = await this.page.getByTestId('cmd-bar-input-tab').all()
const entries = await Promise.all(

View File

@ -28,6 +28,7 @@ type SceneSerialised = {
type ClickHandler = (clickParams?: mouseParams) => Promise<void | boolean>
type MoveHandler = (moveParams?: mouseParams) => Promise<void | boolean>
type DblClickHandler = (clickParams?: mouseParams) => Promise<void | boolean>
type DragToHandler = (dragParams: mouseDragToParams) => Promise<void | boolean>
type DragFromHandler = (
dragParams: mouseDragFromParams
@ -68,7 +69,7 @@ export class SceneFixture {
x: number,
y: number,
{ steps }: { steps: number } = { steps: 20 }
): [ClickHandler, MoveHandler] =>
): [ClickHandler, MoveHandler, DblClickHandler] =>
[
(clickParams?: mouseParams) => {
if (clickParams?.pixelDiff) {
@ -90,6 +91,16 @@ export class SceneFixture {
}
return this.page.mouse.move(x, y, { steps })
},
(clickParams?: mouseParams) => {
if (clickParams?.pixelDiff) {
return doAndWaitForImageDiff(
this.page,
() => this.page.mouse.dblclick(x, y),
clickParams.pixelDiff
)
}
return this.page.mouse.dblclick(x, y)
},
] as const
makeDragHelpers = (
x: number,

View File

@ -6,6 +6,7 @@ export class ToolbarFixture {
public page: Page
extrudeButton!: Locator
offsetPlaneButton!: Locator
startSketchBtn!: Locator
lineBtn!: Locator
rectangleBtn!: Locator
@ -25,6 +26,7 @@ export class ToolbarFixture {
reConstruct = (page: Page) => {
this.page = page
this.extrudeButton = page.getByTestId('extrude')
this.offsetPlaneButton = page.getByTestId('plane-offset')
this.startSketchBtn = page.getByTestId('sketch')
this.lineBtn = page.getByTestId('line')
this.rectangleBtn = page.getByTestId('corner-rectangle')

View File

@ -551,3 +551,129 @@ test(`Verify axis, origin, and horizontal snapping`, async ({
)
})
})
test(`Verify user can double-click to edit a sketch`, async ({
app,
editor,
toolbar,
scene,
}) => {
const initialCode = `closedSketch = startSketchOn('XZ')
|> circle({ center = [8, 5], radius = 2 }, %)
openSketch = startSketchOn('XY')
|> startProfileAt([-5, 0], %)
|> lineTo([0, 5], %)
|> xLine(5, %)
|> tangentialArcTo([10, 0], %)
`
await app.initialise(initialCode)
const pointInsideCircle = {
x: app.viewPortSize.width * 0.63,
y: app.viewPortSize.height * 0.5,
}
const pointOnPathAfterSketching = {
x: app.viewPortSize.width * 0.58,
y: app.viewPortSize.height * 0.5,
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_clickOpenPath, moveToOpenPath, dblClickOpenPath] =
scene.makeMouseHelpers(
pointOnPathAfterSketching.x,
pointOnPathAfterSketching.y
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_clickCircle, moveToCircle, dblClickCircle] = scene.makeMouseHelpers(
pointInsideCircle.x,
pointInsideCircle.y
)
const exitSketch = async () => {
await test.step(`Exit sketch mode`, async () => {
await toolbar.exitSketchBtn.click()
await expect(toolbar.exitSketchBtn).not.toBeVisible()
await expect(toolbar.startSketchBtn).toBeEnabled()
})
}
await test.step(`Double-click on the closed sketch`, async () => {
await moveToCircle()
await dblClickCircle()
await expect(toolbar.startSketchBtn).not.toBeVisible()
await expect(toolbar.exitSketchBtn).toBeVisible()
await editor.expectState({
activeLines: [`|>circle({center=[8,5],radius=2},%)`],
highlightedCode: 'circle({center=[8,5],radius=2},%)',
diagnostics: [],
})
})
await exitSketch()
await test.step(`Double-click on the open sketch`, async () => {
await moveToOpenPath()
await scene.expectPixelColor([250, 250, 250], pointOnPathAfterSketching, 15)
// There is a full execution after exiting sketch that clears the scene.
await app.page.waitForTimeout(500)
await dblClickOpenPath()
await expect(toolbar.startSketchBtn).not.toBeVisible()
await expect(toolbar.exitSketchBtn).toBeVisible()
// Wait for enter sketch mode to complete
await app.page.waitForTimeout(500)
await editor.expectState({
activeLines: [`|>xLine(5,%)`],
highlightedCode: 'xLine(5,%)',
diagnostics: [],
})
})
})
test(`Offset plane point-and-click`, async ({
app,
scene,
editor,
toolbar,
cmdBar,
}) => {
await app.initialise()
// One dumb hardcoded screen pixel value
const testPoint = { x: 700, y: 150 }
const [clickOnXzPlane] = scene.makeMouseHelpers(testPoint.x, testPoint.y)
const expectedOutput = `plane001 = offsetPlane('XZ', 5)`
await test.step(`Look for the blue of the XZ plane`, async () => {
await scene.expectPixelColor([50, 51, 96], testPoint, 15)
})
await test.step(`Go through the command bar flow`, async () => {
await toolbar.offsetPlaneButton.click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'plane',
currentArgValue: '',
headerArguments: { Plane: '', Distance: '' },
highlightedHeaderArg: 'plane',
commandName: 'Offset plane',
})
await clickOnXzPlane()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'distance',
currentArgValue: '5',
headerArguments: { Plane: '1 plane', Distance: '' },
highlightedHeaderArg: 'distance',
commandName: 'Offset plane',
})
await cmdBar.progressCmdBar()
})
await test.step(`Confirm code is added to the editor, scene has changed`, async () => {
await editor.expectEditor.toContain(expectedOutput)
await editor.expectState({
diagnostics: [],
activeLines: [expectedOutput],
highlightedCode: '',
})
await scene.expectPixelColor([74, 74, 74], testPoint, 15)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -56,7 +56,7 @@
"react-json-view": "^1.21.3",
"react-modal": "^3.16.1",
"react-modal-promise": "^1.0.2",
"react-router-dom": "^6.27.0",
"react-router-dom": "^6.28.0",
"sketch-helpers": "^0.0.4",
"three": "^0.166.1",
"ua-parser-js": "^1.0.37",
@ -205,7 +205,7 @@
"setimmediate": "^1.0.5",
"tailwindcss": "^3.4.1",
"ts-node": "^10.0.0",
"typescript": "^5.0.0",
"typescript": "^5.7.2",
"vite": "^5.4.6",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-package-version": "^1.1.0",

View File

@ -18,13 +18,13 @@
"license": "MIT",
"private": false,
"dependencies": {
"@codemirror/autocomplete": "^6.16.3",
"@codemirror/autocomplete": "6.17.0",
"@codemirror/language": "^6.10.2",
"@codemirror/state": "^6.4.1",
"@lezer/highlight": "^1.2.0",
"@ts-stack/markdown": "^1.5.0",
"json-rpc-2.0": "^1.7.0",
"typescript": "^5.5.2",
"typescript": "^5.7.2",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-uri": "^3.0.8"
},

View File

@ -26,7 +26,7 @@ export default class StreamDemuxer extends Queue<Uint8Array> {
private async start(): Promise<void> {
let contentLength: null | number = null
let buffer = new Uint8Array()
let buffer: Uint8Array = new Uint8Array()
for await (const bytes of this) {
buffer = Bytes.append(Uint8Array, buffer, bytes)

View File

@ -95,6 +95,10 @@ export default class Queue<T>
return this
}
[Symbol.asyncDispose](): Promise<void> {
return this.close()
}
get locked(): boolean {
return this.#stream.locked
}

View File

@ -2,10 +2,10 @@
# yarn lockfile v1
"@codemirror/autocomplete@^6.16.3":
version "6.16.3"
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.16.3.tgz#04d5a4e4e44ccae1ba525d47db53a5479bf46338"
integrity sha512-Vl/tIeRVVUCRDuOG48lttBasNQu8usGgXQawBXI7WJAiUDSFOfzflmEsZFZo48mAvAaa4FZ/4/yLLxFtdJaKYA==
"@codemirror/autocomplete@6.17.0":
version "6.17.0"
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.17.0.tgz#24ff5fc37fd91f6439df6f4ff9c8e910cde1b053"
integrity sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.0.0"
@ -182,10 +182,10 @@ tslib@^2.3.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0"
integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==
typescript@^5.5.2:
version "5.5.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.2.tgz#c26f023cb0054e657ce04f72583ea2d85f8d0507"
integrity sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==
typescript@^5.7.2:
version "5.7.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6"
integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==
undici-types@~5.26.4:
version "5.26.5"

View File

@ -5,7 +5,8 @@ export COMMIT=$(git rev-parse --short HEAD)
# package.json
yarn files:set-version
echo "$(jq --arg name 'Zoo Modeling App (Nightly)' '.productName=$name' package.json --indent 2)" > package.json
PACKAGE=$(jq '.productName="Zoo Modeling App (Nightly)" | .name="zoo-modeling-app-nightly"' package.json --indent 2)
echo "$PACKAGE" > package.json
# electron-builder.yml
yq -i '.publish[0].url = "https://dl.zoo.dev/releases/modeling-app/nightly"' electron-builder.yml

View File

@ -22,7 +22,7 @@ import {
import { Coords2d, compareVec2Epsilon2 } from 'lang/std/sketch'
import { useModelingContext } from 'hooks/useModelingContext'
import * as TWEEN from '@tweenjs/tween.js'
import { Axis } from 'lib/selections'
import { Axis, NonCodeSelection } from 'lib/selections'
import { type BaseUnit } from 'lib/settings/settingsTypes'
import { CameraControls } from './CameraControls'
import { EngineCommandManager } from 'lang/std/engineConnection'
@ -654,7 +654,7 @@ export class SceneInfra {
await this.onClickCallback({ mouseEvent, intersects })
}
}
updateOtherSelectionColors = (otherSelections: Axis[]) => {
updateOtherSelectionColors = (otherSelections: NonCodeSelection[]) => {
const axisGroup = this.scene.children.find(
({ userData }) => userData?.type === AXIS_GROUP
)

View File

@ -1,21 +1,26 @@
import { useSelector } from '@xstate/react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useKclContext } from 'lang/KclProvider'
import { Artifact } from 'lang/std/artifactGraph'
import { CommandArgument } from 'lib/commandTypes'
import {
canSubmitSelectionArg,
getSelectionType,
getSelectionCountByType,
getSelectionTypeDisplayText,
} from 'lib/selections'
import { kclManager } from 'lib/singletons'
import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils'
import { modelingMachine } from 'machines/modelingMachine'
import { useEffect, useMemo, useRef, useState } from 'react'
import { StateFrom } from 'xstate'
const semanticEntityNames: { [key: string]: Array<Artifact['type']> } = {
const semanticEntityNames: {
[key: string]: Array<Artifact['type'] | 'defaultPlane'>
} = {
face: ['wall', 'cap', 'solid2D'],
edge: ['segment', 'sweepEdge', 'edgeCutEdge'],
point: [],
plane: ['defaultPlane'],
}
function getSemanticSelectionType(selectionType: Array<Artifact['type']>) {
@ -43,21 +48,13 @@ function CommandBarSelectionInput({
stepBack: () => void
onSubmit: (data: unknown) => void
}) {
const { code } = useKclContext()
const inputRef = useRef<HTMLInputElement>(null)
const { commandBarState, commandBarSend } = useCommandsContext()
const [hasSubmitted, setHasSubmitted] = useState(false)
const selection = useSelector(arg.machineActor, selectionSelector)
const selectionsByType = useMemo(() => {
const selectionRangeEnd = !selection
? null
: selection?.graphSelections[0]?.codeRef?.range[1]
return !selectionRangeEnd || selectionRangeEnd === code.length || !selection
? 'none'
: !selection
? 'none'
: getSelectionType(selection)
}, [selection, code])
return getSelectionCountByType(selection)
}, [selection])
const canSubmitSelection = useMemo<boolean>(
() => canSubmitSelectionArg(selectionsByType, arg),
[selectionsByType]
@ -67,6 +64,30 @@ function CommandBarSelectionInput({
inputRef.current?.focus()
}, [selection, inputRef])
// Show the default planes if the selection type is 'plane'
useEffect(() => {
if (arg.selectionTypes.includes('plane') && !canSubmitSelection) {
toSync(() => {
return Promise.all([
kclManager.showPlanes(),
kclManager.setSelectionFilter(['plane', 'object']),
])
}, reportRejection)()
}
return () => {
toSync(() => {
const promises = [
new Promise(() => kclManager.defaultSelectionFilter()),
]
if (!kclManager._isAstEmpty(kclManager.ast)) {
promises.push(kclManager.hidePlanes())
}
return Promise.all(promises)
}, reportRejection)()
}
}, [])
// Fast-forward through this arg if it's marked as skippable
// and we have a valid selection already
useEffect(() => {
@ -109,11 +130,15 @@ function CommandBarSelectionInput({
{arg.warningMessage}
</p>
)}
<span data-testid="cmd-bar-arg-name" className="sr-only">
{arg.name}
</span>
<input
id="selection"
name="selection"
ref={inputRef}
required
data-testid="cmd-bar-arg-value"
placeholder="Select an entity with your mouse"
className="absolute inset-0 w-full h-full opacity-0 cursor-default"
onKeyDown={(event) => {

View File

@ -818,15 +818,16 @@ const CustomIconMap = {
),
plane: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="plane"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.92871 5.11391L4.43964 5.00995V4.10898V3.60898V3.10898L4.92871 3.21293L5.41778 3.31689L6.29907 3.50421V4.00421V4.50421L5.41778 4.31689V5.21786L4.92871 5.11391ZM11.8774 4.68991L8.1585 3.89945V4.39945V4.89945L11.8774 5.68991V5.18991V4.68991ZM13.7368 5.08515V5.58515V6.08515L14.6181 6.27247V7.17344L15.1071 7.2774L15.5962 7.38135V6.48038V5.98038V5.48038L15.1071 5.37643L14.6181 5.27247L13.7368 5.08515ZM15.5962 9.28233L15.1071 9.17837L14.6181 9.07441V12.8764L15.1071 12.9803L15.5962 13.0843V9.28233ZM15.5962 14.9852L15.1071 14.8813L14.6181 14.7773V15.6783L13.7368 15.491V15.991V16.491L14.6181 16.6783L15.1071 16.7823L15.5962 16.8862V16.3862V15.8862V14.9852ZM11.8774 16.0957V15.5957V15.0957L8.1585 14.3053V14.8053V15.3053L11.8774 16.0957ZM6.29907 14.91V14.41V13.91L5.41778 13.7227V12.8217L4.92871 12.7178L4.43964 12.6138V13.5148V14.0148V14.5148L4.92871 14.6188L5.41778 14.7227L6.29907 14.91ZM4.43964 10.7129L4.92871 10.8168L5.41778 10.9208V7.11883L4.92871 7.01488L4.43964 6.91092V10.7129Z"
d="M10.9781 5.49876L14.6181 6.27247V9.99381L10.9781 9.22011V5.49876ZM10 4.29085L10.9781 4.49876L14.6181 5.27247L14.6182 5.27247L15.5963 5.48038H15.5963V6.48038V10.2017V11.2017L15.5963 11.2017V15.8862V16.8862L14.6181 16.6783L5.41784 14.7227L4.4397 14.5148V13.5148V4.10898V3.10898L5.41784 3.31689L10 4.29085ZM14.6181 10.9938V15.6783L5.41784 13.7227V4.31689L10 5.29085V9.0122V10.0122L10.9781 10.2201L14.6181 10.9938Z"
fill="currentColor"
/>
</svg>

View File

@ -317,6 +317,7 @@ export const ModelingMachineProvider = ({
})
})
}
let selections: Selections = {
graphSelections: [],
otherSelections: [],
@ -375,7 +376,10 @@ export const ModelingMachineProvider = ({
}
}
if (setSelections.selectionType === 'otherSelection') {
if (
setSelections.selectionType === 'axisSelection' ||
setSelections.selectionType === 'defaultPlaneSelection'
) {
if (editorManager.isShiftDown) {
selections = {
graphSelections: selectionRanges.graphSelections,
@ -387,20 +391,11 @@ export const ModelingMachineProvider = ({
otherSelections: [setSelections.selection],
}
}
const { engineEvents, updateSceneObjectColors } =
handleSelectionBatch({
selections: selections,
})
engineEvents &&
engineEvents.forEach((event) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
engineCommandManager.sendSceneCommand(event)
})
updateSceneObjectColors()
return {
selectionRanges: selections,
}
}
if (setSelections.selectionType === 'completeSelection') {
editorManager.selectRange(setSelections.selection)
if (!sketchDetails)

View File

@ -13,7 +13,7 @@ import { isDesktop } from 'lib/isDesktop'
import { ActionButton } from 'components/ActionButton'
import { SettingsFieldInput } from './SettingsFieldInput'
import toast from 'react-hot-toast'
import { APP_VERSION } from 'routes/Settings'
import { APP_VERSION, PACKAGE_NAME } from 'routes/Settings'
import { PATHS } from 'lib/paths'
import {
createAndOpenNewTutorialProject,
@ -24,6 +24,7 @@ import { ForwardedRef, forwardRef, useEffect } from 'react'
import { useLspContext } from 'components/LspProvider'
import { toSync } from 'lib/utils'
import { reportRejection } from 'lib/trap'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel
@ -245,6 +246,9 @@ export const AllSettingsFields = forwardRef(
to inject the version from package.json */}
App version {APP_VERSION}.{' '}
<a
onClick={openExternalBrowserIfDesktop(
`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`
)}
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
target="_blank"
rel="noopener noreferrer"
@ -255,6 +259,9 @@ export const AllSettingsFields = forwardRef(
<p className="max-w-2xl mt-6">
Don't see the feature you want? Check to see if it's on{' '}
<a
onClick={openExternalBrowserIfDesktop(
'https://github.com/KittyCAD/modeling-app/discussions'
)}
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
@ -264,6 +271,25 @@ export const AllSettingsFields = forwardRef(
, and start a discussion if you don't see it! Your feedback will
help us prioritize what to build next.
</p>
{PACKAGE_NAME.indexOf('-nightly') === -1 && (
<p className="max-w-2xl mt-6">
Want to experience the latest and (hopefully) greatest from our
main development branch?{' '}
<a
onClick={openExternalBrowserIfDesktop(
'https://zoo.dev/modeling-app/download/nightly'
)}
href="https://zoo.dev/modeling-app/download/nightly"
target="_blank"
rel="noopener noreferrer"
>
Click here to grab Zoo Modeling App (Nightly)
</a>
. It can be installed side-by-side with the stable version
you're running now. But careful there, a lot less testing is
involved in their release 🤖.
</p>
)}
</div>
</div>
</div>

View File

@ -17,6 +17,9 @@ import {
import { useRouteLoaderData } from 'react-router-dom'
import { PATHS } from 'lib/paths'
import { IndexLoaderData } from 'lib/types'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { err, reportRejection } from 'lib/trap'
import { getArtifactOfTypes } from 'lang/std/artifactGraph'
enum StreamState {
Playing = 'playing',
@ -30,6 +33,7 @@ export const Stream = () => {
const videoRef = useRef<HTMLVideoElement>(null)
const { settings } = useSettingsAuthContext()
const { state, send } = useModelingContext()
const { commandBarState } = useCommandsContext()
const { mediaStream } = useAppStream()
const { overallState, immediateState } = useNetworkContext()
const [streamState, setStreamState] = useState(StreamState.Unset)
@ -260,7 +264,15 @@ export const Stream = () => {
if (!videoRef.current) return
// If we're in sketch mode, don't send a engine-side select event
if (state.matches('Sketch')) return
if (state.matches({ idle: 'showPlanes' })) return
// Only respect default plane selection if we're on a selection command argument
if (
state.matches({ idle: 'showPlanes' }) &&
!(
commandBarState.matches('Gathering arguments') &&
commandBarState.context.currentArgument?.inputType === 'selection'
)
)
return
// If we're mousing up from a camera drag, don't send a select event
if (sceneInfra.camControls.wasDragging === true) return
@ -270,12 +282,49 @@ export const Stream = () => {
}
}
/**
* On double-click of sketch entities we automatically enter sketch mode with the selected sketch,
* allowing for quick editing of sketches. TODO: This should be moved to a more central place.
*/
const enterSketchModeIfSelectingSketch: MouseEventHandler<HTMLDivElement> = (
e
) => {
if (
!isNetworkOkay ||
!videoRef.current ||
state.matches('Sketch') ||
state.matches({ idle: 'showPlanes' }) ||
sceneInfra.camControls.wasDragging === true ||
!btnName(e.nativeEvent).left
) {
return
}
sendSelectEventToEngine(e, videoRef.current)
.then(({ entity_id }) => {
if (!entity_id) {
// No entity selected. This is benign
return
}
const path = getArtifactOfTypes(
{ key: entity_id, types: ['path', 'solid2D', 'segment'] },
engineCommandManager.artifactGraph
)
if (err(path)) {
return path
}
sceneInfra.modelingSend({ type: 'Enter sketch' })
})
.catch(reportRejection)
}
return (
<div
className="absolute inset-0 z-0"
id="stream"
data-testid="stream"
onClick={handleMouseUp}
onDoubleClick={enterSketchModeIfSelectingSketch}
onContextMenu={(e) => e.preventDefault()}
onContextMenuCapture={(e) => e.preventDefault()}
>

View File

@ -169,6 +169,7 @@ export function useEngineConnectionSubscriptions() {
pathToNode: artifact.codeRef.pathToNode,
},
})
return
}
// Artifact is likely an extrusion face

View File

@ -23,6 +23,7 @@ import { codeManager, editorManager, sceneInfra } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint'
import { markOnce } from 'lib/performance'
import { Node } from 'wasm-lib/kcl/bindings/Node'
import { EntityType_type } from '@kittycad/lib/dist/types/src/models'
interface ExecuteArgs {
ast?: Node<Program>
@ -37,6 +38,7 @@ interface ExecuteArgs {
export class KclManager {
private _ast: Node<Program> = {
body: [],
shebang: null,
start: 0,
end: 0,
moduleId: 0,
@ -204,6 +206,7 @@ export class KclManager {
clearAst() {
this._ast = {
body: [],
shebang: null,
start: 0,
end: 0,
moduleId: 0,
@ -279,7 +282,7 @@ export class KclManager {
this.lints = await lintAst({ ast: ast })
sceneInfra.modelingSend({ type: 'code edit during sketch' })
defaultSelectionFilter(execState.memory, this.engineCommandManager)
setSelectionFilterToDefault(execState.memory, this.engineCommandManager)
if (args.zoomToFit) {
let zoomObjectId: string | undefined = ''
@ -566,8 +569,13 @@ export class KclManager {
}
return Promise.all(thePromises)
}
/** TODO: this function is hiding unawaited asynchronous work */
defaultSelectionFilter() {
defaultSelectionFilter(this.programMemory, this.engineCommandManager)
setSelectionFilterToDefault(this.programMemory, this.engineCommandManager)
}
/** TODO: this function is hiding unawaited asynchronous work */
setSelectionFilter(filter: EntityType_type[]) {
setSelectionFilter(filter, this.engineCommandManager)
}
/**
@ -589,18 +597,35 @@ export class KclManager {
}
}
function defaultSelectionFilter(
const defaultSelectionFilter: EntityType_type[] = [
'face',
'edge',
'solid2d',
'curve',
'object',
]
/** TODO: This function is not synchronous but is currently treated as such */
function setSelectionFilterToDefault(
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager
) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
programMemory.hasSketchOrSolid() &&
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'set_selection_filter',
filter: ['face', 'edge', 'solid2d', 'curve'],
},
})
setSelectionFilter(defaultSelectionFilter, engineCommandManager)
}
/** TODO: This function is not synchronous but is currently treated as such */
function setSelectionFilter(
filter: EntityType_type[],
engineCommandManager: EngineCommandManager
) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'set_selection_filter',
filter,
},
})
}

View File

@ -384,7 +384,6 @@ const myVar = funcN(1, 2)`
raw: '2',
},
],
optional: false,
},
},
],
@ -465,7 +464,6 @@ describe('testing pipe operator special', () => {
],
},
],
optional: false,
},
{
type: 'CallExpression',
@ -508,7 +506,6 @@ describe('testing pipe operator special', () => {
end: 60,
},
],
optional: false,
},
{
type: 'CallExpression',
@ -556,7 +553,6 @@ describe('testing pipe operator special', () => {
value: 'myPath',
},
],
optional: false,
},
{
type: 'CallExpression',
@ -598,7 +594,6 @@ describe('testing pipe operator special', () => {
end: 115,
},
],
optional: false,
},
{
type: 'CallExpression',
@ -625,7 +620,6 @@ describe('testing pipe operator special', () => {
end: 130,
},
],
optional: false,
},
],
},
@ -711,7 +705,6 @@ describe('testing pipe operator special', () => {
end: 35,
},
],
optional: false,
},
],
},
@ -1765,7 +1758,6 @@ describe('test UnaryExpression', () => {
raw: '100',
},
],
optional: false,
},
})
})
@ -1837,11 +1829,9 @@ describe('testing nested call expressions', () => {
raw: '3',
},
],
optional: false,
},
},
],
optional: false,
})
})
})
@ -1879,7 +1869,6 @@ describe('should recognise callExpresions in binaryExpressions', () => {
name: 'seg02',
},
],
optional: false,
},
right: {
type: 'Literal',

View File

@ -123,6 +123,7 @@ describe('Testing addSketchTo', () => {
const result = addSketchTo(
{
body: [],
shebang: null,
start: 0,
end: 0,
moduleId: 0,

View File

@ -527,6 +527,45 @@ export function sketchOnExtrudedFace(
}
}
/**
* Append an offset plane to the AST
*/
export function addOffsetPlane({
node,
defaultPlane,
offset,
}: {
node: Node<Program>
defaultPlane: DefaultPlaneStr
offset: Expr
}): { modifiedAst: Node<Program>; pathToNode: PathToNode } {
const modifiedAst = structuredClone(node)
const newPlaneName = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.PLANE)
const newPlane = createVariableDeclaration(
newPlaneName,
createCallExpressionStdLib('offsetPlane', [
createLiteral(defaultPlane.toUpperCase()),
offset,
])
)
modifiedAst.body.push(newPlane)
const pathToNode: PathToNode = [
['body', ''],
[modifiedAst.body.length - 1, 'index'],
['declarations', 'VariableDeclaration'],
['0', 'index'],
['init', 'VariableDeclarator'],
['arguments', 'CallExpression'],
[0, 'index'],
]
return {
modifiedAst,
pathToNode,
}
}
/**
* Modify the AST to create a new sketch using the variable declaration
* of an offset plane. The new sketch just has to come after the offset
@ -688,7 +727,6 @@ export function createCallExpressionStdLib(
name,
},
optional: false,
arguments: args,
}
}
@ -710,7 +748,6 @@ export function createCallExpression(
name,
},
optional: false,
arguments: args,
}
}

View File

@ -63,7 +63,7 @@ log(5, myVar)
})
it('function declaration with call', () => {
const code = [
'fn funcN = (a, b) => {',
'fn funcN(a, b) {',
' return a + b',
'}',
'theVar = 60',
@ -101,7 +101,7 @@ log(5, myVar)
})
it('recast BinaryExpression piped into CallExpression', () => {
const code = [
'fn myFn = (a) => {',
'fn myFn(a) {',
' return a + 1',
'}',
'myVar = 5 + 1',
@ -245,7 +245,7 @@ key = 'c'
expect(recasted).toBe(code)
})
it('comments in a fn block', () => {
const code = `fn myFn = () => {
const code = `fn myFn() {
// this is a comment
yo = { a = { b = { c = '123' } } }

View File

@ -1823,6 +1823,7 @@ export const updateStartProfileAtArgs: SketchLineHelper['updateArgs'] = ({
modifiedAst: {
start: 0,
end: 0,
shebang: null,
moduleId: 0,
body: [],

View File

@ -1,382 +0,0 @@
import { lexer, initPromise } from './wasm'
import { err } from 'lib/trap'
beforeAll(async () => {
await initPromise
})
describe('testing lexer', () => {
it('async lexer works too', async () => {
const code = '1 + 2'
const code2 = `const yo = {key: 'value'}`
const code3 = `const yo = 45 /* this is a comment
const ya = 6 */
const yi=45`
expect(lexer(code)).toEqual(lexer(code))
expect(lexer(code2)).toEqual(lexer(code2))
expect(lexer(code3)).toEqual(lexer(code3))
})
it('test lexer', () => {
expect(stringSummaryLexer('1 + 2')).toEqual([
"number '1' from 0 to 1",
"whitespace ' ' from 1 to 3",
"operator '+' from 3 to 4",
"whitespace ' ' from 4 to 5",
"number '2' from 5 to 6",
])
expect(stringSummaryLexer('54 + 22500 + 6')).toEqual([
"number '54' from 0 to 2",
"whitespace ' ' from 2 to 3",
"operator '+' from 3 to 4",
"whitespace ' ' from 4 to 5",
"number '22500' from 5 to 10",
"whitespace ' ' from 10 to 11",
"operator '+' from 11 to 12",
"whitespace ' ' from 12 to 13",
"number '6' from 13 to 14",
])
expect(stringSummaryLexer('a + bo + t5 - 6')).toEqual([
"word 'a' from 0 to 1",
"whitespace ' ' from 1 to 2",
"operator '+' from 2 to 3",
"whitespace ' ' from 3 to 4",
"word 'bo' from 4 to 6",
"whitespace ' ' from 6 to 7",
"operator '+' from 7 to 8",
"whitespace ' ' from 8 to 9",
"word 't5' from 9 to 11",
"whitespace ' ' from 11 to 12",
"operator '-' from 12 to 13",
"whitespace ' ' from 13 to 14",
"number '6' from 14 to 15",
])
expect(stringSummaryLexer('a + "a str" - 6')).toEqual([
"word 'a' from 0 to 1",
"whitespace ' ' from 1 to 2",
"operator '+' from 2 to 3",
"whitespace ' ' from 3 to 4",
'string \'"a str"\' from 4 to 11',
"whitespace ' ' from 11 to 12",
"operator '-' from 12 to 13",
"whitespace ' ' from 13 to 14",
"number '6' from 14 to 15",
])
expect(stringSummaryLexer("a + 'str'")).toEqual([
"word 'a' from 0 to 1",
"whitespace ' ' from 1 to 2",
"operator '+' from 2 to 3",
"whitespace ' ' from 3 to 4",
"string ''str'' from 4 to 9",
])
expect(stringSummaryLexer("a +'str'")).toEqual([
"word 'a' from 0 to 1",
"whitespace ' ' from 1 to 2",
"operator '+' from 2 to 3",
"string ''str'' from 3 to 8",
])
expect(stringSummaryLexer('a + (sick)')).toEqual([
"word 'a' from 0 to 1",
"whitespace ' ' from 1 to 2",
"operator '+' from 2 to 3",
"whitespace ' ' from 3 to 4",
"brace '(' from 4 to 5",
"word 'sick' from 5 to 9",
"brace ')' from 9 to 10",
])
expect(stringSummaryLexer('a + { sick}')).toEqual([
"word 'a' from 0 to 1",
"whitespace ' ' from 1 to 2",
"operator '+' from 2 to 3",
"whitespace ' ' from 3 to 4",
"brace '{' from 4 to 5",
"whitespace ' ' from 5 to 6",
"word 'sick' from 6 to 10",
"brace '}' from 10 to 11",
])
expect(stringSummaryLexer("log('hi')")).toEqual([
"word 'log' from 0 to 3",
"brace '(' from 3 to 4",
"string ''hi'' from 4 to 8",
"brace ')' from 8 to 9",
])
expect(stringSummaryLexer("log('hi', 'hello')")).toEqual([
"word 'log' from 0 to 3",
"brace '(' from 3 to 4",
"string ''hi'' from 4 to 8",
"comma ',' from 8 to 9",
"whitespace ' ' from 9 to 10",
"string ''hello'' from 10 to 17",
"brace ')' from 17 to 18",
])
expect(stringSummaryLexer('fn funcName = (param1, param2) => {}')).toEqual([
"keyword 'fn' from 0 to 2",
"whitespace ' ' from 2 to 3",
"word 'funcName' from 3 to 11",
"whitespace ' ' from 11 to 12",
"operator '=' from 12 to 13",
"whitespace ' ' from 13 to 14",
"brace '(' from 14 to 15",
"word 'param1' from 15 to 21",
"comma ',' from 21 to 22",
"whitespace ' ' from 22 to 23",
"word 'param2' from 23 to 29",
"brace ')' from 29 to 30",
"whitespace ' ' from 30 to 31",
"operator '=>' from 31 to 33",
"whitespace ' ' from 33 to 34",
"brace '{' from 34 to 35",
"brace '}' from 35 to 36",
])
})
it('test negative and decimal numbers', () => {
expect(stringSummaryLexer('-1')).toEqual([
"operator '-' from 0 to 1",
"number '1' from 1 to 2",
])
expect(stringSummaryLexer('-1.5')).toEqual([
"operator '-' from 0 to 1",
"number '1.5' from 1 to 4",
])
expect(stringSummaryLexer('1.5')).toEqual([
"number '1.5' from 0 to 3",
])
expect(stringSummaryLexer('1.5 + 2.5')).toEqual([
"number '1.5' from 0 to 3",
"whitespace ' ' from 3 to 4",
"operator '+' from 4 to 5",
"whitespace ' ' from 5 to 6",
"number '2.5' from 6 to 9",
])
expect(stringSummaryLexer('1.5 - 2.5')).toEqual([
"number '1.5' from 0 to 3",
"whitespace ' ' from 3 to 4",
"operator '-' from 4 to 5",
"whitespace ' ' from 5 to 6",
"number '2.5' from 6 to 9",
])
expect(stringSummaryLexer('1.5 + -2.5')).toEqual([
"number '1.5' from 0 to 3",
"whitespace ' ' from 3 to 4",
"operator '+' from 4 to 5",
"whitespace ' ' from 5 to 6",
"operator '-' from 6 to 7",
"number '2.5' from 7 to 10",
])
expect(stringSummaryLexer('-1.5 + 2.5')).toEqual([
"operator '-' from 0 to 1",
"number '1.5' from 1 to 4",
"whitespace ' ' from 4 to 5",
"operator '+' from 5 to 6",
"whitespace ' ' from 6 to 7",
"number '2.5' from 7 to 10",
])
})
it('testing piping operator', () => {
const result = stringSummaryLexer(`sketch mySketch {
lineTo(2, 3)
} |> rx(45, %)`)
expect(result).toEqual([
"type 'sketch' from 0 to 6",
"whitespace ' ' from 6 to 7",
"word 'mySketch' from 7 to 15",
"whitespace ' ' from 15 to 16",
"brace '{' from 16 to 17",
"whitespace '\n ' from 17 to 24",
"word 'lineTo' from 24 to 30",
"brace '(' from 30 to 31",
"number '2' from 31 to 32",
"comma ',' from 32 to 33",
"whitespace ' ' from 33 to 34",
"number '3' from 34 to 35",
"brace ')' from 35 to 36",
"whitespace '\n ' from 36 to 41",
"brace '}' from 41 to 42",
"whitespace ' ' from 42 to 43",
"operator '|>' from 43 to 45",
"whitespace ' ' from 45 to 46",
"word 'rx' from 46 to 48",
"brace '(' from 48 to 49",
"number '45' from 49 to 51",
"comma ',' from 51 to 52",
"whitespace ' ' from 52 to 53",
"operator '%' from 53 to 54",
"brace ')' from 54 to 55",
])
})
it('testing array declaration', () => {
const result = stringSummaryLexer(`const yo = [1, 2]`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
"operator '=' from 9 to 10",
"whitespace ' ' from 10 to 11",
"brace '[' from 11 to 12",
"number '1' from 12 to 13",
"comma ',' from 13 to 14",
"whitespace ' ' from 14 to 15",
"number '2' from 15 to 16",
"brace ']' from 16 to 17",
])
})
it('testing object declaration', () => {
const result = stringSummaryLexer(`const yo = {key: 'value'}`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
"operator '=' from 9 to 10",
"whitespace ' ' from 10 to 11",
"brace '{' from 11 to 12",
"word 'key' from 12 to 15",
"colon ':' from 15 to 16",
"whitespace ' ' from 16 to 17",
"string ''value'' from 17 to 24",
"brace '}' from 24 to 25",
])
})
it('testing object property access', () => {
const result = stringSummaryLexer(`const yo = {key: 'value'}
const prop = yo.key
const prop2 = yo['key']
const key = 'key'
const prop3 = yo[key]`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
"operator '=' from 9 to 10",
"whitespace ' ' from 10 to 11",
"brace '{' from 11 to 12",
"word 'key' from 12 to 15",
"colon ':' from 15 to 16",
"whitespace ' ' from 16 to 17",
"string ''value'' from 17 to 24",
"brace '}' from 24 to 25",
"whitespace '\n' from 25 to 26",
"keyword 'const' from 26 to 31",
"whitespace ' ' from 31 to 32",
"word 'prop' from 32 to 36",
"whitespace ' ' from 36 to 37",
"operator '=' from 37 to 38",
"whitespace ' ' from 38 to 39",
"word 'yo' from 39 to 41",
"period '.' from 41 to 42",
"word 'key' from 42 to 45",
"whitespace '\n' from 45 to 46",
"keyword 'const' from 46 to 51",
"whitespace ' ' from 51 to 52",
"word 'prop2' from 52 to 57",
"whitespace ' ' from 57 to 58",
"operator '=' from 58 to 59",
"whitespace ' ' from 59 to 60",
"word 'yo' from 60 to 62",
"brace '[' from 62 to 63",
"string ''key'' from 63 to 68",
"brace ']' from 68 to 69",
"whitespace '\n' from 69 to 70",
"keyword 'const' from 70 to 75",
"whitespace ' ' from 75 to 76",
"word 'key' from 76 to 79",
"whitespace ' ' from 79 to 80",
"operator '=' from 80 to 81",
"whitespace ' ' from 81 to 82",
"string ''key'' from 82 to 87",
"whitespace '\n' from 87 to 88",
"keyword 'const' from 88 to 93",
"whitespace ' ' from 93 to 94",
"word 'prop3' from 94 to 99",
"whitespace ' ' from 99 to 100",
"operator '=' from 100 to 101",
"whitespace ' ' from 101 to 102",
"word 'yo' from 102 to 104",
"brace '[' from 104 to 105",
"word 'key' from 105 to 108",
"brace ']' from 108 to 109",
])
})
it('testing tokenising line comments', () => {
const result = stringSummaryLexer(`const yo = 45 // this is a comment
const yo = 6`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
"operator '=' from 9 to 10",
"whitespace ' ' from 10 to 11",
"number '45' from 11 to 13",
"whitespace ' ' from 13 to 14",
"lineComment '// this is a comment' from 14 to 34",
"whitespace '\n' from 34 to 35",
"keyword 'const' from 35 to 40",
"whitespace ' ' from 40 to 41",
"word 'yo' from 41 to 43",
"whitespace ' ' from 43 to 44",
"operator '=' from 44 to 45",
"whitespace ' ' from 45 to 46",
"number '6' from 46 to 47",
])
})
it('testing tokenising line comments by itself', () => {
const result = stringSummaryLexer(`log('hi')
// comment on a line by itself
const yo=45`)
expect(result).toEqual([
"word 'log' from 0 to 3",
"brace '(' from 3 to 4",
"string ''hi'' from 4 to 8",
"brace ')' from 8 to 9",
"whitespace '\n' from 9 to 10",
"lineComment '// comment on a line by itself' from 10 to 40",
"whitespace '\n' from 40 to 41",
"keyword 'const' from 41 to 46",
"whitespace ' ' from 46 to 47",
"word 'yo' from 47 to 49",
"operator '=' from 49 to 50",
"number '45' from 50 to 52",
])
})
it('testing tokenising block comments', () => {
const result = stringSummaryLexer(`const yo = 45 /* this is a comment
const ya = 6 */
const yi=45`)
expect(result).toEqual([
"keyword 'const' from 0 to 5",
"whitespace ' ' from 5 to 6",
"word 'yo' from 6 to 8",
"whitespace ' ' from 8 to 9",
"operator '=' from 9 to 10",
"whitespace ' ' from 10 to 11",
"number '45' from 11 to 13",
"whitespace ' ' from 13 to 14",
`blockComment '/* this is a comment
const ya = 6 */' from 14 to 50`,
"whitespace '\n' from 50 to 51",
"keyword 'const' from 51 to 56",
"whitespace ' ' from 56 to 57",
"word 'yi' from 57 to 59",
"operator '=' from 59 to 60",
"number '45' from 60 to 62",
])
})
})
// helpers
const stringSummaryLexer = (input: string) => {
const tokens = lexer(input)
if (err(tokens)) return []
return tokens.map(
({ type, value, start, end }) =>
`${type.padEnd(12, ' ')} ${`'${value}'`.padEnd(10, ' ')} from ${String(
start
).padEnd(3, ' ')} to ${end}`
)
}

View File

@ -3,7 +3,6 @@ import init, {
recast_wasm,
execute_wasm,
kcl_lint,
lexer_wasm,
modify_ast_for_sketch_wasm,
is_points_ccw,
get_tangential_arc_to_info,
@ -24,7 +23,6 @@ import { EngineCommandManager } from './std/engineConnection'
import { Discovered } from '../wasm-lib/kcl/bindings/Discovered'
import { KclValue } from '../wasm-lib/kcl/bindings/KclValue'
import type { Program } from '../wasm-lib/kcl/bindings/Program'
import type { Token } from '../wasm-lib/kcl/bindings/Token'
import { Coords2d } from './std/sketch'
import { fileSystemManager } from 'lang/std/fileSystemManager'
import { CoreDumpInfo } from 'wasm-lib/kcl/bindings/CoreDumpInfo'
@ -507,10 +505,6 @@ export const modifyGrid = async (
}
}
export function lexer(str: string): Token[] | Error {
return lexer_wasm(str)
}
export const modifyAstForSketch = async (
engineCommandManager: EngineCommandManager,
ast: Node<Program>,

View File

@ -40,6 +40,10 @@ export type ModelingCommandSchema = {
selection: Selections
radius: KclCommandValue
}
'Offset plane': {
plane: Selections
distance: KclCommandValue
}
'change tool': {
tool: SketchTool
}
@ -276,6 +280,24 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
},
},
},
'Offset plane': {
description: 'Offset a plane.',
icon: 'plane',
args: {
plane: {
inputType: 'selection',
selectionTypes: ['plane'],
multiple: false,
required: true,
skip: true,
},
distance: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_LENGTH,
required: true,
},
},
},
Fillet: {
description: 'Fillet edge',
icon: 'fillet',

View File

@ -54,6 +54,7 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = {
EXTRUDE: 'extrude',
SEGMENT: 'seg',
REVOLVE: 'revolve',
PLANE: 'plane',
} as const
/** The default KCL length expression */
export const KCL_DEFAULT_LENGTH = `5`

View File

@ -21,6 +21,7 @@ import {
} from 'lang/queryAst'
import { CommandArgument } from './commandTypes'
import {
DefaultPlaneStr,
getParentGroup,
SEGMENT_BODIES_PLUS_PROFILE_START,
} from 'clientSideScene/sceneEntities'
@ -46,6 +47,10 @@ export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
export type Axis = 'y-axis' | 'x-axis' | 'z-axis'
export type DefaultPlaneSelection = {
name: DefaultPlaneStr
id: string
}
/** @deprecated Use {@link Artifact} instead. */
type Selection__old =
@ -72,9 +77,11 @@ type Selection__old =
// TODO this is a temporary measure that well be made redundant with: https://github.com/KittyCAD/modeling-app/pull/3836
secondaryRange: SourceRange
}
export type NonCodeSelection = Axis | DefaultPlaneSelection
/** @deprecated Use {@link Selection} instead. */
export type Selections__old = {
otherSelections: Axis[]
otherSelections: NonCodeSelection[]
codeBasedSelections: Selection__old[]
}
export interface Selection {
@ -82,7 +89,7 @@ export interface Selection {
codeRef: CodeRef
}
export type Selections = {
otherSelections: Array<Axis>
otherSelections: Array<NonCodeSelection>
graphSelections: Array<Selection>
}
@ -172,11 +179,31 @@ export async function getEventForSelectWithPoint({
return {
type: 'Set selection',
data: {
selectionType: 'otherSelection',
selectionType: 'axisSelection',
selection: X_AXIS_UUID === data.entity_id ? 'x-axis' : 'y-axis',
},
}
}
// Check for default plane selection
const foundDefaultPlane =
engineCommandManager.defaultPlanes !== null &&
Object.entries(engineCommandManager.defaultPlanes).find(
([, plane]) => plane === data.entity_id
)
if (foundDefaultPlane) {
return {
type: 'Set selection',
data: {
selectionType: 'defaultPlaneSelection',
selection: {
name: foundDefaultPlane[0] as DefaultPlaneStr,
id: data.entity_id,
},
},
}
}
let _artifact = engineCommandManager.artifactGraph.get(data.entity_id)
const codeRefs = getCodeRefsByArtifactId(
data.entity_id,
@ -207,7 +234,7 @@ export function getEventForSegmentSelection(
return {
type: 'Set selection',
data: {
selectionType: 'otherSelection',
selectionType: 'axisSelection',
selection: obj?.userData?.type === X_AXIS ? 'x-axis' : 'y-axis',
},
}
@ -272,7 +299,6 @@ export function handleSelectionBatch({
}): {
engineEvents: Models['WebSocketRequest_type'][]
codeMirrorSelection: EditorSelection
otherSelections: Axis[]
updateSceneObjectColors: () => void
} {
const ranges: ReturnType<typeof EditorSelection.cursor>[] = []
@ -303,7 +329,6 @@ export function handleSelectionBatch({
ranges,
selections.graphSelections.length - 1
),
otherSelections: selections.otherSelections,
updateSceneObjectColors: () =>
updateSceneObjectColors(selections.graphSelections),
}
@ -314,7 +339,6 @@ export function handleSelectionBatch({
0
),
engineEvents,
otherSelections: selections.otherSelections,
updateSceneObjectColors: () =>
updateSceneObjectColors(selections.graphSelections),
}
@ -536,7 +560,8 @@ export function canSweepSelection(selection: Selections) {
}
// This accounts for non-geometry selections under "other"
export type ResolvedSelectionType = [Artifact['type'] | 'other', number]
export type ResolvedSelectionType = Artifact['type'] | 'other'
export type SelectionCountsByType = Map<ResolvedSelectionType, number>
/**
* In the future, I'd like this function to properly return the type of each selected entity based on
@ -545,28 +570,48 @@ export type ResolvedSelectionType = [Artifact['type'] | 'other', number]
* @param selection
* @returns
*/
export function getSelectionType(
export function getSelectionCountByType(
selection?: Selections
): ResolvedSelectionType[] {
if (!selection) return []
const selectionsWithArtifacts = selection.graphSelections.filter(
(s) => !!s.artifact
): SelectionCountsByType | 'none' {
const selectionsByType: SelectionCountsByType = new Map()
if (
!selection ||
(!selection.graphSelections.length && !selection.otherSelections.length)
)
const firstSelection = selectionsWithArtifacts[0]
const firstSelectionType = firstSelection?.artifact?.type
if (!firstSelectionType) return []
const selectionsWithSameType = selectionsWithArtifacts.filter(
(s) => s.artifact?.type === firstSelection.artifact?.type
)
return [[firstSelectionType, selectionsWithSameType.length]]
return 'none'
function incrementOrInitializeSelectionType(type: ResolvedSelectionType) {
const count = selectionsByType.get(type) || 0
selectionsByType.set(type, count + 1)
}
selection.otherSelections.forEach((selection) => {
if (typeof selection === 'string') {
incrementOrInitializeSelectionType('other')
} else if ('name' in selection) {
incrementOrInitializeSelectionType('plane')
}
})
selection.graphSelections.forEach((selection) => {
if (!selection.artifact) {
incrementOrInitializeSelectionType('other')
return
}
incrementOrInitializeSelectionType(selection.artifact.type)
})
return selectionsByType
}
export function getSelectionTypeDisplayText(
selection?: Selections
): string | null {
const selectionsByType = getSelectionType(selection)
const selectionsByType = getSelectionCountByType(selection)
if (selectionsByType === 'none') return null
return (selectionsByType as Exclude<typeof selectionsByType, 'none'>)
return selectionsByType
.entries()
.map(
// Hack for showing "face" instead of "extrude-wall" in command bar text
([type, count]) =>
@ -575,16 +620,17 @@ export function getSelectionTypeDisplayText(
.replace('solid2D', 'face')
.replace('segment', 'face')}${count > 1 ? 's' : ''}`
)
.toArray()
.join(', ')
}
export function canSubmitSelectionArg(
selectionsByType: 'none' | ResolvedSelectionType[],
selectionsByType: 'none' | Map<ResolvedSelectionType, number>,
argument: CommandArgument<unknown> & { inputType: 'selection' }
) {
return (
selectionsByType !== 'none' &&
selectionsByType.every(([type, count]) => {
selectionsByType.entries().every(([type, count]) => {
const foundIndex = argument.selectionTypes.findIndex((s) => s === type)
return (
foundIndex !== -1 &&

View File

@ -252,10 +252,15 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
[
{
id: 'plane-offset',
onClick: () =>
console.error('Plane through normal not yet implemented'),
onClick: ({ commandBarSend }) => {
commandBarSend({
type: 'Find and select command',
data: { name: 'Offset plane', groupId: 'modeling' },
})
},
hotkey: 'O',
icon: 'plane',
status: 'unavailable',
status: 'available',
title: 'Offset plane',
description: 'Create a plane parallel to an existing plane.',
links: [],

View File

@ -34,6 +34,8 @@ export function useCalculateKclExpression({
} {
const { programMemory, code } = useKclContext()
const { context } = useModelingContext()
// If there is no selection, use the end of the code
// so all variables are available
const selectionRange:
| (typeof context)['selectionRanges']['graphSelections'][number]['codeRef']['range']
| undefined = context.selectionRanges.graphSelections[0]?.codeRef?.range
@ -72,11 +74,12 @@ export function useCalculateKclExpression({
}, [programMemory, newVariableName])
useEffect(() => {
if (!programMemory || !selectionRange) return
if (!programMemory) return
const varInfo = findAllPreviousVariables(
kclManager.ast,
kclManager.programMemory,
selectionRange
// If there is no selection, use the end of the code
selectionRange || [code.length, code.length]
)
setAvailableVarInfo(varInfo)
}, [kclManager.ast, kclManager.programMemory, selectionRange])

File diff suppressed because one or more lines are too long

View File

@ -26,6 +26,10 @@ export const APP_VERSION =
window.electron.packageJson.version
: 'main'
export const PACKAGE_NAME = isDesktop()
? window.electron.packageJson.name
: 'zoo-modeling-app'
export const Settings = () => {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()

157
src/wasm-lib/Cargo.lock generated
View File

@ -228,6 +228,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "backtrace-ext"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
dependencies = [
"backtrace",
]
[[package]]
name = "base64"
version = "0.13.1"
@ -765,6 +774,22 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "dhat"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827"
dependencies = [
"backtrace",
"lazy_static",
"mintex",
"parking_lot 0.12.3",
"rustc-hash 1.1.0",
"serde",
"serde_json",
"thousands",
]
[[package]]
name = "diff"
version = "0.1.13"
@ -1625,6 +1650,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@ -1675,7 +1706,7 @@ dependencies = [
[[package]]
name = "kcl-lib"
version = "0.2.25"
version = "0.2.26"
dependencies = [
"anyhow",
"approx 0.5.1",
@ -1690,6 +1721,7 @@ dependencies = [
"dashmap 6.1.0",
"databake",
"derive-docs",
"dhat",
"expectorate",
"fnv",
"form_urlencoded",
@ -1708,6 +1740,7 @@ dependencies = [
"kittycad-modeling-cmds",
"lazy_static",
"measurements",
"miette",
"mime_guess",
"parse-display 0.9.1",
"pretty_assertions",
@ -1733,6 +1766,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"winnow",
"zip",
]
@ -1806,9 +1840,9 @@ dependencies = [
[[package]]
name = "kittycad-modeling-cmds"
version = "0.2.76"
version = "0.2.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2394fe2b28a1c6bd524dec1dbcd7e839c2782a6ecc743085e122cde77ee19cfa"
checksum = "3b77259b37acafa360d98af27431ac394bc8899eeed7037513832ddbee856811"
dependencies = [
"anyhow",
"chrono",
@ -1971,6 +2005,37 @@ dependencies = [
"autocfg",
]
[[package]]
name = "miette"
version = "7.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4edc8853320c2a0dab800fbda86253c8938f6ea88510dc92c5f1ed20e794afc1"
dependencies = [
"backtrace",
"backtrace-ext",
"cfg-if",
"miette-derive",
"owo-colors",
"supports-color",
"supports-hyperlinks",
"supports-unicode",
"terminal_size",
"textwrap",
"thiserror 1.0.68",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf09caffaac8068c346b6df2a7fc27a177fd20b39421a39ce0a211bde679a6c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -2009,6 +2074,12 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff"
[[package]]
name = "mintex"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bec4598fddb13cc7b528819e697852653252b760f1228b7642679bf2ff2cd07"
[[package]]
name = "mio"
version = "1.0.2"
@ -2163,6 +2234,12 @@ dependencies = [
"thiserror 1.0.68",
]
[[package]]
name = "owo-colors"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56"
[[package]]
name = "papergrid"
version = "0.11.0"
@ -2591,7 +2668,7 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustc-hash 2.0.0",
"rustls",
"socket2",
"thiserror 1.0.68",
@ -2608,7 +2685,7 @@ dependencies = [
"bytes",
"rand 0.8.5",
"ring",
"rustc-hash",
"rustc-hash 2.0.0",
"rustls",
"slab",
"thiserror 1.0.68",
@ -2948,6 +3025,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.0.0"
@ -3311,6 +3394,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.5.7"
@ -3396,6 +3485,27 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "supports-color"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9829b314621dfc575df4e409e79f9d6a66a3bd707ab73f23cb4aa3a854ac854f"
dependencies = [
"is_ci",
]
[[package]]
name = "supports-hyperlinks"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c0a1e5168041f5f3ff68ff7d95dcb9c8749df29f6e7e89ada40dd4c9de404ee"
[[package]]
name = "supports-unicode"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
[[package]]
name = "syn"
version = "1.0.109"
@ -3496,6 +3606,27 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "terminal_size"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7"
dependencies = [
"rustix",
"windows-sys 0.48.0",
]
[[package]]
name = "textwrap"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.1.14",
]
[[package]]
name = "thiserror"
version = "1.0.68"
@ -3536,6 +3667,12 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "thousands"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820"
[[package]]
name = "thread_local"
version = "1.1.8"
@ -3614,9 +3751,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.40.0"
version = "1.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33"
dependencies = [
"backtrace",
"bytes",
@ -3955,6 +4092,12 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"

View File

@ -16,7 +16,7 @@ gloo-utils = "0.2.0"
kcl-lib = { path = "kcl" }
kittycad.workspace = true
serde_json = "1.0.128"
tokio = { version = "1.40.0", features = ["sync"] }
tokio = { version = "1.41.1", features = ["sync"] }
toml = "0.8.19"
uuid = { version = "1.11.0", features = ["v4", "js", "serde"] }
wasm-bindgen = "0.2.91"
@ -29,7 +29,7 @@ kittycad = { workspace = true, default-features = true }
kittycad-modeling-cmds = { workspace = true }
pretty_assertions = "1.4.1"
reqwest = { version = "0.12", default-features = false }
tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "time"] }
tokio = { version = "1.41.1", features = ["rt-multi-thread", "macros", "time"] }
twenty-twenty = "0.8"
uuid = { version = "1.11.0", features = ["v4", "js", "serde"] }

View File

@ -12,4 +12,4 @@ kcl-lib = { version = "0.2", path = "../kcl" }
pico-args = "0.5.0"
serde = { version = "1.0.214", features = ["derive"] }
serde_json = "1.0.128"
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] }

View File

@ -203,7 +203,7 @@ fn bad_gateway(msg: String) -> Response<Body> {
resp
}
fn kcl_err(err: anyhow::Error) -> Response<Body> {
fn kcl_err(err: impl std::fmt::Display) -> Response<Body> {
eprintln!("\tBad KCL");
bad_gateway(format!("{err}"))
}

View File

@ -19,5 +19,5 @@ indexmap = "2.6.0"
kcl-lib = { path = "../kcl" }
kittycad = { workspace = true, features = ["clap"] }
kittycad-modeling-cmds = { workspace = true }
tokio = { version = "1.38", features = ["full", "time", "rt", "tracing"] }
tokio = { version = "1.41", features = ["full", "time", "rt", "tracing"] }
uuid = { version = "1.11.0", features = ["v4", "js", "serde"] }

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-lib"
description = "KittyCAD Language implementation and tools"
version = "0.2.25"
version = "0.2.26"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"
@ -21,6 +21,7 @@ convert_case = "0.6.0"
dashmap = "6.1.0"
databake = { version = "0.1.8", features = ["derive"] }
derive-docs = { version = "0.1.29", path = "../derive-docs" }
dhat = { version = "0.3", optional = true }
fnv = "1.0.7"
form_urlencoded = "1.2.1"
futures = { version = "0.3.31" }
@ -33,6 +34,7 @@ kittycad = { workspace = true }
kittycad-modeling-cmds = { workspace = true }
lazy_static = "1.5.0"
measurements = "0.11.0"
miette = "7.2.0"
mime_guess = "2.0.5"
parse-display = "0.9.1"
pyo3 = { version = "0.22.6", optional = true }
@ -50,12 +52,13 @@ url = { version = "2.5.3", features = ["serde"] }
urlencoding = "2.1.3"
uuid = { version = "1.11.0", features = ["v4", "js", "serde"] }
validator = { version = "0.19.0", features = ["derive"] }
web-time = "1.1"
winnow = "0.6.18"
zip = { version = "2.0.0", default-features = false }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = { version = "0.3.72" }
tokio = { version = "1.40.0", features = ["sync", "time"] }
tokio = { version = "1.41.1", features = ["sync", "time"] }
tower-lsp = { version = "0.20.0", default-features = false, features = ["runtime-agnostic"] }
wasm-bindgen = "0.2.91"
wasm-bindgen-futures = "0.4.44"
@ -64,13 +67,14 @@ web-sys = { version = "0.3.72", features = ["console"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
approx = "0.5"
bson = { version = "2.13.0", features = ["uuid-1", "chrono"] }
tokio = { version = "1.39.3", features = ["full"] }
tokio = { version = "1.41.1", features = ["full"] }
tokio-tungstenite = { version = "0.24.0", features = ["rustls-tls-native-roots"] }
tower-lsp = { version = "0.20.0", features = ["proposed"] }
[features]
default = ["engine"]
cli = ["dep:clap"]
dhat-heap = ["dep:dhat"]
# For the lsp server, when run with stdout for rpc we want to disable println.
# This is used for editor extensions that use the lsp server.
disable-println = []
@ -89,8 +93,9 @@ iai = "0.1"
image = { version = "0.25.5", default-features = false, features = ["png"] }
insta = { version = "1.41.1", features = ["json", "filters", "redactions"] }
itertools = "0.13.0"
miette = { version = "7.2.0", features = ["fancy"] }
pretty_assertions = "1.4.1"
tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "time"] }
tokio = { version = "1.41.1", features = ["rt-multi-thread", "macros", "time"] }
twenty-twenty = "0.8.0"
[[bench]]

View File

@ -65,6 +65,10 @@ impl<T> Node<T> {
source_range: SourceRange([self.start, self.end, self.module_id.0 as usize]),
}
}
pub fn contains(&self, pos: usize) -> bool {
self.start <= pos && pos <= self.end
}
}
impl<T: JsonSchema> schemars::JsonSchema for Node<T> {
@ -117,8 +121,12 @@ impl<T> Node<T> {
})
}
pub fn as_source_range(&self) -> SourceRange {
SourceRange([self.start, self.end, self.module_id.as_usize()])
}
pub fn as_source_ranges(&self) -> Vec<SourceRange> {
vec![SourceRange([self.start, self.end, self.module_id.as_usize()])]
vec![self.as_source_range()]
}
}
@ -173,6 +181,8 @@ pub struct Program {
pub body: Vec<BodyItem>,
#[serde(default, skip_serializing_if = "NonCodeMeta::is_empty")]
pub non_code_meta: NonCodeMeta,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shebang: Option<Node<Shebang>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@ -261,19 +271,14 @@ impl Program {
}
pub fn get_hover_value_for_position(&self, pos: usize, code: &str) -> Option<Hover> {
// Check if we are in the non code meta.
if let Some(meta) = self.get_non_code_meta_for_position(pos) {
for node in &meta.start_nodes {
if node.contains(pos) {
// We only care about the shebang.
if let NonCodeValue::Shebang { value: _ } = &node.value {
let source_range: SourceRange = node.into();
return Some(Hover::Comment {
value: r#"The `#!` at the start of a script, known as a shebang, specifies the path to the interpreter that should execute the script. This line is not necessary for your `kcl` to run in the modeling-app. You can safely delete it. If you wish to learn more about what you _can_ do with a shebang, read this doc: [zoo.dev/docs/faq/shebang](https://zoo.dev/docs/faq/shebang)."#.to_string(),
range: source_range.to_lsp_range(code),
});
}
}
// Check if we are in shebang.
if let Some(node) = &self.shebang {
if node.contains(pos) {
let source_range: SourceRange = node.into();
return Some(Hover::Comment {
value: r#"The `#!` at the start of a script, known as a shebang, specifies the path to the interpreter that should execute the script. This line is not necessary for your `kcl` to run in the modeling-app. You can safely delete it. If you wish to learn more about what you _can_ do with a shebang, read this doc: [zoo.dev/docs/faq/shebang](https://zoo.dev/docs/faq/shebang)."#.to_string(),
range: source_range.to_lsp_range(code),
});
}
}
@ -528,6 +533,26 @@ impl Program {
}
}
/// A shebang.
/// This is a special type of comment that is at the top of the file.
/// It looks like this:
/// ```python,no_run
/// #!/usr/bin/env python
/// ```
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, ts_rs::TS, JsonSchema, Bake)]
#[cfg_attr(feature = "pyo3", pyo3::pyclass)]
#[databake(path = kcl_lib::ast::types)]
#[ts(export)]
pub struct Shebang {
pub content: String,
}
impl Shebang {
pub fn new(content: String) -> Self {
Shebang { content }
}
}
/// Identifier of a source file. Uses a u32 to keep the size small.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, ts_rs::TS, JsonSchema, Bake)]
#[cfg_attr(feature = "pyo3", pyo3::pyclass)]
@ -619,7 +644,7 @@ pub enum Expr {
impl Expr {
pub fn get_lsp_folding_range(&self) -> Option<FoldingRange> {
let recasted = self.recast(&FormatOptions::default(), 0, false);
let recasted = self.recast(&FormatOptions::default(), 0, crate::unparser::ExprContext::Other);
// If the code only has one line then we don't need to fold it.
if recasted.lines().count() <= 1 {
return None;
@ -948,13 +973,8 @@ pub struct NonCodeNode {
}
impl Node<NonCodeNode> {
pub fn contains(&self, pos: usize) -> bool {
self.start <= pos && pos <= self.end
}
pub fn format(&self, indentation: &str) -> String {
match &self.value {
NonCodeValue::Shebang { value } => format!("{}\n\n", value),
NonCodeValue::InlineComment {
value,
style: CommentStyle::Line,
@ -994,7 +1014,6 @@ impl Node<NonCodeNode> {
impl NonCodeNode {
pub fn value(&self) -> String {
match &self.value {
NonCodeValue::Shebang { value } => value.clone(),
NonCodeValue::InlineComment { value, style: _ } => value.clone(),
NonCodeValue::BlockComment { value, style: _ } => value.clone(),
NonCodeValue::NewLineBlockComment { value, style: _ } => value.clone(),
@ -1028,15 +1047,6 @@ impl CommentStyle {
#[ts(export)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum NonCodeValue {
/// A shebang.
/// This is a special type of comment that is at the top of the file.
/// It looks like this:
/// ```python,no_run
/// #!/usr/bin/env python
/// ```
Shebang {
value: String,
},
/// An inline comment.
/// Here are examples:
/// `1 + 1 // This is an inline comment`.
@ -1254,7 +1264,6 @@ pub struct ExpressionStatement {
pub struct CallExpression {
pub callee: Node<Identifier>,
pub arguments: Vec<Expr>,
pub optional: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@ -1291,7 +1300,6 @@ impl CallExpression {
Ok(Node::no_src(Self {
callee: Identifier::new(name),
arguments,
optional: false,
digest: None,
}))
}
@ -3059,9 +3067,9 @@ ghi("things")
folding_ranges[1].collapsed_text,
Some("startSketchOn('XY')".to_string())
);
assert_eq!(folding_ranges[2].start_line, 390);
assert_eq!(folding_ranges[2].start_line, 384);
assert_eq!(folding_ranges[2].end_line, 403);
assert_eq!(folding_ranges[2].collapsed_text, Some("fn ghi = (x) => {".to_string()));
assert_eq!(folding_ranges[2].collapsed_text, Some("fn ghi(x) {".to_string()));
}
#[test]
@ -3260,7 +3268,7 @@ const cylinder = startSketchOn('-XZ')
#[tokio::test(flavor = "multi_thread")]
async fn test_parse_return_type_on_functions() {
let some_program_string = r#"fn thing = () => {thing: number, things: string[], more?: string} {
let some_program_string = r#"fn thing(): {thing: number, things: string[], more?: string} {
return 1
}"#;
let module_id = ModuleId::default();
@ -3286,8 +3294,8 @@ const cylinder = startSketchOn('-XZ')
name: "thing".to_owned(),
digest: None
},
13,
18,
23,
module_id,
),
type_: Some(FnArgType::Primitive(FnArgPrimitive::Number)),
@ -3300,8 +3308,8 @@ const cylinder = startSketchOn('-XZ')
name: "things".to_owned(),
digest: None
},
33,
39,
28,
34,
module_id,
),
type_: Some(FnArgType::Array(FnArgPrimitive::String)),
@ -3314,8 +3322,8 @@ const cylinder = startSketchOn('-XZ')
name: "more".to_owned(),
digest: None
},
51,
55,
46,
50,
module_id,
),
type_: Some(FnArgType::Primitive(FnArgPrimitive::String)),
@ -3338,6 +3346,7 @@ const cylinder = startSketchOn('-XZ')
body: Node::no_src(Program {
body: Vec::new(),
non_code_meta: Default::default(),
shebang: None,
digest: None,
}),
return_type: None,
@ -3361,6 +3370,7 @@ const cylinder = startSketchOn('-XZ')
inner: Program {
body: Vec::new(),
non_code_meta: Default::default(),
shebang: None,
digest: None,
},
start: 0,
@ -3388,6 +3398,7 @@ const cylinder = startSketchOn('-XZ')
inner: Program {
body: Vec::new(),
non_code_meta: Default::default(),
shebang: None,
digest: None,
},
start: 0,
@ -3426,6 +3437,7 @@ const cylinder = startSketchOn('-XZ')
inner: Program {
body: Vec::new(),
non_code_meta: Default::default(),
shebang: None,
digest: None,
},
start: 0,

View File

@ -66,6 +66,9 @@ impl Program {
for body_item in slf.body.iter_mut() {
hasher.update(body_item.compute_digest());
}
if let Some(shebang) = &slf.shebang {
hasher.update(&shebang.inner.content);
}
hasher.update(slf.non_code_meta.compute_digest());
});
}
@ -207,9 +210,6 @@ impl ReturnStatement {
impl NonCodeNode {
compute_digest!(|slf, hasher| {
match &slf.value {
NonCodeValue::Shebang { value } => {
hasher.update(value);
}
NonCodeValue::InlineComment { value, style } => {
hasher.update(value);
hasher.update(style.digestable_id());
@ -369,7 +369,6 @@ impl CallExpression {
for argument in slf.arguments.iter_mut() {
hasher.update(argument.compute_digest());
}
hasher.update(if slf.optional { [1] } else { [0] });
});
}

View File

@ -282,8 +282,8 @@ impl EngineConnection {
}
Err(e) => {
match &e {
WebSocketReadError::Read(e) => eprintln!("could not read from WS: {:?}", e),
WebSocketReadError::Deser(e) => eprintln!("could not deserialize msg from WS: {:?}", e),
WebSocketReadError::Read(e) => crate::logln!("could not read from WS: {:?}", e),
WebSocketReadError::Deser(e) => crate::logln!("could not deserialize msg from WS: {:?}", e),
}
*socket_health_tcp_read.lock().unwrap() = SocketHealth::Inactive;
return Err(e);

View File

@ -4,6 +4,26 @@ use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
use crate::{ast::types::ModuleId, executor::SourceRange, lsp::IntoDiagnostic};
/// How did the KCL execution fail
#[derive(thiserror::Error, Debug)]
pub enum ExecError {
#[error("{0}")]
Kcl(#[from] crate::KclError),
#[error("Could not connect to engine: {0}")]
Connection(#[from] ConnectionError),
#[error("PNG snapshot could not be decoded: {0}")]
BadPng(String),
}
/// How did KCL client fail to connect to the engine
#[derive(thiserror::Error, Debug)]
pub enum ConnectionError {
#[error("Could not create a Zoo client: {0}")]
CouldNotMakeClient(anyhow::Error),
#[error("Could not establish connection to engine: {0}")]
Establishing(anyhow::Error),
}
#[derive(Error, Debug, Serialize, Deserialize, ts_rs::TS, Clone, PartialEq, Eq)]
#[ts(export)]
#[serde(tag = "kind", rename_all = "snake_case")]
@ -34,10 +54,56 @@ pub enum KclError {
Internal(KclErrorDetails),
}
#[derive(Debug, Serialize, Deserialize, ts_rs::TS, Clone, PartialEq, Eq)]
#[derive(thiserror::Error, Debug)]
#[error("{}", self.error.get_message())]
pub struct Report {
pub error: KclError,
pub kcl_source: String,
pub filename: String,
}
impl miette::Diagnostic for Report {
fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
let family = match self.error {
KclError::Lexical(_) => "Lexical",
KclError::Syntax(_) => "Syntax",
KclError::Semantic(_) => "Semantic",
KclError::ImportCycle(_) => "ImportCycle",
KclError::Type(_) => "Type",
KclError::Unimplemented(_) => "Unimplemented",
KclError::Unexpected(_) => "Unexpected",
KclError::ValueAlreadyDefined(_) => "ValueAlreadyDefined",
KclError::UndefinedValue(_) => "UndefinedValue",
KclError::InvalidExpression(_) => "InvalidExpression",
KclError::Engine(_) => "Engine",
KclError::Internal(_) => "Internal",
};
let error_string = format!("KCL {family} error");
Some(Box::new(error_string))
}
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
Some(&self.kcl_source)
}
fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
let iter = self
.error
.source_ranges()
.clone()
.into_iter()
.map(miette::SourceSpan::from)
.map(|span| miette::LabeledSpan::new_with_span(None, span));
Some(Box::new(iter))
}
}
#[derive(Debug, Serialize, Deserialize, ts_rs::TS, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
#[error("{message}")]
#[ts(export)]
pub struct KclErrorDetails {
#[serde(rename = "sourceRanges")]
#[label(collection, "Errors")]
pub source_ranges: Vec<SourceRange>,
#[serde(rename = "msg")]
pub message: String,

View File

@ -34,7 +34,7 @@ use crate::{
fs::{FileManager, FileSystem},
settings::types::UnitLength,
std::{args::Arg, StdLib},
Program,
ExecError, Program,
};
/// State for executing a program.
@ -197,24 +197,17 @@ pub struct Environment {
parent: Option<EnvironmentRef>,
}
const NO_META: Vec<Metadata> = Vec::new();
impl Environment {
pub fn root() -> Self {
Self {
// Prelude
bindings: HashMap::from([
("ZERO".to_string(), KclValue::from_number(0.0, Default::default())),
(
"QUARTER_TURN".to_string(),
KclValue::from_number(90.0, Default::default()),
),
(
"HALF_TURN".to_string(),
KclValue::from_number(180.0, Default::default()),
),
(
"THREE_QUARTER_TURN".to_string(),
KclValue::from_number(270.0, Default::default()),
),
("ZERO".to_string(), KclValue::from_number(0.0, NO_META)),
("QUARTER_TURN".to_string(), KclValue::from_number(90.0, NO_META)),
("HALF_TURN".to_string(), KclValue::from_number(180.0, NO_META)),
("THREE_QUARTER_TURN".to_string(), KclValue::from_number(270.0, NO_META)),
]),
parent: None,
}
@ -1020,6 +1013,20 @@ impl From<[usize; 3]> for SourceRange {
}
}
impl From<&SourceRange> for miette::SourceSpan {
fn from(source_range: &SourceRange) -> Self {
let length = source_range.end() - source_range.start();
let start = miette::SourceOffset::from(source_range.start());
Self::new(start, length)
}
}
impl From<SourceRange> for miette::SourceSpan {
fn from(source_range: SourceRange) -> Self {
Self::from(&source_range)
}
}
impl SourceRange {
/// Create a new source range.
pub fn new(start: usize, end: usize, module_id: ModuleId) -> Self {
@ -1902,6 +1909,7 @@ impl ExecutorContext {
program: &Program,
exec_state: &mut ExecState,
) -> Result<Option<ModelingSessionData>, KclError> {
let _stats = crate::log::LogPerfStats::new("Interpretation");
// TODO: Use the top-level file's path.
exec_state.add_module(std::path::PathBuf::from(""));
// Before we even start executing the program, set the units.
@ -2179,12 +2187,16 @@ impl ExecutorContext {
&self,
program: &Program,
exec_state: &mut ExecState,
) -> Result<TakeSnapshot> {
) -> std::result::Result<TakeSnapshot, ExecError> {
self.execute_and_prepare(program, exec_state).await
}
/// Execute the program, return the interpreter and outputs.
pub async fn execute_and_prepare(&self, program: &Program, exec_state: &mut ExecState) -> Result<TakeSnapshot> {
pub async fn execute_and_prepare(
&self,
program: &Program,
exec_state: &mut ExecState,
) -> std::result::Result<TakeSnapshot, ExecError> {
self.run(program, exec_state).await?;
// Zoom to fit.
@ -2216,7 +2228,9 @@ impl ExecutorContext {
modeling_response: OkModelingCmdResponse::TakeSnapshot(contents),
} = resp
else {
anyhow::bail!("Unexpected response from engine: {:?}", resp);
return Err(ExecError::BadPng(format!(
"Instead of a TakeSnapshot response, the engine returned {resp:?}"
)));
};
Ok(contents)
}
@ -3171,6 +3185,7 @@ let w = f() + f()
inner: crate::ast::types::Program {
body: Vec::new(),
non_code_meta: Default::default(),
shebang: None,
digest: None,
},
start: 0,

View File

@ -13,6 +13,8 @@ use crate::{
ExecState, ExecutorContext, KclError, SourceRange,
};
pub type KclObjectFields = HashMap<String, KclValue>;
/// Any KCL value.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
@ -49,7 +51,7 @@ pub enum KclValue {
meta: Vec<Metadata>,
},
Object {
value: HashMap<String, KclValue>,
value: KclObjectFields,
#[serde(rename = "__meta")]
meta: Vec<Metadata>,
},
@ -84,83 +86,6 @@ pub enum KclValue {
},
}
impl KclValue {
pub(crate) fn metadata(&self) -> Vec<Metadata> {
match self {
KclValue::Uuid { value: _, meta } => meta.clone(),
KclValue::Bool { value: _, meta } => meta.clone(),
KclValue::Number { value: _, meta } => meta.clone(),
KclValue::Int { value: _, meta } => meta.clone(),
KclValue::String { value: _, meta } => meta.clone(),
KclValue::Array { value: _, meta } => meta.clone(),
KclValue::Object { value: _, meta } => meta.clone(),
KclValue::TagIdentifier(x) => x.meta.clone(),
KclValue::TagDeclarator(x) => vec![x.metadata()],
KclValue::Plane(x) => x.meta.clone(),
KclValue::Face(x) => x.meta.clone(),
KclValue::Sketch { value } => value.meta.clone(),
KclValue::Sketches { value } => value.iter().flat_map(|sketch| &sketch.meta).copied().collect(),
KclValue::Solid(x) => x.meta.clone(),
KclValue::Solids { value } => value.iter().flat_map(|sketch| &sketch.meta).copied().collect(),
KclValue::ImportedGeometry(x) => x.meta.clone(),
KclValue::Function { meta, .. } => meta.clone(),
KclValue::KclNone { meta, .. } => meta.clone(),
}
}
pub(crate) fn get_solid_set(&self) -> Result<SolidSet> {
match self {
KclValue::Solid(e) => Ok(SolidSet::Solid(e.clone())),
KclValue::Solids { value } => Ok(SolidSet::Solids(value.clone())),
KclValue::Array { value, .. } => {
let solids: Vec<_> = value
.iter()
.enumerate()
.map(|(i, v)| {
v.as_solid().map(|v| v.to_owned()).map(Box::new).ok_or_else(|| {
anyhow::anyhow!(
"expected this array to only contain solids, but element {i} was actually {}",
v.human_friendly_type()
)
})
})
.collect::<Result<_, _>>()?;
Ok(SolidSet::Solids(solids))
}
_ => anyhow::bail!("Not a solid or solids: {:?}", self),
}
}
/// Human readable type name used in error messages. Should not be relied
/// on for program logic.
pub(crate) fn human_friendly_type(&self) -> &'static str {
match self {
KclValue::Uuid { .. } => "Unique ID (uuid)",
KclValue::TagDeclarator(_) => "TagDeclarator",
KclValue::TagIdentifier(_) => "TagIdentifier",
KclValue::Solid(_) => "Solid",
KclValue::Solids { .. } => "Solids",
KclValue::Sketch { .. } => "Sketch",
KclValue::Sketches { .. } => "Sketches",
KclValue::ImportedGeometry(_) => "ImportedGeometry",
KclValue::Function { .. } => "Function",
KclValue::Plane(_) => "Plane",
KclValue::Face(_) => "Face",
KclValue::Bool { .. } => "boolean (true/false value)",
KclValue::Number { .. } => "number",
KclValue::Int { .. } => "integer",
KclValue::String { .. } => "string (text)",
KclValue::Array { .. } => "array (list)",
KclValue::Object { .. } => "object",
KclValue::KclNone { .. } => "None",
}
}
pub(crate) fn is_function(&self) -> bool {
matches!(self, KclValue::Function { .. })
}
}
impl From<SketchSet> for KclValue {
fn from(sg: SketchSet) -> Self {
match sg {
@ -249,8 +174,82 @@ impl From<&KclValue> for Vec<SourceRange> {
}
impl KclValue {
pub(crate) fn metadata(&self) -> Vec<Metadata> {
match self {
KclValue::Uuid { value: _, meta } => meta.clone(),
KclValue::Bool { value: _, meta } => meta.clone(),
KclValue::Number { value: _, meta } => meta.clone(),
KclValue::Int { value: _, meta } => meta.clone(),
KclValue::String { value: _, meta } => meta.clone(),
KclValue::Array { value: _, meta } => meta.clone(),
KclValue::Object { value: _, meta } => meta.clone(),
KclValue::TagIdentifier(x) => x.meta.clone(),
KclValue::TagDeclarator(x) => vec![x.metadata()],
KclValue::Plane(x) => x.meta.clone(),
KclValue::Face(x) => x.meta.clone(),
KclValue::Sketch { value } => value.meta.clone(),
KclValue::Sketches { value } => value.iter().flat_map(|sketch| &sketch.meta).copied().collect(),
KclValue::Solid(x) => x.meta.clone(),
KclValue::Solids { value } => value.iter().flat_map(|sketch| &sketch.meta).copied().collect(),
KclValue::ImportedGeometry(x) => x.meta.clone(),
KclValue::Function { meta, .. } => meta.clone(),
KclValue::KclNone { meta, .. } => meta.clone(),
}
}
pub(crate) fn get_solid_set(&self) -> Result<SolidSet> {
match self {
KclValue::Solid(e) => Ok(SolidSet::Solid(e.clone())),
KclValue::Solids { value } => Ok(SolidSet::Solids(value.clone())),
KclValue::Array { value, .. } => {
let solids: Vec<_> = value
.iter()
.enumerate()
.map(|(i, v)| {
v.as_solid().map(|v| v.to_owned()).map(Box::new).ok_or_else(|| {
anyhow::anyhow!(
"expected this array to only contain solids, but element {i} was actually {}",
v.human_friendly_type()
)
})
})
.collect::<Result<_, _>>()?;
Ok(SolidSet::Solids(solids))
}
_ => anyhow::bail!("Not a solid or solids: {:?}", self),
}
}
/// Human readable type name used in error messages. Should not be relied
/// on for program logic.
pub(crate) fn human_friendly_type(&self) -> &'static str {
match self {
KclValue::Uuid { .. } => "Unique ID (uuid)",
KclValue::TagDeclarator(_) => "TagDeclarator",
KclValue::TagIdentifier(_) => "TagIdentifier",
KclValue::Solid(_) => "Solid",
KclValue::Solids { .. } => "Solids",
KclValue::Sketch { .. } => "Sketch",
KclValue::Sketches { .. } => "Sketches",
KclValue::ImportedGeometry(_) => "ImportedGeometry",
KclValue::Function { .. } => "Function",
KclValue::Plane(_) => "Plane",
KclValue::Face(_) => "Face",
KclValue::Bool { .. } => "boolean (true/false value)",
KclValue::Number { .. } => "number",
KclValue::Int { .. } => "integer",
KclValue::String { .. } => "string (text)",
KclValue::Array { .. } => "array (list)",
KclValue::Object { .. } => "object",
KclValue::KclNone { .. } => "None",
}
}
pub(crate) fn is_function(&self) -> bool {
matches!(self, KclValue::Function { .. })
}
/// Put the number into a KCL value.
pub fn from_number(f: f64, meta: Vec<Metadata>) -> Self {
pub const fn from_number(f: f64, meta: Vec<Metadata>) -> Self {
Self::Number { value: f, meta }
}
@ -287,7 +286,7 @@ impl KclValue {
}
}
pub fn as_object(&self) -> Option<&HashMap<String, KclValue>> {
pub fn as_object(&self) -> Option<&KclObjectFields> {
if let KclValue::Object { value, meta: _ } = &self {
Some(value)
} else {
@ -295,7 +294,7 @@ impl KclValue {
}
}
pub fn into_object(self) -> Option<HashMap<String, KclValue>> {
pub fn into_object(self) -> Option<KclObjectFields> {
if let KclValue::Object { value, meta: _ } = self {
Some(value)
} else {

View File

@ -8,11 +8,54 @@
#[allow(unused_macros)]
macro_rules! println {
($($rest:tt)*) => {
#[cfg(feature = "disable-println")]
{
let _ = format!($($rest)*);
}
#[cfg(not(feature = "disable-println"))]
std::println!($($rest)*)
}
}
#[allow(unused_macros)]
macro_rules! eprintln {
($($rest:tt)*) => {
#[cfg(feature = "disable-println")]
{
let _ = format!($($rest)*);
}
#[cfg(not(feature = "disable-println"))]
std::eprintln!($($rest)*)
}
}
#[allow(unused_macros)]
macro_rules! print {
($($rest:tt)*) => {
#[cfg(feature = "disable-println")]
{
let _ = format!($($rest)*);
}
#[cfg(not(feature = "disable-println"))]
std::print!($($rest)*)
}
}
#[allow(unused_macros)]
macro_rules! eprint {
($($rest:tt)*) => {
#[cfg(feature = "disable-println")]
{
let _ = format!($($rest)*);
}
#[cfg(not(feature = "disable-println"))]
std::eprint!($($rest)*)
}
}
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
mod ast;
mod coredump;
mod docs;
@ -23,6 +66,7 @@ mod fs;
mod function_param;
mod kcl_value;
pub mod lint;
mod log;
mod lsp;
mod parser;
mod settings;
@ -42,13 +86,12 @@ pub use ast::modify::modify_ast_for_sketch;
pub use ast::types::{FormatOptions, ModuleId};
pub use coredump::CoreDump;
pub use engine::{EngineManager, ExecutionKind};
pub use errors::KclError;
pub use errors::{ConnectionError, ExecError, KclError};
pub use executor::{ExecState, ExecutorContext, ExecutorSettings, SourceRange};
pub use lsp::copilot::Backend as CopilotLspBackend;
pub use lsp::kcl::Backend as KclLspBackend;
pub use lsp::kcl::Server as KclLspServerSubCommand;
pub use settings::types::{project::ProjectConfiguration, Configuration, UnitLength};
pub use token::lexer;
// Rather than make executor public and make lots of it pub(crate), just re-export into a new module.
// Ideally we wouldn't export these things at all, they should only be used for testing.
@ -72,6 +115,8 @@ pub mod std_utils {
pub use crate::std::utils::{get_tangential_arc_to_info, is_points_ccw_wasm, TangentialArcInfoInput};
}
#[allow(unused_imports)]
use crate::log::{log, logln};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

116
src/wasm-lib/kcl/src/log.rs Normal file
View File

@ -0,0 +1,116 @@
#![allow(dead_code)]
#[cfg(feature = "dhat-heap")]
use dhat::{HeapStats, Profiler};
use std::env;
use web_time::Instant;
const LOG_ENV_VAR: &str = "ZOO_LOG";
lazy_static::lazy_static! {
static ref ENABLED: bool = {
let env_var = env::var(LOG_ENV_VAR);
let Ok(env_var) = env_var else {
return false;
};
!env_var.is_empty()
};
}
#[cfg(feature = "dhat-heap")]
lazy_static::lazy_static! {
static ref PROFILER: Profiler = Profiler::builder().testing().build();
}
/// Log a message
pub(crate) fn log(msg: impl Into<String>) {
if *ENABLED {
log_inner(msg.into());
}
}
#[allow(unused_macros)]
macro_rules! logln {
($($rest:tt)*) => {
crate::log::log(format!($($rest)*))
}
}
pub(crate) use logln;
#[cfg(not(feature = "disable-println"))]
#[inline]
fn log_inner(msg: String) {
eprintln!("{msg}");
}
#[cfg(all(feature = "disable-println", target_arch = "wasm32"))]
#[inline]
fn log_inner(msg: String) {
web_sys::console::log_1(&msg.into());
}
#[cfg(all(feature = "disable-println", not(target_arch = "wasm32")))]
#[inline]
fn log_inner(_msg: String) {}
/// A helper struct for recording and logging basic performance metrics.
///
/// It will log the metrics when dropped or if `log_now` is called.
pub(crate) struct LogPerfStats<'a> {
msg: &'a str,
start_time: Instant,
#[cfg(feature = "dhat-heap")]
start_stats: HeapStats,
cancelled: bool,
}
impl<'a> LogPerfStats<'a> {
#[cfg(not(feature = "dhat-heap"))]
pub fn new(msg: &'a str) -> Self {
LogPerfStats {
msg,
start_time: Instant::now(),
cancelled: false,
}
}
#[cfg(feature = "dhat-heap")]
pub fn new(msg: &'a str) -> Self {
lazy_static::initialize(&PROFILER);
LogPerfStats {
msg,
start_time: Instant::now(),
start_stats: HeapStats::get(),
cancelled: false,
}
}
pub fn log_now(&self) {
let time = Instant::now().duration_since(self.start_time).as_secs_f64() * 1000.0;
logln!("{}\n time: {time:.3}ms", self.msg);
#[cfg(feature = "dhat-heap")]
{
let stats = HeapStats::get();
let blocks = stats.total_blocks - self.start_stats.total_blocks;
let bytes = (stats.total_bytes - self.start_stats.total_bytes) as f64 / 1_000_000.0;
let cur = stats.curr_bytes as f64 / 1000.0;
let max = stats.curr_bytes as f64 / 1000.0;
logln!(" memory:");
logln!(" allocations: {bytes:.5} MB ({blocks} blocks)");
logln!(" currently allocated: {cur:.3} KB");
logln!(" max allocated: {max:.3} KB");
}
}
/// After `cancel`ing, this object will not log its stats on drop (you can still can `log_now`).
pub fn cancel(&mut self) {
self.cancelled = true;
}
}
impl<'a> Drop for LogPerfStats<'a> {
fn drop(&mut self) {
if !self.cancelled {
self.log_now();
}
}
}

View File

@ -81,11 +81,16 @@ pub fn parse_tokens(tokens: Vec<Token>) -> ParseResult {
/// Invariants:
/// - if there are no errors, then the Option will be Some
/// - if the Option is None, then there will be at least one error in the ParseContext.
#[derive(Debug, Clone)]
pub(crate) struct ParseResult(pub Result<(Option<Node<Program>>, ParseContext), KclError>);
impl ParseResult {
#[cfg(test)]
#[track_caller]
pub fn unwrap(self) -> Node<Program> {
if self.0.is_err() || self.0.as_ref().unwrap().0.is_none() {
eprint!("{self:#?}");
}
self.0.unwrap().0.unwrap()
}

View File

@ -15,15 +15,17 @@ use crate::{
CallExpression, CommentStyle, ElseIf, Expr, ExpressionStatement, FnArgPrimitive, FnArgType, FunctionExpression,
Identifier, IfExpression, ImportItem, ImportStatement, ItemVisibility, Literal, LiteralIdentifier,
LiteralValue, MemberExpression, MemberObject, Node, NonCodeMeta, NonCodeNode, NonCodeValue, ObjectExpression,
ObjectProperty, Parameter, PipeExpression, PipeSubstitution, Program, ReturnStatement, TagDeclarator,
ObjectProperty, Parameter, PipeExpression, PipeSubstitution, Program, ReturnStatement, Shebang, TagDeclarator,
UnaryExpression, UnaryOperator, VariableDeclaration, VariableDeclarator, VariableKind,
},
docs::StdLibFn,
errors::{KclError, KclErrorDetails},
executor::SourceRange,
parser::{
math::BinaryExpressionToken, parser_impl::error::ContextError, PIPE_OPERATOR, PIPE_SUBSTITUTION_OPERATOR,
},
token::{Token, TokenType},
unparser::ExprContext,
};
pub(crate) mod error;
@ -36,6 +38,7 @@ thread_local! {
pub type TokenSlice<'slice, 'input> = &'slice mut &'input [Token];
pub fn run_parser(i: TokenSlice) -> super::ParseResult {
let _stats = crate::log::LogPerfStats::new("Parsing");
ParseContext::init();
let result = program.parse(i).save_err();
@ -49,7 +52,6 @@ pub fn run_parser(i: TokenSlice) -> super::ParseResult {
#[derive(Debug, Clone, Default)]
pub(crate) struct ParseContext {
pub errors: Vec<ParseError>,
#[allow(dead_code)]
pub warnings: Vec<ParseError>,
}
@ -75,14 +77,34 @@ impl ParseContext {
/// Add an error to the current `ParseContext`, panics if there is none.
fn err(e: ParseError) {
// TODO follow warnings replacement with errors
CTXT.with_borrow_mut(|ctxt| ctxt.as_mut().unwrap().errors.push(e));
}
/// Add a warning to the current `ParseContext`, panics if there is none.
#[allow(dead_code)]
fn warn(mut e: ParseError) {
e.severity = error::Severity::Warning;
CTXT.with_borrow_mut(|ctxt| ctxt.as_mut().unwrap().warnings.push(e));
CTXT.with_borrow_mut(|ctxt| {
// Avoid duplicating warnings. This is possible since the parser can try one path, find
// a warning, then backtrack and decide not to take that path and try another. This can
// happen 'high up the stack', so it's impossible to fix where the warnings are generated.
// Ideally we would pass warnings up the call stack rather than use a context object or
// have some way to mark warnings as speculative or committed, but I don't think Winnow
// is flexible enough for that (or at least, not without significant changes to the
// parser).
let warnings = &mut ctxt.as_mut().unwrap().warnings;
for w in warnings.iter_mut().rev() {
if w.source_range == e.source_range {
*w = e;
return;
}
if w.source_range.start() > e.source_range.end() {
break;
}
}
warnings.push(e);
});
}
}
@ -122,11 +144,8 @@ fn expected(what: &'static str) -> StrContext {
fn program(i: TokenSlice) -> PResult<Node<Program>> {
let shebang = opt(shebang).parse_next(i)?;
let mut out: Node<Program> = function_body.parse_next(i)?;
out.shebang = shebang;
// Add the shebang to the non-code meta.
if let Some(shebang) = shebang {
out.non_code_meta.start_nodes.insert(0, shebang);
}
// Match original parser behaviour, for now.
// Once this is merged and stable, consider changing this as I think it's more accurate
// without the -1.
@ -514,7 +533,7 @@ fn whitespace(i: TokenSlice) -> PResult<Vec<Token>> {
/// A shebang is a line at the start of a file that starts with `#!`.
/// If the shebang is present it takes up the whole line.
fn shebang(i: TokenSlice) -> PResult<Node<NonCodeNode>> {
fn shebang(i: TokenSlice) -> PResult<Node<Shebang>> {
// Parse the hash and the bang.
hash.parse_next(i)?;
bang.parse_next(i)?;
@ -537,12 +556,7 @@ fn shebang(i: TokenSlice) -> PResult<Node<NonCodeNode>> {
opt(whitespace).parse_next(i)?;
Ok(Node::new(
NonCodeNode {
value: NonCodeValue::Shebang {
value: format!("#!{}", value),
},
digest: None,
},
Shebang::new(format!("#!{}", value)),
0,
tokens.last().unwrap().end,
tokens.first().unwrap().module_id,
@ -685,7 +699,7 @@ fn object_property(i: TokenSlice) -> PResult<Node<ObjectProperty>> {
let key = identifier.context(expected("the property's key (the name or identifier of the property), e.g. in 'height = 4', 'height' is the property key")).parse_next(i)?;
ignore_whitespace(i);
// Temporarily accept both `:` and `=` for compatibility.
alt((colon, equals))
let sep = alt((colon, equals))
.context(expected(
"`=`, which separates the property's key from the value you're setting it to, e.g. 'height = 4'",
))
@ -696,7 +710,8 @@ fn object_property(i: TokenSlice) -> PResult<Node<ObjectProperty>> {
"the value which you're setting the property to, e.g. in 'height: 4', the value is 4",
))
.parse_next(i)?;
Ok(Node {
let result = Node {
start: key.start,
end: expr.end(),
module_id: key.module_id,
@ -705,7 +720,18 @@ fn object_property(i: TokenSlice) -> PResult<Node<ObjectProperty>> {
value: expr,
digest: None,
},
})
};
if sep.token_type == TokenType::Colon {
ParseContext::warn(ParseError::with_suggestion(
sep.into(),
Some(result.as_source_range()),
"Using `:` to initialize objects is deprecated, prefer using `=`.",
Some(" ="),
));
}
Ok(result)
}
/// Match something that separates properties of an object.
@ -917,26 +943,46 @@ fn if_expr(i: TokenSlice) -> PResult<BoxNode<IfExpression>> {
))
}
fn function_expr(i: TokenSlice) -> PResult<Expr> {
let fn_tok = opt(fun).parse_next(i)?;
ignore_whitespace(i);
let (result, has_arrow) = function_decl.parse_next(i)?;
if fn_tok.is_none() && !has_arrow {
let err = KclError::Syntax(KclErrorDetails {
source_ranges: result.as_source_ranges(),
message: "Anonymous function requires `fn` before `(`".to_owned(),
});
return Err(ErrMode::Cut(err.into()));
}
Ok(Expr::FunctionExpression(Box::new(result)))
}
// Looks like
// (arg0, arg1) => {
// (arg0, arg1) {
// const x = arg0 + arg1;
// return x
// }
fn function_expression(i: TokenSlice) -> PResult<Node<FunctionExpression>> {
fn function_decl(i: TokenSlice) -> PResult<(Node<FunctionExpression>, bool)> {
fn return_type(i: TokenSlice) -> PResult<FnArgType> {
colon(i)?;
ignore_whitespace(i);
argument_type(i)
}
let open = open_paren(i)?;
let start = open.start;
let params = parameters(i)?;
close_paren(i)?;
ignore_whitespace(i);
big_arrow(i)?;
let arrow = opt(big_arrow).parse_next(i)?;
ignore_whitespace(i);
// Optional type arguments.
let return_type = opt(argument_type).parse_next(i)?;
// Optional return type.
let return_type = opt(return_type).parse_next(i)?;
ignore_whitespace(i);
open_brace(i)?;
let body = function_body(i)?;
let end = close_brace(i)?.end;
Ok(Node::new(
let result = Node::new(
FunctionExpression {
params,
body,
@ -946,7 +992,21 @@ fn function_expression(i: TokenSlice) -> PResult<Node<FunctionExpression>> {
start,
end,
open.module_id,
))
);
let has_arrow = if let Some(arrow) = arrow {
ParseContext::warn(ParseError::with_suggestion(
arrow.as_source_range(),
Some(result.as_source_range()),
"Unnecessary `=>` in function declaration",
Some(""),
));
true
} else {
false
};
Ok((result, has_arrow))
}
/// E.g. `person.name`
@ -1048,7 +1108,6 @@ fn noncode_just_after_code(i: TokenSlice) -> PResult<Node<NonCodeNode>> {
// There's an empty line between the body item and the comment,
// This means the comment is a NewLineBlockComment!
let value = match nc.inner.value {
NonCodeValue::Shebang { value } => NonCodeValue::Shebang { value },
// Change block comments to inline, as discussed above
NonCodeValue::BlockComment { value, style } => NonCodeValue::NewLineBlockComment { value, style },
// Other variants don't need to change.
@ -1069,7 +1128,6 @@ fn noncode_just_after_code(i: TokenSlice) -> PResult<Node<NonCodeNode>> {
// There's no newline between the body item and comment,
// so if this is a comment, it must be inline with code.
let value = match nc.inner.value {
NonCodeValue::Shebang { value } => NonCodeValue::Shebang { value },
// Change block comments to inline, as discussed above
NonCodeValue::BlockComment { value, style } => NonCodeValue::InlineComment { value, style },
// Other variants don't need to change.
@ -1269,6 +1327,7 @@ pub fn function_body(i: TokenSlice) -> PResult<Node<Program>> {
Program {
body,
non_code_meta,
shebang: None,
digest: None,
},
start.0,
@ -1456,7 +1515,7 @@ fn expr_allowed_in_pipe_expr(i: TokenSlice) -> PResult<Expr> {
array,
object.map(Box::new).map(Expr::ObjectExpression),
pipe_sub.map(Box::new).map(Expr::PipeSubstitution),
function_expression.map(Box::new).map(Expr::FunctionExpression),
function_expr,
if_expr.map(Expr::IfExpression),
unnecessarily_bracketed,
))
@ -1521,31 +1580,40 @@ fn declaration(i: TokenSlice) -> PResult<BoxNode<VariableDeclaration>> {
let (kind, mut start, dec_end, module_id) = if let Some((kind, token)) = &decl_token {
(*kind, token.start, token.end, token.module_id)
} else {
// TODO warn on const
(VariableKind::Const, id.start, id.end, id.module_id)
};
if let Some(token) = visibility_token {
start = token.start;
}
ignore_whitespace(i);
equals(i)?;
// After this point, the parser is DEFINITELY parsing a variable declaration, because
// `fn`, `let`, `const` etc are all unambiguous. If you've parsed one of those tokens --
// and we certainly have because `kind` was parsed above -- then the following tokens
// MUST continue the variable declaration, otherwise the program is invalid.
//
// This means, from here until this function returns, any errors should be ErrMode::Cut,
// not ErrMode::Backtrack. Because the parser is definitely parsing a variable declaration.
// If there's an error, there's no point backtracking -- instead the parser should fail.
ignore_whitespace(i);
let val = if kind == VariableKind::Fn {
function_expression
.map(Box::new)
let eq = opt(equals).parse_next(i)?;
ignore_whitespace(i);
let val = function_decl
.map(|t| Box::new(t.0))
.map(Expr::FunctionExpression)
.context(expected("a KCL function expression, like () => { return 1 }"))
.parse_next(i)
.context(expected("a KCL function expression, like () { return 1 }"))
.parse_next(i);
if let Some(t) = eq {
let ctxt_end = val.as_ref().map(|e| e.end()).unwrap_or(t.end);
ParseContext::warn(ParseError::with_suggestion(
t.as_source_range(),
Some(SourceRange([id.start, ctxt_end, module_id.as_usize()])),
"Unnecessary `=` in function declaration",
Some(""),
));
}
val
} else {
equals(i)?;
ignore_whitespace(i);
expression
.try_map(|val| {
// Function bodies can be used if and only if declaring a function.
@ -1919,16 +1987,14 @@ fn double_period(i: TokenSlice) -> PResult<Token> {
.parse_next(i)
}
fn colon(i: TokenSlice) -> PResult<()> {
TokenType::Colon.parse_from(i)?;
Ok(())
fn colon(i: TokenSlice) -> PResult<Token> {
TokenType::Colon.parse_from(i)
}
fn equals(i: TokenSlice) -> PResult<()> {
fn equals(i: TokenSlice) -> PResult<Token> {
one_of((TokenType::Operator, "="))
.context(expected("the equals operator, ="))
.parse_next(i)?;
Ok(())
.parse_next(i)
}
fn question_mark(i: TokenSlice) -> PResult<()> {
@ -1936,6 +2002,17 @@ fn question_mark(i: TokenSlice) -> PResult<()> {
Ok(())
}
fn fun(i: TokenSlice) -> PResult<Token> {
any.try_map(|token: Token| match token.token_type {
TokenType::Keyword if token.value == "fn" => Ok(token),
_ => Err(KclError::Syntax(KclErrorDetails {
source_ranges: token.as_source_ranges(),
message: format!("expected 'fn', found {}", token.value.as_str(),),
})),
})
.parse_next(i)
}
/// Parse a comma, optionally followed by some whitespace.
fn comma_sep(i: TokenSlice) -> PResult<()> {
(opt(whitespace), comma, opt(whitespace))
@ -1959,6 +2036,7 @@ fn arguments(i: TokenSlice) -> PResult<Vec<Expr>> {
fn argument_type(i: TokenSlice) -> PResult<FnArgType> {
let type_ = alt((
// Object types
// TODO it is buggy to treat object fields like parameters since the parameters parser assumes a terminating `)`.
(open_brace, parameters, close_brace).map(|(_, params, _)| Ok(FnArgType::Object { properties: params })),
// Array types
(one_of(TokenType::Type), open_bracket, close_bracket).map(|(token, _, _)| {
@ -1989,13 +2067,11 @@ fn argument_type(i: TokenSlice) -> PResult<FnArgType> {
}
fn parameter(i: TokenSlice) -> PResult<(Token, std::option::Option<FnArgType>, bool)> {
let (arg_name, optional, _, _, _, type_) = (
let (arg_name, optional, _, type_) = (
any.verify(|token: &Token| !matches!(token.token_type, TokenType::Brace) || token.value != ")"),
opt(question_mark),
opt(whitespace),
opt(colon),
opt(whitespace),
opt(argument_type),
opt((colon, opt(whitespace), argument_type).map(|tup| tup.2)),
)
.parse_next(i)?;
Ok((arg_name, type_, optional.is_some()))
@ -2071,57 +2147,84 @@ fn binding_name(i: TokenSlice) -> PResult<Node<Identifier>> {
.parse_next(i)
}
fn typecheck_all(std_fn: Box<dyn StdLibFn>, args: &[Expr]) -> PResult<()> {
// Type check the arguments.
for (i, spec_arg) in std_fn.args(false).iter().enumerate() {
let Some(arg) = &args.get(i) else {
// The executor checks the number of arguments, so we don't need to check it here.
continue;
};
typecheck(spec_arg, arg)?;
}
Ok(())
}
fn typecheck(spec_arg: &crate::docs::StdLibFnArg, arg: &&Expr) -> PResult<()> {
match spec_arg.type_.as_ref() {
"TagNode" => match &arg {
Expr::Identifier(_) => {
// These are fine since we want someone to be able to map a variable to a tag declarator.
}
Expr::TagDeclarator(tag) => {
// TODO: Remove this check. It should be redundant.
tag.clone()
.into_valid_binding_name()
.map_err(|e| ErrMode::Cut(ContextError::from(e)))?;
}
e => {
return Err(ErrMode::Cut(
KclError::Syntax(KclErrorDetails {
source_ranges: vec![SourceRange::from(*arg)],
message: format!("Expected a tag declarator like `$name`, found {:?}", e),
})
.into(),
));
}
},
"TagIdentifier" => match &arg {
Expr::Identifier(_) => {}
Expr::MemberExpression(_) => {}
e => {
return Err(ErrMode::Cut(
KclError::Syntax(KclErrorDetails {
source_ranges: vec![SourceRange::from(*arg)],
message: format!("Expected a tag identifier like `tagName`, found {:?}", e),
})
.into(),
));
}
},
_ => {}
}
Ok(())
}
fn fn_call(i: TokenSlice) -> PResult<Node<CallExpression>> {
let fn_name = identifier(i)?;
opt(whitespace).parse_next(i)?;
let _ = terminated(open_paren, opt(whitespace)).parse_next(i)?;
let args = arguments(i)?;
if let Some(std_fn) = crate::std::get_stdlib_fn(&fn_name.name) {
// Type check the arguments.
for (i, spec_arg) in std_fn.args(false).iter().enumerate() {
let Some(arg) = &args.get(i) else {
// The executor checks the number of arguments, so we don't need to check it here.
continue;
};
match spec_arg.type_.as_ref() {
"TagNode" => match &arg {
Expr::Identifier(_) => {
// These are fine since we want someone to be able to map a variable to a tag declarator.
}
Expr::TagDeclarator(tag) => {
// TODO: Remove this check. It should be redundant.
tag.clone()
.into_valid_binding_name()
.map_err(|e| ErrMode::Cut(ContextError::from(e)))?;
}
e => {
return Err(ErrMode::Cut(
KclError::Syntax(KclErrorDetails {
source_ranges: vec![SourceRange::from(*arg)],
message: format!("Expected a tag declarator like `$name`, found {:?}", e),
})
.into(),
));
}
},
"TagIdentifier" => match &arg {
Expr::Identifier(_) => {}
Expr::MemberExpression(_) => {}
e => {
return Err(ErrMode::Cut(
KclError::Syntax(KclErrorDetails {
source_ranges: vec![SourceRange::from(*arg)],
message: format!("Expected a tag identifier like `tagName`, found {:?}", e),
})
.into(),
));
}
},
_ => {}
}
}
typecheck_all(std_fn, &args)?;
}
let end = preceded(opt(whitespace), close_paren).parse_next(i)?.end;
// This should really be done with resolved names, but we don't have warning support there
// so we'll hack this in here.
if fn_name.name == "int" {
assert_eq!(args.len(), 1);
let mut arg_str = args[0].recast(&crate::FormatOptions::default(), 0, ExprContext::Other);
if arg_str.contains('.') && !arg_str.ends_with(".0") {
arg_str = format!("round({arg_str})");
}
ParseContext::warn(ParseError::with_suggestion(
SourceRange::new(fn_name.start, end, fn_name.module_id),
None,
"`int` function is deprecated. You may not need it at all. If you need to round, consider `round`, `ceil`, or `floor`.",
Some(arg_str),
));
}
Ok(Node {
start: fn_name.start,
end,
@ -2129,7 +2232,6 @@ fn fn_call(i: TokenSlice) -> PResult<Node<CallExpression>> {
inner: CallExpression {
callee: fn_name,
arguments: args,
optional: false,
digest: None,
},
})
@ -2184,7 +2286,7 @@ mod tests {
#[test]
fn weird_program_unclosed_paren() {
let tokens = crate::token::lexer("fn firstPrime=(", ModuleId::default()).unwrap();
let tokens = crate::token::lexer("fn firstPrime(", ModuleId::default()).unwrap();
let last = tokens.last().unwrap();
let err: super::error::ErrorKind = program.parse(&tokens).unwrap_err().into();
let err = err.unwrap_parse_error();
@ -2236,7 +2338,7 @@ mod tests {
#[test]
fn test_comments_in_function1() {
let test_program = r#"() => {
let test_program = r#"() {
// comment 0
const a = 1
// comment 1
@ -2246,7 +2348,7 @@ mod tests {
}"#;
let tokens = crate::token::lexer(test_program, ModuleId::default()).unwrap();
let mut slice = tokens.as_slice();
let expr = function_expression.parse_next(&mut slice).unwrap();
let expr = function_decl.map(|t| t.0).parse_next(&mut slice).unwrap();
assert_eq!(expr.params, vec![]);
let comment_start = expr.body.non_code_meta.start_nodes.first().unwrap();
let comment0 = &expr.body.non_code_meta.non_code_nodes.get(&0).unwrap()[0];
@ -2258,13 +2360,13 @@ mod tests {
#[test]
fn test_comments_in_function2() {
let test_program = r#"() => {
const yo = { a: { b: { c: '123' } } } /* block
let test_program = r#"() {
const yo = { a = { b = { c = '123' } } } /* block
comment */
}"#;
let tokens = crate::token::lexer(test_program, ModuleId::default()).unwrap();
let mut slice = tokens.as_slice();
let expr = function_expression.parse_next(&mut slice).unwrap();
let expr = function_decl.map(|t| t.0).parse_next(&mut slice).unwrap();
let comment0 = &expr.body.non_code_meta.non_code_nodes.get(&0).unwrap()[0];
assert_eq!(comment0.value(), "block\ncomment");
}
@ -2316,25 +2418,25 @@ const mySk1 = startSketchAt([0, 0])"#;
#[test]
fn test_whitespace_in_function() {
let test_program = r#"() => {
let test_program = r#"() {
return sg
return sg
}"#;
let tokens = crate::token::lexer(test_program, ModuleId::default()).unwrap();
let mut slice = tokens.as_slice();
let _expr = function_expression.parse_next(&mut slice).unwrap();
let _expr = function_decl.parse_next(&mut slice).unwrap();
}
#[test]
fn test_empty_lines_in_function() {
let test_program = "() => {
let test_program = "() {
return 2
}";
let module_id = ModuleId::from_usize(1);
let tokens = crate::token::lexer(test_program, module_id).unwrap();
let mut slice = tokens.as_slice();
let expr = function_expression.parse_next(&mut slice).unwrap();
let expr = function_decl.map(|t| t.0).parse_next(&mut slice).unwrap();
assert_eq!(
expr,
Node::new(
@ -2350,14 +2452,14 @@ const mySk1 = startSketchAt([0, 0])"#;
raw: "2".to_owned(),
digest: None,
},
32,
33,
29,
30,
module_id,
))),
digest: None,
},
25,
33,
22,
30,
module_id,
))],
non_code_meta: NonCodeMeta {
@ -2367,23 +2469,24 @@ const mySk1 = startSketchAt([0, 0])"#;
value: NonCodeValue::NewLine,
digest: None
},
7,
25,
4,
22,
module_id,
)],
digest: None,
},
shebang: None,
digest: None,
},
7,
47,
4,
44,
module_id,
),
return_type: None,
digest: None,
},
0,
47,
44,
module_id,
)
);
@ -2418,7 +2521,7 @@ const mySk1 = startSketchAt([0, 0])"#;
#[test]
fn many_comments() {
let test_program = r#"// this is a comment
const yo = { a: { b: { c: '123' } } } /* block
const yo = { a = { b = { c = '123' } } } /* block
comment */
const key = 'c'
@ -2455,8 +2558,8 @@ const mySk1 = startSketchAt([0, 0])"#;
},
digest: None,
},
60,
82,
63,
85,
module_id,
),
Node::new(
@ -2464,8 +2567,8 @@ const mySk1 = startSketchAt([0, 0])"#;
value: NonCodeValue::NewLine,
digest: None,
},
82,
86,
85,
89,
module_id,
)
]),
@ -2481,8 +2584,8 @@ const mySk1 = startSketchAt([0, 0])"#;
},
digest: None,
},
103,
129,
106,
132,
module_id,
)]),
non_code_meta.non_code_nodes.get(&1),
@ -2819,7 +2922,7 @@ const mySk1 = startSketchAt([0, 0])"#;
let test_fn = "(let) => { return 1 }";
let module_id = ModuleId::from_usize(2);
let tokens = crate::token::lexer(test_fn, module_id).unwrap();
let err = function_expression.parse(&tokens).unwrap_err().into_inner();
let err = function_decl.parse(&tokens).unwrap_err().into_inner();
let cause = err.cause.unwrap();
// This is the token `let`
assert_eq!(cause.source_ranges(), vec![SourceRange([1, 4, 2])]);
@ -2858,7 +2961,7 @@ const mySk1 = startSketchAt([0, 0])"#;
fn test_pipes_on_pipes() {
let test_program = include_str!("../../../tests/executor/inputs/pipes_on_pipes.kcl");
let tokens = crate::token::lexer(test_program, ModuleId::default()).unwrap();
let _actual = program.parse(&tokens).unwrap();
let _ = run_parser(&mut &*tokens).unwrap();
}
#[test]
@ -2892,12 +2995,12 @@ const mySk1 = startSketchAt([0, 0])"#;
#[test]
fn test_user_function() {
let input = "() => {
let input = "() {
return 2
}";
let tokens = crate::token::lexer(input, ModuleId::default()).unwrap();
let actual = function_expression.parse(&tokens);
let actual = function_decl.parse(&tokens);
assert!(actual.is_ok(), "could not parse test function");
}
@ -3110,6 +3213,7 @@ const mySk1 = startSketchAt([0, 0])"#;
4,
module_id,
))],
shebang: None,
non_code_meta: NonCodeMeta::default(),
digest: None,
},
@ -3128,6 +3232,14 @@ const mySk1 = startSketchAt([0, 0])"#;
assert!(result.is_ok());
}
#[track_caller]
fn assert_no_err(p: &str) -> (Node<Program>, ParseContext) {
let result = crate::parser::top_level_parse(p);
let result = result.0.unwrap();
assert!(result.1.errors.is_empty(), "found: {:#?}", result.1.errors);
(result.0.unwrap(), result.1)
}
#[track_caller]
fn assert_err(p: &str, msg: &str, src: [usize; 2]) {
let result = crate::parser::top_level_parse(p);
@ -3209,6 +3321,16 @@ const height = [obj["a"] -1, 0]"#;
crate::parser::top_level_parse(code).unwrap();
}
#[test]
fn test_anon_fn() {
crate::parser::top_level_parse("foo(42, fn(x) { return x + 1 })").unwrap();
}
#[test]
fn test_anon_fn_no_fn() {
assert_err_contains("foo(42, (x) { return x + 1 })", "Anonymous function requires `fn`");
}
#[test]
fn test_parse_half_pipe() {
let code = "const height = 10
@ -3528,7 +3650,7 @@ e
#[test]
fn test_keyword_ok_in_fn_args_return() {
let some_program_string = r#"fn thing = (param) => {
let some_program_string = r#"fn thing(param) {
return true
}
@ -3658,6 +3780,47 @@ let myBox = box([0,0], -3, -16, -10)
"#;
assert_err(some_program_string, "Unexpected token: |>", [57, 59]);
}
#[test]
fn warn_object_expr() {
let some_program_string = "{ foo: bar }";
let (_, ctxt) = assert_no_err(some_program_string);
assert_eq!(ctxt.warnings.len(), 1);
assert_eq!(
ctxt.warnings[0].apply_suggestion(some_program_string).unwrap(),
"{ foo = bar }"
)
}
#[test]
fn warn_fn_int() {
let some_program_string = r#"int(1.0)
int(42.3)"#;
let (_, ctxt) = assert_no_err(some_program_string);
assert_eq!(ctxt.warnings.len(), 2);
let replaced = ctxt.warnings[1].apply_suggestion(some_program_string).unwrap();
let replaced = ctxt.warnings[0].apply_suggestion(&replaced).unwrap();
assert_eq!(replaced, "1.0\nround(42.3)");
}
#[test]
fn warn_fn_decl() {
let some_program_string = r#"fn foo = () => {
return 0
}"#;
let (_, ctxt) = assert_no_err(some_program_string);
assert_eq!(ctxt.warnings.len(), 2);
let replaced = ctxt.warnings[0].apply_suggestion(some_program_string).unwrap();
let replaced = ctxt.warnings[1].apply_suggestion(&replaced).unwrap();
// Note the whitespace here is bad, but we're just testing the suggestion spans really. In
// real life we might reformat after applying suggestions.
assert_eq!(
replaced,
r#"fn foo () {
return 0
}"#
);
}
}
#[cfg(test)]
@ -3673,11 +3836,14 @@ mod snapshot_math_tests {
fn $func_name() {
let module_id = crate::ast::types::ModuleId::default();
let tokens = crate::token::lexer($test_kcl_program, module_id).unwrap();
ParseContext::init();
let actual = match binary_expression.parse(&tokens) {
Ok(x) => x,
Err(_e) => panic!("could not parse test"),
};
insta::assert_json_snapshot!(actual);
let _ = ParseContext::take();
}
};
}
@ -3709,6 +3875,7 @@ mod snapshot_tests {
let module_id = crate::ast::types::ModuleId::default();
let tokens = crate::token::lexer($test_kcl_program, module_id).unwrap();
print_tokens(&tokens);
ParseContext::init();
let actual = match program.parse(&tokens) {
Ok(x) => x,
Err(e) => panic!("could not parse test: {e:?}"),
@ -3718,6 +3885,7 @@ mod snapshot_tests {
settings.bind(|| {
insta::assert_json_snapshot!(actual);
});
let _ = ParseContext::take();
}
};
}

View File

@ -24,9 +24,11 @@ pub struct ContextError<C = StrContext> {
#[derive(Debug, Clone)]
pub(crate) struct ParseError {
pub source_range: SourceRange,
#[allow(dead_code)]
pub context_range: Option<SourceRange>,
pub message: String,
#[allow(dead_code)]
pub suggestion: String,
pub suggestion: Option<String>,
pub severity: Severity,
}
@ -34,25 +36,38 @@ impl ParseError {
pub(super) fn err(source_range: SourceRange, message: impl ToString) -> ParseError {
ParseError {
source_range,
context_range: None,
message: message.to_string(),
suggestion: String::new(),
suggestion: None,
severity: Severity::Error,
}
}
#[allow(dead_code)]
pub(super) fn with_suggestion(
source_range: SourceRange,
context_range: Option<SourceRange>,
message: impl ToString,
suggestion: impl ToString,
suggestion: Option<impl ToString>,
) -> ParseError {
ParseError {
source_range,
context_range,
message: message.to_string(),
suggestion: suggestion.to_string(),
suggestion: suggestion.map(|s| s.to_string()),
severity: Severity::Error,
}
}
#[cfg(test)]
pub fn apply_suggestion(&self, src: &str) -> Option<String> {
let suggestion = self.suggestion.as_ref()?;
Some(format!(
"{}{}{}",
&src[0..self.source_range.start()],
suggestion,
&src[self.source_range.end()..]
))
}
}
impl From<ParseError> for KclError {
@ -73,6 +88,8 @@ pub(crate) enum Severity {
/// Helper enum for the below conversion of Winnow errors into either a parse error or an unexpected
/// error.
// TODO we should optimise the size of SourceRange and thus ParseError
#[allow(clippy::large_enum_variant)]
pub(super) enum ErrorKind {
Parse(ParseError),
Internal(KclError),

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3649
expression: actual
snapshot_kind: text
---
@ -52,7 +51,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 39,
"optional": false,
"start": 18,
"type": "CallExpression",
"type": "CallExpression"
@ -97,7 +95,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 63,
"optional": false,
"start": 47,
"type": "CallExpression",
"type": "CallExpression"
@ -149,7 +146,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 96,
"optional": false,
"start": 71,
"type": "CallExpression",
"type": "CallExpression"
@ -201,7 +197,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 121,
"optional": false,
"start": 104,
"type": "CallExpression",
"type": "CallExpression"
@ -230,7 +225,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 143,
"optional": false,
"start": 129,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3720
expression: actual
snapshot_kind: text
---
@ -68,7 +67,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 23,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3722
expression: actual
snapshot_kind: text
---
@ -64,7 +63,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 80,
"optional": false,
"start": 62,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -83,7 +83,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 66,
"optional": false,
"start": 54,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3737
expression: actual
snapshot_kind: text
---
@ -52,7 +51,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 37,
"optional": false,
"start": 17,
"type": "CallExpression",
"type": "CallExpression"
@ -104,7 +102,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 75,
"optional": false,
"start": 49,
"type": "CallExpression",
"type": "CallExpression"
@ -149,7 +146,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 104,
"optional": false,
"start": 87,
"type": "CallExpression",
"type": "CallExpression"
@ -201,7 +197,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 145,
"optional": false,
"start": 116,
"type": "CallExpression",
"type": "CallExpression"
@ -222,7 +217,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 165,
"optional": false,
"start": 157,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3745
expression: actual
snapshot_kind: text
---
@ -52,7 +51,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 37,
"optional": false,
"start": 17,
"type": "CallExpression",
"type": "CallExpression"
@ -97,7 +95,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 58,
"optional": false,
"start": 41,
"type": "CallExpression",
"type": "CallExpression"
@ -118,7 +115,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 70,
"optional": false,
"start": 62,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -32,7 +32,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 30,
"optional": false,
"start": 14,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3750
expression: actual
snapshot_kind: text
---
@ -36,7 +35,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 18,
"optional": false,
"start": 14,
"type": "CallExpression",
"type": "CallExpression"
@ -65,7 +63,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 29,
"optional": false,
"start": 22,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3751
expression: actual
snapshot_kind: text
---
@ -35,7 +34,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 30,
"optional": false,
"start": 14,
"type": "CallExpression",
"type": "CallExpression"
@ -79,7 +77,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 49,
"optional": false,
"start": 34,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3752
expression: actual
snapshot_kind: text
---
@ -61,7 +60,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 22,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3753
expression: actual
snapshot_kind: text
---
@ -96,7 +95,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 36,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3754
expression: actual
snapshot_kind: text
---
@ -61,7 +60,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 19,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3755
expression: actual
snapshot_kind: text
---
@ -96,7 +95,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 35,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3756
expression: actual
snapshot_kind: text
---
@ -96,7 +95,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 35,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3757
expression: actual
snapshot_kind: text
---
@ -50,7 +49,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 37,
"optional": false,
"start": 17,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3758
expression: actual
snapshot_kind: text
---
@ -41,7 +40,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 28,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3760
expression: actual
snapshot_kind: text
---
@ -47,7 +46,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 15,
"optional": false,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3761
expression: actual
snapshot_kind: text
---
@ -36,7 +35,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 36,
"optional": false,
"start": 17,
"type": "CallExpression",
"type": "CallExpression"
@ -119,7 +117,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 85,
"optional": false,
"start": 44,
"type": "CallExpression",
"type": "CallExpression"
@ -148,7 +145,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 107,
"optional": false,
"start": 93,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3762
expression: actual
snapshot_kind: text
---
@ -45,7 +44,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 47,
"optional": false,
"start": 28,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3658
expression: actual
snapshot_kind: text
---
@ -53,7 +52,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 35,
"optional": false,
"start": 23,
"type": "CallExpression",
"type": "CallExpression"
@ -72,7 +70,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 36,
"optional": false,
"start": 14,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -35,7 +35,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 38,
"optional": false,
"start": 19,
"type": "CallExpression",
"type": "CallExpression"
@ -56,7 +55,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 132,
"optional": false,
"start": 115,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3822
expression: actual
snapshot_kind: text
---
@ -45,7 +44,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 64,
"optional": false,
"start": 52,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3660
expression: actual
snapshot_kind: text
---
@ -45,7 +44,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 31,
"optional": false,
"start": 19,
"type": "CallExpression",
"type": "CallExpression"
@ -72,7 +70,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 35,
"optional": false,
"start": 14,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3661
expression: actual
snapshot_kind: text
---
@ -65,7 +64,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 36,
"optional": false,
"start": 23,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,7 +1,6 @@
---
source: kcl/src/parser/parser_impl.rs
expression: actual
snapshot_kind: text
---
{
"body": [

View File

@ -32,7 +32,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 29,
"optional": false,
"start": 11,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -1,6 +1,5 @@
---
source: kcl/src/parser/parser_impl.rs
assertion_line: 3718
expression: actual
snapshot_kind: text
---
@ -35,7 +34,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 29,
"optional": false,
"start": 11,
"type": "CallExpression",
"type": "CallExpression"
@ -86,7 +84,6 @@ snapshot_kind: text
"type": "Identifier"
},
"end": 53,
"optional": false,
"start": 33,
"type": "CallExpression",
"type": "CallExpression"

View File

@ -99,9 +99,30 @@ async fn execute(test_name: &str, render_to_png: bool) {
});
}
Err(e) => {
assert_snapshot(test_name, "Error from executing", || {
insta::assert_snapshot!("execution_error", e);
});
match e {
crate::errors::ExecError::Kcl(error) => {
// Snapshot the KCL error with a fancy graphical report.
// This looks like a Cargo compile error, with arrows pointing
// to source code, underlines, etc.
let report = crate::errors::Report {
error,
filename: format!("{test_name}.kcl"),
kcl_source: read("input.kcl", test_name),
};
let report = miette::Report::new(report);
let report = format!("{:?}", report);
assert_snapshot(test_name, "Error from executing", || {
insta::assert_snapshot!("execution_error", report);
});
}
e => {
// These kinds of errors aren't expected to occur. We don't
// snapshot them because they indicate there's something wrong
// with the Rust test, not with the KCL code being tested.
panic!("{e}")
}
};
}
}
}

View File

@ -1,4 +1,4 @@
use std::{any::type_name, collections::HashMap, num::NonZeroU32};
use std::{any::type_name, num::NonZeroU32};
use anyhow::Result;
use kcmc::{websocket::OkWebSocketResponseData, ModelingCmd};
@ -11,6 +11,7 @@ use crate::{
ExecState, ExecutorContext, ExtrudeSurface, KclValue, Metadata, Sketch, SketchSet, SketchSurface, Solid,
SolidSet, SourceRange, TagIdentifier,
},
kcl_value::KclObjectFields,
std::{shapes::SketchOrSurface, sketch::FaceTag, FnAsArg},
};
@ -1169,7 +1170,7 @@ impl<'a> FromKclValue<'a> for super::sketch::PlaneData {
}
// Case 2: custom plane
let obj = arg.as_object()?;
let_field_of!(obj, plane, &std::collections::HashMap<String, KclValue>);
let_field_of!(obj, plane, &KclObjectFields);
let origin = plane.get("origin").and_then(FromKclValue::from_kcl_val).map(Box::new)?;
let x_axis = plane
.get("xAxis")
@ -1359,7 +1360,7 @@ impl<'a> FromKclValue<'a> for super::revolve::AxisAndOrigin {
}
// Case 2: custom planes.
let obj = arg.as_object()?;
let_field_of!(obj, custom, &HashMap<String, KclValue>);
let_field_of!(obj, custom, &KclObjectFields);
let_field_of!(custom, origin);
let_field_of!(custom, axis);
Some(Self::Custom { axis, origin })
@ -1419,7 +1420,7 @@ impl<'a> FromKclValue<'a> for i64 {
}
impl<'a> FromKclValue<'a> for &'a str {
fn from_kcl_val(arg: &'a KclValue) -> Option<&'a str> {
fn from_kcl_val(arg: &'a KclValue) -> Option<Self> {
let KclValue::String { value, meta: _ } = arg else {
return None;
};
@ -1427,8 +1428,8 @@ impl<'a> FromKclValue<'a> for &'a str {
}
}
impl<'a> FromKclValue<'a> for &'a HashMap<String, KclValue> {
fn from_kcl_val(arg: &'a KclValue) -> Option<&'a HashMap<String, KclValue>> {
impl<'a> FromKclValue<'a> for &'a KclObjectFields {
fn from_kcl_val(arg: &'a KclValue) -> Option<Self> {
let KclValue::Object { value, meta: _ } = arg else {
return None;
};

View File

@ -31,7 +31,7 @@ pub async fn map(exec_state: &mut ExecState, args: Args) -> Result<KclValue, Kcl
/// `[f(a), f(b), f(c)]`
/// ```no_run
/// const r = 10 // radius
/// fn drawCircle = (id) => {
/// fn drawCircle(id) {
/// return startSketchOn("XY")
/// |> circle({ center: [id * 2 * r, 0], radius: r}, %)
/// }
@ -49,7 +49,7 @@ pub async fn map(exec_state: &mut ExecState, args: Args) -> Result<KclValue, Kcl
/// // Call `map`, using an anonymous function instead of a named one.
/// const circles = map(
/// [1..3],
/// (id) => {
/// fn(id) {
/// return startSketchOn("XY")
/// |> circle({ center: [id * 2 * r, 0], radius: r}, %)
/// }
@ -149,7 +149,7 @@ pub async fn reduce(exec_state: &mut ExecState, args: Args) -> Result<KclValue,
/// // Use a `reduce` to draw the remaining decagon sides.
/// // For each number in the array 1..10, run the given function,
/// // which takes a partially-sketched decagon and adds one more edge to it.
/// fullDecagon = reduce([1..10], startOfDecagonSketch, (i, partialDecagon) => {
/// fullDecagon = reduce([1..10], startOfDecagonSketch, fn(i, partialDecagon) {
/// // Draw one edge of the decagon.
/// let x = cos(stepAngle * i) * radius
/// let y = sin(stepAngle * i) * radius

View File

@ -27,7 +27,7 @@ pub async fn int(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, Kc
/// startSketchOn('XZ')
/// |> circle({ center = [0, 0], radius = 2 }, %)
/// |> extrude(5, %)
/// |> patternTransform(n, (id) => {
/// |> patternTransform(n, fn(id) {
/// return { translate = [4 * id, 0, 0] }
/// }, %)
/// ```

Some files were not shown because too many files have changed in this diff Show More