Skip to content
Merged
Show file tree
Hide file tree
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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ jobs:
rustup toolchain install 1.75 --profile minimal
cargo +1.75 test --offline -p codeupipe

rust-lint:
name: Rust linter
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/codeupipe-lint-rs
steps:
- uses: actions/checkout@v5
# A workspace of its own: it downloads syn and serde_json from
# crates.io, at the versions in its Cargo.lock.
- name: Format
run: cargo fmt --check
- name: Clippy
run: cargo clippy --locked --all-targets -- -D warnings
- name: Tests
run: cargo test --locked
- name: The linter on the Rust code of this repository
run: >-
cargo run --locked -q -- --strict src tests ../codeupipe-rs/src
../codeupipe-rs/examples ../codeupipe-rs/tests ../../conformance/runners/rust/src
- name: The oldest Rust that the crate supports (rust-version)
run: |
rustup toolchain install 1.75 --profile minimal
cargo +1.75 test --locked

export:
name: Export against dynamic run
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ scorecard/results/
node_modules/

/target/
/packages/codeupipe-lint-rs/target/
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ If you know Unix, these are close to things you know.
| Hook | A git hook: code that runs before and after a step |
| State | `$?` and `PIPESTATUS` |

This repository has six packages.
This repository has seven packages.

| Package | Contents | Reference |
|---|---|---|
Expand All @@ -37,7 +37,8 @@ This repository has six packages.
| `codeupipe` for TypeScript | The eight primitives. No dependencies | [packages/codeupipe-ts/README.md](packages/codeupipe-ts/README.md) |
| `codeupipe-lint` for TypeScript | A linter for pipeline code in TypeScript | [packages/codeupipe-lint-ts/README.md](packages/codeupipe-lint-ts/README.md) |
| `codeupipe-compile` for TypeScript | Writes a pipeline as plain TypeScript with no import of codeupipe | [packages/codeupipe-compile-ts/README.md](packages/codeupipe-compile-ts/README.md) |
| `codeupipe` for Rust | The primitives, 0.1.0. No dependencies. No compiler or linter yet | [packages/codeupipe-rs/README.md](packages/codeupipe-rs/README.md) |
| `codeupipe` for Rust | The primitives, 0.1.0. No dependencies. No compiler yet | [packages/codeupipe-rs/README.md](packages/codeupipe-rs/README.md) |
| `codeupipe-lint` for Rust | A linter for pipeline code in Rust, for the rules that the compiler does not check | [packages/codeupipe-lint-rs/README.md](packages/codeupipe-lint-rs/README.md) |

## Install

Expand Down Expand Up @@ -205,7 +206,8 @@ An editable install needs pip 21.3 or later.
| `packages/codeupipe-ts/` | The TypeScript runtime, with its tests and its example |
| `packages/codeupipe-lint-ts/` | The TypeScript linter, with its tests |
| `packages/codeupipe-compile-ts/` | The TypeScript compiler, with its tests |
| `packages/codeupipe-rs/` | The Rust runtime, with its tests and its example |
| `packages/codeupipe-rs/` | The Rust runtime, with its tests and its examples |
| `packages/codeupipe-lint-rs/` | The Rust linter, with its tests. A Cargo workspace of its own |
| `conformance/` | The contract ([API.md](conformance/API.md)), the cases, the runners, the benchmark |
| `tests/` | The tests for the two Python packages |
| `examples/pricing/` | The example, with its test in `tests/examples/` |
Expand All @@ -215,15 +217,17 @@ An editable install needs pip 21.3 or later.
The root `pyproject.toml` is not a package. It holds the test settings. The
root `package.json` is not a package: it makes the three TypeScript packages
one npm workspace. The root `Cargo.toml` is not a package either: it makes
the Rust runtime and its conformance runner one Cargo workspace.
the Rust runtime and its conformance runner one Cargo workspace, which
builds with no download. The Rust linter needs `syn`, so it is a workspace
of its own.

## Other languages

The contract and the conformance cases do not depend on a language. Python
is the first runtime and TypeScript is the second
([packages/codeupipe-ts/](packages/codeupipe-ts/)), with its own linter and
its own compiler. Rust is the third
([packages/codeupipe-rs/](packages/codeupipe-rs/)); its plan is
([packages/codeupipe-rs/](packages/codeupipe-rs/)), with its own linter; its plan is
[docs/rust-plan.md](docs/rust-plan.md). A runtime in another language must implement
the contract and add a conformance runner.

