1 unstable release
Uses old Rust 2015
0.0.1 | Nov 29, 2022 |
---|
#4 in #cui
64KB
2K
SLoC
CUI is a language for building UIs, and is implemented in this repository using Rust procedural macros. Code compiled from CUI runs in a browser, and consists of an HTML component and a Webassembly binary.
This repository is primarily for people interested in the development of the CUI language itself! If you are interested in building apps with CUI, take a look at the README for the cui-tools repository.
This repository includes cui-tools as a git submodule to make testing the library easier.
Installation
You will need rustc and wasm-pack to build the library and run tests:
Then:
git clone https://github.com/thisminute/cascading-ui.git
cd cascading-ui
# you only need to do this submodule step if you plan to use cui-tools
git submodule update --init --recursive
Windows users may also need to run the following in the root directory:
rustup toolchain install stable-x86_64-pc-windows-gnu
rustup default stable-x86_64-pc-windows-gnu
Once everything is set up, there are two ways to test the library:
- Use the included cui-tools to build an app that uses the library. Edit files in
cui-tools/app/src
to make changes to the app, thencargo run
in the cui-tools directory. - Use the tests in the
tests
directory. Edit or add tests, then usewasm-pack test --headless --firefox
(or--chrome
, or--safari
) to run them.
For more information about each workflow, take a look at the README files in cui-tools
and tests
respectively.
Understanding the Code
Execution Steps
Rust folder structure
"lib.rs" and "mod.rs" are special names that are entry points for the folders they are in. The top level is ./src/lib.rs
. The whole project is a library, and every subsequent directory contains a private module included from there.
src/lib.rs
Execution of the code starts in one of 3 procedural macros exported from lib.rs. cui
is the main one, test_setup
and test_header
are helpers used for writing tests.
Data flow
This diagram is helpful for remembering the order that the steps happen in, reference it to help with this section and if you get lost while navigating the code!
cui -> lex ->
tokens -> parse* ->
ast -> analyze ->
semantics -> render ->
semantics -> compile ->
html/wasm
* this library starts at the parse step (src/transform/parse.rs
), because lexing is taken care of us by some libraries, so we have some tokens to start
In words: When a piece of CUI code is compiled, it is first lexed into tokens, which are then parsed into an Abstract Syntax Tree (AST). Semantic analysis is performed on the AST to generate an object representing the meaning of the code. This "Semantics" object is rendered into an abstract representation of an HTML DOM, which is finally compiled into text strings containing HTML and CSS, and executables containing Webassembly.
Data flows between data structures (the data
module, in the left column of the diagram) by way of transformations between those structures (the transform
module, in the right column of the diagram). All of the code sits in the src
directory, and starts in lib.rs
, then passes to transform/parse.rs
to create an object which is described in data/ast.rs
, which are then passed to transform/analyze.rs
to create a semantics object defined in data/semantics.rs
, and so on through the diagram. The write
transformation is defined in several parts, one for each of several outputs that don't resemble each other - HTML, CSS, and Rust code that is then compiled into a Wasm binary. Each output is generated with a different trait implemented on the Semantics struct.
This is the core of CUI (And could be fairly easy to adapt to other syntaxes!). In src/misc
are files outside of this core flow, such as the helper context
which is used during semantic analysis.
Transformations in detail
Transformations are not entirely conceptually distinct from one another - some of the tasks that a transformation does could have been placed in a different transformation to achieve the same result. For example, different kinds of blocks (class, element, or event) are written to different arrays during parsing, but the same thing could be achieved by writing to a single blocks
array during parsing and then determining what kind of block each block is later, during analysis.
A general principle is to place logic as early as it can happen without losing information that we need later. In our block parsing example, by determining whether a block is a class, element, or event in parsing, we lose the ability to tell whether a particular class came before or after another element. For example:
// 1
.some_rule {}
some_rule {}
// 2
some_rule {}
.some_rule {}
Both of these bits of code parse into exactly the same AST, because both create a classes
array with one item and an elements
array with one item, and it is impossible to tell after parsing which order they were in originally. This is in fact desired behavior! CUI syntax should not require us to place classes before elements for the rules to apply, and as there is currently no planned distinction between putting an element and a class in either order (note: the order of any element relative to other elements on the other hand is very important, and preserved in the elements
array), we are okay with completely eliminating that information from the pipeline in the very first transformation, and it saves us from having to worry about that information affecting something in a later transformation.
Parse
Parsing takes tokens derived from some CUI input and provided to us by the syn
crate, and it transforms them into an AST. The AST is a minimum representation of the input - it is close to 1:1 with the original code, but unlike the input code it is in a tree rather than a sequence of tokens. The AST should represent the minimum information necessary to reproduce a CUI program. For example:
.box {
content {}
}
box {}
box {}
The root of the AST parsed from this code would contain a class block with an element block inside of it, and then 2 separate element blocks, like this:
// AST (made of blocks)
/ .box - content
page - box
\ box
Analyze and Render
Analyzing an AST generates a different tree structure which we call "semantics", which represents the meaning of the code in a way that continues to reflect the structure of the input, but which is filled out with information that will later let us transform the tree into a different shape, as well as any other information we need to gather along the way. To illustrate why the tree must be transformed, we can look at the tree structure of the desired output for the above code:
// DOM (made of elements)
page - box - content
\ box - content
Analysis walks the AST (which we say consists of blocks) and creates a tree modeled after it (which we say consists of groups), and then runs steps we call rendering, which gather and fill out information in the groups, creating two-way links between element and class nodes in addition to the normal tree structure. In this case, these allow us to walk from either box
group to the .box
group, and from there to the content
element group:
// semantics (made of groups, looks like AST)
/ .box - content
page - box
\ box
// also has paths to be walked in this order
page - box - .box - content
\ box /
Only one group node exists for the .box
class, and only one for the content
elements, but these elements can be reached from two places. This semantics tree can now be used to generate a DOM during rendering.
Compile
The semantics tree compiles to an HTML document and a TokenStream of Rust code to be compiled into Webassembly. We create the HTML document in compile/html.rs
. This step describes how to recursively generate a single string containing an HTML document that can then get written to a file, leveraging compile/css.rs
to complete the contents of style tags and attributes. In a separate step, the TokenStream is generated by a complicated process in compile/wasm
, and finally compiled by Rust where the macro invocation was. The HTML and CSS static content compilation steps are fairly simple compared to the Webassembly compilation step.
The assembled binary needs to know about the 3 kinds of groups - elements, classes, and listeners - and to be aware of 3 things we can do to each group - nothing, render it to the DOM, or register it to happen when an element matching some class is created later. We may do nothing to some groups if everything that needed to happen already happened during rendering and is captured in the static content.
The last complication we'll cover here is that the binary performs only limited operations for groups that have already been rendered, during something like an initialization step for the page. This code is generated by the "initialize" register and render functions, which run during build and produce simple procedural code, as opposed to the "runtime" register and render commands, which are whole functions that are called at runtime.
To be as clear as possible: The functions in initialize/register.rs
and initialize/render.rs
run at build time, whereas those in runtime/register.rs
and runtime/render.rs
will be called in a browser by the runtime. Note what's inside the quote!
macros in each file.
Tests
./tests
has a collection of CUI examples that render different features, and then checks the DOM to see that it rendered as expected! Run them with wasm-pack test --headless --firefox --chrome
(or just one browser; chrome happens to run faster). See ./tests/README.md
for details.
Debugging
We use cui-tools
to provide a server and some debugging tools to work on CUI. Use git submodule update --init --recursive
to pull the contents of the directory if it is empty. Then, you can cd cui-tools
and ./run.sh
to run the dev server.
Dependencies
~0.7–11MB
~67K SLoC