Skip to content

Module system extensions and assemblies #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# Module system extensions and assemblies

This doc describes possible extensions to the current `import`/`export` syntax for modules. This includes support for assemblies.

This doc is fairly detailed, but that is mostly to prove out the designs and ensure internal consistency, etc. Consider all syntax to be a strawman for iteration and the whole doc to be a starting point for discussing the design rather than a complete proposal.

The doc is ordered by requirements, rather than a logical breakdown of the complete feature. Much is left unspecified for now, especially where I don't think it will affect other design choices. What is spec'ed and what isn't should be understood as a comment on overall prioritisation.

Previous discussion:

- [Initial modules proposal (`use` statements)](https://github.com/KittyCAD/modeling-app/issues/4080)
- [Implementation of initial module system](https://github.com/KittyCAD/modeling-app/pull/4149)

## Import all items from a module

Importing all items (aka glob imports) is primarily a convenience so users don't need to make a long list of names in an `import` expression. I think this will get more important with some ideas I have for modularising std, but more on that elsewhere.

The basic idea is we allow `import * from "foo.kcl"` which imports all `export`ed names from foo.kcl into the namespace of the current file.

There are downsides to allowing this: primarily if an exported name is added to foo.kcl then that can cause an error in the importing file if the name already exists (which may be from another import, e.g., `import * from "bar.kcl"`). It's also a bit harder to follow the code without tooling (you can't tell at a glance where a name is defined).

Syntax: `*` is common in many PLs, but is a bit opaque for new programmers. A keyword like `all`, `everything`, or `any` would be better, but that is one less name we can all use for programming (we can't use a contextual keyword here). I think it would be a bit too subtle to use no names to mean all names (e.g., `import from "foo.kcl"`).
Copy link
Collaborator

@adamchalmers adamchalmers Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about import all symbols from "foo.kcl"? It would be unambiguous because if you're trying to import two parts called "all" and "symbols" you would need a comma between them.

You could also do import a, b, and all other from "foo.kcl".


Renaming: one of the drawbacks of glob imports is that you can't rename items in the glob. To facilitate that, I propose that `*` (or whichever keyword) is allowed at the end of a list names and imports all exported names which haven't appeared in the list. So `import a, b, * from "foo.kcl"` is allowed (assuming `a` and `b` are defined in foo.kcl) and has the same meaning as `import * from "foo.kcl"`; more usefully `import a as c, b as d, * from "foo.kcl"` imports the same items but renames `a` to `c` and `b` to `d` (`a` and `b` are not available). There would be an error if the names `c` or `d` are used elsewhere, including in the `*`.

Optional extension: name clashes from glob imports are only an error if the name is actually used, not just imported.

Alternative: explicit names take priority over implicitly imported names (from globs). I do not like this. It is what is done in Rust and it causes issues (it's also pretty subtle to explain).

## Import constants from a module

We permit `export` on constant declarations (e.g., `export foo = 42`) and those exported constants can be imported in the same way as functions. The difficulty is around side effects, design there is postponed, blocked on discussing side-effects and the KCL model of computation.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about allowing the export of literals and literals only, for now? They wouldn't have any side-effects.


## Import the module itself as a name

There's an important question about what this actually means in terms of the side-effects, let's postpone that, blocked on discussing side-effects and the KCL model of computation. For now assume there is some (unnamed) geometry exposed from a module, and also constants and functions which we wish to name via the module.

`import "foo.kcl"` imports the module of foo.kcl with the name `foo`. We also permit renaming: `import "foo.kcl" as bar` imports the module of foo.kcl with the name `bar`. If there are characters in the filename which are not permitted in identifier names (e.g., spaces, `-`, or `.` other than separating the extension), then renaming is required (there is no 'clever' renaming such as mapping `-` to `_`).

We may wish to allow the module itself to be imported as well as specific names: `import a, self from "foo.kcl"` (I'm reusing the `self` keyword from Rust, but there may be a better one for KCL) would import `a` from `foo.kcl` and the module itself as `foo`. Renaming (`self as bar`) would have the obvious meaning.

We may also want to allow an assignment style rename: `bar = import "foo.kcl"` with the same meaning as `import "foo.kcl" as bar`. This would be more natural for assemblies. Although it provides two ways to do the same thing (generally bad), I think both ways are natural (and I am thinking of using `as` elsewhere with the same meaning to implement tagging, but that's way out of scope for this doc). If we do allow this we *may* also consider it for single name imports,e.g., `bar = import baz from "foo.kcl"`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the assignment syntax


### Assemblies

A module may or may not contain top-level geometry (as well as zero or more named constants or functions). If it does, then the name of the module (whether renamed, assigned or whatever) should work as an assembly. E.g., assuming foo.kcl contains top-level geometry, then after `import "foo.kcl"`, `foo` can be used in the same way as if the geometry from foo.kcl were defined in the importing file as a constant called `foo`.

When importing a module, the module is treated as an object with name (and type) inherited from the name of the module (or the alias used for the import). Each top-level, geometric entity is available as a numeric field, numbered in order. Where an item is just a variable use, that creates a named field as well as a numbered one with the same value. Exported variables and functions are available as named fields (see below). So, continuing the example above, `foo` has type `foo` which is an object type.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the order in which entities are defined should be important. If you reorder two parts in a KCL file which don't depend on each other, I would be surprised if that breaks some other code in some other module which imports it. Maybe we should force users to name their parts if they want to be exported.


Obviously there is a massive caveat about design questions around side effects.

There is an open question about how the UI can modify assemblies from other files (note that the same feature will be used for assemblies from other projects which must be immutable to the UI).

## Foreign formats

`import` can be used to import objects defined in other CAD formats, replacing the `import` function in the standard library. E.g., `model = import("tests/inputs/cube.obj")` is replaced by `import "tests/inputs/cube.obj" as model`. Such objects are treated in the same way as other assemblies, but they are always opaque (no AST, etc.) and read-only.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering about this. I like the idea of combining these cases.


### Attributes

Currently, the [`import` std lib](https://zoo.dev/docs/kcl/import) function allows providing parameters to import:

- `format`: the format of the file, presumably uses the extension to derive this if not specified
- `coords`: the coordinate system to be used: `{ forward, up }` TODO what is the format of these?
- `units`: the length units to be used

This would be supported using attribute syntax, e.g.,:

```
@(lengthUnit = mm)
import "foo.obj"
```

Only for non-KCL files. Values:

- `format`: string, one of "fbx", "gltf", "glb", "obj", "ply", "sldprt", "step", "stl"
- `lengthUnit`: a length unit, `mm`, ...
- `coords`: a coordinate system, one of "zoo", "opengl", "vulkan"


## Re-exporting imported names

We permit `export import ...` (and `export x = import ...`, if we support that form, see above). These make the imported names (in their renamed form, as appropriate) visible outside the current module in the same was as an exported constant or function (as well as making them available within the module).

## Import graphs

It is permitted to import a name multiple times and there is no name clash if the name points at exactly the same item (even if the path of imports to the item is different). It is permitted to import an item multiple times with different names.

Cycles of imports are permitted (we could offer a lint which detects these). When tracing imports, a file is never read twice; there is no fixpoint computation required.

E.g.,

```
// In foo.kcl

import * from "bar.kcl"
export x = ...

// In bar.kcl

import * from "foo.kcl"
export y = ...
```

The above is allowed and `x` and `y` can be named in both files. If the import in bar.kcl is changed to `import x from "foo.kcl"`, there is no change. If it is changed to `import y from "foo.kcl"` there is no error (though we might warn that the import does nothing), `y` is visible in both modules and `x` is only visible in foo.kcl. If the import in bar.kcl is changed to `import from "foo.kcl"`, `x` is visible in both files, `y` is visible in foo.kcl, `foo` and `foo::x` are visible in bar.kcl. Note that `foo` is not visible in `foo.kcl`.

## Naming items in modules

```
name ::= path ident | ident
path ::= path_root | path ident `::`
path_root ::= ident? `::`
```

e.g., `foo`, `foo::bar::baz`, `::bar::baz`

A `name` can be used anywhere an identifier is used today *to refer to an item*, e.g., to access a function, constant, or type, but not to identify a field in a record or when creating a variable.

If an identifier is in scope (or is `std`), it may be used as the root of the path. If it is a module, then exported constants, functions, and submodules (see below) within the module may follow in the path. Where the path starts with `::`, it means items within the top-level of the project.

Examples:

```
import "foo.kcl"

a = foo::someConst
b = ::bar::someFn()
c = foo::someConstRecord.field
d = baz::anotherConst

e = turns::ZERO // if `turns` is a module in std, re-exported in the prelude, or alternatively `std::turns::ZERO`
```

### Alternative: dot syntax

Use `.` rather than `::` and treat paths to items in modules in the same way as fields in a struct. This does give us submodules 'for free' and means less syntax, however it means that submodules do not follow directories and (I think) makes code harder to read.

## Submodules

### Requirements

- Submodules should *only* map to directories, no equivalent of mod.rs
- Submodules should not be declared, the file being present should be enough. OTOH, opting-in to stuff like this does make some bugs less likely.
- Need to take account of importing a module as a base for paths


### Mapping directories to modules

- KCL files in a directory are submodules of that directory
- E.g., `foo/bar.kcl` defines a submodule `bar` inside `foo`
- It is OK to have a directory and KCL file with the same name, e.g., if `foo.kcl` exists next to `foo`, that is fine and they represent the same module `foo`.
- Files in a directory are always in scope as modules in the corresponding file. E.g., if `bar.kcl` exists in the directory `foo`, then `bar` is available as a module name inside `foo.kcl`.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, is this saying that users don't need to explicitly use an import statement to use things in subdirectories/submodules of the current module?

Directory structure:

foo/
  globals.kcl
foo.kcl
// foo/globals.kcl
export A = 2
// foo.kcl
globals::A + 40 // This works?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is correct. For clarity, from another module foo::globals::A would work, and in foo A would not without an import.


### Paths

A module variable is always required to start a path. Modules are always considered exported. Starting a path with `::` will refer to any items in the root of the project.

```
import "foo.kcl"
foo::bar // ok
foo::bar::qux // ok
baz::bar // ok if bar exists inside baz.kcl in a subdirectory with the same name as the current file
```

### Namespacing

Technical note:

Modules and non-module items are in separate namespaces (that is, modules and non-module items cannot be used in the same syntactic space, except when imported). So local names and sub-modules may exist with the same name. This also makes sense when considering files as parts - the file is both a part and a module, but they are not the same object. When importing a name, it imports the name in all namespaces in which it is exported.

Note that in the future, types will also be in a separate namespace.


## Importing from other directories within the project

File paths (as well as filenames) may be used inside the quoted string of an `import`. Paths may contain `/` (but not `\`, even on Windows). Paths are always relative to the top-level of the project (not relative to the current file or absolute paths), and may not include `.` or `..` (alternative: we could relax this requirement and allow relative paths as long as traversing the path never leaves the project directory) or global identifiers (`c:`, etc. or starting with '/'). Paths are ignored in the name of the module as imported. E.g., `import "foo.kcl"` brings in `foo` from the root directory of the project, `import "baz/bar/foo.kcl"` brings in `foo` from `foo.kcl` found at `baz/bar/`, `import a from "baz/bar/foo.kcl"` brings in `a` from the same file. The path of the file makes no difference to the imported name or to privacy.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative imports are pretty nice in that you can move a directory without having to update all the import statements within the files. But it seems like we can always add this later as a non-breaking change.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The weird thing about everything being relative to the project is that in order to execute, you always need an extra bit of information about where the project root is. In ZMA, we have that. In the CLI, when a user invokes it with the path to a KCL file, the project root may be ambiguous. It would be nice if we could avoid the case where if you cd and run the same file, it breaks.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contra-argument, is that relative imports are not so great because it means if you move just a file, you have to update all the relative imports.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second comment is an interesting thing. I think it comes down to whether a 'program' is a single file or a project. And with file-based imports and the desire to be able to execute parts independently, we are leaning towards the former. I find project-relative paths to be much more intuitive though (e.g., I hate the behaviour of include_str in Rust).

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contra-argument, is that relative imports are not so great because it means if you move just a file, you have to update all the relative imports.

This tells me that both ways are equally valid and useful. It seems arbitrary to favor one over the other. As a user, both are useful in different situations, so I stand by my statement that I think having both would be nice because I have more context on my own project than the language implementer about which is going to cause me more work.

we are leaning towards the former.

By "former", I assume you mean project-based.

Now that I think of it, the proposal would probably be a breaking change for kcl-samples because people open the entire directory in ZMA. I mean, I know I have, because I cloned the repo (recently archived) into my Zoo projects directory. But it's actually a set of projects, one in each subdirectory. So I don't know. None of them have a project.toml file to disambiguate. So how would ZMA know where the project root is?

Would we be okay saying that people have to open each directory individually?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, sorry by former I meant file-based because files often stand alone - samples being another example of that


Directories cannot be imported or imported from, e.g., `import foo from "baz/bar"` is not allowed.

All modules within a project are nameable, there is no need for `export` anywhere on a module. If all items in a module are private (i.e., none are `export`ed), the module itself can still be named, but it is useless (like an empty object) and a glob import would import nothing.

## Imports from the standard library

Imports from the standard library work like other imports, but the import path begins with `std` and has no `.kcl` extension. The interpreter knows where to find the standard library which works like any other KCL project.

There is an implicit import `import * from 'std'` in every file unless it includes a `@no_std` attribute. Items in `std` can be imported too. There is no explicit prelude, all of std is treated like a prelude.


## Importing from other projects

This is mostly postponed for future work. We would use the same syntax and semantics as the rest of the system, however the string in the import would include some indicator that the target is outside the project and how to locate it.

Example with very, very strawman syntax: `import a from "cargo://some-library/foo.kcl"` would import `a` from the file `foo.kcl` in the project `some-library` located in the `cargo` (lol) repository. `import a from "local://../some-library/foo.kcl` would import `a` from the file `foo.kcl` from a sibling directory of the current project called `some-library`. The point of these examples is not the syntax for identifying other projects or the places where an external project might live, just to show how the `import` statement might be extended to support other projects.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to do the golang dep thing and just allow github.com/kittycad/kcl-samples/my-wheel.kcl as an import. cc @paultag and @Irev-Dev who we've talked about this before, this allows people to use urls and saves us having to create some package manager for a while, PLUS promotes git usage

Copy link

@paultag paultag Oct 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strong concur; it also lets us avoid the incredible complexity of a package manager (and associated tooling which we'd have to handle), as well as it adds a lot of clarity in the modules (no namespace squatting; food-fight adjudication over copyrights and shit.

It also gives a level of "official" status to our repos and other trusted orgs -- it's hard to tell if https://pypi.org/project/cryptography/ is official pyca or some rano without looking closely; or like tokio_xyz -- is that tokio or some rando? Hard to tell unless you find the repo (which may or may not get linked).

Love having everything be a git url, or even some intermediate like go's module url protocol if we wanted to get wicked fancy with it (for sub-folder imports this would be required so we know where the module root is; likely with special-casing for github as go-get has until they support ?kcl-import=1)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather we use an object than a string, e.g. "import a from { repo = "https://github.com/KittyCAD/kcl-samples/my-wheel.kcl", branch = "next" } rather than trying to stuff all the things like filename, branch, directory into a string. Making it an object lets us add more fields in the future e.g. auth for private repos


Privacy across multiple projects is postponed, but I would like to keep it simple, e.g., just keeping the single `export` keyword to expose names to other projects as well as other modules in the same project.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In most programming languages, it's really useful to distinguish between exposed to the project and exposed outside the project, as in pub vs. pub(crate) in Rust. The most common use case is that you want to expose something from a module for testing, but you don't want to expose it to the world. But it can also come up for helper or util functions for libraries. I think that the lack of this will lead to more things being exported than should, i.e. people learning to default to export and private library internals exposed to the world because there's no other way to reuse code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think there is some work to do still for cross-project import/export. (This comment is from quite a while ago, I still think simple is important, but I'm not sure if just export is enough). I think we would want something for exporting from a project, but that depends a bit on importing - I'm not sold on the simple strawman of importing a file from a repo - I think we might want some project scoping which would include what is exported.


### Versioning

Versioning info and other extensions should all be specified within the file identifier string. Details of the design postponed.

### Cycles between projects

Design postponed.


## Non-top-level imports

Imports may appear anywhere within a file (including within conditionals, etc). A name may only be used following its import and at the same or narrower scope.

E.g.,

```
// At top level
import "foo.kcl"
a = foo // ok

b = bar // error
import "bar.kcl"

x = ... {
import "baz.kcl"
c = baz // ok
d = bar // ok
}

e = baz // error
```

Caveat: postponed design work around side-effects.


## Importing data

`import` can also be used to import data, e.g., `import "foo.json"` would create a `foo` object with the json data. Exactly how that works is postponed design, the interesting questions I see are how arbitrary data is represented in our quite limited object data structure, and how we specify what format to treat data as (is it just implied by the extension or can it be overridden? Can we support arbitrary data formats by allowing the user to provide a decoder, etc.)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this, great idea. I know Jordan would love that for his QR code data.