diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..7ccfbdc --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,124 @@ +name: Python Package + +# Builds the naja-schematic wheel/sdist (Python backend + the WASM viewer +# bundle), tests it against published najaeda, and on a `python-v` +# tag publishes it to PyPI. +# +# Publishing uses PyPI trusted publishing (no token): the PyPI project must +# list this repository, this workflow file and the `pypi` environment as a +# trusted publisher. + +on: + push: + branches: [ main ] + tags: [ "python-v*" ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + # imgui for the build, naja only for its version header + # (CMakeLists.txt reads NajaVersion.h.in); not recursive. + submodules: true + + - name: Set up Emscripten + uses: mymindstorm/setup-emsdk@v14 + with: + version: latest + + - name: Build the viewer bundle + run: | + emcmake cmake -S . -B build-wasm-module \ + -DCMAKE_BUILD_TYPE=Release \ + -DNAJA_SCHEMATIC_WASM_MODULE=ON + cmake --build build-wasm-module -j + cp build-wasm-module/naja-schematic.js python/naja_schematic/static/ + cp LICENSE python/LICENSE + + - name: Check the tag matches the package version + if: startsWith(github.ref, 'refs/tags/python-v') + run: | + version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' python/naja_schematic/__init__.py) + test "python-v$version" = "${GITHUB_REF_NAME}" || { + echo "Tag ${GITHUB_REF_NAME} does not match __version__ $version"; exit 1; } + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build wheel and sdist + run: | + python -m pip install build + python -m build python --outdir dist + + - name: Check the bundle is in both archives + run: | + unzip -l dist/*.whl | grep -q 'naja_schematic/static/naja-schematic.js' + tar tzf dist/*.tar.gz | grep -q 'naja_schematic/static/naja-schematic.js' + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + test: + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, macos-latest ] + python-version: [ "3.10", "3.13" ] + # Oldest najaeda the package declares support for, and the newest. + najaeda: [ "==0.7.24", "" ] + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Install the built wheel + shell: bash + run: | + python -m pip install "$(ls dist/*.whl)[test]" "najaeda${{ matrix.najaeda }}" + + - name: Run tests against the installed package + # From outside python/, so the tests import the installed wheel + # rather than the source tree. + working-directory: ${{ runner.temp }} + run: python -m pytest -v ${{ github.workspace }}/python/tests + + - name: Smoke-test the CLI + shell: bash + run: | + echo '{"request":"load_root"}' \ + | naja-schematic --xilinx --verilog python/tests/data/design.v --stdio \ + | grep -q '"root_response"' + + publish: + needs: [ build, test ] + if: startsWith(github.ref, 'refs/tags/python-v') + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index cd033d6..71ff588 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,11 @@ imgui.ini # Private, local-only working notes (never shared/committed). notes/ naja_sv_diagnostics.log -scripts/__pycache__/najaeda_server.cpython-314.pyc + +# Viewer bundle: built by CI (-DNAJA_SCHEMATIC_WASM_MODULE=ON), shipped in the wheel. +python/naja_schematic/static/naja-schematic.js +python/dist/ +*.egg-info/ +python/LICENSE +.pytest_cache/ +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index 8f50e12..78a056f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,11 @@ standalone build, a `--diagnosis ` CLI flag (`main_native.cpp`) that loads a design and a diagnosis JSON in one command, so an external caller (a script, or naja-agent's skill) can open a fully annotated view without a human clicking through File > Open .../Load Diagnosis JSON... by hand. This -closes the "opening a view" half of the loop for native builds; the WASM/ -browser build still has no equivalent one-shot launch path (see "Wire -protocol" below — it depends on a live server + browser tab already being -connected). What's still missing upstream either way: neither kepler-formal +closes the "opening a view" half of the loop for native builds. The browser +build has the same one-shot launch through the `naja_schematic` Python +package's CLI (`naja-schematic --verilog d.v --diagnosis diag.json --open`, +see "Python package" below), and notebooks get it through +`naja_schematic.show(diagnosis=...)`. What's still missing upstream either way: neither kepler-formal nor naja-scope emits `diagnosis_response` JSON today, so an adapter that turns kepler-formal's log/text output into `diagnosis_response` items is the remaining piece to actually produce the file this flag consumes. @@ -116,8 +117,10 @@ emrun --port 8080 naja-schematic.html The WASM app doesn't link naja directly — it talks to a netlist server over a WebSocket instead. Start the server first, optionally with `--verilog ` (a single Verilog netlist) and `--liberty [...]` (one flag, -listing the Liberty files defining its cell library — see the `argparse` -setup in `najaeda_server.py`'s `__main__` block): +listing the Liberty files defining its cell library — see +`build_arg_parser()` in `python/naja_schematic/server.py`; +`scripts/najaeda_server.py` is just a wrapper that runs that CLI from the +source tree without installing the package): ```bash python3 scripts/najaeda_server.py # serves ws://localhost:8081/ws @@ -150,6 +153,38 @@ assume a reader moving between modes can reuse the same invocation shape. (`scripts/test_server.py` is a minimal canned-response stub for protocol testing without a real netlist backend.) +### Python package (`python/`, PyPI `naja-schematic`) + +`python/naja_schematic` packages the Python side of the protocol with the +WASM viewer, so najaeda users get a viewer from `pip install` alone: + +- `protocol.py` — the Python protocol implementation: `handle_request(dict) + -> [dict]`, transport-agnostic, answering from the live `NLUniverse`. This + is the file to keep in step with `LocalSNLProvider.cpp`. +- `server.py` — the `naja-schematic` CLI (same flags as above, plus `--host`, + `--open`, `--diagnosis `, `--stdio`) and its two transports: a + WebSocket server that also serves the viewer page on the same port + (`static/index.html` sets `Module.najaWsUrl` from `location`), and JSON + lines on stdin/stdout for a host that relays messages itself. In `--stdio` + mode fd 1 is pointed at stderr (naja's C++ logger writes to stdout) and + protocol output goes to a private dup of the original stdout. With + `--diagnosis`, a `diagnosis_response` is pushed after every + `root_response` (the viewer clears diagnoses on each root load). +- `widget.py` — `naja_schematic.show()`: an anywidget for Jupyter/Colab/ + VSCode notebooks. Its ES module is the bundle + `static/widget.js`; the + viewer's requests come back over the widget comm channel as + `{"json": ""}` and are answered in the kernel, so the view shows + the netlist as edited by earlier cells. `Schematic.annotate(items)` + pushes diagnoses. + +The viewer bundle `static/naja-schematic.js` is **not** checked in: it's the +WASM target configured with `-DNAJA_SCHEMATIC_WASM_MODULE=ON` (single file, +wasm inlined, `createNajaSchematic({canvas, ...})` factory, one instance per +canvas), built by `.github/workflows/python-package.yml`, which also tests +the wheel and publishes it to PyPI on a `python-v` tag (version in +`naja_schematic/__init__.py`). For local work set `NAJA_SCHEMATIC_BUNDLE` to +a locally built bundle (see `_bundle.py`). Tests: `pytest python/tests`. + ## Architecture ### Dual-mode, shared core @@ -161,16 +196,24 @@ data is sourced: `while` loop calling `appFrame()`. Backed by `LocalSNLProvider`, which loads netlists directly through the naja SNL C++ API in-process (Verilog, SystemVerilog, or pre-built SNL directories). -- **`src/main_wasm.cpp`** — browser/VSCode webview: SDL2 + OpenGL ES via - Emscripten's `emscripten_set_main_loop()`. Backed by `WebSocketProvider`, - a thin wrapper around `WebSocketClient` that connects to - `ws://localhost:8081/ws` (implemented by `scripts/najaeda_server.py`, a - Python asyncio server built on `najaeda`). +- **`src/main_wasm.cpp`** — browser/VSCode webview/notebook: SDL2 + OpenGL + ES via Emscripten's `emscripten_set_main_loop()`. Backed by + `JsBridgeProvider` when the host page sets `Module.najaSend` (embedded + mode: the host owns the transport, requests go out through + `Module.najaSend(json)` and replies come back through the embind-exported + `Module.deliverMessage(json)` — what the notebook widget uses), otherwise + by `WebSocketProvider`, a thin wrapper around `WebSocketClient` that + connects to `Module.najaWsUrl` or `ws://localhost:8081/ws` (served by the + `naja_schematic` Python package, see above). `main()` maps SDL's + hard-coded `"#canvas"` selector to the instance's own `Module.canvas` + (`specialHTMLTargets`), so several viewers can share a page, and with + `Module.najaEmbedded` limits keyboard capture to the focused canvas. Both providers implement **`INetlistProvider`** (`src/INetlistProvider.h`): `send()`, `on_open()`/`on_message()`/`on_close()`/`on_error()` callback registration, and `start()`. `LocalSNLProvider::start()` fires `on_open` -synchronously and answers requests in-process; `WebSocketProvider` is a passive +synchronously and answers requests in-process, as does `JsBridgeProvider`'s +(the host channel is already up); `WebSocketProvider` is a passive wrapper since the underlying socket connects in its constructor. `src/AppLogic.h/.cpp` holds the logic shared by both entry points: @@ -191,7 +234,7 @@ Requests/responses are JSON with a `"request"`/`"response"` type field (e.g. `load_root`, `load_instance`, `load_primitives`, `load_terms`, `load_nets`, `load_equipotential` → `*_response`). Both `LocalSNLProvider` (native, `buildRootResponse()`/`buildInstancesResponse()`/etc.) and -`najaeda_server.py` (WASM/browser) must independently implement this same +`python/naja_schematic/protocol.py` (WASM/browser/notebook) must independently implement this same protocol — when changing one side, check the other. `load_nets`/`nets_response` mirrors `load_terms`/`terms_response` exactly @@ -208,7 +251,7 @@ Both `nets_response` and the `has_nets` flag filter out anonymous scalar constant nets (unnamed 1'b0/1'b1 tie-offs, e.g. naja's implicit tie-off of an unconnected input) — structural noise, not user-authored signals — via `isAnonymousConstantNet()`/`hasVisibleNets()` (`LocalSNLProvider.cpp`) and -`is_anonymous_constant_net()`/`has_nets()` (`najaeda_server.py`), the same +`is_anonymous_constant_net()`/`has_nets()` (`protocol.py`), the same spirit as `hasVisiblePrimitiveInstances()` filtering `isAssign()` primitives. A *named* or *bus* constant is still shown — the filter is deliberately narrow (unnamed **and** scalar **and** constant 0/1) so it @@ -235,7 +278,7 @@ entered it through (the start pin, or the input pin of the cell being crossed; several if the net is reached more than once) -- *not* every reader on the net, unlike `equipotential_response`. Nets are returned breadth-first from the requested one and de-duplicated, capped at `kMaxTraceNets` (`MAX_TRACE_NETS` in -`najaeda_server.py`, 500) with `truncated: true` when hit. `AppLogic.cpp` adds +`protocol.py`, 500) with `truncated: true` when hit. `AppLogic.cpp` adds each to `GUIData` in order; the incremental layout in `EquipotentialView.cpp` relies on that ordering (each net shares an already-placed instance with an earlier one) to chain the cone right-to-left. Reachable from the tree @@ -281,9 +324,11 @@ the already-loaded netlist rather than loading anything: ] } ``` -`najaeda_server.py` doesn't implement this yet (no upstream data source — -see Vision above); `scripts/test_server.py` sends a canned example after -`load_root` as a demo/test fixture. Native/standalone mode has no server at +Nothing upstream produces it yet (see Vision above), so the Python side +only relays a file or list it's given: the `naja-schematic --diagnosis +` CLI and `Schematic.annotate()`/`show(diagnosis=...)` in notebooks +push it after each `root_response`; `scripts/test_server.py` sends a canned +example after `load_root` as a demo/test fixture. Native/standalone mode has no server at all, so it gets diagnosis data via **File > Load Diagnosis JSON...** (reads a `{"items": [...]}` file or a bare array through the same `DiagnosisItem` parser) instead. @@ -291,7 +336,7 @@ all, so it gets diagnosis data via **File > Load Diagnosis JSON...** `get_properties`/`properties_response` is a general name/value inspector for whatever object the UI asks about — an instance (including the top design itself), a term/pin, or a net — answered by both `LocalSNLProvider` -(`buildPropertiesResponse()`) and `najaeda_server.py` the same request/ +(`buildPropertiesResponse()`) and `protocol.py` the same request/ response way as `load_terms` etc. (unlike `diagnosis_response`, it's not a push). The object is identified the same way `DiagnosisItem` identifies things — a slash-joined instance-name path, root excluded — rather than @@ -378,7 +423,7 @@ stable inputs for an external tool like kepler-formal to reference). `get_properties` reuses this same path/pathKey convention (`splitPathKey()` in `Types.h` is the inverse of `pathKey()`) so its request-building code in `NetlistTree.cpp`/`EquipotentialView.cpp` and its resolution code in -`LocalSNLProvider.cpp`/`najaeda_server.py` need no id/name translation layer +`LocalSNLProvider.cpp`/`protocol.py` need no id/name translation layer of their own. ### VSCode integration diff --git a/CMakeLists.txt b/CMakeLists.txt index 5dbc5ff..6c92edb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,6 +95,7 @@ if(EMSCRIPTEN_BUILD) ${CORE_SOURCES} src/WebSocketClient.cpp src/WebSocketProvider.cpp + src/JsBridgeProvider.cpp src/main_wasm.cpp ) @@ -113,7 +114,6 @@ if(EMSCRIPTEN_BUILD) "-sEXIT_RUNTIME=0" "-sEXPORTED_RUNTIME_METHODS=['requestFullscreen']" "-sWASM=1" - "--shell-file=${CMAKE_SOURCE_DIR}/src/shell_minimal.html" "-lwebsocket.js" "-lembind" "-fexceptions" @@ -121,9 +121,29 @@ if(EMSCRIPTEN_BUILD) "-sEXCEPTION_CATCHING_ALLOWED=['main','naja-schematic']" ) + # Default: a full page (naja-schematic.html + .js + .wasm) for emrun. + # NAJA_SCHEMATIC_WASM_MODULE: one self-contained naja-schematic.js (wasm + # inlined) defining a createNajaSchematic({canvas, ...}) factory, so a host + # page can embed one viewer per canvas -- what the naja_schematic Python + # package ships (its index.html and Jupyter widget both load it). + option(NAJA_SCHEMATIC_WASM_MODULE + "Build the WASM viewer as an embeddable single-file JS module" OFF) + if(NAJA_SCHEMATIC_WASM_MODULE) + list(APPEND EM_LINK_FLAGS + "-sMODULARIZE=1" + "-sEXPORT_NAME=createNajaSchematic" + "-sSINGLE_FILE=1" + "-sENVIRONMENT=web" + ) + set(NAJA_SCHEMATIC_WASM_SUFFIX ".js") + else() + list(APPEND EM_LINK_FLAGS "--shell-file=${CMAKE_SOURCE_DIR}/src/shell_minimal.html") + set(NAJA_SCHEMATIC_WASM_SUFFIX ".html") + endif() + target_compile_options(naja-schematic PRIVATE ${EM_COMPILE_FLAGS}) target_link_options(naja-schematic PRIVATE ${EM_LINK_FLAGS}) - set_target_properties(naja-schematic PROPERTIES SUFFIX ".html") + set_target_properties(naja-schematic PROPERTIES SUFFIX ${NAJA_SCHEMATIC_WASM_SUFFIX}) target_compile_definitions(naja-schematic PRIVATE NAJA_SCHEMATIC_GIT_HASH="${NAJA_SCHEMATIC_GIT_HASH}" NAJA_VERSION_STRING="${NAJA_VERSION}" diff --git a/REUSE.toml b/REUSE.toml index 859d176..a998d08 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,6 +11,7 @@ path = [ ".github/workflows/emscripten.yml", ".github/workflows/native-linux.yml", ".github/workflows/native-macos.yml", + ".github/workflows/python-package.yml", ".github/workflows/reuse.yml", ".gitignore", ".gitmodules", @@ -19,6 +20,21 @@ path = [ "CMakeLists.txt", "CMakePresets.json", "README.md", + "python/README.md", + "python/naja_schematic/__init__.py", + "python/naja_schematic/__main__.py", + "python/naja_schematic/_bundle.py", + "python/naja_schematic/protocol.py", + "python/naja_schematic/server.py", + "python/naja_schematic/static/index.html", + "python/naja_schematic/static/widget.js", + "python/naja_schematic/widget.py", + "python/pyproject.toml", + "python/tests/conftest.py", + "python/tests/data/design.v", + "python/tests/test_protocol.py", + "python/tests/test_transports.py", + "python/tests/test_widget.py", "scripts/fakeram45_64x32.lib", "scripts/najaeda_server.py", "scripts/test_server.py", @@ -36,6 +52,8 @@ path = [ "src/GUIData.cpp", "src/GUIData.h", "src/INetlistProvider.h", + "src/JsBridgeProvider.cpp", + "src/JsBridgeProvider.h", "src/imgui_user_config.h", "src/LocalSNLProvider.cpp", "src/LocalSNLProvider.h", diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..69290a4 --- /dev/null +++ b/python/README.md @@ -0,0 +1,61 @@ +# naja-schematic (Python package) + +Interactive schematic viewer for [najaeda](https://pypi.org/project/najaeda/) +netlists: the [naja-schematic](https://github.com/najaeda/naja-schematic) +WASM viewer, packaged with the Python backend that answers it from a live +najaeda netlist. + +```bash +pip install naja-schematic +``` + +## In a notebook (Jupyter, Google Colab, VSCode) + +```python +from najaeda import netlist +import naja_schematic + +netlist.load_primitives("xilinx") +top = netlist.load_verilog("design.v") + +naja_schematic.show() # interactive view in the cell output +``` + +The view is answered from the netlist in the running kernel, so it shows +the design as the notebook has built or edited it so far; call `show()` +again after an edit for a fresh view. Diagnoses (e.g. from a formal +equivalence check) can be overlaid: + +```python +view = naja_schematic.show() +view.annotate([{"kind": "instance", "path": ["u_sub"], "severity": "error", + "message": "not equivalent", "source": "kepler-formal"}]) +view +``` + +## From a shell + +```bash +naja-schematic --verilog design.v --liberty cells.lib --open +naja-schematic --sv top.sv --top top --diagnosis report.json --open +``` + +serves the viewer page and its WebSocket on `http://localhost:8081/` and +opens it in a browser. `--stdio` speaks the same protocol as JSON lines on +stdin/stdout instead, for a host (e.g. an editor extension) that relays +messages itself. `naja-schematic --help` lists every option. + +## Development + +The viewer bundle (`naja_schematic/static/naja-schematic.js`) is built from +the C++ sources, not checked in. From the repository root: + +```bash +emcmake cmake -S . -B build-wasm-module -DCMAKE_BUILD_TYPE=Release -DNAJA_SCHEMATIC_WASM_MODULE=ON +cmake --build build-wasm-module +export NAJA_SCHEMATIC_BUNDLE=$PWD/build-wasm-module/naja-schematic.js # or copy it into static/ +pip install -e "python[test]" && pytest python/tests +``` + +Releases are built and published by `.github/workflows/python-package.yml` +on a `python-v` tag. diff --git a/python/naja_schematic/__init__.py b/python/naja_schematic/__init__.py new file mode 100644 index 0000000..ca32f56 --- /dev/null +++ b/python/naja_schematic/__init__.py @@ -0,0 +1,21 @@ +"""naja-schematic: interactive schematic viewer for najaeda netlists. + +- In a notebook (Jupyter, Colab, VSCode): ``naja_schematic.show()`` displays + the design currently loaded with najaeda. +- From a shell: ``naja-schematic --verilog design.v --open`` serves the + viewer page and opens it in a browser. +""" +__version__ = "0.1.0" + +from .protocol import diagnosis_response, handle_request + + +def show(height=600, diagnosis=None): + """Display a view of the loaded design in the current notebook; see + naja_schematic.widget.show().""" + # Imported lazily: the CLI/server path doesn't need anywidget/IPython. + from .widget import show as _show + return _show(height=height, diagnosis=diagnosis) + + +__all__ = ["__version__", "show", "handle_request", "diagnosis_response"] diff --git a/python/naja_schematic/__main__.py b/python/naja_schematic/__main__.py new file mode 100644 index 0000000..bc40eef --- /dev/null +++ b/python/naja_schematic/__main__.py @@ -0,0 +1,3 @@ +from .server import main + +main() diff --git a/python/naja_schematic/_bundle.py b/python/naja_schematic/_bundle.py new file mode 100644 index 0000000..3aefc46 --- /dev/null +++ b/python/naja_schematic/_bundle.py @@ -0,0 +1,31 @@ +"""Locating the viewer files shipped in static/. + +naja-schematic.js is the WASM viewer built as a single-file, modularized +Emscripten bundle (-DNAJA_SCHEMATIC_WASM_MODULE=ON); release wheels carry it +in static/, but it is a build product and not checked in. For a development +checkout, point NAJA_SCHEMATIC_BUNDLE at a locally built one instead. +""" +import os +from pathlib import Path + +STATIC_DIR = Path(__file__).parent / "static" +BUNDLE_NAME = "naja-schematic.js" + +# The only files the HTTP server hands out (no path traversal to worry about). +_SERVED = {"index.html", BUNDLE_NAME} + + +def bundle_path(): + """Path of the viewer bundle, or None if this install has none.""" + override = os.environ.get("NAJA_SCHEMATIC_BUNDLE") + path = Path(override) if override else STATIC_DIR / BUNDLE_NAME + return path if path.is_file() else None + + +def static_file(name): + if name not in _SERVED: + return None + if name == BUNDLE_NAME: + return bundle_path() + path = STATIC_DIR / name + return path if path.is_file() else None diff --git a/python/naja_schematic/protocol.py b/python/naja_schematic/protocol.py new file mode 100644 index 0000000..d0d0199 --- /dev/null +++ b/python/naja_schematic/protocol.py @@ -0,0 +1,712 @@ +"""naja-schematic wire protocol, answered from the live najaeda netlist. + +Python side of the request/response protocol the viewer speaks (see +CLAUDE.md, "Wire protocol"); LocalSNLProvider.cpp implements the same +protocol in C++ for the native build -- when changing one side, check the +other. Nothing here knows about transports: see server.py (WebSocket and +stdio) and widget.py (Jupyter/Colab comm channel). +""" +import json +import logging + +from najaeda import naja + +log = logging.getLogger("naja_schematic") + +MIN_NAJAEDA_VERSION = (0, 7, 24) + + +def check_najaeda_version(): + version_str = naja.getVersion() + try: + version = tuple(int(p) for p in version_str.split(".")) + except ValueError: + log.warning("Could not parse najaeda version %r; skipping version check", version_str) + return + if version < MIN_NAJAEDA_VERSION: + min_str = ".".join(str(p) for p in MIN_NAJAEDA_VERSION) + raise RuntimeError( + f"najaeda {version_str} is too old (need >= {min_str}, for " + f"SNLEquipotential.Mode support used by load_equipotential). " + f"Upgrade with: pip install -U najaeda" + ) + +def get_design_ref(ref_msg): + if not ref_msg: + return None + return ( + ref_msg.get("db_id"), + ref_msg.get("library_id"), + ref_msg.get("design_id"), + ) + +def get_path(top, instance_ids): + current = top + path = [] + naja_path = naja.SNLPath() + for inst_id in instance_ids: + instance = current.getInstanceByID(inst_id) + if not instance: + return None + current = instance.getModel() + naja_path = naja.SNLPath(naja_path, instance) + return naja_path + +def has_visible_primitive_instances(design): + return any(not instance.getModel().isAssign() + for instance in design.getPrimitiveInstances()) + +def is_anonymous_constant_net(net): + # Anonymous scalar constant nets (1'b0/1'b1 tie-offs, e.g. an unconnected + # input najaeda ties off implicitly) are structural noise, not + # user-authored signals -- filtered out of both has_nets and the Nets + # tree listing, same spirit as has_visible_primitive_instances() filtering + # isAssign() primitives. Named or bus constants are left alone: a name + # means someone authored it, and a bus is shown as a whole even if every + # bit happens to be tied. + return (not net.getName() + and not isinstance(net, naja.SNLBusNet) + and (net.isConstant0() or net.isConstant1())) + +def has_nets(design): + # No hasNets() convenience binding (unlike hasTerms()/hasNonPrimitiveInstances()); + # short-circuit on the first visible net rather than materializing the whole list. + return any(not is_anonymous_constant_net(net) for net in design.getNets()) + +def get_source_loc(obj): + # RTL source location for an elaborated object (SNLRTLInfos), populated + # today only by the SystemVerilog/slang frontend. None means "no link + # available" -- not an error. + if not obj.hasSourceLoc(): + return None + file, line, column, end_line, end_column = obj.getSourceLoc() + return { + "file": file, + "line": line, + "end_line": end_line, + "column": column, + "end_column": end_column, + } + +def serialize_model(model, child_id, name, source_loc=None): + result = { + "name": name, + "child_id": child_id, + "model_name": model.getName(), + "design_ref": { + "db_id": model.getDB().getID(), + "library_id": model.getLibrary().getID(), + "design_id": model.getID(), + }, + "has_terms": model.hasTerms(), + "has_primitives": has_visible_primitive_instances(model), + "has_instances": model.hasNonPrimitiveInstances(), + "has_nets": has_nets(model), + } + if source_loc is not None: + result["source_loc"] = source_loc + return result + +def direction_to_int(direction): + if direction == naja.SNLTerm.Direction.Input: + return 0 + elif direction == naja.SNLTerm.Direction.Output: + return 1 + else: + return 2 + +def direction_to_string(direction): + # Matches Types.h's toString(Direction) on the C++ side (LocalSNLProvider + # uses the same enum ordering via snlDirToInt/Direction). + return ["Input", "Output", "Inout"][direction_to_int(direction)] + + +def resolve_instance_path(top, path): + # Walk an instance-name path (root excluded) down from the top design, + # the same convention DiagnosisItem/get_properties use elsewhere (see + # CLAUDE.md's "Path matching convention") rather than provider-specific + # numeric ids. Returns (design, instance): the design that owns any + # terminal lookup at this point (top if path is empty, else the last + # instance's model), and that last instance itself (None if path is + # empty). Returns (None, None) if any segment doesn't resolve. + design = top + instance = None + for name in path: + if design is None: + return None, None + instance = design.getInstance(name) + if not instance: + return None, None + design = instance.getModel() + return design, instance + + +# Upper bound on nets returned by one trace_driver request (mirrors +# LocalSNLProvider's kMaxTraceNets): every net is a schematic wire plus its +# instance boxes, so an unbounded cone through a big design would bury the view. +MAX_TRACE_NETS = 500 + + +def resolve_start_point(top, path_ids, term_id, bit): + # The net-component a load_equipotential/trace_driver request starts from: + # a top-level bit term (empty path), or the SNLOccurrence of the tail + # instance's inst term. Returns None if it can't be resolved. + path = get_path(top, path_ids) + if path is None: + return None + if path.empty(): + term = top.getTermByID(term_id) + if term is None: + return None + if bit is not None: + if not isinstance(term, naja.SNLBusTerm): + return None + return term.getBusTermBit(bit) + return term + design = path.getModel() + term = design.getTermByID(term_id) + if term is None: + return None + if bit is not None: + if not isinstance(term, naja.SNLBusTerm): + return None + term = term.getBusTermBit(bit) + inst_term = path.getTailInstance().getInstTerm(term) + if inst_term is None: + return None + return naja.SNLOccurrence(path.getHeadPath(), inst_term) + + +def term_key(term): + return ("T", term.getID(), + term.getBit() if isinstance(term, naja.SNLBusTermBit) else None) + + +def occurrence_key(occ): + inst_term = occ.getInstTerm() + return (tuple(inst.getID() for inst in occ.getPath().getInstances()), + inst_term.getInstance().getID(), + term_key(inst_term.getBitTerm())) + + +def net_display_name(net): + # "name" for a scalar net, "name[bit]" for a bus-net bit; None when the + # pin is unconnected. + if net is None: + return None + if isinstance(net, naja.SNLBusNetBit): + return f"{net.getName()}[{net.getBit()}]" + return net.getName() + + +def equipotential_to_json(equipotential, sinks=None): + # Wire-format body of an equipotential (no "response" key): its top-level + # terms plus every leaf inst-term occurrence on the net. + # With `sinks` (a set of term_key/occurrence_key values) only the net's + # drivers and those listed receivers are emitted -- a driver trace shows + # the path it followed, not every reader on the net. + occurrences = [] + terms = [] + for occ in equipotential.getInstTermOccurrences(): + instTerm = occ.getInstTerm() + if (sinks is not None + and instTerm.getDirection() == naja.SNLTerm.Direction.Input + and occurrence_key(occ) not in sinks): + continue + # Each path entry is [name, child_id, model_name]: the model name lets + # the schematic label the hierarchical module boxes it draws around a + # driver trace (see EquipotentialView's hierarchy grouping). + path = [[inst.getName(), inst.getID(), inst.getModel().getName()] + for inst in occ.getPath().getInstances()] + path.append([instTerm.getInstance().getName(), instTerm.getInstance().getID(), + instTerm.getInstance().getModel().getName()]) + term = instTerm.getBitTerm() + inst_model = instTerm.getInstance().getModel() + has_instances = (inst_model.hasNonPrimitiveInstances() or + has_visible_primitive_instances(inst_model)) + occurrences.append({ + "path": path, + "term_id": term.getID(), + "name": term.getName(), + "direction": direction_to_int(term.getDirection()), + "bit": term.getBit() if isinstance(term, naja.SNLBusTermBit) else None, + "design_ref": { + "db_id": inst_model.getDB().getID(), + "library_id": inst_model.getLibrary().getID(), + "design_id": inst_model.getID(), + }, + "has_instances": has_instances, + # Lets the view tell whether every pin of this instance is already + # on screen (solid box) or only a subset (dashed, expandable). + "bit_term_count": sum(1 for _ in inst_model.getBitTerms()), + "source_loc": get_source_loc(instTerm.getInstance()), + # Net on this inst term inside the instance's parent design -- + # shown in the schematic's pin hover tooltip. + "net": net_display_name(instTerm.getNet()) + }) + for term in equipotential.getTerms(): + # A top-level output is a receiver of the net; an input/inout drives it. + if (sinks is not None + and term.getDirection() == naja.SNLTerm.Direction.Output + and term_key(term) not in sinks): + continue + terms.append({ + "name": term.getName(), + "child_id": term.getID(), + "direction": direction_to_int(term.getDirection()), + "bit": term.getBit() if isinstance(term, naja.SNLBusTermBit) else None, + "net": net_display_name(term.getNet()) + }) + return {"occurrences": occurrences, "terms": terms} + + +def equipotential_key(equipotential): + # Identity of a net for de-duplicating the cone: the set of things on it. + return (frozenset(occurrence_key(occ) for occ in equipotential.getInstTermOccurrences()), + frozenset(term_key(term) for term in equipotential.getTerms())) + + +def sink_key(sink): + # `sink` is an SNLOccurrence of an inst term, or a top-level bit term. + if isinstance(sink, naja.SNLOccurrence): + return occurrence_key(sink) + return term_key(sink) + + +def trace_driver_cone(starts): + # Breadth-first from the start nets toward the drivers, so each net in the + # result shares an instance with an earlier one (the layout relies on that + # to chain nets left-to-right). The cone ends at sequential cells and at + # cells with no timing model (blackboxes): no combinational arc to cross. + # Each net carries the receiver pins the trace entered it through (the + # start pin, or the input pin of the cell being crossed); a net reached + # through several of them accumulates all of them. + # Returns ([(SNLEquipotential, sinks)], truncated). + mode = naja.SNLEquipotential.Mode.TraverseAssigns + cone = [] + index_of = {} + + def enqueue(sink): + equipotential = naja.SNLEquipotential(sink, mode=mode) + key = equipotential_key(equipotential) + if key not in index_of: + index_of[key] = len(cone) + cone.append((equipotential, set())) + cone[index_of[key]][1].add(sink_key(sink)) + return key + + for start in starts: + if start is not None: + enqueue(start) + + i = 0 + while i < len(cone): + for occ in list(cone[i][0].getInstTermOccurrences()): + driver = occ.getInstTerm() + if driver.getDirection() != naja.SNLTerm.Direction.Output: + continue + model = driver.getInstance().getModel() + if model.isSequential() or not model.hasModeling(): + continue + for inp in naja.SNLInstance.getCombinatorialInputs(driver): + sink = naja.SNLOccurrence(occ.getPath(), inp) + # Already-known nets don't grow the cone, so only cap new ones. + if (len(cone) >= MAX_TRACE_NETS and + equipotential_key(naja.SNLEquipotential(sink, mode=mode)) not in index_of): + return cone, True + enqueue(sink) + i += 1 + return cone, False + + +# --------------------------------------------------------------------------- +# Request dispatch +# --------------------------------------------------------------------------- +# Every handler takes the decoded request and returns the list of response +# messages to send back (usually one; [] when the request is ignored). They +# all read the live NLUniverse, so a design edited in the same process (e.g. +# a notebook cell) is what the next request sees. + +def _error(response_type, gui_id=0): + return [{"response": response_type, "gui_id": gui_id}] + + +def _handle_load_root(u, request): + log.debug("load_root request") + top = u.getTopDesign() + if not top: + return _error("root_response") + return [{ + "response": "root_response", + "root": serialize_model(top, 0, top.getName()) + }] + + +def _handle_design_request(u, request): + # load_instance / load_primitives / load_instances / load_terms / load_nets: + # all address a design by its design_ref. + req_type = request.get("request") + gui_id = request.get("gui_id", 0) + design_ref_message = request.get("design_ref") + if not design_ref_message: + log.warning("Missing design_ref in %s request", req_type) + return [] + + design_ref = get_design_ref(design_ref_message) + log.debug("%s for: %s", req_type, design_ref) + design = u.getSNLDesign(design_ref) + if not design: + return _error(f"{req_type}_response", gui_id) + + if req_type == "load_instance": + return [{ + "response": "instance_response", + "gui_id": gui_id, + "instance": { + "design_ref": { + "db_id": design.getDB().getID(), + "library_id": design.getLibrary().getID(), + "design_id": design.getID(), + }, + "has_terms": design.hasTerms(), + "has_primitives": has_visible_primitive_instances(design), + "has_instances": design.hasNonPrimitiveInstances() + } + }] + + if req_type in {"load_primitives", "load_instances"}: + children = [] + instances = (design.getPrimitiveInstances() + if req_type == "load_primitives" + else design.getNonPrimitiveInstances()) + for instance in instances: + model = instance.getModel() + if model.isAssign(): + continue + children.append(serialize_model( + model, instance.getID(), instance.getName(), + get_source_loc(instance))) + return [{ + "response": req_type.replace("load_", "") + "_response", + "gui_id": gui_id, + "children": children + }] + + if req_type == "load_terms": + terms = [ + {"name": term.getName(), + "child_id": term.getID(), + "direction": direction_to_int(term.getDirection()), + "msb": term.getMSB() if isinstance(term, naja.SNLBusTerm) else None, + "lsb": term.getLSB() if isinstance(term, naja.SNLBusTerm) else None, + } for term in design.getTerms() + ] + return [{"response": "terms_response", "gui_id": gui_id, "children": terms}] + + # load_nets + nets = [] + for net in design.getNets(): + if is_anonymous_constant_net(net): + continue + entry = {"name": net.getName()} + if isinstance(net, naja.SNLBusNet): + entry["msb"] = net.getMSB() + entry["lsb"] = net.getLSB() + nets.append(entry) + return [{"response": "nets_response", "gui_id": gui_id, "children": nets}] + + +def _handle_load_equipotential(u, request): + path_ids = request.get("path", []) + term_id = request.get("term_id", {}) + bit = request.get("bit", None) + log.debug("load_equipotential for path: %s term_id: %s bit: %s", path_ids, term_id, bit) + start_point = resolve_start_point(u.getTopDesign(), path_ids, term_id, bit) + if start_point is None: + return _error("equipotential_response") + equipotential = naja.SNLEquipotential( + start_point, mode=naja.SNLEquipotential.Mode.TraverseAssigns) + response = equipotential_to_json(equipotential) + response["response"] = "equipotential_response" + return [response] + + +def _handle_trace_driver(u, request): + # Full combinational fan-in cone of a term's net, back to the drivers: + # one message holding every net in the cone. "bits" (a list) traces + # several bits of one bus term at once. + path_ids = request.get("path", []) + term_id = request.get("term_id", {}) + bits = request.get("bits", None) + log.debug("trace_driver for path: %s term_id: %s bit: %s bits: %s", + path_ids, term_id, request.get("bit"), bits) + top = u.getTopDesign() + if bits is not None: + starts = [resolve_start_point(top, path_ids, term_id, b) for b in bits] + else: + starts = [resolve_start_point(top, path_ids, term_id, request.get("bit", None))] + cone, truncated = trace_driver_cone(starts) + if truncated: + log.warning("trace_driver: cone truncated at %d nets", MAX_TRACE_NETS) + return [{ + "response": "trace_driver_response", + "equipotentials": [equipotential_to_json(e, sinks) for e, sinks in cone], + "truncated": truncated + }] + + +def _handle_expand_instance_terms(u, request): + path_key = request.get("path_key", "") + design_ref = get_design_ref(request.get("design_ref")) + log.debug("expand_instance_terms for path_key=%r design_ref=%s", path_key, design_ref) + design = u.getSNLDesign(design_ref) if design_ref else None + if not design: + log.warning("expand_instance_terms: design not found for %s", design_ref) + terms = [] + + if design: + for term in design.getTerms(): + if isinstance(term, naja.SNLBusTerm): + lo, hi = sorted((term.getLSB(), term.getMSB())) + for b in range(lo, hi + 1): + bit_term = term.getBusTermBit(b) + if bit_term: + terms.append({ + "name": f"{term.getName()}[{b}]", + "child_id": bit_term.getID(), + "direction": direction_to_int(bit_term.getDirection()), + "bit": b, + }) + else: + terms.append({ + "name": term.getName(), + "child_id": term.getID(), + "direction": direction_to_int(term.getDirection()), + }) + + return [{ + "response": "expanded_instance_terms", + "path_key": path_key, + "terms": terms + }] + + +def _handle_load_instance_internals(u, request): + path_key = request.get("path_key", "") + design_ref = get_design_ref(request.get("design_ref")) + model = u.getSNLDesign(design_ref) if design_ref else None + children = [] + nets = [] + + if model: + for instance in model.getNonPrimitiveInstances(): + sub = instance.getModel() + children.append(serialize_model( + sub, instance.getID(), instance.getName(), + get_source_loc(instance))) + for instance in model.getPrimitiveInstances(): + sub = instance.getModel() + if sub.isAssign(): + continue + children.append(serialize_model( + sub, instance.getID(), instance.getName(), + get_source_loc(instance))) + + def emit_bit_net(bit_net, name, bit): + pins = [] + for comp in bit_net.getComponents(): + if isinstance(comp, naja.SNLInstTerm): + bt = comp.getBitTerm() + pins.append({ + "name": bt.getName(), + "child_id": bt.getID(), + "direction": direction_to_int(bt.getDirection()), + "bit": bt.getBit() if isinstance(bt, naja.SNLBusTermBit) else None, + "inst_id": comp.getInstance().getID(), + }) + elif isinstance(comp, naja.SNLBitTerm): + pins.append({ + "name": comp.getName(), + "child_id": comp.getID(), + "direction": direction_to_int(comp.getDirection()), + "bit": comp.getBit() if isinstance(comp, naja.SNLBusTermBit) else None, + }) + if len(pins) < 2: + return + entry = {"name": name, "pins": pins} + if bit is not None: + entry["bit"] = bit + nets.append(entry) + + for net in model.getNets(): + if isinstance(net, naja.SNLBusNet): + lo, hi = sorted((net.getLSB(), net.getMSB())) + for b in range(lo, hi + 1): + bit_net = net.getBit(b) + if bit_net: + emit_bit_net(bit_net, net.getName(), b) + else: + emit_bit_net(net, net.getName(), None) + + return [{ + "response": "instance_internals_response", + "path_key": path_key, + "children": children, + "nets": nets + }] + + +def _handle_load_source(u, request): + file = request.get("file", "") + line = request.get("line", 0) + text = "" + found = False + try: + with open(file, "r") as f: + text = f.read() + found = True + except OSError as e: + log.warning("Failed to read source file %s: %s", file, e) + return [{ + "response": "source_response", + "file": file, + "line": line, + "found": found, + "text": text + }] + + +def _handle_get_properties(u, request): + kind = request.get("kind", "instance") + path = request.get("path", []) + properties = [] + subject = "" + + top = u.getTopDesign() + if top is None: + log.warning("get_properties: no design loaded") + else: + design, instance = resolve_instance_path(top, path) + if path and design is None: + log.warning("get_properties: could not resolve instance path %s", path) + elif kind == "term": + terminal = request.get("terminal", "") + subject = "/".join(path + [terminal]) if path else terminal + if design is not None and terminal: + term = design.getTerm(terminal) + if term is not None: + bit_arg = request.get("bit") + if bit_arg is not None: + bit = (term.getBusTermBit(bit_arg) + if isinstance(term, naja.SNLBusTerm) else None) + if bit is not None: + properties = [ + {"name": "Name", "value": bit.getName()}, + {"name": "Direction", "value": direction_to_string(bit.getDirection())}, + {"name": "Bit", "value": str(bit.getBit())}, + ] + else: + properties = [ + {"name": "Name", "value": term.getName()}, + {"name": "Direction", "value": direction_to_string(term.getDirection())}, + ] + if isinstance(term, naja.SNLBusTerm): + properties.append({"name": "MSB", "value": str(term.getMSB())}) + properties.append({"name": "LSB", "value": str(term.getLSB())}) + elif kind == "net": + net_name = request.get("net", "") + subject = "/".join(path + [net_name]) if path else net_name + if design is not None and net_name: + net = design.getNet(net_name) + if net is not None: + bit_arg = request.get("bit") + if bit_arg is not None: + bit = (net.getBit(bit_arg) + if isinstance(net, naja.SNLBusNet) else None) + if bit is not None: + properties = [ + {"name": "Name", "value": bit.getName()}, + {"name": "Bit", "value": str(bit.getBit())}, + ] + else: + properties = [ + {"name": "Name", "value": net.getName()}, + ] + if isinstance(net, naja.SNLBusNet): + properties.append({"name": "MSB", "value": str(net.getMSB())}) + properties.append({"name": "LSB", "value": str(net.getLSB())}) + else: # "instance" + if not path: + subject = top.getName() + properties = [ + {"name": "Name", "value": subject}, + {"name": "Type", "value": "Top Design"}, + ] + elif instance is not None: + subject = instance.getName() + model = instance.getModel() + properties = [ + {"name": "Name", "value": subject}, + {"name": "Model", "value": model.getName() if model else ""}, + {"name": "Type", "value": "Primitive" if model and model.isPrimitive() else "Hierarchical"}, + ] + + return [{ + "response": "properties_response", + "subject": subject, + "properties": properties + }] + + +_HANDLERS = { + "load_root": _handle_load_root, + "load_instance": _handle_design_request, + "load_primitives": _handle_design_request, + "load_instances": _handle_design_request, + "load_terms": _handle_design_request, + "load_nets": _handle_design_request, + "load_equipotential": _handle_load_equipotential, + "trace_driver": _handle_trace_driver, + "expand_instance_terms": _handle_expand_instance_terms, + "load_instance_internals": _handle_load_instance_internals, + "load_source": _handle_load_source, + "get_properties": _handle_get_properties, +} + + +def handle_request(request): + """Answer one decoded viewer request. + + Returns the list of response messages (dicts) to send back, in order. + Transport-agnostic: the WebSocket server, the stdio server and the + notebook widget all wrap this. + """ + req_type = request.get("request") + handler = _HANDLERS.get(req_type) + if handler is None: + log.warning("Unknown request type: %s", req_type) + return [] + u = naja.NLUniverse.get() + if u is None: + # Nothing loaded yet (e.g. a widget shown before any netlist cell ran). + return _error("root_response") if req_type == "load_root" else [] + try: + return handler(u, request) + except Exception: + # One bad request must not take the connection/widget down with it. + log.exception("Error while handling %s request", req_type) + return [] + + +def handle_message(message): + """Same as handle_request(), on a JSON string; returns JSON strings.""" + return [json.dumps(r) for r in handle_request(json.loads(message))] + + +def diagnosis_response(items): + """Build a diagnosis_response push message from a list of diagnosis + items (dicts with kind/path/terminal/severity/message/source), or from a + {"items": [...]} document as File > Load Diagnosis JSON... accepts.""" + if isinstance(items, dict): + items = items.get("items", []) + return {"response": "diagnosis_response", "items": list(items)} diff --git a/python/naja_schematic/server.py b/python/naja_schematic/server.py new file mode 100644 index 0000000..5185722 --- /dev/null +++ b/python/naja_schematic/server.py @@ -0,0 +1,269 @@ +"""Transports for the naja-schematic protocol, plus the `naja-schematic` CLI. + +- WebSocket (`serve()`): what the browser build connects to. The same port + also serves the viewer page itself (index.html + the bundled wasm module), + so `naja-schematic design.v --open` is a one-command launch. +- stdio (`serve_stdio()`): one JSON message per line on stdin/stdout, for a + host that spawns the server as a child process (e.g. an editor extension) + and relays messages itself. + +Both answer requests through protocol.handle_request(), against whatever +design is loaded in this process's NLUniverse. +""" +import argparse +import asyncio +import http +import json +import logging +import os +import sys +import webbrowser +from glob import glob + +from . import protocol +from ._bundle import static_file + +log = logging.getLogger("naja_schematic") + +DEFAULT_PORT = 8081 + +_CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", +} + + +def _diagnosis_push(diagnosis): + return json.dumps(protocol.diagnosis_response(diagnosis)) if diagnosis else None + + +def _answer(message, diagnosis_push): + # Responses to one request, plus the diagnosis push right after the root + # is (re)loaded: the viewer clears its DiagnosisStore on every + # root_response, so the annotations have to follow it. + replies = protocol.handle_message(message) + if diagnosis_push and any('"root_response"' in r for r in replies): + replies.append(diagnosis_push) + return replies + + +# --------------------------------------------------------------------------- +# WebSocket (+ static viewer page) server +# --------------------------------------------------------------------------- + +def _static_response(connection, request): + # Any non-upgrade HTTP request is a request for the viewer page. + from websockets.datastructures import Headers + from websockets.http11 import Response + + if request.headers.get("Upgrade", "").lower() == "websocket": + return None # continue with the WebSocket handshake + + name = request.path.split("?", 1)[0].lstrip("/") or "index.html" + path = static_file(name) + if path is None: + hint = "" + if name == "naja-schematic.js": + hint = (" -- the viewer bundle is not built into this install; " + "set NAJA_SCHEMATIC_BUNDLE to a naja-schematic.js built " + "with -DNAJA_SCHEMATIC_WASM_MODULE=ON") + return connection.respond(http.HTTPStatus.NOT_FOUND, f"Not found: {name}{hint}\n") + body = path.read_bytes() + headers = Headers([ + ("Content-Type", _CONTENT_TYPES.get(path.suffix, "application/octet-stream")), + ("Content-Length", str(len(body))), + ("Cache-Control", "no-cache"), + ]) + return Response(http.HTTPStatus.OK, http.HTTPStatus.OK.phrase, headers, body) + + +async def serve(host="localhost", port=DEFAULT_PORT, diagnosis=None, open_browser=False): + """Serve the viewer page and its WebSocket on host:port, forever.""" + from websockets.asyncio.server import serve as ws_serve + from websockets.exceptions import ConnectionClosed + + diagnosis_push = _diagnosis_push(diagnosis) + + async def handle_connection(websocket): + log.info("Client connected") + try: + async for message in websocket: + log.debug("Received: %s", message) + for reply in _answer(message, diagnosis_push): + await websocket.send(reply) + except ConnectionClosed as e: + log.info("Client disconnected: %s", e) + + async with ws_serve(handle_connection, host, port, process_request=_static_response, + max_size=None): + url = f"http://{host}:{port}/" + log.info("Serving naja-schematic on %s (WebSocket ws://%s:%d/ws)", url, host, port) + if open_browser: + webbrowser.open(url) + await asyncio.Future() # run forever + + +# --------------------------------------------------------------------------- +# stdio server +# --------------------------------------------------------------------------- + +def serve_stdio(diagnosis=None, stdin=None, stdout=None): + """Answer one JSON request per stdin line with JSON lines on stdout, + until stdin closes. Logging must not go to stdout in this mode.""" + stdin = stdin or sys.stdin + stdout = stdout or sys.stdout + diagnosis_push = _diagnosis_push(diagnosis) + for line in stdin: + line = line.strip() + if not line: + continue + try: + replies = _answer(line, diagnosis_push) + except json.JSONDecodeError as e: + log.error("Ignoring malformed request %r: %s", line, e) + continue + for reply in replies: + stdout.write(reply + "\n") + stdout.flush() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def build_arg_parser(): + parser = argparse.ArgumentParser( + prog="naja-schematic", + description="Serve a netlist to the naja-schematic viewer (browser page " + "+ WebSocket, or stdio).") + parser.add_argument("--host", default="localhost", + help="Interface to listen on (default: localhost)") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, + help=f"Port to serve on (default: {DEFAULT_PORT})") + parser.add_argument("--open", action="store_true", + help="Open the viewer in a web browser once the server is up") + parser.add_argument("--stdio", action="store_true", + help="Speak the protocol as JSON lines on stdin/stdout instead " + "of serving WebSocket/HTTP") + parser.add_argument("--diagnosis", metavar="FILE", + help="Diagnosis JSON ({\"items\": [...]} or a bare array) to " + "push to the viewer after the design loads") + parser.add_argument("--verbose", "-v", action="store_true", + help="Log every request (to stderr)") + parser.add_argument("--xilinx", action="store_true", + help="Load Xilinx primitives") + parser.add_argument("--allow_unknown_designs", action="store_true", + help="Allow unknown designs when loading the design.") + parser.add_argument("--liberty", nargs="*", help="List of liberty files to load") + parser.add_argument("--verilog", type=str, + help="Verilog netlist to load") + parser.add_argument("--systemverilog", "--sv", nargs="+", metavar="FILE", + help="SystemVerilog file(s) to load (elaborated with slang)") + parser.add_argument("--flist", "-f", type=str, metavar="FILE", + help="SystemVerilog command file (slang -f syntax: sources, " + "+incdir+, +define+, ...); may be combined with --systemverilog") + parser.add_argument("--top", type=str, + help="SystemVerilog only: top module to elaborate") + parser.add_argument("--define", "-D", action="append", metavar="NAME[=VALUE]", + help="SystemVerilog only: preprocessor define (repeatable)") + return parser + + +def load_design(args): + """Load the liberty files and the Verilog/SystemVerilog design named by + the parsed CLI arguments into the NLUniverse; returns the top.""" + from najaeda import netlist + + is_sv = bool(args.systemverilog or args.flist) + if args.xilinx: + log.info("Loading Xilinx primitives") + netlist.load_primitives('xilinx') + + if args.liberty: + # Expand wildcards ourselves, for shells that pass them through. + expanded_liberty_files = [] + for lib in args.liberty: + if '*' in lib: + expanded_liberty_files.extend(glob(lib)) + else: + expanded_liberty_files.append(lib) + for lib in expanded_liberty_files: + log.info("Loading liberty file: %s", lib) + netlist.load_liberty(lib) + + if is_sv: + sv_files = args.systemverilog or [] + sources = sv_files + ([f"-f {args.flist}"] if args.flist else []) + log.info("Loading SystemVerilog: %s", ", ".join(sources)) + config = netlist.SystemVerilogConfig() + config.flist = args.flist + config.top = args.top + config.defines = args.define + config.blackbox_unknown_modules = args.allow_unknown_designs + top = netlist.load_system_verilog(sv_files, config=config) + else: + log.info("Loading Verilog netlist: %s", args.verilog) + config = netlist.VerilogConfig() + config.allow_unknown_designs = args.allow_unknown_designs + top = netlist.load_verilog(args.verilog, config=config) + log.info("Design loaded: %s", top.get_name()) + return top + + +def load_diagnosis_file(path): + with open(path) as f: + return json.load(f) + + +def main(argv=None): + parser = build_arg_parser() + args = parser.parse_args(argv) + + is_sv = bool(args.systemverilog or args.flist) + if not args.verilog and not is_sv: + parser.error("provide a design: --verilog, or --systemverilog and/or --flist") + if args.verilog and is_sv: + parser.error("--verilog cannot be combined with --systemverilog/--flist") + if not is_sv and (args.top or args.define): + parser.error("--top/--define only apply to --systemverilog/--flist") + if is_sv and args.liberty: + # Same restriction as the native standalone: the SystemVerilog loader + # has no liberty hook, so fail loudly rather than silently ignore it. + parser.error("--liberty is not supported with --systemverilog/--flist") + if args.stdio and args.open: + parser.error("--open does not apply to --stdio") + + # stderr only: stdout is the protocol channel in --stdio mode. + logging.basicConfig(stream=sys.stderr, format="%(message)s", + level=logging.DEBUG if args.verbose else logging.INFO) + if not args.verbose: + logging.getLogger("websockets").setLevel(logging.WARNING) + + try: + protocol.check_najaeda_version() + except RuntimeError as e: + raise SystemExit(str(e)) + + protocol_out = None + if args.stdio: + # naja's C++ logger writes to fd 1: keep a private copy of the real + # stdout for protocol messages and point fd 1 at stderr, so nothing + # else can land on the protocol channel. + sys.stdout.flush() + protocol_out = os.fdopen(os.dup(1), "w") + os.dup2(2, 1) + + diagnosis = load_diagnosis_file(args.diagnosis) if args.diagnosis else None + load_design(args) + + if args.stdio: + serve_stdio(diagnosis, stdout=protocol_out) + return + try: + asyncio.run(serve(args.host, args.port, diagnosis, args.open)) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/python/naja_schematic/static/index.html b/python/naja_schematic/static/index.html new file mode 100644 index 0000000..1a1f444 --- /dev/null +++ b/python/naja_schematic/static/index.html @@ -0,0 +1,34 @@ + + + + + naja-schematic + + + + + +
Loading viewer...
+ + + + diff --git a/python/naja_schematic/static/widget.js b/python/naja_schematic/static/widget.js new file mode 100644 index 0000000..3330f96 --- /dev/null +++ b/python/naja_schematic/static/widget.js @@ -0,0 +1,81 @@ +// anywidget front end for naja_schematic.Schematic. widget.py appends this +// to naja-schematic.js (which defines createNajaSchematic) to form the +// widget's ES module. Each rendered view gets its own module instance and +// canvas; protocol messages travel over the widget's comm channel as +// {json: ""} (see JsBridgeProvider.h on the C++ side). + +const STYLE_ID = "naja-schematic-style"; + +function injectStyle() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + // !important stops SDL from pinning an inline pixel size on the canvas; + // main_wasm.cpp follows the CSS size instead. + style.textContent = ` + .naja-schematic-wrap { position: relative; width: 100%; background: #000; } + .naja-schematic-canvas { position: absolute; left: 0; top: 0; width: 100% !important; + height: 100% !important; border: 0; padding: 0; display: block; outline: none; } + .naja-schematic-status { position: absolute; inset: 0; display: flex; align-items: center; + justify-content: center; color: #ddd; font: 13px sans-serif; pointer-events: none; }`; + document.head.appendChild(style); +} + +function render({ model, el }) { + injectStyle(); + const wrap = document.createElement("div"); + wrap.className = "naja-schematic-wrap"; + wrap.style.height = `${model.get("height")}px`; + const canvas = document.createElement("canvas"); + canvas.className = "naja-schematic-canvas"; + canvas.tabIndex = -1; + canvas.addEventListener("contextmenu", (e) => e.preventDefault()); + // Keyboard input only goes to the viewer while it has focus. + canvas.addEventListener("mousedown", () => canvas.focus()); + const status = document.createElement("div"); + status.className = "naja-schematic-status"; + status.textContent = "Loading viewer..."; + wrap.append(canvas, status); + el.appendChild(wrap); + + let app = null; + let disposed = false; + const pending = []; + + const onMessage = (content) => { + if (!content || typeof content.json !== "string") return; + if (app) app.deliverMessage(content.json); + else pending.push(content.json); + }; + const onHeight = () => { wrap.style.height = `${model.get("height")}px`; }; + model.on("msg:custom", onMessage); + model.on("change:height", onHeight); + + const shutdown = (m) => { + try { m._naja_shutdown(); } catch (e) { /* already stopped */ } + // Give the WebGL context back: browsers cap how many a page may hold. + canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); + }; + + createNajaSchematic({ + canvas, + najaEmbedded: true, + najaSend: (json) => model.send({ json }), + }).then((m) => { + if (disposed) { shutdown(m); return; } + app = m; + status.remove(); + for (const json of pending.splice(0)) app.deliverMessage(json); + }).catch((e) => { + status.textContent = `Failed to start the viewer: ${e}`; + }); + + return () => { + disposed = true; + model.off("msg:custom", onMessage); + model.off("change:height", onHeight); + if (app) shutdown(app); + }; +} + +export default { render }; diff --git a/python/naja_schematic/widget.py b/python/naja_schematic/widget.py new file mode 100644 index 0000000..1b4af5c --- /dev/null +++ b/python/naja_schematic/widget.py @@ -0,0 +1,92 @@ +"""Jupyter/Colab/VSCode-notebook view of the loaded netlist (anywidget). + +The viewer runs in the notebook's output area; its requests come back to +this kernel over the widget comm channel and are answered by +protocol.handle_request() against the live NLUniverse -- so a view shows +the netlist as the notebook has built or edited it so far. +""" +import functools +import json +import logging + +import anywidget +import traitlets + +from . import protocol +from ._bundle import STATIC_DIR, bundle_path + +log = logging.getLogger("naja_schematic") + + +@functools.cache +def _widget_esm(): + bundle = bundle_path() + if bundle is None: + raise RuntimeError( + "The naja-schematic viewer bundle is not part of this install. " + "Install a released wheel (pip install naja-schematic), or build " + "it with -DNAJA_SCHEMATIC_WASM_MODULE=ON and point " + "NAJA_SCHEMATIC_BUNDLE at the resulting naja-schematic.js.") + # The bundle is a classic script defining createNajaSchematic; the glue + # appended after it is the ES module anywidget loads. + return bundle.read_text() + "\n" + (STATIC_DIR / "widget.js").read_text() + + +class Schematic(anywidget.AnyWidget): + """An interactive naja-schematic view of the design in NLUniverse.""" + + height = traitlets.Int(600).tag(sync=True) + + def __init__(self, **kwargs): + # Read before AnyWidget.__init__, which turns it into a synced trait. + # Not a class attribute: that would read the bundle at import time + # and fail the import when no bundle is installed. + self._esm = _widget_esm() + super().__init__(**kwargs) + self._diagnosis_push = None + self.on_msg(self._on_viewer_message) + + def _send_json(self, message): + self.send({"json": message}) + + def _on_viewer_message(self, _widget, content, _buffers): + message = content.get("json") if isinstance(content, dict) else None + if not isinstance(message, str): + return + try: + replies = protocol.handle_message(message) + except json.JSONDecodeError as e: + log.error("Ignoring malformed viewer request %r: %s", message, e) + return + for reply in replies: + self._send_json(reply) + # The viewer clears its diagnoses on every root_response, so + # re-annotate after the root is (re)loaded. + if self._diagnosis_push and '"root_response"' in reply: + self._send_json(self._diagnosis_push) + + def annotate(self, items): + """Overlay diagnosis items on the view (and keep them across reloads). + + `items`: a list of diagnosis dicts -- kind ("instance"|"net"), path + (instance names, root excluded), terminal (nets only), severity + ("info"|"warning"|"error"), message, source -- or a + {"items": [...]} document, as File > Load Diagnosis JSON... reads. + Pass [] to clear. + """ + self._diagnosis_push = json.dumps(protocol.diagnosis_response(items)) + self._send_json(self._diagnosis_push) + + +def show(height=600, diagnosis=None): + """Display a view of the design currently loaded with najaeda. + + Evaluate it as the last expression of a cell (or pass it to + IPython.display.display). Requests are answered from the live netlist, + so run show() again after editing the design to get a fresh view. + """ + # (anywidget itself turns on Colab's custom widget manager.) + view = Schematic(height=height) + if diagnosis is not None: + view.annotate(diagnosis) + return view diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..0423618 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "naja-schematic" +dynamic = ["version"] +description = "Interactive schematic viewer for najaeda netlists: in Jupyter/Colab notebooks or a browser" +readme = "README.md" +license = "Apache-2.0" +authors = [{name = "Naja Authors", email = "contact@keplertech.io"}] +requires-python = ">=3.10" +dependencies = [ + "najaeda>=0.7.24", + "websockets>=13", + "anywidget>=0.9", +] +classifiers = [ + "Framework :: Jupyter", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", +] + +[project.optional-dependencies] +test = ["pytest"] + +[project.scripts] +naja-schematic = "naja_schematic.server:main" + +[project.urls] +Homepage = "https://github.com/najaeda/naja-schematic" + +[tool.hatch.version] +path = "naja_schematic/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["naja_schematic"] +# The viewer bundle is a CI build product (gitignored), so list it +# explicitly or hatch would skip it along with other ignored files. +artifacts = ["naja_schematic/static/naja-schematic.js"] + +[tool.hatch.build.targets.sdist] +include = ["naja_schematic", "tests", "README.md"] +artifacts = ["naja_schematic/static/naja-schematic.js"] diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..333980d --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,13 @@ +from pathlib import Path + +import pytest +from najaeda import netlist + +DATA = Path(__file__).parent / "data" + + +@pytest.fixture(scope="session") +def top(): + # NLUniverse is process-global: load the test design once for all tests. + netlist.load_primitives("xilinx") + return netlist.load_verilog(str(DATA / "design.v")) diff --git a/python/tests/data/design.v b/python/tests/data/design.v new file mode 100644 index 0000000..4fa3ca8 --- /dev/null +++ b/python/tests/data/design.v @@ -0,0 +1,10 @@ +module sub(input a, input b, output y); + wire n; + LUT2 #(.INIT(4'h8)) u_and (.I0(a), .I1(b), .O(n)); + assign y = n; +endmodule +module top(input clk, input [1:0] d, output q); + wire s; + sub u_sub (.a(d[0]), .b(d[1]), .y(s)); + FDRE r (.C(clk), .CE(1'b1), .R(1'b0), .D(s), .Q(q)); +endmodule diff --git a/python/tests/test_protocol.py b/python/tests/test_protocol.py new file mode 100644 index 0000000..dfc94a7 --- /dev/null +++ b/python/tests/test_protocol.py @@ -0,0 +1,103 @@ +import json +from types import SimpleNamespace + +from naja_schematic import protocol +from naja_schematic.protocol import handle_message, handle_request + + +def root(top): + (reply,) = handle_request({"request": "load_root"}) + assert reply["response"] == "root_response" + return reply["root"] + + +def top_terms(top): + ref = root(top)["design_ref"] + (reply,) = handle_request({"request": "load_terms", "design_ref": ref}) + return {t["name"]: t for t in reply["children"]} + + +def test_load_root(top): + r = root(top) + assert r["name"] == "top" + assert r["has_instances"] and r["has_primitives"] and r["has_terms"] and r["has_nets"] + + +def test_design_requests(top): + ref = root(top)["design_ref"] + (inst,) = handle_request({"request": "load_instances", "design_ref": ref, "gui_id": 7}) + assert inst["response"] == "instances_response" and inst["gui_id"] == 7 + assert [c["name"] for c in inst["children"]] == ["u_sub"] + + (prims,) = handle_request({"request": "load_primitives", "design_ref": ref}) + assert [(c["name"], c["model_name"]) for c in prims["children"]] == [("r", "FDRE")] + + terms = top_terms(top) + assert terms["d"]["msb"] == 1 and terms["d"]["lsb"] == 0 + assert terms["q"]["direction"] == 1 + + (nets,) = handle_request({"request": "load_nets", "design_ref": ref}) + names = [n["name"] for n in nets["children"]] + assert "s" in names + # The implicit 1'b0/1'b1 tie-offs on r.CE/r.R are filtered out. + assert "" not in names + + +def test_unknown_design_ref_is_an_error_reply(top): + bad = {"db_id": 99, "library_id": 99, "design_id": 99} + (reply,) = handle_request({"request": "load_terms", "design_ref": bad, "gui_id": 3}) + assert reply == {"response": "load_terms_response", "gui_id": 3} + + +def test_equipotential_and_trace(top): + q = top_terms(top)["q"] + (eq,) = handle_request({"request": "load_equipotential", "path": [], "term_id": q["child_id"]}) + assert eq["response"] == "equipotential_response" + assert [o["path"][-1][2] for o in eq["occurrences"]] == ["FDRE"] + + (trace,) = handle_request({"request": "trace_driver", "path": [], "term_id": q["child_id"]}) + assert trace["response"] == "trace_driver_response" + # q is driven by a flop: the cone stops right there. + assert len(trace["equipotentials"]) == 1 and not trace["truncated"] + + +def test_get_properties(top): + (reply,) = handle_request({"request": "get_properties", "kind": "instance", "path": ["u_sub"]}) + assert reply["subject"] == "u_sub" + assert {"name": "Model", "value": "sub"} in reply["properties"] + + (reply,) = handle_request({"request": "get_properties", "kind": "term", "path": [], + "terminal": "d", "bit": 1}) + assert {"name": "Bit", "value": "1"} in reply["properties"] + + (reply,) = handle_request({"request": "get_properties", "kind": "instance", "path": ["nope"]}) + assert reply["properties"] == [] + + +def test_unknown_request_is_ignored(top): + assert handle_request({"request": "bogus"}) == [] + + +def test_handler_exception_is_contained(top, monkeypatch): + def boom(u, request): + raise ValueError("boom") + monkeypatch.setitem(protocol._HANDLERS, "load_root", boom) + assert handle_request({"request": "load_root"}) == [] + + +def test_no_universe(monkeypatch): + fake_naja = SimpleNamespace(NLUniverse=SimpleNamespace(get=lambda: None)) + monkeypatch.setattr(protocol, "naja", fake_naja) + assert handle_request({"request": "load_root"}) == [{"response": "root_response", "gui_id": 0}] + assert handle_request({"request": "load_terms", "design_ref": {}}) == [] + + +def test_handle_message_round_trips_json(top): + (reply,) = handle_message(json.dumps({"request": "load_root"})) + assert json.loads(reply)["root"]["name"] == "top" + + +def test_diagnosis_response_accepts_document_or_list(): + item = {"kind": "instance", "path": ["u_sub"], "severity": "error", "message": "m"} + assert protocol.diagnosis_response([item]) == {"response": "diagnosis_response", "items": [item]} + assert protocol.diagnosis_response({"items": [item]})["items"] == [item] diff --git a/python/tests/test_transports.py b/python/tests/test_transports.py new file mode 100644 index 0000000..4b33f7a --- /dev/null +++ b/python/tests/test_transports.py @@ -0,0 +1,78 @@ +import asyncio +import io +import json +import socket +import urllib.request + +import pytest + +from naja_schematic import server + +DIAGNOSIS = [{"kind": "instance", "path": ["u_sub"], "severity": "warning", + "message": "check me", "source": "test"}] + + +def test_stdio_answers_and_pushes_diagnosis_after_root(top): + stdin = io.StringIO(json.dumps({"request": "load_root"}) + "\n\nnot json\n" + + json.dumps({"request": "bogus"}) + "\n") + stdout = io.StringIO() + server.serve_stdio(DIAGNOSIS, stdin=stdin, stdout=stdout) + replies = [json.loads(line) for line in stdout.getvalue().splitlines()] + assert [r["response"] for r in replies] == ["root_response", "diagnosis_response"] + assert replies[1]["items"] == DIAGNOSIS + + +def _free_port(): + with socket.socket() as s: + s.bind(("localhost", 0)) + return s.getsockname()[1] + + +def test_websocket_server_serves_page_and_protocol(top, tmp_path, monkeypatch): + websockets = pytest.importorskip("websockets") + from websockets.asyncio.client import connect + + bundle = tmp_path / "naja-schematic.js" + bundle.write_text("var createNajaSchematic = () => Promise.resolve({});") + monkeypatch.setenv("NAJA_SCHEMATIC_BUNDLE", str(bundle)) + port = _free_port() + + async def scenario(): + task = asyncio.create_task(server.serve("localhost", port, DIAGNOSIS)) + base = f"http://localhost:{port}" + for _ in range(100): # wait for the listener + try: + with socket.create_connection(("localhost", port)): + break + except OSError: + await asyncio.sleep(0.05) + + def get(path): + try: + with urllib.request.urlopen(base + path) as r: + return r.status, r.headers["Content-Type"], r.read() + except urllib.error.HTTPError as e: + return e.code, None, e.read() + + status, ctype, body = await asyncio.to_thread(get, "/") + assert status == 200 and ctype.startswith("text/html") and b"createNajaSchematic" in body + status, _, body = await asyncio.to_thread(get, "/naja-schematic.js") + assert status == 200 and body == bundle.read_bytes() + status, _, _ = await asyncio.to_thread(get, "/../server.py") + assert status == 404 + + async with connect(f"ws://localhost:{port}/ws") as ws: + await ws.send(json.dumps({"request": "load_root"})) + first = json.loads(await ws.recv()) + second = json.loads(await ws.recv()) + task.cancel() + return first, second + + first, second = asyncio.run(scenario()) + assert first["response"] == "root_response" + assert second == {"response": "diagnosis_response", "items": DIAGNOSIS} + + +def test_cli_rejects_liberty_with_systemverilog(): + with pytest.raises(SystemExit): + server.main(["--sv", "a.sv", "--liberty", "x.lib"]) diff --git a/python/tests/test_widget.py b/python/tests/test_widget.py new file mode 100644 index 0000000..8d8a146 --- /dev/null +++ b/python/tests/test_widget.py @@ -0,0 +1,49 @@ +import json + +import pytest + +pytest.importorskip("anywidget") +from naja_schematic import widget # noqa: E402 + + +@pytest.fixture +def view(tmp_path, monkeypatch): + bundle = tmp_path / "naja-schematic.js" + bundle.write_text("var createNajaSchematic;") + monkeypatch.setenv("NAJA_SCHEMATIC_BUNDLE", str(bundle)) + widget._widget_esm.cache_clear() + v = widget.Schematic(height=300) + sent = [] + monkeypatch.setattr(v, "send", lambda content, buffers=None: sent.append(content)) + yield v, sent + widget._widget_esm.cache_clear() + + +def test_esm_is_bundle_plus_glue(view): + v, _ = view + assert v._esm.startswith("var createNajaSchematic;") + assert "export default { render }" in v._esm + + +def test_viewer_requests_are_answered_over_the_comm(top, view): + v, sent = view + v._on_viewer_message(v, {"json": json.dumps({"request": "load_root"})}, []) + (reply,) = sent + assert json.loads(reply["json"])["root"]["name"] == "top" + + +def test_annotate_pushes_now_and_after_each_root(top, view): + v, sent = view + items = [{"kind": "instance", "path": ["u_sub"], "severity": "error", "message": "m"}] + v.annotate(items) + v._on_viewer_message(v, {"json": json.dumps({"request": "load_root"})}, []) + kinds = [json.loads(m["json"])["response"] for m in sent] + assert kinds == ["diagnosis_response", "root_response", "diagnosis_response"] + + +def test_missing_bundle_is_a_clear_error(tmp_path, monkeypatch): + monkeypatch.setenv("NAJA_SCHEMATIC_BUNDLE", str(tmp_path / "missing.js")) + widget._widget_esm.cache_clear() + with pytest.raises(RuntimeError, match="viewer bundle"): + widget.Schematic() + widget._widget_esm.cache_clear() diff --git a/scripts/najaeda_server.py b/scripts/najaeda_server.py index 53a8a94..d9f1187 100644 --- a/scripts/najaeda_server.py +++ b/scripts/najaeda_server.py @@ -1,734 +1,14 @@ -from glob import glob -from najaeda import netlist, naja -import argparse -import asyncio -import websockets -import json +# Development entry point for the netlist server the WASM viewer talks to. +# The implementation lives in the naja_schematic Python package +# (python/naja_schematic: protocol.py answers requests, server.py is this +# CLI); this wrapper runs it from a source checkout without installing it. +# Installed equivalent: `naja-schematic ...` or `python -m naja_schematic ...`. +import sys +from pathlib import Path -PORT = 8081 -MIN_NAJAEDA_VERSION = (0, 7, 24) - - -def check_najaeda_version(): - version_str = naja.getVersion() - try: - version = tuple(int(p) for p in version_str.split(".")) - except ValueError: - print(f"⚠️ Could not parse najaeda version {version_str!r}; skipping version check") - return - if version < MIN_NAJAEDA_VERSION: - min_str = ".".join(str(p) for p in MIN_NAJAEDA_VERSION) - raise SystemExit( - f"najaeda {version_str} is too old (need >= {min_str}, for " - f"SNLEquipotential.Mode support used by load_equipotential). " - f"Upgrade with: pip install -U najaeda" - ) - -def get_design_ref(ref_msg): - if not ref_msg: - return None - return ( - ref_msg.get("db_id"), - ref_msg.get("library_id"), - ref_msg.get("design_id"), - ) - -def get_path(top, instance_ids): - current = top - path = [] - naja_path = naja.SNLPath() - for inst_id in instance_ids: - instance = current.getInstanceByID(inst_id) - if not instance: - return None - current = instance.getModel() - naja_path = naja.SNLPath(naja_path, instance) - return naja_path - -def has_visible_primitive_instances(design): - return any(not instance.getModel().isAssign() - for instance in design.getPrimitiveInstances()) - -def is_anonymous_constant_net(net): - # Anonymous scalar constant nets (1'b0/1'b1 tie-offs, e.g. an unconnected - # input najaeda ties off implicitly) are structural noise, not - # user-authored signals -- filtered out of both has_nets and the Nets - # tree listing, same spirit as has_visible_primitive_instances() filtering - # isAssign() primitives. Named or bus constants are left alone: a name - # means someone authored it, and a bus is shown as a whole even if every - # bit happens to be tied. - return (not net.getName() - and not isinstance(net, naja.SNLBusNet) - and (net.isConstant0() or net.isConstant1())) - -def has_nets(design): - # No hasNets() convenience binding (unlike hasTerms()/hasNonPrimitiveInstances()); - # short-circuit on the first visible net rather than materializing the whole list. - return any(not is_anonymous_constant_net(net) for net in design.getNets()) - -def get_source_loc(obj): - # RTL source location for an elaborated object (SNLRTLInfos), populated - # today only by the SystemVerilog/slang frontend. None means "no link - # available" -- not an error. - if not obj.hasSourceLoc(): - return None - file, line, column, end_line, end_column = obj.getSourceLoc() - return { - "file": file, - "line": line, - "end_line": end_line, - "column": column, - "end_column": end_column, - } - -def serialize_model(model, child_id, name, source_loc=None): - result = { - "name": name, - "child_id": child_id, - "model_name": model.getName(), - "design_ref": { - "db_id": model.getDB().getID(), - "library_id": model.getLibrary().getID(), - "design_id": model.getID(), - }, - "has_terms": model.hasTerms(), - "has_primitives": has_visible_primitive_instances(model), - "has_instances": model.hasNonPrimitiveInstances(), - "has_nets": has_nets(model), - } - if source_loc is not None: - result["source_loc"] = source_loc - return result - -def direction_to_int(direction): - if direction == naja.SNLTerm.Direction.Input: - return 0 - elif direction == naja.SNLTerm.Direction.Output: - return 1 - else: - return 2 - -def direction_to_string(direction): - # Matches Types.h's toString(Direction) on the C++ side (LocalSNLProvider - # uses the same enum ordering via snlDirToInt/Direction). - return ["Input", "Output", "Inout"][direction_to_int(direction)] - - -def resolve_instance_path(top, path): - # Walk an instance-name path (root excluded) down from the top design, - # the same convention DiagnosisItem/get_properties use elsewhere (see - # CLAUDE.md's "Path matching convention") rather than provider-specific - # numeric ids. Returns (design, instance): the design that owns any - # terminal lookup at this point (top if path is empty, else the last - # instance's model), and that last instance itself (None if path is - # empty). Returns (None, None) if any segment doesn't resolve. - design = top - instance = None - for name in path: - if design is None: - return None, None - instance = design.getInstance(name) - if not instance: - return None, None - design = instance.getModel() - return design, instance - - -# Upper bound on nets returned by one trace_driver request (mirrors -# LocalSNLProvider's kMaxTraceNets): every net is a schematic wire plus its -# instance boxes, so an unbounded cone through a big design would bury the view. -MAX_TRACE_NETS = 500 - - -def resolve_start_point(top, path_ids, term_id, bit): - # The net-component a load_equipotential/trace_driver request starts from: - # a top-level bit term (empty path), or the SNLOccurrence of the tail - # instance's inst term. Returns None if it can't be resolved. - path = get_path(top, path_ids) - if path is None: - return None - if path.empty(): - term = top.getTermByID(term_id) - if term is None: - return None - if bit is not None: - if not isinstance(term, naja.SNLBusTerm): - return None - return term.getBusTermBit(bit) - return term - design = path.getModel() - term = design.getTermByID(term_id) - if term is None: - return None - if bit is not None: - if not isinstance(term, naja.SNLBusTerm): - return None - term = term.getBusTermBit(bit) - inst_term = path.getTailInstance().getInstTerm(term) - if inst_term is None: - return None - return naja.SNLOccurrence(path.getHeadPath(), inst_term) - - -def term_key(term): - return ("T", term.getID(), - term.getBit() if isinstance(term, naja.SNLBusTermBit) else None) - - -def occurrence_key(occ): - inst_term = occ.getInstTerm() - return (tuple(inst.getID() for inst in occ.getPath().getInstances()), - inst_term.getInstance().getID(), - term_key(inst_term.getBitTerm())) - - -def equipotential_to_json(equipotential, sinks=None): - # Wire-format body of an equipotential (no "response" key): its top-level - # terms plus every leaf inst-term occurrence on the net. - # With `sinks` (a set of term_key/occurrence_key values) only the net's - # drivers and those listed receivers are emitted -- a driver trace shows - # the path it followed, not every reader on the net. - occurrences = [] - terms = [] - for occ in equipotential.getInstTermOccurrences(): - instTerm = occ.getInstTerm() - if (sinks is not None - and instTerm.getDirection() == naja.SNLTerm.Direction.Input - and occurrence_key(occ) not in sinks): - continue - # Each path entry is [name, child_id, model_name]: the model name lets - # the schematic label the hierarchical module boxes it draws around a - # driver trace (see EquipotentialView's hierarchy grouping). - path = [[inst.getName(), inst.getID(), inst.getModel().getName()] - for inst in occ.getPath().getInstances()] - path.append([instTerm.getInstance().getName(), instTerm.getInstance().getID(), - instTerm.getInstance().getModel().getName()]) - term = instTerm.getBitTerm() - inst_model = instTerm.getInstance().getModel() - has_instances = (inst_model.hasNonPrimitiveInstances() or - has_visible_primitive_instances(inst_model)) - occurrences.append({ - "path": path, - "term_id": term.getID(), - "name": term.getName(), - "direction": direction_to_int(term.getDirection()), - "bit": term.getBit() if isinstance(term, naja.SNLBusTermBit) else None, - "design_ref": { - "db_id": inst_model.getDB().getID(), - "library_id": inst_model.getLibrary().getID(), - "design_id": inst_model.getID(), - }, - "has_instances": has_instances, - # Lets the view tell whether every pin of this instance is already - # on screen (solid box) or only a subset (dashed, expandable). - "bit_term_count": sum(1 for _ in inst_model.getBitTerms()), - "source_loc": get_source_loc(instTerm.getInstance()) - }) - for term in equipotential.getTerms(): - # A top-level output is a receiver of the net; an input/inout drives it. - if (sinks is not None - and term.getDirection() == naja.SNLTerm.Direction.Output - and term_key(term) not in sinks): - continue - terms.append({ - "name": term.getName(), - "child_id": term.getID(), - "direction": direction_to_int(term.getDirection()), - "bit": term.getBit() if isinstance(term, naja.SNLBusTermBit) else None - }) - return {"occurrences": occurrences, "terms": terms} - - -def equipotential_key(equipotential): - # Identity of a net for de-duplicating the cone: the set of things on it. - return (frozenset(occurrence_key(occ) for occ in equipotential.getInstTermOccurrences()), - frozenset(term_key(term) for term in equipotential.getTerms())) - - -def sink_key(sink): - # `sink` is an SNLOccurrence of an inst term, or a top-level bit term. - if isinstance(sink, naja.SNLOccurrence): - return occurrence_key(sink) - return term_key(sink) - - -def trace_driver_cone(starts): - # Breadth-first from the start nets toward the drivers, so each net in the - # result shares an instance with an earlier one (the layout relies on that - # to chain nets left-to-right). The cone ends at sequential cells and at - # cells with no timing model (blackboxes): no combinational arc to cross. - # Each net carries the receiver pins the trace entered it through (the - # start pin, or the input pin of the cell being crossed); a net reached - # through several of them accumulates all of them. - # Returns ([(SNLEquipotential, sinks)], truncated). - mode = naja.SNLEquipotential.Mode.TraverseAssigns - cone = [] - index_of = {} - - def enqueue(sink): - equipotential = naja.SNLEquipotential(sink, mode=mode) - key = equipotential_key(equipotential) - if key not in index_of: - index_of[key] = len(cone) - cone.append((equipotential, set())) - cone[index_of[key]][1].add(sink_key(sink)) - return key - - for start in starts: - if start is not None: - enqueue(start) - - i = 0 - while i < len(cone): - for occ in list(cone[i][0].getInstTermOccurrences()): - driver = occ.getInstTerm() - if driver.getDirection() != naja.SNLTerm.Direction.Output: - continue - model = driver.getInstance().getModel() - if model.isSequential() or not model.hasModeling(): - continue - for inp in naja.SNLInstance.getCombinatorialInputs(driver): - sink = naja.SNLOccurrence(occ.getPath(), inp) - # Already-known nets don't grow the cone, so only cap new ones. - if (len(cone) >= MAX_TRACE_NETS and - equipotential_key(naja.SNLEquipotential(sink, mode=mode)) not in index_of): - return cone, True - enqueue(sink) - i += 1 - return cone, False - - -async def send_error(websocket, response_type, gui_id=0): - await websocket.send(json.dumps({ - "response": response_type, - "gui_id": gui_id - })) - -async def handle_connection(websocket): - print("🟢 Client connected") - try: - async for message in websocket: - print(f"📩 Received: {message}") - request = json.loads(message) - req_type = request.get("request") - design_ref_message = request.get("design_ref") - gui_id = request.get("gui_id", 0) - - u = naja.NLUniverse.get() - - if req_type == "load_root": - print("📦 LoadRoot request received") - top = u.getTopDesign() - - if not top: - send_error(websocket, "root_response") - else: - await websocket.send(json.dumps({ - "response": "root_response", - "root": serialize_model(top, 0, top.getName()) - })) - - elif req_type in {"load_instance", "load_primitives", "load_instances", "load_terms", "load_nets"}: - if not design_ref_message: - print("⚠️ Missing design_ref in request") - continue - - design_ref = get_design_ref(design_ref_message) - print(f"🔍 {req_type} for: {design_ref}") - design = u.getSNLDesign(design_ref) - - if not design: - await send_error(websocket, f"{req_type}_response", gui_id) - continue - - if req_type == "load_instance": - await websocket.send(json.dumps({ - "response": "instance_response", - "gui_id": gui_id, - "instance": { - "design_ref": { - "db_id": design.getDB().getID(), - "library_id": design.getLibrary().getID(), - "design_id": design.getID(), - }, - "has_terms": design.hasTerms(), - "has_primitives": has_visible_primitive_instances(design), - "has_instances": design.hasNonPrimitiveInstances() - } - })) - - elif req_type in {"load_primitives", "load_instances"}: - children = [] - instances = (design.getPrimitiveInstances() - if req_type == "load_primitives" - else design.getNonPrimitiveInstances()) - - for instance in instances: - model = instance.getModel() - if model.isAssign(): - continue - children.append(serialize_model( - model, instance.getID(), instance.getName(), - get_source_loc(instance))) - - response_type = req_type.replace("load_", "") + "_response" - await websocket.send(json.dumps({ - "response": response_type, - "gui_id": gui_id, - "children": children - })) - elif req_type == "load_terms": - terms = [ - { "name": term.getName(), - "child_id": term.getID(), - "direction": direction_to_int(term.getDirection()), - "msb": term.getMSB() if isinstance(term, naja.SNLBusTerm) else None, - "lsb": term.getLSB() if isinstance(term, naja.SNLBusTerm) else None, - } for term in design.getTerms() - ] - await websocket.send(json.dumps({ - "response": "terms_response", - "gui_id": gui_id, - "children": terms - })) - elif req_type == "load_nets": - nets = [] - for net in design.getNets(): - if is_anonymous_constant_net(net): - continue - entry = {"name": net.getName()} - if isinstance(net, naja.SNLBusNet): - entry["msb"] = net.getMSB() - entry["lsb"] = net.getLSB() - nets.append(entry) - await websocket.send(json.dumps({ - "response": "nets_response", - "gui_id": gui_id, - "children": nets - })) - - elif req_type == "load_equipotential": - path_ids = request.get("path", []) - term_id = request.get("term_id", {}) - bit = request.get("bit", None) - print(f"⚡ LoadEquipotential request for path: {path_ids} and term_id: {term_id} bit: {bit}") - start_point = resolve_start_point(u.getTopDesign(), path_ids, term_id, bit) - print(f"🔍 Start point: {start_point}") - if start_point is None: - await send_error(websocket, "equipotential_response") - continue - equipotential = naja.SNLEquipotential( - start_point, mode=naja.SNLEquipotential.Mode.TraverseAssigns) - response = equipotential_to_json(equipotential) - response["response"] = "equipotential_response" - await websocket.send(json.dumps(response)) - - elif req_type == "trace_driver": - # Full combinational fan-in cone of a term's net, back to the - # drivers: one message holding every net in the cone. "bits" - # (a list) traces several bits of one bus term at once. - path_ids = request.get("path", []) - term_id = request.get("term_id", {}) - bits = request.get("bits", None) - print(f"⚡ TraceDriver request for path: {path_ids} and term_id: {term_id} bit: {request.get('bit')} bits: {bits}") - top = u.getTopDesign() - if bits is not None: - starts = [resolve_start_point(top, path_ids, term_id, b) for b in bits] - else: - starts = [resolve_start_point(top, path_ids, term_id, request.get("bit", None))] - cone, truncated = trace_driver_cone(starts) - if truncated: - print(f"⚠️ trace_driver: cone truncated at {MAX_TRACE_NETS} nets") - await websocket.send(json.dumps({ - "response": "trace_driver_response", - "equipotentials": [equipotential_to_json(e, sinks) for e, sinks in cone], - "truncated": truncated - })) - - elif req_type == "expand_instance_terms": - path_key = request.get("path_key", "") - design_ref = get_design_ref(design_ref_message) - print(f"🔎 expand_instance_terms for path_key={path_key!r} design_ref={design_ref}") - design = u.getSNLDesign(design_ref) if design_ref else None - if not design: - print(f"⚠️ expand_instance_terms: design not found for {design_ref}") - terms = [] - - if design: - for term in design.getTerms(): - if isinstance(term, naja.SNLBusTerm): - lo, hi = sorted((term.getLSB(), term.getMSB())) - for b in range(lo, hi + 1): - bit_term = term.getBusTermBit(b) - if bit_term: - terms.append({ - "name": f"{term.getName()}[{b}]", - "child_id": bit_term.getID(), - "direction": direction_to_int(bit_term.getDirection()), - "bit": b, - }) - else: - terms.append({ - "name": term.getName(), - "child_id": term.getID(), - "direction": direction_to_int(term.getDirection()), - }) - - print(f"📤 Sending expanded_instance_terms for {path_key!r}: {len(terms)} terms") - await websocket.send(json.dumps({ - "response": "expanded_instance_terms", - "path_key": path_key, - "terms": terms - })) - - elif req_type == "load_instance_internals": - path_key = request.get("path_key", "") - design_ref = get_design_ref(design_ref_message) - model = u.getSNLDesign(design_ref) if design_ref else None - children = [] - nets = [] - - if model: - for instance in model.getNonPrimitiveInstances(): - sub = instance.getModel() - children.append(serialize_model( - sub, instance.getID(), instance.getName(), - get_source_loc(instance))) - for instance in model.getPrimitiveInstances(): - sub = instance.getModel() - if sub.isAssign(): - continue - children.append(serialize_model( - sub, instance.getID(), instance.getName(), - get_source_loc(instance))) - - def emit_bit_net(bit_net, name, bit): - pins = [] - for comp in bit_net.getComponents(): - if isinstance(comp, naja.SNLInstTerm): - bt = comp.getBitTerm() - pins.append({ - "name": bt.getName(), - "child_id": bt.getID(), - "direction": direction_to_int(bt.getDirection()), - "bit": bt.getBit() if isinstance(bt, naja.SNLBusTermBit) else None, - "inst_id": comp.getInstance().getID(), - }) - elif isinstance(comp, naja.SNLBitTerm): - pins.append({ - "name": comp.getName(), - "child_id": comp.getID(), - "direction": direction_to_int(comp.getDirection()), - "bit": comp.getBit() if isinstance(comp, naja.SNLBusTermBit) else None, - }) - if len(pins) < 2: - return - entry = {"name": name, "pins": pins} - if bit is not None: - entry["bit"] = bit - nets.append(entry) - - for net in model.getNets(): - if isinstance(net, naja.SNLBusNet): - lo, hi = sorted((net.getLSB(), net.getMSB())) - for b in range(lo, hi + 1): - bit_net = net.getBit(b) - if bit_net: - emit_bit_net(bit_net, net.getName(), b) - else: - emit_bit_net(net, net.getName(), None) - - await websocket.send(json.dumps({ - "response": "instance_internals_response", - "path_key": path_key, - "children": children, - "nets": nets - })) - - elif req_type == "load_source": - file = request.get("file", "") - line = request.get("line", 0) - text = "" - found = False - try: - with open(file, "r") as f: - text = f.read() - found = True - except OSError as e: - print(f"⚠️ Failed to read source file {file}: {e}") - - await websocket.send(json.dumps({ - "response": "source_response", - "file": file, - "line": line, - "found": found, - "text": text - })) - - elif req_type == "get_properties": - kind = request.get("kind", "instance") - path = request.get("path", []) - properties = [] - subject = "" - - top = u.getTopDesign() - if top is None: - print("⚠️ get_properties: no design loaded") - else: - design, instance = resolve_instance_path(top, path) - if path and design is None: - print(f"⚠️ get_properties: could not resolve instance path {path}") - elif kind == "term": - terminal = request.get("terminal", "") - subject = "/".join(path + [terminal]) if path else terminal - if design is not None and terminal: - term = design.getTerm(terminal) - if term is not None: - bit_arg = request.get("bit") - if bit_arg is not None: - bit = (term.getBusTermBit(bit_arg) - if isinstance(term, naja.SNLBusTerm) else None) - if bit is not None: - properties = [ - {"name": "Name", "value": bit.getName()}, - {"name": "Direction", "value": direction_to_string(bit.getDirection())}, - {"name": "Bit", "value": str(bit.getBit())}, - ] - else: - properties = [ - {"name": "Name", "value": term.getName()}, - {"name": "Direction", "value": direction_to_string(term.getDirection())}, - ] - if isinstance(term, naja.SNLBusTerm): - properties.append({"name": "MSB", "value": str(term.getMSB())}) - properties.append({"name": "LSB", "value": str(term.getLSB())}) - elif kind == "net": - net_name = request.get("net", "") - subject = "/".join(path + [net_name]) if path else net_name - if design is not None and net_name: - net = design.getNet(net_name) - if net is not None: - bit_arg = request.get("bit") - if bit_arg is not None: - bit = (net.getBit(bit_arg) - if isinstance(net, naja.SNLBusNet) else None) - if bit is not None: - properties = [ - {"name": "Name", "value": bit.getName()}, - {"name": "Bit", "value": str(bit.getBit())}, - ] - else: - properties = [ - {"name": "Name", "value": net.getName()}, - ] - if isinstance(net, naja.SNLBusNet): - properties.append({"name": "MSB", "value": str(net.getMSB())}) - properties.append({"name": "LSB", "value": str(net.getLSB())}) - else: # "instance" - if not path: - subject = top.getName() - properties = [ - {"name": "Name", "value": subject}, - {"name": "Type", "value": "Top Design"}, - ] - elif instance is not None: - subject = instance.getName() - model = instance.getModel() - properties = [ - {"name": "Name", "value": subject}, - {"name": "Model", "value": model.getName() if model else ""}, - {"name": "Type", "value": "Primitive" if model and model.isPrimitive() else "Hierarchical"}, - ] - - await websocket.send(json.dumps({ - "response": "properties_response", - "subject": subject, - "properties": properties - })) - - else: - print(f"⚠️ Unknown request type: {req_type}") - - except websockets.exceptions.ConnectionClosed as e: - print(f"🔴 Client disconnected: {e}") - - -async def main(): - print(f"🚀 WebSocket server starting on ws://localhost:{PORT}") - async with websockets.serve(handle_connection, "localhost", PORT): - await asyncio.Future() # Run forever +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) +from naja_schematic.server import main # noqa: E402 if __name__ == "__main__": - check_najaeda_version() - - parser = argparse.ArgumentParser(description="najaeda WebSocket Server") - parser.add_argument("--port", type=int, default=8081, - help="Port to run the websocket server on (default: 8081)") - parser.add_argument("--xilinx", action="store_true", - help="Load Xilinx primitives") - parser.add_argument("--allow_unknown_designs", action="store_true", - help="Allow unknown designs when loading the design.") - parser.add_argument("--liberty", nargs="*", help="List of liberty files to load") - parser.add_argument("--verilog", type=str, - help="Verilog netlist to load") - parser.add_argument("--systemverilog", "--sv", nargs="+", metavar="FILE", - help="SystemVerilog file(s) to load (elaborated with slang)") - parser.add_argument("--flist", "-f", type=str, metavar="FILE", - help="SystemVerilog command file (slang -f syntax: sources, " - "+incdir+, +define+, ...); may be combined with --systemverilog") - parser.add_argument("--top", type=str, - help="SystemVerilog only: top module to elaborate") - parser.add_argument("--define", "-D", action="append", metavar="NAME[=VALUE]", - help="SystemVerilog only: preprocessor define (repeatable)") - args = parser.parse_args() - - is_sv = bool(args.systemverilog or args.flist) - if not args.verilog and not is_sv: - parser.error("provide a design: --verilog, or --systemverilog and/or --flist") - if args.verilog and is_sv: - parser.error("--verilog cannot be combined with --systemverilog/--flist") - if not is_sv and (args.top or args.define): - parser.error("--top/--define only apply to --systemverilog/--flist") - if is_sv and args.liberty: - # Same restriction as the native standalone: the SystemVerilog loader - # has no liberty hook, so fail loudly rather than silently ignore it. - parser.error("--liberty is not supported with --systemverilog/--flist") - - PORT = args.port - - if args.xilinx: - print("📦 Loading Xilinx primitives") - netlist.load_primitives('xilinx') - - # Load the liberty libraries and the Verilog/SystemVerilog design - if args.liberty: - #if arg contains *, expand to list of files - expanded_liberty_files = [] - for lib in args.liberty: - if '*' in lib: - expanded_liberty_files.extend(glob(lib)) - else: - expanded_liberty_files.append(lib) - for lib in expanded_liberty_files: - print(f"📚 Loading liberty file: {lib}") - netlist.load_liberty(lib) - - if is_sv: - sv_files = args.systemverilog or [] - sources = sv_files + ([f"-f {args.flist}"] if args.flist else []) - print(f"📄 Loading SystemVerilog: {', '.join(sources)}") - config = netlist.SystemVerilogConfig() - config.flist = args.flist - config.top = args.top - config.defines = args.define - config.blackbox_unknown_modules = args.allow_unknown_designs - top = netlist.load_system_verilog(sv_files, config=config) - else: - print(f"📄 Loading Verilog netlist: {args.verilog}") - config = netlist.VerilogConfig() - config.allow_unknown_designs = args.allow_unknown_designs - top = netlist.load_verilog(args.verilog, config=config) - print(f"✅ Design loaded: {top.get_name()}") - - asyncio.run(main()) + main() diff --git a/src/JsBridgeProvider.cpp b/src/JsBridgeProvider.cpp new file mode 100644 index 0000000..5db2c25 --- /dev/null +++ b/src/JsBridgeProvider.cpp @@ -0,0 +1,66 @@ +#ifdef __EMSCRIPTEN__ + +#include "JsBridgeProvider.h" + +#include +#include + +#include "Console.h" + +EM_JS(int, naja_bridge_available, (), { + return typeof Module['najaSend'] === 'function' ? 1 : 0; +}); + +EM_JS(void, naja_bridge_send, (const char* msg), { + Module['najaSend'](UTF8ToString(msg)); +}); + +// One viewer per module instance (the host instantiates the module once per +// view), so a single current provider is enough. +static JsBridgeProvider* g_bridge = nullptr; + +static void deliverMessage(const std::string& msg) { + if (g_bridge) { + g_bridge->deliver(msg); + } else { + Console::Error("deliverMessage called before the bridge provider exists"); + } +} + +EMSCRIPTEN_BINDINGS(naja_bridge) { + emscripten::function("deliverMessage", &deliverMessage); +} + +JsBridgeProvider::JsBridgeProvider() { g_bridge = this; } + +JsBridgeProvider::~JsBridgeProvider() { + if (g_bridge == this) g_bridge = nullptr; +} + +bool JsBridgeProvider::available() { return naja_bridge_available() != 0; } + +void JsBridgeProvider::send(const std::string& msg) { + naja_bridge_send(msg.c_str()); +} + +void JsBridgeProvider::on_open(std::function callback) { + open_cb_ = std::move(callback); +} + +void JsBridgeProvider::on_message(std::function callback) { + msg_cb_ = std::move(callback); +} + +// The host channel's lifetime is the page's: there's no close/error to report. +void JsBridgeProvider::on_close(std::function) {} +void JsBridgeProvider::on_error(std::function) {} + +void JsBridgeProvider::start() { + if (open_cb_) open_cb_(); +} + +void JsBridgeProvider::deliver(const std::string& msg) { + if (msg_cb_) msg_cb_(msg); +} + +#endif // __EMSCRIPTEN__ diff --git a/src/JsBridgeProvider.h b/src/JsBridgeProvider.h new file mode 100644 index 0000000..91507ee --- /dev/null +++ b/src/JsBridgeProvider.h @@ -0,0 +1,36 @@ +#pragma once +#ifdef __EMSCRIPTEN__ + +#include "INetlistProvider.h" +#include + +// INetlistProvider implementation for embedded mode (Jupyter/Colab widget, +// editor webview): the page hosting the WASM module owns the transport. +// Requests go out through the host-supplied Module.najaSend(json) hook; +// replies and pushes come back through the embind-exported +// Module.deliverMessage(json). Used by main_wasm.cpp whenever the host sets +// Module.najaSend, instead of WebSocketProvider. +class JsBridgeProvider : public INetlistProvider { + public: + JsBridgeProvider(); + ~JsBridgeProvider() override; + + // True when the host page provided Module.najaSend. + static bool available(); + + void send(const std::string& msg) override; + void on_open(std::function callback) override; + void on_message(std::function callback) override; + void on_close(std::function callback) override; + void on_error(std::function callback) override; + // The host channel is already up: fires on_open synchronously. + void start() override; + + void deliver(const std::string& msg); + + private: + std::function open_cb_; + std::function msg_cb_; +}; + +#endif // __EMSCRIPTEN__ diff --git a/src/main_wasm.cpp b/src/main_wasm.cpp index 4504954..56ae127 100644 --- a/src/main_wasm.cpp +++ b/src/main_wasm.cpp @@ -1,7 +1,12 @@ -// Browser / VSCode webview entry point. -// Compiled with Emscripten; uses WebSocketProvider to talk to a remote naja server. +// Browser / VSCode webview / notebook entry point. +// Compiled with Emscripten. Talks to the netlist backend through +// JsBridgeProvider when the host page supplies Module.najaSend (embedded +// mode, e.g. the naja_schematic Jupyter widget), otherwise through +// WebSocketProvider, at Module.najaWsUrl if set (the naja-schematic server's +// own page sets it) or ws://localhost:8081/ws. #ifdef __EMSCRIPTEN__ +#include #include #include #include @@ -12,11 +17,47 @@ #include "AppLogic.h" #include "GUIData.h" +#include "JsBridgeProvider.h" #include "WebSocketProvider.h" #include "Console.h" static AppState g_state; +EM_JS_DEPS(naja_main, "$specialHTMLTargets,$stringToNewUTF8"); + +// SDL2 addresses its canvas by the "#canvas" selector. Map that selector to +// this module's own Module.canvas rather than whatever document.querySelector +// finds first, so several viewers can live on one page (one per notebook +// cell) -- specialHTMLTargets is per module instance. +EM_JS(void, naja_bind_canvas, (), { + if (Module['canvas']) specialHTMLTargets['#canvas'] = Module['canvas']; +}); + +EM_JS(int, naja_is_embedded, (), { + return Module['najaEmbedded'] ? 1 : 0; +}); + +// Caller frees; nullptr when the host didn't set Module.najaWsUrl. +EM_JS(char*, naja_ws_url, (), { + const url = Module['najaWsUrl']; + return url ? stringToNewUTF8(url) : 0; +}); + +static std::string webSocketUrl() { + std::string url = "ws://localhost:8081/ws"; + if (char* hostUrl = naja_ws_url()) { + url = hostUrl; + free(hostUrl); + } + return url; +} + +// Lets an embedding host stop the viewer when its view is torn down (e.g. +// the notebook output is cleared), as Module._naja_shutdown(). +extern "C" EMSCRIPTEN_KEEPALIVE void naja_shutdown() { + emscripten_cancel_main_loop(); +} + // The is sized by CSS (it fills the browser window / fullscreen, see // shell_minimal.html). SDL follows window resizes on its own, but not every // canvas size change (e.g. entering element fullscreen), so also track the CSS @@ -44,6 +85,12 @@ static void mainLoop() { } int main() { + naja_bind_canvas(); + if (naja_is_embedded()) { + // Only take keystrokes while the canvas has focus: by default SDL listens + // on the whole window, which would swallow typing in notebook cells. + SDL_SetHint(SDL_HINT_EMSCRIPTEN_KEYBOARD_ELEMENT, "#canvas"); + } SDL_Init(SDL_INIT_VIDEO); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0); @@ -64,7 +111,10 @@ int main() { ImGui_ImplOpenGL3_Init("#version 300 es"); g_state.guiData = new GUIData(); - g_state.provider = new WebSocketProvider("ws://localhost:8081/ws"); + if (JsBridgeProvider::available()) + g_state.provider = new JsBridgeProvider(); + else + g_state.provider = new WebSocketProvider(webSocketUrl()); try { setupProvider(g_state); diff --git a/thirdparty/naja b/thirdparty/naja index 51461f0..70af6f9 160000 --- a/thirdparty/naja +++ b/thirdparty/naja @@ -1 +1 @@ -Subproject commit 51461f006e0c52e3c749debbfe3b2b08d15caf91 +Subproject commit 70af6f95d14f21c0126cc1bfd69b55becb0cf356