Expand Down
4 changes: 4 additions & 0 deletions conformance/runners/rust/src/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ fn reference_write(data: &mut HashMap<String, f64>, key: &str) {

/// The reference workload: ten map writes behind ten function calls, with no
/// framework. A pipeline's time is reported relative to it.
// cup: allow-plain the timing loop of the bench target: it measures the pipeline, so it is not a step of it
fn reference() -> HashMap<String, f64> {
let mut data = HashMap::from([("a".to_string(), 1.0)]);
for key in REFERENCE_KEYS {
Expand All @@ -27,6 +28,7 @@ fn reference() -> HashMap<String, f64> {
data
}

// cup: allow-plain the timing loop of the bench target: it measures the pipeline, so it is not a step of it
fn timed(f: &mut impl FnMut()) -> Duration {
let started = Instant::now();
for _ in 0..RUNS {
Expand All @@ -38,6 +40,7 @@ fn timed(f: &mut impl FnMut()) -> Duration {
/// (µs per run, time relative to the reference workload). µs per run is the
/// best of REPEATS x RUNS. Each repeat times the reference and then `f`,
/// back to back, and the median of the ratios is reported.
// cup: allow-plain the timing loop of the bench target: it measures the pipeline, so it is not a step of it
fn measure(mut f: impl FnMut()) -> (f64, f64) {
let mut reference_run = || {
black_box(reference());
Expand All @@ -60,6 +63,7 @@ fn measure(mut f: impl FnMut()) -> (f64, f64) {
}

/// Time one scenario. `pipeline` is built from its steps; `input` is its input.
// cup: allow-plain the timing loop of the bench target: it measures the pipeline, so it is not a step of it
pub fn bench(id: Value, pipeline: Pipeline, input: codeupipe::Payload) -> Value {
let output = match pipeline.run(&input) {
Ok(output) => output.to_dict(),
Expand Down
1 change: 1 addition & 0 deletions conformance/runners/rust/src/cup_ops/sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn set_run_again(run_again: Option<RunAgain>) {
RUN_AGAIN.with(|slot| slot.replace(run_again));
}

// cup: allow-plain the edge of the runner: it runs the pipeline again for the run_again op
pub fn run_again(input: Map) -> Result<(), StepError> {
// Taken out of the cell first: the run that it starts reads the cell too.
let run_again = RUN_AGAIN.with(|slot| slot.borrow().clone());
Expand Down
4 changes: 4 additions & 0 deletions conformance/runners/rust/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use codeupipe::{Map, Value};

/// Read one JSON value from `text`.
// cup: allow-plain the JSON reader and writer of the runner, which must not download a crate
pub fn read(text: &str) -> Result<Value, String> {
let mut reader = Reader { text, at: 0 };
let value = reader.value()?;
Expand All @@ -21,6 +22,7 @@ pub fn write(value: &Value) -> String {
out
}

// cup: allow-plain the JSON reader and writer of the runner, which must not download a crate
fn write_into(value: &Value, out: &mut String) {
match value {
Value::Null => out.push_str("null"),
Expand Down Expand Up @@ -54,6 +56,7 @@ fn write_into(value: &Value, out: &mut String) {
}
}

// cup: allow-plain the JSON reader and writer of the runner, which must not download a crate
fn write_string(s: &str, out: &mut String) {
out.push('"');
for c in s.chars() {
Expand All @@ -75,6 +78,7 @@ struct Reader<'a> {
at: usize,
}

// cup: allow-plain the JSON reader and writer of the runner, which must not download a crate
impl Reader<'_> {
fn problem(&self, what: &str) -> String {
format!("not JSON at byte {}: {what}", self.at)
Expand Down
7 changes: 7 additions & 0 deletions conformance/runners/rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ impl<P: Predicate + 'static> UseFilter for AsValve<'_, P> {
}
}

// cup: allow-plain the entry of the runner: it builds each case's pipeline from the JSON vocabulary and reports the result
fn add_filter(spec: &Map, to: impl UseFilter) -> Result<(), Unsupported> {
match text(spec, "op")?.as_str() {
"set" => to.add(SetFilter {
Expand Down Expand Up @@ -122,6 +123,7 @@ fn add_filter(spec: &Map, to: impl UseFilter) -> Result<(), Unsupported> {
Ok(())
}

// cup: allow-plain the entry of the runner: it builds each case's pipeline from the JSON vocabulary and reports the result
fn add_tap(spec: &Map, pipeline: &mut Pipeline) -> Result<(), Unsupported> {
match text(spec, "op")?.as_str() {
"record" => pipeline.add_tap(RecordTap {
Expand All @@ -136,6 +138,7 @@ fn add_tap(spec: &Map, pipeline: &mut Pipeline) -> Result<(), Unsupported> {
Ok(())
}

// cup: allow-plain the entry of the runner: it builds each case's pipeline from the JSON vocabulary and reports the result
fn add_valve(when: &Map, filter: &Map, pipeline: &mut Pipeline) -> Result<(), Unsupported> {
match text(when, "op")?.as_str() {
"equals" => {
Expand All @@ -161,6 +164,7 @@ fn steps_of(spec: &Map) -> Result<&[Value], Unsupported> {
.ok_or_else(|| Unsupported("'steps' is not a list".to_string()))
}

// cup: allow-plain the entry of the runner: it builds each case's pipeline from the JSON vocabulary and reports the result
fn build(steps: &[Value], pipeline: &mut Pipeline) -> Result<(), Unsupported> {
for step in steps {
let step = step
Expand Down Expand Up @@ -210,6 +214,7 @@ fn names(labels: &[String], named: &impl Fn(&str) -> String) -> Value {
)
}

// cup: allow-plain the entry of the runner: it builds each case's pipeline from the JSON vocabulary and reports the result
fn run_case(spec: &Map) -> Result<Value, Unsupported> {
let steps = steps_of(spec)?;
let mut pipeline = Pipeline::new();
Expand Down Expand Up @@ -292,6 +297,7 @@ fn run_case(spec: &Map) -> Result<Value, Unsupported> {
Ok(Value::Map(result))
}

// cup: allow-plain the entry of the runner: it builds each case's pipeline from the JSON vocabulary and reports the result
fn bench_case(spec: &Map) -> Result<Value, Unsupported> {
let mut pipeline = Pipeline::new();
build(steps_of(spec)?, &mut pipeline)?;
Expand All @@ -302,6 +308,7 @@ fn bench_case(spec: &Map) -> Result<Value, Unsupported> {
Ok(bench::bench(value(spec, "id")?, pipeline, input))
}

// cup: allow-plain the entry of the runner: it builds each case's pipeline from the JSON vocabulary and reports the result
fn main() -> ExitCode {
let target = std::env::args().nth(1).unwrap_or_default();
let run: fn(&Map) -> Result<Value, Unsupported> = match target.as_str() {
Expand Down
37 changes: 35 additions & 2 deletions docs/rust-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ The packages are beside the others, with `-rs` where TypeScript has `-ts`.
A root `Cargo.toml` makes the Rust crates one Cargo workspace. It is not a
package, as the root `package.json` is not a package. `Cargo.lock` is
committed, since the workspace has a binary (the runner). `target/` goes in
`.gitignore`.
`.gitignore`. The linter (phase 4) is a workspace of its own, with its own
`Cargo.lock`: it needs `syn` from crates.io, and the root workspace builds
with no download.

One primitive is in each file, and the file name is the type name in
snake_case, as in Python: `DiscountFilter` is in `discount_filter.rs`.
Expand Down Expand Up @@ -479,6 +481,37 @@ as `typescript:static` does, and the refusal tests pass.

## Phase 4: the linter

**Done** (2026-10-27): `packages/codeupipe-lint-rs/`, the crate
`codeupipe-lint` 0.1.0, with its own README. It is a codeupipe pipeline of
eleven Filters that carry a `LintRun` (a type of its own, D11), on `syn`
(`full`, `visit`) and `serde_json` for `cup.config.json`. The rules are
CUP-I000, A003, A005, I003, I005, I006, I007, and the warnings I008 and
I009; the arguments, the config file, the markers and the report are those
of the TypeScript linter. Decided on the way:

- **The file name is snake_case** (`discount_filter.rs`), and a primitive is
a type that implements a primitive trait in the same file.
- **CUP-I008 is reported for each `impl`** of a type that is not a
primitive, not once for the type: in Rust the code of a type is in its
`impl` blocks, so the marker goes next to the code. Each `impl` of a
primitive (a helper method of a Filter, its `new`) is the primitive's code.
- **Comments are found between the tokens**, since a syntax tree has none.
Text in a doc comment or a string is not a marker.
- **A name is followed to its `let`** with the scopes of Rust: a later `let`
replaces it, a closure sees the function around it, a nested function does
not, and the types declared in a block are seen in all of it.

CI runs it with `--strict` on all the Rust code of the repository (the
runtime, its examples and tests, the conformance runner, and the linter), as
the Python linter runs. That needed 45 `cup: allow-plain` markers (the
runtime's own types, the runner's JSON reader and entry, the linter's
visitors) and a fix in the runtime's tests: six step types declared in a
test function moved to the top of the file, and the test of a plain nested
Pipeline's label says why with `cup: allow-anonymous`.

What follows is the plan as it was written.


Rust's types already check several rules:

| Rule | In Rust |
Expand All @@ -505,7 +538,7 @@ correctness. The others are about form.
| 1 | Core crate, example, README | About 600 lines with tests (TypeScript: 415 lines of source) |
| 2 | Runner, `run.py`, bench, CI, documents | About 400 lines, most of it the JSON reader and the `cup_ops` |
| 3 | Compiler and `rust:static`, if the benchmark says so | About 1,000 lines (TypeScript: 851 lines of source) |
| 4 | Linter | About 1,200 lines |
| 4 | Linter. **Done** | About 1,200 lines (written: 2,060 of source with its comments, 950 of tests) |

Phases 1 and 2 are one release: `codeupipe` 0.1.0 for Rust, in the
conformance matrix. Phases 3 and 4 are later versions.
123 changes: 123 additions & 0 deletions packages/codeupipe-lint-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading