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
31 changes: 6 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ on:
pull_request:
branches: [main]

env:
CARGO_TERM_COLOR: always

jobs:
ocaml:
name: OCaml build (${{ matrix.os }})
Expand All @@ -21,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: "5.1"
ocaml-compiler: "5.4"
dune-cache: true
- name: Install dune
run: opam install --yes dune
Expand All @@ -31,30 +28,15 @@ jobs:
run: opam exec -- dune build
- name: Test
run: opam exec -- dune runtest

rust:
name: Rust build (httui-lsp)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Build
working-directory: bin/httui-lsp
run: cargo build --all-targets
- name: Check formatting
working-directory: bin/httui-lsp
run: cargo fmt --all -- --check
- name: Clippy
working-directory: bin/httui-lsp
run: cargo clippy --all-targets -- -D warnings
- name: Format check
if: runner.os == 'Linux'
run: |
opam install --yes ocamlformat.0.28.1
opam exec -- dune build @fmt

lezer-refs:
name: Lezer grammar (refs)
runs-on: ubuntu-latest
if: hashFiles('grammars-lezer/lezer-httui-refs/package.json') != ''
defaults:
run:
working-directory: grammars-lezer/lezer-httui-refs
Expand All @@ -75,7 +57,6 @@ jobs:
tree-sitter-refs:
name: Tree-sitter grammar (refs)
runs-on: ubuntu-latest
if: hashFiles('lib/grammars/tree-sitter-httui-refs/grammar.js') != ''
defaults:
run:
working-directory: lib/grammars/tree-sitter-httui-refs
Expand Down
2 changes: 2 additions & 0 deletions .ocamlformat
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
version = 0.28.1
profile = default
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ Lezer grammars, the OCaml semantic library, and the LSP server binary.
### Prerequisites

- **OCaml 5.1+** and **dune** (install via opam)
- **Rust stable** (install via rustup)
- **Node.js 22+** (for grammar tooling: lezer-generator, tree-sitter CLI)
- **tree-sitter CLI** (`brew install tree-sitter` or via npm)

Expand All @@ -25,9 +24,9 @@ make setup-hooks # installs commit-msg, pre-push, pre-commit hooks
### Build and test

```bash
make build # OCaml lib + grammars + Rust binary
make build # OCaml lib + LSP binary + grammars
make test # all tests across all languages
make lint # formatters, linters, clippy
make lint # formatters and linters
```

## Commit style
Expand Down
39 changes: 15 additions & 24 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,62 +1,57 @@
# Top-level Makefile orchestrating common tasks across the OCaml library,
# Rust LSP binary, and grammars (tree-sitter + Lezer). Intended as the
# canonical entrypoint for contributors and CI.
# the OCaml LSP binary, and grammars (tree-sitter + Lezer). Intended as
# the canonical entrypoint for contributors and CI.

LEZER_REFS_DIR := grammars-lezer/lezer-httui-refs
TS_REFS_DIR := lib/grammars/tree-sitter-httui-refs
LSP_DIR := bin/httui-lsp

.PHONY: help install setup-hooks build build-ocaml build-grammars build-lsp \
test test-ocaml test-grammars test-lsp \
lint lint-ocaml lint-rust lint-js \
clean clean-ocaml clean-grammars clean-lsp \
test test-ocaml test-grammars bench \
lint lint-ocaml lint-js \
clean clean-ocaml clean-grammars \
regenerate-grammars audit

help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)

install: ## Install all dependencies
opam install --yes --deps-only --with-test .
cd $(LSP_DIR) && cargo fetch
cd $(LEZER_REFS_DIR) && npm install
cd $(TS_REFS_DIR) && npm install

setup-hooks: ## Install git hooks (commit-msg, pre-push, pre-commit)
bash scripts/setup-hooks.sh

build: build-ocaml build-grammars build-lsp ## Build everything
build: build-ocaml build-grammars ## Build everything

build-ocaml: ## Build OCaml library
build-ocaml: ## Build OCaml library + LSP binary
opam exec -- dune build

build-grammars: ## Build all grammars
cd $(LEZER_REFS_DIR) && npm run build
cd $(TS_REFS_DIR) && npx tree-sitter generate

build-lsp: ## Build LSP server binary
cd $(LSP_DIR) && cargo build --all-targets
build-lsp: ## Build only the LSP server binary
opam exec -- dune build $(LSP_DIR)/httui_lsp.exe

test: test-ocaml test-grammars test-lsp ## Run all tests
test: test-ocaml test-grammars ## Run all tests

test-ocaml: ## Run OCaml tests
test-ocaml: ## Run OCaml tests (library + LSP binary)
opam exec -- dune runtest

test-grammars: ## Run grammar corpus tests
cd $(LEZER_REFS_DIR) && npm test
cd $(TS_REFS_DIR) && npx tree-sitter test

test-lsp: ## Run Rust tests for LSP binary
cd $(LSP_DIR) && cargo test --all-targets
bench: build-ocaml ## Run the LSP transport benchmark
python3 bench/lsp_roundtrip.py

lint: lint-ocaml lint-rust lint-js ## Run all linters
lint: lint-ocaml lint-js ## Run all linters

lint-ocaml: ## OCaml formatter check
opam exec -- dune build @fmt

lint-rust: ## Rust fmt + clippy
cd $(LSP_DIR) && cargo fmt --all -- --check
cd $(LSP_DIR) && cargo clippy --all-targets -- -D warnings

lint-js: ## JS/TS lint via eslint + prettier
cd $(LEZER_REFS_DIR) && npx eslint --max-warnings 0 src test
cd $(LEZER_REFS_DIR) && npx prettier --check src test
Expand All @@ -68,16 +63,12 @@ regenerate-grammars: ## Regenerate parser artifacts from grammar definitions
audit: ## Run security audits across ecosystems
cd $(LEZER_REFS_DIR) && npm audit --audit-level=moderate
cd $(TS_REFS_DIR) && npm audit --audit-level=moderate
cd $(LSP_DIR) && cargo audit || true # cargo-audit may not be installed everywhere

clean: clean-ocaml clean-grammars clean-lsp ## Remove all build artifacts
clean: clean-ocaml clean-grammars ## Remove all build artifacts

clean-ocaml: ## Clean OCaml build outputs
opam exec -- dune clean

clean-grammars: ## Clean grammar build outputs
rm -rf $(LEZER_REFS_DIR)/dist $(LEZER_REFS_DIR)/src/parser.js $(LEZER_REFS_DIR)/src/parser.terms.js
rm -rf $(TS_REFS_DIR)/build

clean-lsp: ## Clean Rust build outputs
cd $(LSP_DIR) && cargo clean
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ grammars-lezer/ Lezer grammars for CodeMirror 6 first-paint rendering
(paired with tree-sitter for httui-owned languages)
grammars-external/ Upstream tree-sitter grammars vendored as submodules
(postgres, mysql, sqlite — arriving in later work)
bin/httui-lsp/ Rust binary that wraps the OCaml library via FFI
and depends on httui-core for storage and execution
bin/httui-lsp/ LSP server binary (pure OCaml) — an IO shell
around the library; storage reads only, never
touches the OS keychain
spec/ Canonical specifications (token kinds vocabulary,
protocol notes)
bench/ Benchmark fixtures and harness
Expand All @@ -25,8 +26,7 @@ OWNERS.md Per-grammar ownership declarations

## Quick start

Prerequisites: OCaml 5.1+, Rust stable, Node 22+, tree-sitter CLI, opam,
dune.
Prerequisites: OCaml 5.1+, Node 22+, tree-sitter CLI, opam, dune.

```bash
git clone [email protected]:httuicom/httui-lang.git
Expand All @@ -46,13 +46,12 @@ Every PR runs the following checks in CI before it can be merged:

| Check | Scope |
|---|---|
| OCaml build + tests | Ubuntu and macOS |
| Rust build, fmt, clippy | `bin/httui-lsp` |
| OCaml build + tests (library + LSP binary) | Ubuntu and macOS |
| Lezer grammar build + corpus tests | `grammars-lezer/lezer-httui-refs` |
| Tree-sitter grammar generate + corpus tests | `lib/grammars/tree-sitter-httui-refs` |
| Tree-sitter sanitizers (ASan + UBSan) | scanner.c when present |
| Cross-grammar sync test | shared corpus for httui-owned grammars |
| Dependency audit | npm + cargo |
| Dependency audit | npm |

PRs that touch grammars also require the audit checklist from the PR
template to be completed.
Expand Down
15 changes: 14 additions & 1 deletion bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,17 @@ Cold-start metrics tracked separately: LSP spawn-to-ready, time-to-first-
completion after `didOpen`, first parse + semantic tokens, and schema cache
lookup. Memory budget (RSS) tracked on both medium and large fixtures.

Bench harness implementation pending.
## Harness

- `lsp_roundtrip.py` — transport baseline against the built `httui-lsp`
binary: spawn-to-initialize, request round-trip (framing + JSON +
dispatch), and didChange ingestion. No external dependencies.

```bash
dune build
make bench # or: python3 bench/lsp_roundtrip.py
```

Feature-level operations (hover, completion, semantic tokens,
diagnostics) gain sections here as the server implements them; the
fixtures above become their inputs.
151 changes: 151 additions & 0 deletions bench/lsp_roundtrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""LSP transport benchmark: spawn-to-initialize and request round-trip.

Measures the httui-lsp binary over its real transport (stdio, LSP framing)
with no external dependencies. This is the harness baseline — as server
features land (hover, completion, semantic tokens), each gets a section
here and budgets gate CI.

Usage:
python3 bench/lsp_roundtrip.py [path-to-httui-lsp]
(default: _build/default/bin/httui-lsp/httui_lsp.exe)
"""

import json
import statistics
import subprocess
import sys
import time
from pathlib import Path

DEFAULT_SERVER = "_build/default/bin/httui-lsp/httui_lsp.exe"
WARMUP = 200
ITERATIONS = 5000


class LspClient:
def __init__(self, server_path):
self.proc = subprocess.Popen(
[server_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
self.next_id = 1

def send(self, payload):
body = json.dumps(payload).encode()
frame = b"Content-Length: %d\r\n\r\n" % len(body) + body
self.proc.stdin.write(frame)
self.proc.stdin.flush()

def recv(self):
length = None
while True:
line = self.proc.stdout.readline().strip()
if not line:
break
key, _, value = line.partition(b":")
if key.lower() == b"content-length":
length = int(value)
if length is None:
raise RuntimeError("missing content-length in server response")
return json.loads(self.proc.stdout.read(length))

def request(self, method, params=None):
rid = self.next_id
self.next_id += 1
payload = {"jsonrpc": "2.0", "id": rid, "method": method}
if params is not None:
payload["params"] = params
self.send(payload)
return self.recv()

def notify(self, method, params=None):
payload = {"jsonrpc": "2.0", "method": method}
if params is not None:
payload["params"] = params
self.send(payload)


def percentile(sorted_values, p):
return sorted_values[round((len(sorted_values) - 1) * p)]


def report(name, samples_ms):
samples_ms.sort()
print(
f"{name:32s} n={len(samples_ms):>5} "
f"p50={percentile(samples_ms, 0.50):.4f}ms "
f"p95={percentile(samples_ms, 0.95):.4f}ms "
f"p99={percentile(samples_ms, 0.99):.4f}ms "
f"max={samples_ms[-1]:.4f}ms"
)


def main():
server = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_SERVER
if not Path(server).exists():
sys.exit(f"server binary not found: {server} (run `dune build` first)")

t0 = time.perf_counter()
client = LspClient(server)
init = client.request(
"initialize",
{"processId": None, "rootUri": None, "capabilities": {}},
)
spawn_ms = (time.perf_counter() - t0) * 1e3
name = init["result"]["serverInfo"]["name"]
print(f"server: {name} spawn -> initialize: {spawn_ms:.3f} ms")
client.notify("initialized", {})

doc = "# bench\n\n```http alias=req1\nGET https://api.example.com\n```\n" * 30
client.notify(
"textDocument/didOpen",
{
"textDocument": {
"uri": "file:///tmp/bench.md",
"languageId": "markdown",
"version": 1,
"text": doc,
}
},
)

# Round-trip floor: an unknown method still exercises framing, JSON
# parse and dispatch on both sides (server answers MethodNotFound).
def probe():
client.request("httui/benchProbe")

for _ in range(WARMUP):
probe()
samples = []
for _ in range(ITERATIONS):
t = time.perf_counter()
probe()
samples.append((time.perf_counter() - t) * 1e3)
report("request round-trip (dispatch)", samples)

# didChange ingestion: full-sync document replace per notification,
# measured via a probe request behind each batch (notifications have
# no response of their own).
version = 2
samples = []
for _ in range(1000):
t = time.perf_counter()
client.notify(
"textDocument/didChange",
{
"textDocument": {"uri": "file:///tmp/bench.md", "version": version},
"contentChanges": [{"text": doc}],
},
)
probe()
samples.append((time.perf_counter() - t) * 1e3)
version += 1
report("didChange(2KB doc) + probe", samples)

client.request("shutdown")
client.notify("exit")
client.proc.wait(timeout=5)


if __name__ == "__main__":
main()
Loading
Loading