From c5ab336eeaf55e624467987566289030b4c3ec84 Mon Sep 17 00:00:00 2001 From: "Sean T. Allen" Date: Sat, 10 Oct 2026 20:54:13 -0400 Subject: [PATCH] Add pony-agent-server A query server for AI coding agents that need Pony type information while editing code. It compiles a package and answers questions about types, definitions, capabilities, and structure over a JSON-per-line protocol on stdin/stdout. Queries: inspect (type and definition at a position), scope (visible names), exports (public types in a package), type_api (callable methods at a capability), check (subtype compatibility), implementors (types providing a trait/interface), errors, reload, status. --- .github/workflows/pr.yml | 12 +- .gitignore | 1 + .release-notes/add-pony-agent-server.md | 11 + AGENTS.md | 5 +- CMakeLists.txt | 21 +- src/libponyc/CMakeLists.txt | 1 + src/libponyc/type/subtype_ffi.c | 22 + src/libponyc/type/subtype_ffi.h | 15 + tools/corral.json | 4 +- .../pony_compiler/compile_session.pony | 23 + .../pony_compiler/pony_compiler/types.pony | 8 + tools/pony-agent-server/CMakeLists.txt | 11 + tools/pony-agent-server/README.md | 189 ++ tools/pony-agent-server/agent_server.pony | 593 +++++ tools/pony-agent-server/ast_helpers.pony | 194 ++ tools/pony-agent-server/cap_subtyping.pony | 75 + tools/pony-agent-server/check_query.pony | 213 ++ tools/pony-agent-server/exports_query.pony | 146 ++ .../pony-agent-server/implementors_query.pony | 202 ++ tools/pony-agent-server/inspect_query.pony | 229 ++ tools/pony-agent-server/main.pony | 151 ++ tools/pony-agent-server/method_info.pony | 333 +++ .../pony-agent-server/pony_agent_server.pony | 4 + tools/pony-agent-server/scope_query.pony | 243 +++ .../pony-agent-server/test/fixture/main.pony | 226 ++ tools/pony-agent-server/test/main.pony | 1938 +++++++++++++++++ tools/pony-agent-server/test/test.pony | 3 + tools/pony-agent-server/type_api_query.pony | 496 +++++ tools/pony-agent-server/version.pony.in | 9 + 29 files changed, 5368 insertions(+), 10 deletions(-) create mode 100644 .release-notes/add-pony-agent-server.md create mode 100644 src/libponyc/type/subtype_ffi.c create mode 100644 src/libponyc/type/subtype_ffi.h create mode 100644 tools/pony-agent-server/CMakeLists.txt create mode 100644 tools/pony-agent-server/README.md create mode 100644 tools/pony-agent-server/agent_server.pony create mode 100644 tools/pony-agent-server/ast_helpers.pony create mode 100644 tools/pony-agent-server/cap_subtyping.pony create mode 100644 tools/pony-agent-server/check_query.pony create mode 100644 tools/pony-agent-server/exports_query.pony create mode 100644 tools/pony-agent-server/implementors_query.pony create mode 100644 tools/pony-agent-server/inspect_query.pony create mode 100644 tools/pony-agent-server/main.pony create mode 100644 tools/pony-agent-server/method_info.pony create mode 100644 tools/pony-agent-server/pony_agent_server.pony create mode 100644 tools/pony-agent-server/scope_query.pony create mode 100644 tools/pony-agent-server/test/fixture/main.pony create mode 100644 tools/pony-agent-server/test/main.pony create mode 100644 tools/pony-agent-server/test/test.pony create mode 100644 tools/pony-agent-server/type_api_query.pony create mode 100644 tools/pony-agent-server/version.pony.in diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b06c43d213..fa53c598d4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -377,7 +377,7 @@ jobs: run: | cmake --preset debug cmake --build --preset debug - cmake --build --preset debug --target pony-doc-tests pony-lint-tests pony-lsp-tests pony-dep-tests + cmake --build --preset debug --target pony-doc-tests pony-lint-tests pony-lsp-tests pony-dep-tests pony-agent-server-tests - name: Test pony-doc run: ctest --preset debug -R pony-doc-tests - name: Test pony-lint @@ -386,6 +386,8 @@ jobs: run: ctest --preset debug -R pony-lsp-tests - name: Test pony-dep run: ctest --preset debug -R pony-dep-tests + - name: Test pony-agent-server + run: ctest --preset debug -R pony-agent-server-tests - name: Lint tools run: cd build/debug && PONYPATH=../../tools/lib/ponylang/pony_compiler ./pony-lint ../../tools/ - name: Lint examples @@ -418,7 +420,7 @@ jobs: run: | cmake --preset armv8-debug cmake --build --preset armv8-debug - cmake --build --preset armv8-debug --target pony-doc-tests pony-lint-tests pony-lsp-tests pony-dep-tests + cmake --build --preset armv8-debug --target pony-doc-tests pony-lint-tests pony-lsp-tests pony-dep-tests pony-agent-server-tests - name: Test pony-doc run: ctest --preset armv8-debug -R pony-doc-tests - name: Test pony-lint @@ -427,6 +429,8 @@ jobs: run: ctest --preset armv8-debug -R pony-lsp-tests - name: Test pony-dep run: ctest --preset armv8-debug -R pony-dep-tests + - name: Test pony-agent-server + run: ctest --preset armv8-debug -R pony-agent-server-tests tools-windows: needs: [changes, maybe-build-windows] @@ -459,7 +463,7 @@ jobs: run: | cmake --preset windows-x86-64 cmake --build --preset windows-x86-64-debug - cmake --build --preset windows-x86-64-debug --target pony-doc-tests pony-lint-tests pony-lsp-tests pony-dep-tests + cmake --build --preset windows-x86-64-debug --target pony-doc-tests pony-lint-tests pony-lsp-tests pony-dep-tests pony-agent-server-tests - name: Test pony-doc run: ctest --preset windows-x86-64-debug -R pony-doc-tests - name: Test pony-lint @@ -468,6 +472,8 @@ jobs: run: ctest --preset windows-x86-64-debug -R pony-lsp-tests - name: Test pony-dep run: ctest --preset windows-x86-64-debug -R pony-dep-tests + - name: Test pony-agent-server + run: ctest --preset windows-x86-64-debug -R pony-agent-server-tests # ----- ssl-backends suite ----- diff --git a/.gitignore b/.gitignore index 51d276f0fb..da71f071b3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ tools/pony-lsp/version.pony tools/pony-lint/version.pony tools/pony-doc/version.pony tools/pony-dep/version.pony +tools/pony-agent-server/version.pony # Python bytecode __pycache__/ diff --git a/.release-notes/add-pony-agent-server.md b/.release-notes/add-pony-agent-server.md new file mode 100644 index 0000000000..21db7d04bb --- /dev/null +++ b/.release-notes/add-pony-agent-server.md @@ -0,0 +1,11 @@ +## Add pony-agent-server + +A new experimental tool for AI coding agents that need Pony type information while editing code. It compiles a package and answers queries about types, definitions, capabilities, and structure. The query interface is experimental and subject to change. + +Start the server on a package directory: + +```bash +pony-agent-server /path/to/my-project +``` + +Supported queries: `inspect`, `scope`, `exports`, `type_api`, `check`, `implementors`, `errors`, `reload`, and `status`. The server reads one JSON query per line from stdin and writes one JSON response per line to stdout. diff --git a/AGENTS.md b/AGENTS.md index e941283ba3..3cfc6d3959 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ The output goes in `build/debug`. Use `--preset release` for a release build. Th Tests are registered with ctest and grouped by label. Two labels matter: - **`ci-core`** — built by a normal `cmake --build --preset debug`. The C/C++ compiler tests (`libponyc.tests`), the runtime tests (`libponyrt.tests`), the stdlib suite, full-program integration tests, and grammar validation. -- **`tools`** — **not** built by a normal `cmake --build --preset debug`. The self-hosted tool test suites: pony-compiler, pony-lsp, pony-lint, pony-doc, and pony-dep. +- **`tools`** — **not** built by a normal `cmake --build --preset debug`. The self-hosted tool test suites: pony-compiler, pony-lsp, pony-lint, pony-doc, pony-dep, and pony-agent-server. ### Core tests (ci-core) @@ -87,6 +87,7 @@ Tool test binaries must be built explicitly before running. Build one target, th - `cmake --build --preset debug --target pony-lint-tests && ctest --preset debug -R pony-lint-tests` - `cmake --build --preset debug --target pony-doc-tests && ctest --preset debug -R pony-doc-tests` - `cmake --build --preset debug --target pony-dep-tests && ctest --preset debug -R pony-dep-tests` +- `cmake --build --preset debug --target pony-agent-server-tests && ctest --preset debug -R pony-agent-server-tests` Build all tool test binaries at once with `cmake --build --preset debug --target tool-tests`, then run them with `ctest --preset debug -L tools`. @@ -100,7 +101,7 @@ Run the `pony-lint` binary (built by a normal `cmake --build --preset debug`) ag cd build/debug && PONYPATH=../../tools/lib/ponylang/pony_compiler ./pony-lint ../../tools/pony-lint/ ``` -The same works for `../../tools/pony-lsp/`, `../../tools/pony-doc/`, and `../../tools/pony-dep/`. +The same works for `../../tools/pony-lsp/`, `../../tools/pony-doc/`, `../../tools/pony-dep/`, and `../../tools/pony-agent-server/`. ## Opening PRs diff --git a/CMakeLists.txt b/CMakeLists.txt index c8129a7c7a..434f472461 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -859,6 +859,7 @@ else() add_subdirectory(tools/pony-lint) add_subdirectory(tools/pony-doc) add_subdirectory(tools/pony-dep) + add_subdirectory(tools/pony-agent-server) # Test binaries compiled with the just-built ponyc. Built on demand, NOT under # ALL: each runs ponyc to compile builtin and the stdlib packages, so building @@ -897,12 +898,18 @@ else() PATHS ${CMAKE_SOURCE_DIR}/tools/lib/ponylang/pony_compiler ${PONY_SSL_LIB_DIR} WATCH ${CMAKE_SOURCE_DIR}/tools/pony-dep) + add_pony_binary(pony-agent-server-tests + NAME pony-agent-server-tests + SOURCE ${CMAKE_SOURCE_DIR}/tools/pony-agent-server/test + PATHS ${CMAKE_SOURCE_DIR}/tools/lib/ponylang/pony_compiler + WATCH ${CMAKE_SOURCE_DIR}/tools/pony-agent-server) + # On-demand aggregate: build every self-hosted tool test in one target. Run # `cmake --build --target tool-tests` before `ctest -L tools`. add_custom_target(tool-tests) add_dependencies(tool-tests pony-compiler-tests pony-doc-tests pony-lint-tests pony-lsp-tests - pony-dep-tests) + pony-dep-tests pony-agent-server-tests) # ctest registration. Every test binary builds under ALL, so ctest only runs # them. Labels group the suites: `ci-core` is the core CI suite (the old @@ -944,6 +951,9 @@ else() add_test(NAME pony-dep-tests COMMAND ${PONY_OUTPUT_DIR}/pony-dep-tests${CMAKE_EXECUTABLE_SUFFIX} --sequential WORKING_DIRECTORY ${PONY_OUTPUT_DIR}) + add_test(NAME pony-agent-server-tests + COMMAND ${PONY_OUTPUT_DIR}/pony-agent-server-tests${CMAKE_EXECUTABLE_SUFFIX} --sequential + WORKING_DIRECTORY ${PONY_OUTPUT_DIR}) set_tests_properties(pony-lint-tests PROPERTIES ENVIRONMENT "PONYPATH=${CMAKE_SOURCE_DIR}/packages;PONYTEST_DB_DIR=${CMAKE_SOURCE_DIR}/.ponytest/pony-lint-tests") set_tests_properties(pony-doc-tests PROPERTIES @@ -952,6 +962,8 @@ else() ENVIRONMENT "PONYPATH=${CMAKE_SOURCE_DIR}/packages;PONYTEST_DB_DIR=${CMAKE_SOURCE_DIR}/.ponytest/pony-dep-tests") set_tests_properties(pony-lsp-tests PROPERTIES ENVIRONMENT "PONYTEST_DB_DIR=${CMAKE_SOURCE_DIR}/.ponytest/pony-lsp-tests") + set_tests_properties(pony-agent-server-tests PROPERTIES + ENVIRONMENT "PONYPATH=${CMAKE_SOURCE_DIR}/packages;PONYTEST_DB_DIR=${CMAKE_SOURCE_DIR}/.ponytest/pony-agent-server-tests") add_test(NAME pony-compiler-tests COMMAND ${PONY_OUTPUT_DIR}/pony-compiler-tests${CMAKE_EXECUTABLE_SUFFIX} --sequential WORKING_DIRECTORY ${PONY_OUTPUT_DIR}) @@ -967,7 +979,7 @@ else() ENVIRONMENT "PONYPATH=${CMAKE_SOURCE_DIR}/tools/lib/ponylang/pony_compiler:${CMAKE_SOURCE_DIR}/packages;PONYTEST_DB_DIR=${CMAKE_SOURCE_DIR}/.ponytest/pony-compiler-tests") endif() set_tests_properties(pony-doc-tests pony-lsp-tests pony-lint-tests pony-compiler-tests - pony-dep-tests PROPERTIES LABELS tools) + pony-dep-tests pony-agent-server-tests PROPERTIES LABELS tools) # Compile-then-run ci-core tests. Each compiles Pony sources with the freshly # built ponyc and then runs (or diffs) the result, so they depend on ponyc @@ -1045,6 +1057,7 @@ else() ${PONY_OUTPUT_DIR}/pony-lint${CMAKE_EXECUTABLE_SUFFIX} ${PONY_OUTPUT_DIR}/pony-doc${CMAKE_EXECUTABLE_SUFFIX} ${PONY_OUTPUT_DIR}/pony-dep${CMAKE_EXECUTABLE_SUFFIX} + ${PONY_OUTPUT_DIR}/pony-agent-server${CMAKE_EXECUTABLE_SUFFIX} DESTINATION ${PONY_VERSIONED_DIR}/bin) # Runtime + compiler static libs, into the lib dir ponyc searches at run time @@ -1138,7 +1151,7 @@ else() endif() endforeach() file(MAKE_DIRECTORY \"\${_prefix}/bin\" \"\${_prefix}/lib\") - foreach(_tool ponyc pony-lsp pony-lint pony-doc pony-dep) + foreach(_tool ponyc pony-lsp pony-lint pony-doc pony-dep pony-agent-server) set(_t \"\${_prefix}/${PONY_VERSIONED_DIR}/bin/\${_tool}\") if(EXISTS \"\${_t}\") file(CREATE_LINK \"\${_t}\" \"\${_prefix}/bin/\${_tool}\" SYMBOLIC) @@ -1152,7 +1165,7 @@ else() endforeach() ") elseif(PONY_INSTALL_SYMLINKS AND WIN32) - foreach(_tool ponyc pony-lsp pony-lint pony-doc pony-dep) + foreach(_tool ponyc pony-lsp pony-lint pony-doc pony-dep pony-agent-server) install(PROGRAMS $ DESTINATION bin RENAME ${_tool}.exe) endforeach() diff --git a/src/libponyc/CMakeLists.txt b/src/libponyc/CMakeLists.txt index 1221be9117..18d49417d7 100644 --- a/src/libponyc/CMakeLists.txt +++ b/src/libponyc/CMakeLists.txt @@ -94,6 +94,7 @@ add_library(libponyc STATIC type/sanitise.c type/subtype.c type/subtype_cache.c + type/subtype_ffi.c type/type_assume.c type/typealias.c type/typeparam.c diff --git a/src/libponyc/type/subtype_ffi.c b/src/libponyc/type/subtype_ffi.c new file mode 100644 index 0000000000..75b03aa74a --- /dev/null +++ b/src/libponyc/type/subtype_ffi.c @@ -0,0 +1,22 @@ +#include "subtype_ffi.h" +#include "assemble.h" +#include "subtype.h" + +bool is_subtype_for_defs(ast_t* sub_def, token_id sub_cap, + ast_t* super_def, token_id super_cap, pass_opt_t* opt) +{ + ast_t* sub_type = + type_for_class(opt, sub_def, sub_def, sub_cap, TK_NONE, false); + ast_setdata(sub_type, sub_def); + + ast_t* super_type = + type_for_class(opt, super_def, super_def, super_cap, TK_NONE, false); + ast_setdata(super_type, super_def); + + bool result = is_subtype(sub_type, super_type, NULL, opt); + + ast_free_unattached(sub_type); + ast_free_unattached(super_type); + + return result; +} diff --git a/src/libponyc/type/subtype_ffi.h b/src/libponyc/type/subtype_ffi.h new file mode 100644 index 0000000000..b5d26a49d2 --- /dev/null +++ b/src/libponyc/type/subtype_ffi.h @@ -0,0 +1,15 @@ +#ifndef SUBTYPE_FFI_H +#define SUBTYPE_FFI_H + +#include +#include "../ast/ast.h" +#include "../pass/pass.h" + +PONY_EXTERN_C_BEGIN + +bool is_subtype_for_defs(ast_t* sub_def, token_id sub_cap, + ast_t* super_def, token_id super_cap, pass_opt_t* opt); + +PONY_EXTERN_C_END + +#endif diff --git a/tools/corral.json b/tools/corral.json index 717fdf121e..ae1617f9bf 100644 --- a/tools/corral.json +++ b/tools/corral.json @@ -8,7 +8,9 @@ "pony-doc", "pony-doc/test", "pony-dep", - "pony-dep/test" + "pony-dep/test", + "pony-agent-server", + "pony-agent-server/test" ], "deps": [ { diff --git a/tools/lib/ponylang/pony_compiler/pony_compiler/compile_session.pony b/tools/lib/ponylang/pony_compiler/pony_compiler/compile_session.pony index 5fc2e3fcec..05be9b65cd 100644 --- a/tools/lib/ponylang/pony_compiler/pony_compiler/compile_session.pony +++ b/tools/lib/ponylang/pony_compiler/pony_compiler/compile_session.pony @@ -96,6 +96,29 @@ class CompileSession _pass_opt.limit = limit() @ast_passes_program(_raw, _pass_opt) + fun box is_sendable(type_ast: AST box): Bool => + """ + True when the given type is sendable (iso, val, or tag). + Handles nominal, union, intersection, tuple, and arrow types. + """ + @sendable(type_ast.raw, _pass_opt) + + fun box is_subtype( + sub_def: AST box, + sub_cap: TokenId, + super_def: AST box, + super_cap: TokenId) + : Bool + => + """ + True when a nominal type built from `sub_def` at `sub_cap` is a + subtype of a nominal type built from `super_def` at `super_cap`. + Both definitions must be entity AST nodes (TK_CLASS, TK_ACTOR, + TK_PRIMITIVE, TK_TRAIT, TK_INTERFACE, TK_STRUCT). + """ + @is_subtype_for_defs( + sub_def.raw, sub_cap, super_def.raw, super_cap, _pass_opt) + fun ref dispose() => """ Release pass_opt resources. Must be called when the session is no longer diff --git a/tools/lib/ponylang/pony_compiler/pony_compiler/types.pony b/tools/lib/ponylang/pony_compiler/pony_compiler/types.pony index 3b9f5901dc..421619f9bc 100644 --- a/tools/lib/ponylang/pony_compiler/pony_compiler/types.pony +++ b/tools/lib/ponylang/pony_compiler/pony_compiler/types.pony @@ -1,3 +1,11 @@ +use @sendable[Bool](type_ast: Pointer[_AST] box, opt: _PassOpt box) +use @is_subtype_for_defs[Bool]( + sub_def: Pointer[_AST] box, + sub_cap: I32, + super_def: Pointer[_AST] box, + super_cap: I32, + opt: _PassOpt box) + primitive Types """ AST type extraction utilities. diff --git a/tools/pony-agent-server/CMakeLists.txt b/tools/pony-agent-server/CMakeLists.txt new file mode 100644 index 0000000000..fc3552d335 --- /dev/null +++ b/tools/pony-agent-server/CMakeLists.txt @@ -0,0 +1,11 @@ +project(tools.pony-agent-server) + +# Generate version.pony from template +# (.gitignore prevents version.pony from dirtying the git tree) +configure_file(version.pony.in ${CMAKE_CURRENT_SOURCE_DIR}/version.pony @ONLY) + +add_pony_binary(tools.pony-agent-server ALL + NAME pony-agent-server + SOURCE ${CMAKE_CURRENT_SOURCE_DIR} + PATHS ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/../lib/ponylang/pony_compiler + WATCH ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/tools/pony-agent-server/README.md b/tools/pony-agent-server/README.md new file mode 100644 index 0000000000..80f2b6f0e3 --- /dev/null +++ b/tools/pony-agent-server/README.md @@ -0,0 +1,189 @@ +# pony-agent-server + +A query server for AI coding agents that need Pony type information while editing code. It compiles a package and answers questions about types, definitions, capabilities, and structure. + +The query interface is experimental and subject to change. + +## Usage + +```text +pony-agent-server [-D define]... +``` + +The server compiles the package on startup, then reads one JSON query per line from stdin and writes one JSON response per line to stdout. Diagnostic messages go to stderr. + +Pass `-D` flags the same way you would pass them to `ponyc`. For example, `-D openssl_3.0.x` for OpenSSL 3.x. + +Run `--queries` to print a summary of all queries and their input format. + +## Protocol + +Every query is a JSON object with a `"query"` field. An optional `"id"` field (string or integer) is echoed back in the response. + +On success, the response contains the query-specific fields described below. On error, the response contains an `"error"` field with a message. + +```json +{"query":"status", "id":1} +{"id":1, "status":"ready"} +``` + +## Queries + +### inspect + +Type and definition of the AST node at a source position. + +```json +{"query":"inspect", "file":"/path/to/file.pony", "line":10, "column":5} +``` + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | The node's type as the compiler resolved it | +| `node_kind` | string | AST node kind (e.g. `"letref"`, `"call"`) | +| `line`, `column` | integer | Position of the node | +| `definition` | object | Where the symbol is defined: `file`, `line`, `column`, `kind`, `declared_type` | +| `enclosing` | object | Enclosing context: `method`, `receiver_cap`, `type` | +| `in_recover` | boolean | Whether the position is inside a `recover` block | +| `sendable` | boolean | Whether the type is sendable (iso, val, or tag) | + +### scope + +Every name visible at a source position, with recover-block accessibility. + +```json +{"query":"scope", "file":"/path/to/file.pony", "line":10, "column":5} +``` + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `in_recover` | boolean | Whether the position is inside a `recover` block | +| `entries` | array | Each entry has `name`, `type`, `kind`, `accessible`, and optionally `reason` | + +When `accessible` is false, `reason` explains why (typically because a non-sendable reference is blocked by a recover expression). + +### exports + +Public types exported by a package. + +```json +{"query":"exports", "package":"collections"} +``` + +The package can be a qualified name like `"collections"` or an absolute path. + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `package_path` | string | Resolved path to the package | +| `entries` | array | Each entry has `name`, `kind` (class, actor, primitive, etc.), and optionally `docstring` | + +### type_api + +Methods callable on a type at a given capability. There are two ways to query: by type name with an explicit capability, or by source position (the type and capability are read from the compiler's AST). + +By name: + +```json +{"query":"type_api", "type":"Array[String]", "cap":"ref"} +``` + +By position: + +```json +{"query":"type_api", "file":"/path/to/file.pony", "line":10, "column":5} +``` + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | The type name or expression | +| `kind` | string | Entity kind: `"class"`, `"actor"`, `"primitive"`, `"trait"`, `"interface"`, `"struct"`, or `"union"` | +| `callable` | array | Methods callable at this capability | +| `not_callable` | array | Methods not callable, each with a `method` and `reason` | +| `constructors` | array | Available constructors | +| `traits` | array | Traits and interfaces the type provides | + +Each method object has: `name`, `receiver` (capability), `params` (array of `{name, type, has_default}`), `return`, `partial` (boolean), `kind` (`"fun"`, `"be"`, or `"new"`), and `source_type` (the type that defined the method). + +### check + +Subtype check between two types using the compiler's subtype checker. + +```json +{"query":"check", "source":"Collector ref", "target":"Describable ref"} +``` + +Types are a name optionally followed by a capability. Without a capability, the type's default is used (ref for classes, val for primitives, tag for actors). + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `compatible` | boolean | Whether the source is a subtype of the target | +| `source_type` | string | Source type with resolved capability | +| `target_type` | string | Target type with resolved capability | +| `reason` | string | Explanation of the result | +| `suggestions` | array | When incompatible, suggestions for making it work | + +### implementors + +Types that implement a given trait or interface. + +```json +{"query":"implementors", "type":"Describable"} +``` + +The target must be a trait or interface. + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | The queried trait or interface name | +| `nominal` | array | Types that explicitly declare the trait/interface in their provides list | +| `structural` | array | Types that match the interface's method signatures without declaring it (interfaces only; traits require nominal declaration) | + +### errors + +Compilation errors from the last compile or reload. + +```json +{"query":"errors"} +``` + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `errors` | array | Each error has `message`, and optionally `file`, `line`, `column` | + +### reload + +Recompile the package. The server recompiles from the same directory it was started with and replaces the in-memory program on success. Queries issued after a failed reload get the previous successful compilation. + +```json +{"query":"reload"} +``` + +The response is the same as the initial compile: `{"status":"ready"}` on success, or `{"status":"error", "errors":[...]}` on failure. + +### status + +Whether the server has a compiled program ready for queries. + +```json +{"query":"status"} +``` + +Response fields: + +| Field | Type | Description | +|-------|------|-------------| +| `status` | string | `"ready"` or `"not_compiled"` | diff --git a/tools/pony-agent-server/agent_server.pony b/tools/pony-agent-server/agent_server.pony new file mode 100644 index 0000000000..126ab65019 --- /dev/null +++ b/tools/pony-agent-server/agent_server.pony @@ -0,0 +1,593 @@ +use "files" +use "json" +use "pony_compiler" + +use @get_compiler_exe_directory[Bool]( + output_path: Pointer[U8] tag, + argv0: Pointer[U8] tag) +use @ponyint_pool_alloc_size[Pointer[U8] val](size: USize) +use @ponyint_pool_free_size[None](size: USize, p: Pointer[U8] tag) + +actor AgentServer + """ + Stdin/stdout JSON query server for AI agents working with compiled + Pony programs. Compiles a package on startup, then accepts one + JSON query per line and writes one JSON response per line. + """ + let _env: Env + let _argv0: String val + let _user_flags: Array[String val] val + var _program: (Program val | None) + var _session: (CompileSession | None) + var _package_path: (FilePath | None) + let _input_buffer: String ref + var _last_errors: Array[Error] val + var _listening: Bool + + new create( + env: Env, + argv0: String val, + user_flags: Array[String val] val = recover val Array[String val] end) + => + _env = env + _argv0 = argv0 + _user_flags = user_flags + _program = None + _session = None + _package_path = None + _input_buffer = String + _last_errors = recover val Array[Error] end + _listening = false + + be compile(package: FilePath, id: JSONValue = "") => + """ + Compile the package and begin listening for queries on stdin. + """ + _package_path = package + _env.err.print("compiling: " + package.path) + + let installation_paths = _find_installation_paths(_argv0) + let pony_path = _get_pony_path(_env) + + let search_paths: Array[String val] val = + recover val + let tmp = installation_paths.clone() + for p in pony_path.values() do + tmp.push(p) + end + consume tmp + end + + let session = + CompileSession( + package, + search_paths + where + user_flags = _user_flags, + release = false, + verbosity = VerbosityQuiet, + limit = PassFinaliser) + + match \exhaustive\ session.program() + | let prog: Program val => + _program = prog + _session = session + _last_errors = recover val Array[Error] end + _env.err.print("compilation successful") + _emit_response( + JSONObject + .update("id", id) + .update("status", "ready") + .update("message", "compilation successful")) + if _listening then + _env.exitcode(0) + else + _start_listening() + _listening = true + end + | None => + let errs = session.errors() + _last_errors = errs + _env.err.print( + "compilation failed with " + errs.size().string() + " error(s)") + var err_arr = JSONArray + for e in errs.values() do + err_arr = err_arr.push(e.msg) + end + _emit_response( + JSONObject + .update("id", id) + .update("status", "error") + .update("errors", err_arr)) + session.dispose() + if not _listening then + _env.exitcode(1) + end + end + + fun ref _start_listening() => + _env.input( + object iso is InputNotify + let _server: AgentServer tag = this + + fun ref apply(data: Array[U8] iso) => + _server._receive_data(consume data) + + fun ref dispose() => + _server._input_closed() + end, + 512) + + be _receive_data(data: Array[U8] iso) => + _input_buffer.append(consume data) + while true do + try + let nl = _input_buffer.find("\n")? + let line = _input_buffer.substring(0, nl.isize()) + _input_buffer.delete(0, nl.usize() + 1) + let trimmed = line.clone() .> strip() + if trimmed.size() > 0 then + _handle_line(consume trimmed) + end + else + break + end + end + + be _input_closed() => + match _session + | let s: CompileSession => + s.dispose() + end + + fun ref _handle_line(line: String val) => + match \exhaustive\ JSONParser.parse(line) + | let request: JSONValue => + let nav = JSONNav(request) + let id_nav = nav("id") + let id: JSONValue = + if id_nav.found() then + try + id_nav.as_i64()? + else + try id_nav.as_string()? + else "" end + end + else + "" + end + + let query_nav = nav("query") + if query_nav.found() then + try + let query = query_nav.as_string()? + match query + | "inspect" => + _handle_inspect(id, nav) + | "scope" => + _handle_scope(id, nav) + | "exports" => + _handle_exports(id, nav) + | "type_api" => + _handle_type_api(id, nav) + | "check" => + _handle_check(id, nav) + | "implementors" => + _handle_implementors(id, nav) + | "errors" => + _handle_errors(id) + | "reload" => + _handle_reload(id) + | "status" => + _handle_status(id) + else + _emit_error(id, "unknown query: " + query) + end + else + _emit_error(id, "query field must be a string") + end + else + _emit_error(id, "missing 'query' field") + end + | let err: JSONParseError => + _emit_error("", "JSON parse error: " + err.message) + end + + fun ref _handle_inspect(id: JSONValue, nav: JSONNav) => + try + let file = nav("file").as_string()? + let line = nav("line").as_i64()? + let column = nav("column").as_i64()? + + match \exhaustive\ _session + | let session: CompileSession => + let inspect_result = + InspectQuery(session, file, line.usize(), column.usize()) + match \exhaustive\ inspect_result + | let result: InspectResult val => + var def_obj = JSONObject + match result.definition_file + | let f: String val => + def_obj = def_obj.update("file", f) + end + if result.definition_line > 0 then + def_obj = def_obj + .update("line", I64.from[USize](result.definition_line)) + .update("column", I64.from[USize](result.definition_column)) + end + match result.definition_kind + | let k: String val => + def_obj = def_obj.update("kind", k) + end + match result.declared_type + | let dt: String val => + def_obj = def_obj.update("declared_type", dt) + end + + var enc_obj = JSONObject + match result.enclosing_method + | let m: String val => + enc_obj = enc_obj.update("method", m) + end + match result.enclosing_receiver_cap + | let c: String val => + enc_obj = enc_obj.update("receiver_cap", c) + end + match result.enclosing_type + | let t: String val => + enc_obj = enc_obj.update("type", t) + end + + _emit_response( + JSONObject + .update("id", id) + .update("type", result.type_string) + .update("node_kind", result.node_kind) + .update("line", I64.from[USize](result.line)) + .update("column", I64.from[USize](result.column)) + .update("definition", def_obj) + .update("enclosing", enc_obj) + .update("in_recover", result.in_recover) + .update("sendable", result.sendable)) + | let err: String val => + _emit_error(id, err) + end + else + _emit_error(id, "no compiled program available") + end + else + _emit_error(id, "inspect requires 'file', 'line', 'column'") + end + + fun ref _handle_scope(id: JSONValue, nav: JSONNav) => + try + let file = nav("file").as_string()? + let line = nav("line").as_i64()? + let column = nav("column").as_i64()? + + match \exhaustive\ _session + | let session: CompileSession => + let scope_result = + ScopeQuery(session, file, line.usize(), column.usize()) + match \exhaustive\ scope_result + | let result: ScopeResult val => + var entries_arr = JSONArray + for entry in result.entries.values() do + var entry_obj = JSONObject + .update("name", entry.name) + .update("type", entry.type_string) + .update("kind", entry.kind) + .update("accessible", entry.accessible) + match entry.reason + | let r: String val => + entry_obj = entry_obj.update("reason", r) + end + entries_arr = entries_arr.push(entry_obj) + end + _emit_response( + JSONObject + .update("id", id) + .update("in_recover", result.in_recover) + .update("entries", entries_arr)) + | let err: String val => + _emit_error(id, err) + end + else + _emit_error(id, "no compiled program available") + end + else + _emit_error(id, "scope requires 'file', 'line', 'column'") + end + + fun ref _handle_exports(id: JSONValue, nav: JSONNav) => + try + let package_name = nav("package").as_string()? + + match \exhaustive\ _session + | let session: CompileSession => + let exports_result = + ExportsQuery(session, package_name) + match \exhaustive\ exports_result + | let result: ExportsResult val => + var entries_arr = JSONArray + for entry in result.entries.values() do + var entry_obj = JSONObject + .update("name", entry.name) + .update("kind", entry.kind) + match entry.docstring + | let d: String val => + entry_obj = entry_obj.update("docstring", d) + end + entries_arr = entries_arr.push(entry_obj) + end + _emit_response( + JSONObject + .update("id", id) + .update("package_path", result.package_path) + .update("entries", entries_arr)) + | let err: String val => + _emit_error(id, err) + end + else + _emit_error(id, "no compiled program available") + end + else + _emit_error(id, "exports requires 'package'") + end + + fun ref _handle_type_api(id: JSONValue, nav: JSONNav) => + match \exhaustive\ _session + | let session: CompileSession => + let type_nav = nav("type") + let cap_nav = nav("cap") + let file_nav = nav("file") + + let api_result = + if type_nav.found() and cap_nav.found() then + try + TypeAPIQuery.from_type( + session, + type_nav.as_string()?, + cap_nav.as_string()?) + else + _emit_error(id, "type_api 'type' and 'cap' must be strings") + return + end + elseif file_nav.found() then + try + TypeAPIQuery.from_position( + session, + file_nav.as_string()?, + nav("line").as_i64()?.usize(), + nav("column").as_i64()?.usize()) + else + _emit_error( + id, + "type_api position requires 'file', 'line', 'column'") + return + end + else + _emit_error( + id, + "type_api requires 'type'+'cap' or 'file'+'line'+'column'") + return + end + + match \exhaustive\ api_result + | let result: TypeAPIResult val => + var callable_arr = JSONArray + for m in result.callable.values() do + callable_arr = callable_arr.push(_method_to_json(m)) + end + + var not_callable_arr = JSONArray + for (m, reason) in result.not_callable.values() do + not_callable_arr = + not_callable_arr.push( + JSONObject + .update("method", _method_to_json(m)) + .update("reason", reason)) + end + + var ctor_arr = JSONArray + for m in result.constructors.values() do + ctor_arr = ctor_arr.push(_method_to_json(m)) + end + + var traits_arr = JSONArray + for t in result.traits.values() do + traits_arr = traits_arr.push(t) + end + + _emit_response( + JSONObject + .update("id", id) + .update("type", result.type_name) + .update("kind", result.type_kind) + .update("callable", callable_arr) + .update("not_callable", not_callable_arr) + .update("constructors", ctor_arr) + .update("traits", traits_arr)) + | let err: String val => + _emit_error(id, err) + end + else + _emit_error(id, "no compiled program available") + end + + fun _method_to_json(m: MethodInfo val): JSONObject => + var params_arr = JSONArray + for p in m.params.values() do + var param_obj = JSONObject + .update("name", p.name) + .update("type", p.param_type) + if p.has_default then + param_obj = param_obj.update("has_default", true) + end + params_arr = params_arr.push(param_obj) + end + + JSONObject + .update("name", m.name) + .update("receiver", m.receiver_cap) + .update("params", params_arr) + .update("return", m.return_type) + .update("partial", m.is_partial) + .update("kind", m.kind) + .update("source_type", m.source_type) + + fun ref _handle_check(id: JSONValue, nav: JSONNav) => + try + let source = nav("source").as_string()? + let target = nav("target").as_string()? + + match \exhaustive\ _session + | let session: CompileSession => + let check_result = CheckQuery(session, source, target) + match \exhaustive\ check_result + | let result: CheckResult val => + var suggestions_arr = JSONArray + for s in result.suggestions.values() do + suggestions_arr = suggestions_arr.push(s) + end + _emit_response( + JSONObject + .update("id", id) + .update("compatible", result.compatible) + .update("source_type", result.source_type) + .update("target_type", result.target_type) + .update("reason", result.reason) + .update("suggestions", suggestions_arr)) + | let err: String val => + _emit_error(id, err) + end + else + _emit_error(id, "no compiled program available") + end + else + _emit_error(id, "check requires 'source' and 'target'") + end + + fun ref _handle_implementors(id: JSONValue, nav: JSONNav) => + try + let type_name = nav("type").as_string()? + + match \exhaustive\ _session + | let session: CompileSession => + let impl_result = ImplementorsQuery(session, type_name) + match \exhaustive\ impl_result + | let result: ImplementorsResult val => + var nominal_arr = JSONArray + for n in result.nominal.values() do + nominal_arr = nominal_arr.push(n) + end + var structural_arr = JSONArray + for s in result.structural.values() do + structural_arr = structural_arr.push(s) + end + _emit_response( + JSONObject + .update("id", id) + .update("type", result.type_name) + .update("nominal", nominal_arr) + .update("structural", structural_arr)) + | let err: String val => + _emit_error(id, err) + end + else + _emit_error(id, "no compiled program available") + end + else + _emit_error(id, "implementors requires 'type'") + end + + fun ref _handle_errors(id: JSONValue) => + var err_arr = JSONArray + for e in _last_errors.values() do + var err_obj = JSONObject.update("message", e.msg) + match e.file + | let f: String val => + err_obj = err_obj.update("file", f) + end + if e.position > Position.min() then + err_obj = err_obj + .update("line", I64.from[USize](e.position.line())) + .update("column", I64.from[USize](e.position.column())) + end + err_arr = err_arr.push(err_obj) + end + _emit_response( + JSONObject + .update("id", id) + .update("errors", err_arr)) + + fun ref _handle_reload(id: JSONValue) => + match _package_path + | let pkg: FilePath => + match _session + | let old_session: CompileSession => + old_session.dispose() + end + _session = None + _program = None + compile(pkg, id) + else + _emit_error(id, "no package to reload") + end + + fun ref _handle_status(id: JSONValue) => + match _program + | let _: Program val => + _emit_response( + JSONObject + .update("id", id) + .update("status", "ready")) + else + _emit_response( + JSONObject + .update("id", id) + .update("status", "not_compiled")) + end + + fun _emit_response(obj: JSONObject) => + _env.out.print(obj.print()) + + fun _emit_error(id: JSONValue, message: String) => + _env.out.print( + JSONObject + .update("id", id) + .update("error", message) + .print()) + + fun tag _find_installation_paths(argv0: String val): Array[String val] val => + match \exhaustive\ _find_exe_directory(argv0) + | let dir: String val => + recover val + Array[String val](2) + .> push(recover val Path.join(dir, "../packages") end) + .> push(recover val Path.join(dir, "../../packages") end) + end + | None => + recover val Array[String val] end + end + + fun tag _find_exe_directory(argv0: String val): (String val | None) => + let buf_size: USize = 4096 + let buf = @ponyint_pool_alloc_size(buf_size) + if @get_compiler_exe_directory(buf, argv0.cstring()) then + let result = recover val String.copy_cstring(buf) end + @ponyint_pool_free_size(buf_size, buf) + result + else + @ponyint_pool_free_size(buf_size, buf) + None + end + + fun tag _get_pony_path(env: Env): Array[String val] val => + match \exhaustive\ PonyPath(env) + | let p: String => + let split: Array[String] iso = Path.split_list(p) + consume split + | None => + recover val Array[String val] end + end diff --git a/tools/pony-agent-server/ast_helpers.pony b/tools/pony-agent-server/ast_helpers.pony new file mode 100644 index 0000000000..e8194433d6 --- /dev/null +++ b/tools/pony-agent-server/ast_helpers.pony @@ -0,0 +1,194 @@ +use "files" +use "pony_compiler" + +primitive ASTHelpers + """ + Walk AST parent chains, look up type definitions by name, and + determine entity default capabilities. + """ + + fun find_module(program: Program val, file: String val) + : (Module val | None) + => + """ + Find the module whose source path matches `file`. Normalizes + the query path to an absolute path before comparing. + """ + let abs_file = Path.abs(file) + for pkg in program.packages() do + for mod in pkg.modules() do + if mod.file == abs_file then + return mod + end + end + end + + fun find_enclosing_recover(node: AST box): (AST box | None) => + """ + Walk up the parent chain to find the nearest TK_RECOVER. Stops + at method or type boundaries. + """ + var current: (AST box | None) = node.parent() + repeat + match \exhaustive\ current + | let ast: AST box => + match ast.id() + | TokenIds.tk_recover() => return ast + | TokenIds.tk_fun() + | TokenIds.tk_be() + | TokenIds.tk_new() + | TokenIds.tk_class() + | TokenIds.tk_actor() + | TokenIds.tk_primitive() + | TokenIds.tk_struct() + | TokenIds.tk_trait() + | TokenIds.tk_interface() => + return None + end + current = ast.parent() + | None => + return None + end + until current is None end + None + + fun is_inside(node: AST box, ancestor: AST box): Bool => + """ + True when `node` is a descendant of `ancestor`. + """ + var current: (AST box | None) = node.parent() + repeat + match \exhaustive\ current + | let ast: AST box => + if ast == ancestor then return true end + current = ast.parent() + | None => + return false + end + until current is None end + false + + fun find_enclosing_method(node: AST box): (AST box | None) => + """ + Walk up the parent chain to find the nearest TK_FUN, TK_BE, or TK_NEW. + """ + var current: (AST box | None) = node.parent() + repeat + match \exhaustive\ current + | let ast: AST box => + match ast.id() + | TokenIds.tk_fun() + | TokenIds.tk_be() + | TokenIds.tk_new() => + return ast + end + current = ast.parent() + | None => + return None + end + until current is None end + None + + fun find_enclosing_type(node: AST box): (AST box | None) => + """ + Walk up the parent chain to find the nearest type definition + (TK_CLASS, TK_ACTOR, TK_PRIMITIVE, TK_STRUCT, TK_TRAIT, or + TK_INTERFACE). + """ + var current: (AST box | None) = node.parent() + repeat + match \exhaustive\ current + | let ast: AST box => + match ast.id() + | TokenIds.tk_class() + | TokenIds.tk_actor() + | TokenIds.tk_primitive() + | TokenIds.tk_struct() + | TokenIds.tk_trait() + | TokenIds.tk_interface() => + return ast + end + current = ast.parent() + | None => + return None + end + until current is None end + None + + fun in_recover_block(node: AST box): Bool => + """ + Walk up the parent chain looking for TK_RECOVER. Stops at method + or type boundaries. + """ + var current: (AST box | None) = node.parent() + repeat + match \exhaustive\ current + | let ast: AST box => + match ast.id() + | TokenIds.tk_recover() => return true + | TokenIds.tk_fun() + | TokenIds.tk_be() + | TokenIds.tk_new() + | TokenIds.tk_class() + | TokenIds.tk_actor() + | TokenIds.tk_primitive() + | TokenIds.tk_struct() + | TokenIds.tk_trait() + | TokenIds.tk_interface() => + return false + end + current = ast.parent() + | None => + return false + end + until current is None end + false + + fun find_type_def(program: Program val, type_name: String val) + : (AST box | String val | None) + => + """ + Find an entity definition by unqualified name across all packages. + Returns the AST node, an error string when the name is ambiguous, + or None when nothing matches. + """ + var found: (AST box | None) = None + for pkg in program.packages() do + for mod in pkg.modules() do + var child: (AST box | None) = mod.ast.child() + while true do + match \exhaustive\ child + | let node: AST box => + if TokenIds.is_entity(node.id()) then + try + let n = node(0)?.token_value() as String val + if n == type_name then + match \exhaustive\ found + | let _: AST box => + return "ambiguous type name: " + type_name + + " (found in multiple packages)" + | None => + found = node + end + end + end + end + child = node.sibling() + | None => + break + end + end + end + end + found + + fun default_cap(def: AST box): TokenId => + """ + The default capability for an entity: tag for actors, val for + primitives, ref for everything else. + """ + match def.id() + | TokenIds.tk_actor() => TokenIds.tk_tag() + | TokenIds.tk_primitive() => TokenIds.tk_val() + else TokenIds.tk_ref() + end diff --git a/tools/pony-agent-server/cap_subtyping.pony b/tools/pony-agent-server/cap_subtyping.pony new file mode 100644 index 0000000000..7b59d166f9 --- /dev/null +++ b/tools/pony-agent-server/cap_subtyping.pony @@ -0,0 +1,75 @@ +use "pony_compiler" + +use @is_cap_sub_cap[Bool]( + sub: I32, subalias: I32, super_cap: I32, supalias: I32) + +primitive CapSubtyping + """ + Capability subtyping via the compiler's `is_cap_sub_cap`. + """ + + fun is_sub(sub: String val, super_cap: String val): Bool => + """ + True when `sub` is a subtype of `super_cap`. Both are capability + names ("iso", "trn", "ref", "val", "box", "tag"). Returns false + for unrecognized names. + """ + let sub_id = _cap_token_id(sub) + let super_id = _cap_token_id(super_cap) + if (sub_id == TokenIds.tk_none()) or (super_id == TokenIds.tk_none()) then + return false + end + @is_cap_sub_cap(sub_id, TokenIds.tk_none(), super_id, TokenIds.tk_none()) + + fun is_sub_ephemeral( + sub: String val, + sub_ephemeral: Bool, + super_cap: String val, + super_ephemeral: Bool) + : Bool + => + """ + Subtype check with ephemeral modifiers. An ephemeral capability + (`iso^`, `trn^`) can become more types than its non-ephemeral + counterpart. + """ + let sub_id = _cap_token_id(sub) + let super_id = _cap_token_id(super_cap) + if (sub_id == TokenIds.tk_none()) or (super_id == TokenIds.tk_none()) then + return false + end + let sub_eph = + if sub_ephemeral then TokenIds.tk_ephemeral() + else TokenIds.tk_none() + end + let super_eph = + if super_ephemeral then TokenIds.tk_ephemeral() + else TokenIds.tk_none() + end + @is_cap_sub_cap(sub_id, sub_eph, super_id, super_eph) + + fun cap_string(token_id: TokenId): String val => + """ + The capability name for a token ID, or "" for non-capability + tokens. + """ + match token_id + | TokenIds.tk_iso() => "iso" + | TokenIds.tk_trn() => "trn" + | TokenIds.tk_ref() => "ref" + | TokenIds.tk_val() => "val" + | TokenIds.tk_box() => "box" + | TokenIds.tk_tag() => "tag" + else "" + end + + fun _cap_token_id(name: String val): TokenId => + match name + | "iso" => TokenIds.tk_iso() + | "trn" => TokenIds.tk_trn() + | "ref" => TokenIds.tk_ref() + | "val" => TokenIds.tk_val() + | "box" => TokenIds.tk_box() + | "tag" => TokenIds.tk_tag() + else TokenIds.tk_none() + end diff --git a/tools/pony-agent-server/check_query.pony b/tools/pony-agent-server/check_query.pony new file mode 100644 index 0000000000..0857befe15 --- /dev/null +++ b/tools/pony-agent-server/check_query.pony @@ -0,0 +1,213 @@ +use "pony_compiler" + +class val CheckResult + """ + Whether a source type is a subtype of a target type, with an + explanation when it is not and suggestions for making it compatible. + """ + let compatible: Bool + let source_type: String val + let target_type: String val + let reason: String val + let suggestions: Array[String val] val + + new val create( + compatible': Bool, + source_type': String val, + target_type': String val, + reason': String val, + suggestions': Array[String val] val) + => + compatible = compatible' + source_type = source_type' + target_type = target_type' + reason = reason' + suggestions = suggestions' + +primitive CheckQuery + """ + Check whether one type is a subtype of another using the compiler's + own subtype checker. Accepts type names with capabilities (e.g. + "String val", "Stringable ref") and returns compatibility with an + explanation. + """ + + fun apply( + session: CompileSession box, + source: String val, + target: String val) + : (CheckResult val | String val) + => + """ + Check whether `source` is a subtype of `target`. Both are type + names optionally followed by a capability ("String val", + "Collector ref"). When no capability is given, the type's default + is used. + """ + let program = + match \exhaustive\ session.program() + | let p: Program val => p + | None => return "no compiled program available" + end + + (let source_name, let source_cap) = _parse_type(source) + (let target_name, let target_cap) = _parse_type(target) + + if source_name.size() == 0 then + return "cannot parse source type: " + source + end + if target_name.size() == 0 then + return "cannot parse target type: " + target + end + + let source_def = + match \exhaustive\ ASTHelpers.find_type_def(program, source_name) + | let t: AST box => t + | let err: String val => return err + | None => return "source type not found: " + source_name + end + + let target_def = + match \exhaustive\ ASTHelpers.find_type_def(program, target_name) + | let t: AST box => t + | let err: String val => return err + | None => return "target type not found: " + target_name + end + + let source_cap_id = _cap_token_id(source_cap, source_def) + let target_cap_id = _cap_token_id(target_cap, target_def) + + let is_sub = + session.is_subtype( + source_def, source_cap_id, target_def, target_cap_id) + + let source_display: String val = + source_name + " " + _cap_name(source_cap_id) + let target_display: String val = + target_name + " " + _cap_name(target_cap_id) + + if is_sub then + let reason: String val = + source_display + " is a subtype of " + target_display + CheckResult( + true, + source_display, + target_display, + reason, + recover val Array[String val] end) + else + let reason: String val = + source_display + " is not a subtype of " + target_display + let suggestions = + _suggest( + source_name, + source_cap, + source_def, + target_name, + target_cap, + target_def, + session) + CheckResult(false, source_display, target_display, reason, suggestions) + end + + fun _parse_type(type_str: String val): (String val, String val) => + """ + Split "TypeName cap" into (name, cap). Returns ("TypeName", "") + when no capability is given. + """ + try + let space = type_str.rfind(" ")? + let name: String val = type_str.substring(0, space) + let cap: String val = type_str.substring(space + 1) + if _is_cap(cap) then + return (name, cap) + end + end + (type_str, "") + + fun _is_cap(s: String val): Bool => + match s + | "iso" | "trn" | "ref" | "val" | "box" | "tag" => true + else false + end + + fun _cap_token_id(cap: String val, def: AST box): TokenId => + """ + Token ID for a capability name. When `cap` is empty, returns the + type's default capability based on its entity kind. + """ + match cap + | "iso" => TokenIds.tk_iso() + | "trn" => TokenIds.tk_trn() + | "ref" => TokenIds.tk_ref() + | "val" => TokenIds.tk_val() + | "box" => TokenIds.tk_box() + | "tag" => TokenIds.tk_tag() + else ASTHelpers.default_cap(def) + end + + fun _cap_name(token_id: TokenId): String val => + CapSubtyping.cap_string(token_id) + + fun _suggest( + source_name: String val, + source_cap: String val, + source_def: AST box, + target_name: String val, + target_cap: String val, + target_def: AST box, + session: CompileSession box) + : Array[String val] val + => + let suggestions = Array[String val] + + let source_cap_id = _cap_token_id(source_cap, source_def) + let target_cap_id = _cap_token_id(target_cap, target_def) + + if source_name == target_name then + if not CapSubtyping.is_sub( + _cap_name(source_cap_id), _cap_name(target_cap_id)) + then + let sc = _cap_name(source_cap_id) + let tc = _cap_name(target_cap_id) + if (tc == "val") or (tc == "iso") then + let s: String val = + "Create the value inside a recover " + tc + " block" + suggestions.push(s) + end + if (sc == "ref") and (tc == "val") then + suggestions.push( + "Use consume on an iso reference to get val") + end + let s: String val = + "Change the target to accept " + sc + " or box" + suggestions.push(s) + end + else + let target_kind = target_def.id() + if (target_kind == TokenIds.tk_trait()) or + (target_kind == TokenIds.tk_interface()) + then + let source_at_ref = + session.is_subtype( + source_def, + TokenIds.tk_ref(), + target_def, + TokenIds.tk_ref()) + if source_at_ref then + let s: String val = + source_name + " satisfies " + target_name + + " structurally but the capabilities are incompatible" + suggestions.push(s) + else + let s: String val = + source_name + " does not implement " + target_name + suggestions.push(s) + end + end + end + + let out = recover iso Array[String val](suggestions.size()) end + for s in suggestions.values() do out.push(s) end + consume out + diff --git a/tools/pony-agent-server/exports_query.pony b/tools/pony-agent-server/exports_query.pony new file mode 100644 index 0000000000..6d15aa5d75 --- /dev/null +++ b/tools/pony-agent-server/exports_query.pony @@ -0,0 +1,146 @@ +use "pony_compiler" + +class val ExportEntry + """ + One public type in a package. + """ + let name: String val + let kind: String val + let docstring: (String val | None) + + new val create( + name': String val, + kind': String val, + docstring': (String val | None) = None) + => + name = name' + kind = kind' + docstring = docstring' + +class val ExportsResult + """ + Every public type in a package, sorted by name. + """ + let package_path: String val + let entries: Array[ExportEntry val] val + + new val create( + package_path': String val, + entries': Array[ExportEntry val] iso) + => + package_path = package_path' + entries = consume entries' + +primitive ExportsQuery + """ + List the public types exported by a package. + """ + + fun apply( + session: CompileSession box, + package_name: String val) + : (ExportsResult val | String val) + => + """ + Return every public type in the named package, or a string + describing why the lookup failed. The package can be specified + by qualified name (e.g. "builtin") or by absolute path. + """ + let program = + match \exhaustive\ session.program() + | let p: Program val => p + | None => return "no compiled program available" + end + + let pkg = + match \exhaustive\ _find_package(program, package_name) + | let p: Package val => p + | None => return "package not found: " + package_name + end + + let entries = + recover iso + let arr = Array[ExportEntry val] + for mod in pkg.modules() do + _collect_types(mod.ast, arr) + end + _sort(arr) + arr + end + + ExportsResult(pkg.path, consume entries) + + fun _find_package(program: Program val, name: String val) + : (Package val | None) + => + try return program(name)? end + for pkg in program.packages() do + if (pkg.qualified_name == name) or (pkg.path == name) then + return pkg + end + end + + fun _collect_types( + module_ast: AST val, + entries: Array[ExportEntry val] ref) + => + var child_node = module_ast.child() + while true do + match \exhaustive\ child_node + | let ast: AST val => + let kind = _type_kind(ast.id()) + if kind.size() > 0 then + try + let id_node = ast(0)? + match id_node.token_value() + | let n: String val => + try + if n(0)? != '_' then + let doc = _docstring(ast) + entries.push(ExportEntry(n, kind, doc)) + end + end + end + end + end + child_node = ast.sibling() + | None => break + end + end + + fun _type_kind(token_id: TokenId): String val => + match token_id + | TokenIds.tk_class() => "class" + | TokenIds.tk_actor() => "actor" + | TokenIds.tk_primitive() => "primitive" + | TokenIds.tk_trait() => "trait" + | TokenIds.tk_interface() => "interface" + | TokenIds.tk_type() => "type_alias" + | TokenIds.tk_struct() => "struct" + else "" + end + + fun _docstring(type_ast: AST box): (String val | None) => + try + let doc_node = type_ast(6)? + if doc_node.id() == TokenIds.tk_string() then + doc_node.token_value() + end + end + + fun _sort(arr: Array[ExportEntry val] ref) => + let size = arr.size() + if size < 2 then return end + var i: USize = 1 + while i < size do + try + let entry = arr(i)? + var j = i + while (j > 0) and (arr(j - 1)?.name > entry.name) do + arr(j)? = arr(j - 1)? + j = j - 1 + end + arr(j)? = entry + end + i = i + 1 + end diff --git a/tools/pony-agent-server/implementors_query.pony b/tools/pony-agent-server/implementors_query.pony new file mode 100644 index 0000000000..1912d56d7d --- /dev/null +++ b/tools/pony-agent-server/implementors_query.pony @@ -0,0 +1,202 @@ +use "pony_compiler" + +class val ImplementorsResult + """ + Types that implement a given trait or interface, split into nominal + (explicitly declared in the provides list) and structural (matching + the interface's method set without declaring it). + """ + let type_name: String val + let nominal: Array[String val] val + let structural: Array[String val] val + + new val create( + type_name': String val, + nominal': Array[String val] val, + structural': Array[String val] val) + => + type_name = type_name' + nominal = nominal' + structural = structural' + +primitive ImplementorsQuery + """ + Find all types that implement a given trait or interface. Nominal + implementors are found by scanning provides lists. Structural + implementors (interfaces only) are found using the compiler's + subtype checker. + """ + + fun apply( + session: CompileSession box, + type_name: String val) + : (ImplementorsResult val | String val) + => + """ + Find all types that implement the named trait or interface. + Returns an error string when the type is not found or is not a + trait or interface. + """ + let program = + match \exhaustive\ session.program() + | let p: Program val => p + | None => return "no compiled program available" + end + + let target_def = + match \exhaustive\ ASTHelpers.find_type_def(program, type_name) + | let t: AST box => t + | let err: String val => return err + | None => return "type not found: " + type_name + end + + let target_kind = target_def.id() + if (target_kind != TokenIds.tk_trait()) and + (target_kind != TokenIds.tk_interface()) + then + return type_name + " is not a trait or interface" + end + + let is_interface = target_kind == TokenIds.tk_interface() + let target_pkg = _entity_package(target_def) + + let nominal_results = Array[String val] + let structural_results = Array[String val] + + for pkg in program.packages() do + let pkg_path = pkg.path + for mod in pkg.modules() do + var child: (AST box | None) = mod.ast.child() + while true do + match \exhaustive\ child + | let node: AST box => + if _is_concrete_entity(node.id()) then + try + let name = node(0)?.token_value() as String val + + // Skip private types from other packages + if name(0)? == '_' then + if pkg_path != target_pkg then + child = node.sibling() + continue + end + end + + if _provides_nominally(node, type_name) then + nominal_results.push(name) + elseif is_interface then + let cap = ASTHelpers.default_cap(node) + if session.is_subtype(node, cap, target_def, cap) then + structural_results.push(name) + end + end + end + end + child = node.sibling() + | None => break + end + end + end + end + + _sort(nominal_results) + _sort(structural_results) + + let nom_out = recover iso Array[String val](nominal_results.size()) end + for n in nominal_results.values() do nom_out.push(n) end + let struct_out = + recover iso Array[String val](structural_results.size()) end + for s in structural_results.values() do struct_out.push(s) end + + ImplementorsResult(type_name, consume nom_out, consume struct_out) + + fun _provides_nominally(entity: AST box, target: String val): Bool => + """ + True when the entity's provides list names `target` directly. + Walks all children of the provides node and recurses into + intersection and union types. + """ + try + let provides = entity(3)? + if provides.id() == TokenIds.tk_provides() then + var child: (AST box | None) = provides.child() + while true do + match \exhaustive\ child + | let c: AST box => + if _provides_walk(c, target) then return true end + child = c.sibling() + | None => break + end + end + end + end + false + + fun _provides_walk(node: AST box, target: String val): Bool => + match node.id() + | TokenIds.tk_nominal() => + try + let name = node(1)?.token_value() as String val + if name == target then return true end + end + false + | TokenIds.tk_isecttype() | TokenIds.tk_uniontype() => + var child: (AST box | None) = node.child() + while true do + match \exhaustive\ child + | let c: AST box => + if _provides_walk(c, target) then return true end + child = c.sibling() + | None => break + end + end + false + else + false + end + + fun _is_concrete_entity(token_id: TokenId): Bool => + match token_id + | TokenIds.tk_class() + | TokenIds.tk_actor() + | TokenIds.tk_primitive() + | TokenIds.tk_struct() => + true + else false + end + + fun _entity_package(entity: AST box): String val => + var current: (AST box | None) = entity.parent() + while true do + match \exhaustive\ current + | let ast: AST box => + match ast.id() + | TokenIds.tk_package() => + match ast.token_value() + | let s: String val => return s + end + return "" + end + current = ast.parent() + | None => return "" + end + end + "" + + fun _sort(arr: Array[String val] ref) => + """ + Simple insertion sort for small result sets. + """ + var i: USize = 1 + while i < arr.size() do + try + let key = arr(i)? + var j = i + while (j > 0) and (arr(j - 1)? > key) do + arr(j)? = arr(j - 1)? + j = j - 1 + end + arr(j)? = key + end + i = i + 1 + end diff --git a/tools/pony-agent-server/inspect_query.pony b/tools/pony-agent-server/inspect_query.pony new file mode 100644 index 0000000000..73a1ccaafb --- /dev/null +++ b/tools/pony-agent-server/inspect_query.pony @@ -0,0 +1,229 @@ +use "pony_compiler" + +class val InspectResult + """ + Position-based inspection result: the node's type, its definition + site, and enclosing context. + """ + let type_string: String val + let node_kind: String val + let line: USize + let column: USize + let definition_file: (String val | None) + let definition_line: USize + let definition_column: USize + let definition_kind: (String val | None) + let declared_type: (String val | None) + let enclosing_method: (String val | None) + let enclosing_receiver_cap: (String val | None) + let enclosing_type: (String val | None) + let in_recover: Bool + let sendable: Bool + + new val create( + type_string': String val, + node_kind': String val, + line': USize, + column': USize, + definition_file': (String val | None) = None, + definition_line': USize = 0, + definition_column': USize = 0, + definition_kind': (String val | None) = None, + declared_type': (String val | None) = None, + enclosing_method': (String val | None) = None, + enclosing_receiver_cap': (String val | None) = None, + enclosing_type': (String val | None) = None, + in_recover': Bool = false, + sendable': Bool = false) + => + type_string = type_string' + node_kind = node_kind' + line = line' + column = column' + definition_file = definition_file' + definition_line = definition_line' + definition_column = definition_column' + definition_kind = definition_kind' + declared_type = declared_type' + enclosing_method = enclosing_method' + enclosing_receiver_cap = enclosing_receiver_cap' + enclosing_type = enclosing_type' + in_recover = in_recover' + sendable = sendable' + +primitive InspectQuery + """ + Find the type, definition, and enclosing context of the AST node at + a given source position. + """ + + fun apply( + session: CompileSession box, + file: String val, + line: USize, + column: USize) + : (InspectResult val | String val) + => + """ + Return the inspection result for the node at the given position, + or a string describing why the lookup failed. + """ + let program = + match \exhaustive\ session.program() + | let p: Program val => p + | None => return "no compiled program available" + end + + let module = + match \exhaustive\ ASTHelpers.find_module(program, file) + | let m: Module val => m + | None => return "file not found in compiled program: " + file + end + + let index = module.create_position_index() + + let node = + match \exhaustive\ index.find_node_at(line, column) + | let n: AST box => n + | None => return "no AST node at position " + + line.string() + ":" + column.string() + end + + let node_kind = TokenIds.string(node.id()) + + let type_str = + match \exhaustive\ Types.get_ast_type(node) + | let ts: String val => ts + | None => + match \exhaustive\ _type_from_definition(node) + | let ts: String val => ts + | None => + return "no type information for " + node_kind + + " at " + line.string() + ":" + column.string() + end + end + + (let def_file, let def_line, let def_col, let def_kind, let decl_type) = + _definition_info(node) + + (let enc_method, let enc_receiver_cap) = + match \exhaustive\ ASTHelpers.find_enclosing_method(node) + | let m: AST box => + (try m(1)?.token_value() end, try m(0)?.get_print() end) + | None => (None, None) + end + + let enc_type = + match \exhaustive\ ASTHelpers.find_enclosing_type(node) + | let t: AST box => + try t(0)?.token_value() end + | None => None + end + + let in_recover' = ASTHelpers.in_recover_block(node) + let sendable' = + match \exhaustive\ node.ast_type() + | let type_ast: AST box => session.is_sendable(type_ast) + | None => false + end + + InspectResult( + type_str, + node_kind, + node.line(), + node.pos(), + def_file, + def_line, + def_col, + def_kind, + decl_type, + enc_method, + enc_receiver_cap, + enc_type, + in_recover', + sendable') + + fun _type_from_definition(node: AST box): (String val | None) => + let defs = node.definitions() + if defs.size() > 0 then + try + let def = defs(0)? + match def.id() + | TokenIds.tk_flet() + | TokenIds.tk_fvar() + | TokenIds.tk_embed() => + try + def(1)?.type_string() + end + | TokenIds.tk_let() + | TokenIds.tk_var() => + def.ast_type_string() + | TokenIds.tk_param() => + try + def(1)?.type_string() + end + | TokenIds.tk_fun() + | TokenIds.tk_new() => + try + def(4)?.type_string() + end + | TokenIds.tk_be() => + "None val^" + | TokenIds.tk_class() + | TokenIds.tk_actor() + | TokenIds.tk_primitive() + | TokenIds.tk_struct() + | TokenIds.tk_trait() + | TokenIds.tk_interface() + | TokenIds.tk_type() => + try + let id_node = def(0)? + id_node.token_value() + end + end + end + end + + fun _definition_info(node: AST box) + : ((String val | None), USize, USize, (String val | None), + (String val | None)) + => + let defs = node.definitions() + if defs.size() > 0 then + try + let def = defs(0)? + let def_file = + match \exhaustive\ def.source_file() + | let sf: String val => sf + | None => None + end + let def_line = def.line() + let def_col = def.pos() + let def_kind = TokenIds.string(def.id()) + let decl_type = _declared_type_at(def) + return (def_file, def_line, def_col, def_kind, decl_type) + end + end + (None, 0, 0, None, None) + + fun _declared_type_at(def: AST box): (String val | None) => + """ + The type as declared at the definition site, not the inferred type + at the reference. + """ + match def.id() + | TokenIds.tk_flet() + | TokenIds.tk_fvar() + | TokenIds.tk_embed() => + try def(1)?.type_string() end + | TokenIds.tk_let() + | TokenIds.tk_var() => + def.ast_type_string() + | TokenIds.tk_param() => + try def(1)?.type_string() end + | TokenIds.tk_fun() + | TokenIds.tk_new() => + try def(4)?.type_string() end + | TokenIds.tk_be() => + "None val^" + end diff --git a/tools/pony-agent-server/main.pony b/tools/pony-agent-server/main.pony new file mode 100644 index 0000000000..01826bb301 --- /dev/null +++ b/tools/pony-agent-server/main.pony @@ -0,0 +1,151 @@ +use "cli" +use "files" +use "path:../lib/ponylang/pony_compiler/" +use "pony_compiler" + +actor Main + new create(env: Env) => + let cs = + try + CommandSpec.leaf( + "pony-agent-server", + "Query server for AI agents working with Pony code", + [ + OptionSpec.bool( + "version", + "Print version and exit" + where short' = 'V', default' = false) + OptionSpec.bool( + "queries", + "Print supported queries and exit" + where short' = 'Q', default' = false) + OptionSpec.string_seq( + "define", + "Compile-time define (repeatable, e.g. -D openssl_3.0.x)" + where short' = 'D') + ], + [ + ArgSpec.string( + "package", + "Path to the Pony package to compile" + where default' = "") + ])? .> add_help()? + else + env.err.print("internal error: invalid command spec") + env.exitcode(1) + return + end + + let cmd = + match \exhaustive\ CommandParser(cs).parse(env.args, env.vars) + | let c: Command => c + | let ch: CommandHelp => + ch.print_help(env.out) + return + | let se: SyntaxError => + env.err.print(se.string()) + env.exitcode(1) + return + end + + if cmd.option("version").bool() then + env.out.print("pony-agent-server " + Version()) + return + end + + if cmd.option("queries").bool() then + _print_queries(env) + return + end + + let package_path = cmd.arg("package").string() + if package_path.size() == 0 then + env.err.print("error: package path is required") + env.err.print( + "usage: pony-agent-server [-D define]...") + env.err.print( + "run with --help for options" + + " or --queries for the query reference") + env.exitcode(1) + return + end + + let file_path = FilePath(FileAuth(env.root), package_path) + + let argv0 = + try env.args(0)? + else "" + end + + let defines = cmd.option("define").string_seq() + let user_flags: Array[String val] val = + recover val + let flags = Array[String val] + for d in defines.values() do + flags.push(d) + end + consume flags + end + + let server = AgentServer(env, argv0, user_flags) + server.compile(file_path) + + fun _print_queries(env: Env) => + env.out.print(QueryHelp()) + +primitive \nodoc\ QueryHelp + fun apply(): String => + "pony-agent-server queries\n" + + "\n" + + "The server reads one JSON object per line from stdin\n" + + "and writes one JSON response per line to stdout.\n" + + "Every query has a \"query\" field. An optional \"id\"\n" + + "field (string or integer) is echoed back.\n" + + "\n" + + "inspect\n" + + " Type and definition at a source position.\n" + + " {\"query\":\"inspect\",\n" + + " \"file\":\"...\", \"line\":N, \"column\":N}\n" + + "\n" + + "scope\n" + + " Names visible at a source position.\n" + + " {\"query\":\"scope\",\n" + + " \"file\":\"...\", \"line\":N, \"column\":N}\n" + + "\n" + + "exports\n" + + " Public types in a package.\n" + + " {\"query\":\"exports\", \"package\":\"...\"}\n" + + "\n" + + "type_api\n" + + " Callable methods for a type at a capability.\n" + + " {\"query\":\"type_api\",\n" + + " \"type\":\"...\", \"cap\":\"ref\"}\n" + + " {\"query\":\"type_api\",\n" + + " \"file\":\"...\", \"line\":N, \"column\":N}\n" + + "\n" + + "check\n" + + " Subtype check between two types.\n" + + " {\"query\":\"check\",\n" + + " \"source\":\"...\", \"target\":\"...\"}\n" + + " Types can include a capability:\n" + + " \"String val\", \"Collector ref\".\n" + + " Without one, the type's default is used.\n" + + "\n" + + "implementors\n" + + " Types that implement a trait or interface.\n" + + " {\"query\":\"implementors\", \"type\":\"...\"}\n" + + "\n" + + "errors\n" + + " Errors from the last compile or reload.\n" + + " {\"query\":\"errors\"}\n" + + "\n" + + "reload\n" + + " Recompile the package.\n" + + " {\"query\":\"reload\"}\n" + + "\n" + + "status\n" + + " Whether the server has a compiled program.\n" + + " {\"query\":\"status\"}\n" + + "\n" + + "Error responses have an \"error\" field\n" + + "instead of query-specific fields." diff --git a/tools/pony-agent-server/method_info.pony b/tools/pony-agent-server/method_info.pony new file mode 100644 index 0000000000..09dbf73c69 --- /dev/null +++ b/tools/pony-agent-server/method_info.pony @@ -0,0 +1,333 @@ +use "pony_compiler" + +class val ParamInfo + """ + One parameter of a method: name, type, and whether it has a default + value. + """ + let name: String val + let param_type: String val + let has_default: Bool + + new val create( + name': String val, + param_type': String val, + has_default': Bool = false) + => + name = name' + param_type = param_type' + has_default = has_default' + +class val MethodInfo + """ + A method's signature: name, receiver capability, parameters, return + type, partiality, kind (fun/be/new), and the type it was defined on. + """ + let name: String val + let receiver_cap: String val + let params: Array[ParamInfo val] val + let return_type: String val + let is_partial: Bool + let kind: String val + let source_type: String val + + new val create( + name': String val, + receiver_cap': String val, + params': Array[ParamInfo val] val, + return_type': String val, + is_partial': Bool, + kind': String val, + source_type': String val) + => + name = name' + receiver_cap = receiver_cap' + params = params' + return_type = return_type' + is_partial = is_partial' + kind = kind' + source_type = source_type' + +primitive MethodEnumerator + """ + Collect methods from a type's AST, including inherited methods from + the provides list. A method on the type itself shadows an inherited + method with the same name. + """ + + fun methods(type_ast: AST box, type_name: String val) + : Array[MethodInfo val] val + => + """ + All methods on `type_ast`, including those inherited through the + provides list. + """ + let result = Array[MethodInfo val] + let own = _own_methods(type_ast, type_name) + let own_names = _collect_names(own) + let inherited = _inherited_methods(type_ast, own_names) + for m in own.values() do result.push(m) end + for m in inherited.values() do result.push(m) end + let out: Array[MethodInfo val] iso = + recover iso + let arr = Array[MethodInfo val](result.size()) + arr + end + for m in result.values() do out.push(m) end + consume out + + fun callable_at( + all_methods: Array[MethodInfo val] val, + cap: String val) + : (Array[MethodInfo val] val, Array[(MethodInfo val, String val)] val) + => + """ + Split methods into callable and not-callable for a receiver with + the given capability. Constructors are excluded — they are always + callable regardless of receiver cap. + """ + let callable = recover iso Array[MethodInfo val] end + let not_callable = recover iso Array[(MethodInfo val, String val)] end + + for m in all_methods.values() do + if m.kind == "new" then continue end + + if m.kind == "be" then + // Behaviors are callable on tag and anything that subtypes tag + callable.push(m) + elseif CapSubtyping.is_sub(cap, m.receiver_cap) then + callable.push(m) + else + not_callable.push( + (m, cap + " is not a subtype of " + m.receiver_cap)) + end + end + + (consume callable, consume not_callable) + + fun _own_methods(type_ast: AST box, type_name: String val) + : Array[MethodInfo val] + => + let result = Array[MethodInfo val] + try + let members = type_ast(4)? + if members.id() != TokenIds.tk_members() then return result end + + var child: (AST box | None) = members.child() + while true do + match \exhaustive\ child + | let node: AST box => + match node.id() + | TokenIds.tk_fun() | TokenIds.tk_be() | TokenIds.tk_new() => + match _extract_method(node, type_name) + | let m: MethodInfo val => result.push(m) + end + end + child = node.sibling() + | None => break + end + end + end + result + + fun _extract_method(method_ast: AST box, type_name: String val) + : (MethodInfo val | None) + => + try + let kind: String val = + match method_ast.id() + | TokenIds.tk_fun() => "fun" + | TokenIds.tk_be() => "be" + | TokenIds.tk_new() => "new" + else return None + end + + let cap_node = method_ast(0)? + let receiver_cap = CapSubtyping.cap_string(cap_node.id()) + + let name_node = method_ast(1)? + let name = + match name_node.token_value() + | let s: String val => s + else return None + end + + let params_node = method_ast(3)? + let params = _extract_params(params_node) + + let return_type: String val = + if kind == "be" then + "None val" + else + try + let rt = method_ast(4)? + match rt.type_string() + | let s: String val => s + else "" + end + else + "" + end + end + + let is_partial = + try + method_ast(6)?.id() == TokenIds.tk_question() + else + false + end + + MethodInfo( + name, + receiver_cap, + params, + return_type, + is_partial, + kind, + type_name) + end + + fun _extract_params(params_node: AST box): Array[ParamInfo val] val => + if params_node.id() != TokenIds.tk_params() then + return recover val Array[ParamInfo val] end + end + + recover val + let result = Array[ParamInfo val] + var child: (AST box | None) = params_node.child() + while true do + match \exhaustive\ child + | let param: AST box => + if param.id() == TokenIds.tk_param() then + try + let name_node = param(0)? + let name = + match name_node.token_value() + | let s: String val => s + else "" + end + let param_type = + try + let type_node = param(1)? + if type_node.id() != TokenIds.tk_none() then + match type_node.type_string() + | let s: String val => s + else "" + end + else "" + end + else "" + end + let has_default = + try + param(2)?.id() != TokenIds.tk_none() + else + false + end + result.push(ParamInfo(name, param_type, has_default)) + end + end + child = param.sibling() + | None => break + end + end + result + end + + fun _inherited_methods( + type_ast: AST box, + own_names: Array[String val]) + : Array[MethodInfo val] + => + let result = Array[MethodInfo val] + try + let provides = type_ast(3)? + if provides.id() != TokenIds.tk_provides() then return result end + + let provides_type = provides.child() + match provides_type + | let pt: AST box => + _collect_from_provides(pt, own_names, result) + end + end + result + + fun _collect_from_provides( + type_node: AST box, + exclude_names: Array[String val], + result: Array[MethodInfo val]) + => + match type_node.id() + | TokenIds.tk_nominal() => + let defs = type_node.definitions() + if defs.size() > 0 then + try + let def = defs(0)? + let type_name = + try + match def(0)?.token_value() + | let s: String val => s + else "" + end + else "" + end + if type_name.size() > 0 then + let inherited = _own_methods(def, type_name) + for m in inherited.values() do + if not _name_in(m.name, exclude_names) then + result.push(m) + end + end + _inherited_methods_recurse(def, exclude_names, result) + end + end + end + | TokenIds.tk_isecttype() | TokenIds.tk_uniontype() => + var child: (AST box | None) = type_node.child() + while true do + match \exhaustive\ child + | let c: AST box => + _collect_from_provides(c, exclude_names, result) + child = c.sibling() + | None => break + end + end + end + + fun _inherited_methods_recurse( + type_ast: AST box, + exclude_names: Array[String val], + result: Array[MethodInfo val]) + => + try + let provides = type_ast(3)? + if provides.id() != TokenIds.tk_provides() then return end + + let provides_type = provides.child() + match provides_type + | let pt: AST box => + let all_names = _collect_result_names(exclude_names, result) + _collect_from_provides(pt, all_names, result) + end + end + + fun _collect_names(infos: Array[MethodInfo val]): Array[String val] => + let names = Array[String val](infos.size()) + for m in infos.values() do names.push(m.name) end + names + + fun _collect_result_names( + exclude: Array[String val], + result: Array[MethodInfo val]) + : Array[String val] + => + let names = Array[String val](exclude.size() + result.size()) + for n in exclude.values() do names.push(n) end + for m in result.values() do names.push(m.name) end + names + + fun _name_in(name: String val, names: Array[String val]): Bool => + for n in names.values() do + if n == name then return true end + end + false diff --git a/tools/pony-agent-server/pony_agent_server.pony b/tools/pony-agent-server/pony_agent_server.pony new file mode 100644 index 0000000000..35eb7b11b6 --- /dev/null +++ b/tools/pony-agent-server/pony_agent_server.pony @@ -0,0 +1,4 @@ +""" +pony-agent-server: compile a Pony package and answer JSON queries +about its types, definitions, and structure over stdin/stdout. +""" diff --git a/tools/pony-agent-server/scope_query.pony b/tools/pony-agent-server/scope_query.pony new file mode 100644 index 0000000000..906b48b651 --- /dev/null +++ b/tools/pony-agent-server/scope_query.pony @@ -0,0 +1,243 @@ +use "pony_compiler" + +class val ScopeEntry + """ + One name visible at a source position. When `accessible` is false, + `reason` explains why the name cannot be used (typically because it + is blocked by a recover expression). + """ + let name: String val + let type_string: String val + let kind: String val + let accessible: Bool + let reason: (String val | None) + + new val create( + name': String val, + type_string': String val, + kind': String val, + accessible': Bool = true, + reason': (String val | None) = None) + => + name = name' + type_string = type_string' + kind = kind' + accessible = accessible' + reason = reason' + +class val ScopeResult + """ + Every name reachable from a source position, annotated with + recover-block accessibility. + """ + let entries: Array[ScopeEntry val] val + let in_recover: Bool + + new val create( + entries': Array[ScopeEntry val] iso, + in_recover': Bool) + => + entries = consume entries' + in_recover = in_recover' + +primitive ScopeQuery + """ + Collect every name visible at a source position and determine + whether each is accessible given the recover-block context. + """ + + fun apply( + session: CompileSession box, + file: String val, + line: USize, + column: USize) + : (ScopeResult val | String val) + => + """ + Return the scope at the given position, or a string describing + why the lookup failed. + """ + let program = + match \exhaustive\ session.program() + | let p: Program val => p + | None => return "no compiled program available" + end + + let module = + match \exhaustive\ ASTHelpers.find_module(program, file) + | let m: Module val => m + | None => return "file not found in compiled program: " + file + end + + let index = module.create_position_index() + + let node = + match \exhaustive\ index.find_node_at(line, column) + | let n: AST box => n + | None => return "no AST node at position " + + line.string() + ":" + column.string() + end + + let in_recover = ASTHelpers.in_recover_block(node) + let recover_node: (AST box | None) = + if in_recover then + ASTHelpers.find_enclosing_recover(node) + else + None + end + + let entries = recover iso Array[ScopeEntry val] end + let seen = Array[String val] + + var current: (AST box | None) = node + while true do + match \exhaustive\ current + | let ast: AST box => + if ast.has_scope() then + match ast.symbol_table() + | let st: SymbolTable => + for (sym_name, def) in st.iter() do + try if sym_name(0)? == '$' then continue end end + if seen.contains(sym_name + where predicate = {(a, b) => a == b }) + then continue end + seen.push(sym_name) + + let kind = _classify(def) + let type_str = _type_string_for(def) + (let accessible, let reason) = + _accessibility( + session, def, kind, in_recover, recover_node) + entries.push( + ScopeEntry( + sym_name, type_str, kind, accessible, reason)) + end + end + end + current = ast.parent() + | None => break + end + end + + ScopeResult(consume entries, in_recover) + + fun _classify(def: AST box): String val => + match def.id() + | TokenIds.tk_let() | TokenIds.tk_var() => "local" + | TokenIds.tk_flet() | TokenIds.tk_fvar() + | TokenIds.tk_embed() => "field" + | TokenIds.tk_param() => "parameter" + | TokenIds.tk_class() | TokenIds.tk_actor() + | TokenIds.tk_primitive() | TokenIds.tk_struct() + | TokenIds.tk_trait() | TokenIds.tk_interface() + | TokenIds.tk_type() => "type" + | TokenIds.tk_fun() | TokenIds.tk_be() + | TokenIds.tk_new() => "method" + | TokenIds.tk_typeparam() => "typeparam" + else + "other" + end + + fun _type_string_for(def: AST box): String val => + match def.id() + | TokenIds.tk_let() | TokenIds.tk_var() => + match def.ast_type_string() + | let s: String val => s + else "" + end + | TokenIds.tk_flet() | TokenIds.tk_fvar() | TokenIds.tk_embed() => + try + match def(1)?.type_string() + | let s: String val => s + else "" + end + else + "" + end + | TokenIds.tk_param() => + try + match def(1)?.type_string() + | let s: String val => s + else "" + end + else + "" + end + else + "" + end + + fun _accessibility( + session: CompileSession box, + def: AST box, + kind: String val, + in_recover: Bool, + recover_node: (AST box | None)) + : (Bool, (String val | None)) + => + if not in_recover then return (true, None) end + + if kind == "field" then + return (false, "this is tag in recover block") + end + + // Only value bindings need the sendability check + if (kind != "local") and (kind != "parameter") then + return (true, None) + end + + match recover_node + | let rn: AST box => + if ASTHelpers.is_inside(def, rn) then + return (true, None) + end + end + + match def.ast_type() + | let type_ast: AST box => + if session.is_sendable(type_ast) then + (true, None) + else + let cap = + _trailing_cap( + match def.ast_type_string() + | let s: String val => s + else "" + end) + if cap.size() > 0 then + (false, cap + " not accessible in recover block") + else + (false, "not accessible in recover block") + end + end + else + (true, None) + end + + fun _trailing_cap(type_str: String val): String val => + var s = type_str + while s.size() > 0 do + try + let last = s(s.size() - 1)? + if (last == '^') or (last == '!') then + s = s.trim(0, s.size() - 1) + else + break + end + else + break + end + end + try + let idx = s.rfind(" ")? + let cap = s.trim(idx.usize() + 1) + try + let first = cap(0)? + if (first >= 'a') and (first <= 'z') then cap + else "" + end + else "" + end + else + "" + end diff --git a/tools/pony-agent-server/test/fixture/main.pony b/tools/pony-agent-server/test/fixture/main.pony new file mode 100644 index 0000000000..c990c0c046 --- /dev/null +++ b/tools/pony-agent-server/test/fixture/main.pony @@ -0,0 +1,226 @@ +trait Describable + fun describe(): String + +interface Countable + fun count(): USize + +class Collector is Describable + let _name: String val + var _items: Array[String] ref + + new create(name: String val) => + _name = name + _items = Array[String] + + fun ref add(item: String) => + _items.push(item) + + fun box size(): USize => + _items.size() + + fun describe(): String => + _name + ": " + _items.size().string() + " items" + +class IsoHolder + var _data: Array[U8] iso + + new create() => + _data = recover iso Array[U8] end + + fun ref set_data(data: Array[U8] iso) => + _data = consume data + +primitive Helpers + fun make_val_string(): String val => + recover val + let s = String + s.append("hello") + s + end + + fun do_recover(): String iso^ => + recover iso + let x: USize = 42 + x.string() + end + +actor Worker + let _name: String val + var _count: USize + + new create(name: String val) => + _name = name + _count = 0 + + be work(item: String val) => + _count = _count + 1 + +class GenericBox[A: Any val] + let _value: A + + new create(value: A) => + _value = value + + fun get(): A => + _value + +actor Main + new create(env: Env) => + let collector = Collector("test") + collector.add("one") + let s = collector.size() + let desc = collector.describe() + + let worker = Worker("w1") + worker.work("task") + + let box_str = GenericBox[String]("hello") + + let helpers_result = Helpers.make_val_string() + let iso_string = Helpers.do_recover() + +class CapMethods + var _data: USize + + new create() => + _data = 0 + + new from_value(v: USize) => + _data = v + + fun box read_only(): USize => + _data + + fun ref mutate(v: USize) => + _data = v + + fun val immutable_op(): String => + _data.string() + + fun tag identity(): None => + None + + fun box partial_lookup(key: USize): USize ? => + if key == 0 then error end + _data + key + + fun box with_default(a: USize, b: USize = 10): USize => + a + b + _data + +trait Steppable + fun step(): USize + +class SteppableCollector is (Describable & Steppable) + let _name: String val + var _pos: USize + + new create(name: String val) => + _name = name + _pos = 0 + + fun describe(): String => + _name + + fun step(): USize => + _pos + +trait Greetable + fun greet(): String => "hello" + fun farewell(): String => "goodbye" + +class GreetableUser is Greetable + new create() => None + + fun greet(): String => "hi" + // farewell() is NOT overridden — inherited from Greetable + +class _PrivateHelper + new create() => None + fun helper(): String => "private" + +type StringOrNone is (String | None) + +class DocumentedType + """ + A type with a docstring for testing exports. + """ + new create() => None + +struct ExportStruct + new create() => None + +class ScopeFixture + let _val_field: String val + var _count: USize + + new create() => + _val_field = "field" + _count = 0 + + fun ref scope_test(): String iso^ => + let outer_val: String val = "hello" + let outer_ref: String ref = "world".clone() + recover iso + let inner_local: USize = 42 + inner_local.string() + end + +primitive UnionProducer + fun get_value(flag: Bool): (Collector | SteppableCollector) => + if flag then Collector("union") else SteppableCollector("union") end + +class TypeApiFixture + fun ref use_types(env: Env) => + let union_val = UnionProducer.get_value(true) + union_val.describe() + let c: CapMethods val = CapMethods.from_value(42) + env.out.print(c.immutable_op()) + let divergent = DivergentProducer.get(true) + divergent.shared() + let vp_holder = ViewpointHolder[String]("test") + let vp_result = vp_holder.get() + +class CountableItem is Countable + var _n: USize + + new create(n: USize) => + _n = n + + fun count(): USize => + _n + +class StructuralCountable + var _n: USize + + new create(n: USize) => + _n = n + + fun count(): USize => + _n + +primitive StructuralPrimitive + fun count(): USize => + 99 + +class DivergentA + new create() => None + fun box shared(): USize => 0 + fun box info(): String => "a" + +class DivergentB + new create() => None + fun box shared(): USize => 0 + fun box info(): USize => 0 + +primitive DivergentProducer + fun get(flag: Bool): (DivergentA | DivergentB) => + if flag then DivergentA else DivergentB end + +class ViewpointHolder[A] + let _data: A + + new create(data: A) => + _data = consume data + + fun get(): this->A => + _data diff --git a/tools/pony-agent-server/test/main.pony b/tools/pony-agent-server/test/main.pony new file mode 100644 index 0000000000..ebb8c79ee7 --- /dev/null +++ b/tools/pony-agent-server/test/main.pony @@ -0,0 +1,1938 @@ +use "pony_test" +use "files" +use "pony_compiler" +use ".." + +actor \nodoc\ Main is TestList + new create(env: Env) => + PonyTest(env, this) + + new make() => + None + + fun tag tests(test: PonyTest) => + test(_InspectTypeTest) + test(_InspectDefinitionTest) + test(_InspectEnclosingTest) + test(_InspectRecoverTest) + test(_InspectSendableTest) + test(_InspectErrorTest) + test(_ScopeBasicTest) + test(_ScopeRecoverTest) + test(_ScopeErrorTest) + test(_ExportsBasicTest) + test(_ExportsPrivateFilterTest) + test(_ExportsDocstringTest) + test(_ExportsBuiltinTest) + test(_ExportsErrorTest) + test(_CapSubtypingBasicTest) + test(_CapSubtypingEphemeralTest) + test(_CapSubtypingInvalidTest) + test(_MethodEnumeratorOwnTest) + test(_MethodEnumeratorInheritedTest) + test(_MethodEnumeratorParamsTest) + test(_MethodEnumeratorCallableAtTest) + test(_TypeApiFromTypeTest) + test(_TypeApiFromPositionTest) + test(_TypeApiConstructorsTest) + test(_TypeApiTraitsTest) + test(_TypeApiUnionTest) + test(_TypeApiUnionDivergentTest) + test(_TypeApiViewpointTest) + test(_TypeApiEphemeralTest) + test(_TypeApiGenericTest) + test(_TypeApiErrorTest) + test(_CheckCompatibleTest) + test(_CheckIncompatibleTest) + test(_CheckCapMismatchTest) + test(_CheckSuggestionsTest) + test(_CheckErrorTest) + test(_ImplementorsNominalTest) + test(_ImplementorsStructuralTest) + test(_ImplementorsIntersectionProvidesTest) + test(_ImplementorsPrivateFilterTest) + test(_ImplementorsErrorTest) + +class \nodoc\ iso _InspectTypeTest is UnitTest + fun name(): String => "inspect/type" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // collector at line 70: collector.add("one") + match InspectQuery(session, fixture_file, 70, 5) + | let r: InspectResult val => + h.assert_eq[String val]("Collector ref", r.type_string) + | let err: String val => + h.fail("inspect at 70:5 failed: " + err) + end + + // worker at line 74: let worker = Worker("w1") + match InspectQuery(session, fixture_file, 74, 5) + | let r: InspectResult val => + h.assert_eq[String val]("Worker tag", r.type_string) + | let err: String val => + h.fail("inspect at 74:5 failed: " + err) + end + + // box_str at line 77: let box_str = GenericBox[String]("hello") + match InspectQuery(session, fixture_file, 77, 5) + | let r: InspectResult val => + h.assert_eq[String val]( + "GenericBox[String val] ref", r.type_string) + | let err: String val => + h.fail("inspect at 77:5 failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _InspectDefinitionTest is UnitTest + fun name(): String => "inspect/definition" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + match InspectQuery(session, fixture_file, 70, 5) + | let r: InspectResult val => + match r.definition_kind + | let k: String val => + h.assert_eq[String val]("TK_LET", k) + | None => + h.fail("expected definition_kind 'tk_let', got None") + end + | let err: String val => + h.fail("inspect at 70:5 failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _InspectEnclosingTest is UnitTest + fun name(): String => "inspect/enclosing" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // _items.push(item) inside Collector.add at line 16 + match InspectQuery(session, fixture_file, 16, 5) + | let r: InspectResult val => + match r.enclosing_method + | let m: String val => + h.assert_eq[String val]("add", m) + | None => + h.fail("expected enclosing method 'add', got None") + end + + match r.enclosing_type + | let t: String val => + h.assert_eq[String val]("Collector", t) + | None => + h.fail("expected enclosing type 'Collector', got None") + end + + match r.enclosing_receiver_cap + | let c: String val => + h.assert_eq[String val]("ref", c) + | None => + h.fail("expected enclosing receiver cap 'ref', got None") + end + | let err: String val => + h.fail("inspect at 16:5 failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _InspectRecoverTest is UnitTest + fun name(): String => "inspect/recover" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // Inside Helpers.make_val_string recover block, line 36 + match InspectQuery(session, fixture_file, 36, 7) + | let r: InspectResult val => + h.assert_true( + r.in_recover, + "expected in_recover=true inside recover block") + | let err: String val => + h.fail("inspect at 36:7 failed: " + err) + end + + // Outside recover, line 70 (Main.create body) + match InspectQuery(session, fixture_file, 70, 5) + | let r: InspectResult val => + h.assert_false( + r.in_recover, + "expected in_recover=false outside recover block") + | let err: String val => + h.fail("inspect at 70:5 failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _InspectSendableTest is UnitTest + fun name(): String => "inspect/sendable" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // helpers_result = Helpers.make_val_string() — String val, sendable + match InspectQuery(session, fixture_file, 79, 5) + | let r: InspectResult val => + h.assert_true( + r.sendable, + "expected sendable=true for val type, got type: " + r.type_string) + | let err: String val => + h.fail("inspect at 79:5 failed: " + err) + end + + // collector is Collector ref — not sendable + match InspectQuery(session, fixture_file, 70, 5) + | let r: InspectResult val => + h.assert_false( + r.sendable, + "expected sendable=false for ref type, got type: " + r.type_string) + | let err: String val => + h.fail("inspect at 70:5 failed: " + err) + end + + // worker is Worker tag — sendable + match InspectQuery(session, fixture_file, 74, 5) + | let r: InspectResult val => + h.assert_true( + r.sendable, + "expected sendable=true for tag type, got type: " + r.type_string) + | let err: String val => + h.fail("inspect at 74:5 failed: " + err) + end + + // iso_string is String iso — sendable + match InspectQuery(session, fixture_file, 80, 5) + | let r: InspectResult val => + h.assert_true( + r.sendable, + "expected sendable=true for iso type, got type: " + r.type_string) + | let err: String val => + h.fail("inspect at 80:5 failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _InspectErrorTest is UnitTest + fun name(): String => "inspect/error" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + match InspectQuery(session, "/nonexistent.pony", 1, 1) + | let _: InspectResult val => + h.fail("expected error for nonexistent file, got result") + | let err: String val => + h.assert_true( + err.contains("not found"), + "error should mention 'not found': " + err) + end + + let fixture_file = _FixtureHelper.fixture_path(h) + match InspectQuery(session, fixture_file, 9999, 1) + | let _: InspectResult val => + h.fail("expected error for invalid position, got result") + | let err: String val => + h.assert_true( + err.contains("no AST node"), + "error should mention 'no AST node': " + err) + end + + session.dispose() + +class \nodoc\ iso _ScopeBasicTest is UnitTest + fun name(): String => "scope/basic" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // collector.add("one") at line 70 inside Main.create + match ScopeQuery(session, fixture_file, 70, 5) + | let result: ScopeResult val => + h.assert_false( + result.in_recover, + "expected in_recover=false in Main.create") + + match _FixtureHelper.find_scope_entry(result, "collector") + | let e: ScopeEntry val => + h.assert_eq[String val]("local", e.kind) + h.assert_eq[String val]("Collector ref", e.type_string) + h.assert_true(e.accessible, "collector should be accessible") + | None => + h.fail("expected 'collector' in scope") + end + + match _FixtureHelper.find_scope_entry(result, "env") + | let e: ScopeEntry val => + h.assert_eq[String val]("parameter", e.kind) + h.assert_eq[String val]("Env val", e.type_string) + h.assert_true(e.accessible, "env should be accessible") + | None => + h.fail("expected 'env' in scope") + end + + match _FixtureHelper.find_scope_entry(result, "create") + | let e: ScopeEntry val => + h.assert_eq[String val]("method", e.kind) + | None => + h.fail("expected 'create' in scope") + end + + match _FixtureHelper.find_scope_entry(result, "Collector") + | let e: ScopeEntry val => + h.assert_eq[String val]("type", e.kind) + | None => + h.fail("expected 'Collector' in scope") + end + | let err: String val => + h.fail("scope at 70:5 failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ScopeRecoverTest is UnitTest + fun name(): String => "scope/recover" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // inner_local.string() at line 164 inside ScopeFixture.scope_test + // recover block + match ScopeQuery(session, fixture_file, 164, 7) + | let result: ScopeResult val => + h.assert_true( + result.in_recover, + "expected in_recover=true inside recover block") + + // inner_local is defined inside the recover — accessible + match _FixtureHelper.find_scope_entry(result, "inner_local") + | let e: ScopeEntry val => + h.assert_eq[String val]("local", e.kind) + h.assert_true( + e.accessible, + "inner_local should be accessible inside recover") + | None => + h.fail("expected 'inner_local' in scope") + end + + // outer_val is String val — sendable, so accessible + match _FixtureHelper.find_scope_entry(result, "outer_val") + | let e: ScopeEntry val => + h.assert_eq[String val]("local", e.kind) + h.assert_eq[String val]("String val", e.type_string) + h.assert_true( + e.accessible, + "outer_val (val) should be accessible in recover") + | None => + h.fail("expected 'outer_val' in scope") + end + + // outer_ref is String ref — not sendable, so inaccessible + match _FixtureHelper.find_scope_entry(result, "outer_ref") + | let e: ScopeEntry val => + h.assert_eq[String val]("local", e.kind) + h.assert_eq[String val]("String ref", e.type_string) + h.assert_false( + e.accessible, + "outer_ref (ref) should not be accessible in recover") + match e.reason + | let r: String val => + h.assert_true( + r.contains("ref not accessible"), + "reason should explain inaccessibility: " + r) + | None => + h.fail("expected a reason for inaccessible outer_ref") + end + | None => + h.fail("expected 'outer_ref' in scope") + end + + // _val_field is a field — inaccessible because this is tag + match _FixtureHelper.find_scope_entry(result, "_val_field") + | let e: ScopeEntry val => + h.assert_eq[String val]("field", e.kind) + h.assert_false( + e.accessible, + "_val_field should not be accessible in recover") + match e.reason + | let r: String val => + h.assert_true( + r.contains("tag"), + "field reason should mention tag: " + r) + | None => + h.fail("expected a reason for inaccessible _val_field") + end + | None => + h.fail("expected '_val_field' in scope") + end + | let err: String val => + h.fail("scope at 164:7 failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ScopeErrorTest is UnitTest + fun name(): String => "scope/error" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + match ScopeQuery(session, "/nonexistent.pony", 1, 1) + | let _: ScopeResult val => + h.fail("expected error for nonexistent file, got result") + | let err: String val => + h.assert_true( + err.contains("not found"), + "error should mention 'not found': " + err) + end + + let fixture_file = _FixtureHelper.fixture_path(h) + match ScopeQuery(session, fixture_file, 9999, 1) + | let _: ScopeResult val => + h.fail("expected error for invalid position, got result") + | let err: String val => + h.assert_true( + err.contains("no AST node"), + "error should mention 'no AST node': " + err) + end + + session.dispose() + +class \nodoc\ iso _ExportsBasicTest is UnitTest + fun name(): String => "exports/basic" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + let fixture_dir = + Path.dir(Path.join(Path.dir(__loc.file()), "fixture/main.pony")) + match ExportsQuery(session, fixture_dir) + | let result: ExportsResult val => + h.assert_true( + result.entries.size() > 0, + "expected at least one export") + h.assert_true( + result.package_path.size() > 0, + "expected non-empty package_path") + + match _FixtureHelper.find_export(result, "Collector") + | let e: ExportEntry val => + h.assert_eq[String val]("class", e.kind) + | None => + h.fail("expected 'Collector' in exports") + end + + match _FixtureHelper.find_export(result, "Worker") + | let e: ExportEntry val => + h.assert_eq[String val]("actor", e.kind) + | None => + h.fail("expected 'Worker' in exports") + end + + match _FixtureHelper.find_export(result, "Helpers") + | let e: ExportEntry val => + h.assert_eq[String val]("primitive", e.kind) + | None => + h.fail("expected 'Helpers' in exports") + end + + match _FixtureHelper.find_export(result, "Describable") + | let e: ExportEntry val => + h.assert_eq[String val]("trait", e.kind) + | None => + h.fail("expected 'Describable' in exports") + end + + match _FixtureHelper.find_export(result, "Countable") + | let e: ExportEntry val => + h.assert_eq[String val]("interface", e.kind) + | None => + h.fail("expected 'Countable' in exports") + end + + match _FixtureHelper.find_export(result, "StringOrNone") + | let e: ExportEntry val => + h.assert_eq[String val]("type_alias", e.kind) + | None => + h.fail("expected 'StringOrNone' in exports") + end + + match _FixtureHelper.find_export(result, "ExportStruct") + | let e: ExportEntry val => + h.assert_eq[String val]("struct", e.kind) + | None => + h.fail("expected 'ExportStruct' in exports") + end + + // Verify sorted order + var prev_name: String val = "" + for entry in result.entries.values() do + h.assert_true( + entry.name >= prev_name, + "entries not sorted: '" + prev_name + + "' before '" + entry.name + "'") + prev_name = entry.name + end + | let err: String val => + h.fail("exports query failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ExportsPrivateFilterTest is UnitTest + fun name(): String => "exports/private_filter" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + let fixture_dir = + Path.dir(Path.join(Path.dir(__loc.file()), "fixture/main.pony")) + match ExportsQuery(session, fixture_dir) + | let result: ExportsResult val => + match _FixtureHelper.find_export(result, "_PrivateHelper") + | let _: ExportEntry val => + h.fail("private type '_PrivateHelper' should not appear in exports") + | None => + h.assert_true(true) + end + | let err: String val => + h.fail("exports query failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ExportsDocstringTest is UnitTest + fun name(): String => "exports/docstring" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + let fixture_dir = + Path.dir(Path.join(Path.dir(__loc.file()), "fixture/main.pony")) + match ExportsQuery(session, fixture_dir) + | let result: ExportsResult val => + match _FixtureHelper.find_export(result, "DocumentedType") + | let e: ExportEntry val => + match e.docstring + | let d: String val => + h.assert_true( + d.contains("docstring for testing"), + "docstring should contain expected text: " + d) + | None => + h.fail("expected docstring for DocumentedType") + end + | None => + h.fail("expected 'DocumentedType' in exports") + end + + // Types without docstrings should have None + match _FixtureHelper.find_export(result, "Collector") + | let e: ExportEntry val => + match e.docstring + | let _: String val => + h.fail("Collector should have no docstring") + | None => + h.assert_true(true) + end + | None => + h.fail("expected 'Collector' in exports") + end + | let err: String val => + h.fail("exports query failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ExportsBuiltinTest is UnitTest + fun name(): String => "exports/builtin" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + match ExportsQuery(session, "builtin") + | let result: ExportsResult val => + h.assert_true( + result.entries.size() > 0, + "builtin should have exports") + + match _FixtureHelper.find_export(result, "String") + | let e: ExportEntry val => + h.assert_eq[String val]("class", e.kind) + | None => + h.fail("expected 'String' in builtin exports") + end + + match _FixtureHelper.find_export(result, "None") + | let e: ExportEntry val => + h.assert_eq[String val]("primitive", e.kind) + | None => + h.fail("expected 'None' in builtin exports") + end + | let err: String val => + h.fail("exports for 'builtin' failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ExportsErrorTest is UnitTest + fun name(): String => "exports/error" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + match ExportsQuery(session, "nonexistent_package_xyz") + | let _: ExportsResult val => + h.fail("expected error for nonexistent package, got result") + | let err: String val => + h.assert_true( + err.contains("not found"), + "error should mention 'not found': " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiFromTypeTest is UnitTest + fun name(): String => "type_api/from_type" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // CapMethods at val: box/val/tag callable, ref not callable + match TypeAPIQuery.from_type(session, "CapMethods", "val") + | let result: TypeAPIResult val => + h.assert_eq[String val]("class", result.type_kind) + h.assert_eq[String val]("CapMethods", result.type_name) + + // read_only (box), immutable_op (val), identity (tag), + // partial_lookup (box), with_default (box) = 5 callable + h.assert_eq[USize](5, result.callable.size()) + h.assert_true( + _FixtureHelper.has_method(result.callable, "read_only"), + "read_only (box) should be callable at val") + h.assert_true( + _FixtureHelper.has_method(result.callable, "immutable_op"), + "immutable_op (val) should be callable at val") + h.assert_true( + _FixtureHelper.has_method(result.callable, "identity"), + "identity (tag) should be callable at val") + + // mutate (ref) not callable + h.assert_eq[USize](1, result.not_callable.size()) + h.assert_true( + _FixtureHelper.has_not_callable(result.not_callable, "mutate"), + "mutate (ref) should not be callable at val") + | let err: String val => + h.fail("type_api from_type failed: " + err) + end + + // CapMethods at ref: ref/box/tag callable, val not callable + match TypeAPIQuery.from_type(session, "CapMethods", "ref") + | let result: TypeAPIResult val => + h.assert_eq[USize](5, result.callable.size()) + h.assert_true( + _FixtureHelper.has_method(result.callable, "mutate"), + "mutate (ref) should be callable at ref") + h.assert_true( + _FixtureHelper.has_method(result.callable, "read_only"), + "read_only (box) should be callable at ref") + + h.assert_eq[USize](1, result.not_callable.size()) + h.assert_true( + _FixtureHelper.has_not_callable(result.not_callable, "immutable_op"), + "immutable_op (val) should not be callable at ref") + | let err: String val => + h.fail("type_api from_type at ref failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiFromPositionTest is UnitTest + fun name(): String => "type_api/from_position" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // collector at line 70 is Collector ref + match TypeAPIQuery.from_position(session, fixture_file, 70, 5) + | let result: TypeAPIResult val => + h.assert_eq[String val]("class", result.type_kind) + h.assert_true( + result.type_name.contains("Collector"), + "type_name should contain Collector: " + result.type_name) + + // At ref: add (ref), size (box), describe (box) all callable + h.assert_eq[USize](3, result.callable.size()) + h.assert_eq[USize](0, result.not_callable.size()) + h.assert_true( + _FixtureHelper.has_method(result.callable, "add"), + "add should be callable at ref") + h.assert_true( + _FixtureHelper.has_method(result.callable, "size"), + "size should be callable at ref") + h.assert_true( + _FixtureHelper.has_method(result.callable, "describe"), + "describe should be callable at ref") + | let err: String val => + h.fail("type_api from_position failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiConstructorsTest is UnitTest + fun name(): String => "type_api/constructors" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + match TypeAPIQuery.from_type(session, "CapMethods", "val") + | let result: TypeAPIResult val => + h.assert_eq[USize](2, result.constructors.size()) + + var has_create = false + var has_from_value = false + for c in result.constructors.values() do + h.assert_eq[String val]("new", c.kind) + if c.name == "create" then has_create = true end + if c.name == "from_value" then has_from_value = true end + end + h.assert_true(has_create, "expected 'create' constructor") + h.assert_true(has_from_value, "expected 'from_value' constructor") + | let err: String val => + h.fail("type_api constructors failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiTraitsTest is UnitTest + fun name(): String => "type_api/traits" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Collector is Describable — single trait + match TypeAPIQuery.from_type(session, "Collector", "ref") + | let result: TypeAPIResult val => + h.assert_eq[USize](1, result.traits.size()) + try + h.assert_true( + result.traits(0)?.contains("Describable"), + "trait should contain Describable: " + result.traits(0)?) + else + h.fail("error accessing trait") + end + | let err: String val => + h.fail("type_api traits for Collector failed: " + err) + end + + // SteppableCollector is (Describable & Steppable) — two traits + match TypeAPIQuery.from_type(session, "SteppableCollector", "ref") + | let result: TypeAPIResult val => + h.assert_eq[USize](2, result.traits.size()) + var has_describable = false + var has_steppable = false + for t in result.traits.values() do + if t.contains("Describable") then has_describable = true end + if t.contains("Steppable") then has_steppable = true end + end + h.assert_true(has_describable, + "expected Describable in traits") + h.assert_true(has_steppable, + "expected Steppable in traits") + | let err: String val => + h.fail("type_api traits for SteppableCollector failed: " + err) + end + + // Worker (actor) — no provides list + match TypeAPIQuery.from_type(session, "Worker", "tag") + | let result: TypeAPIResult val => + h.assert_eq[String val]("actor", result.type_kind) + h.assert_eq[USize](0, result.traits.size()) + | let err: String val => + h.fail("type_api traits for Worker failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiUnionTest is UnitTest + fun name(): String => "type_api/union" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // union_val.describe() at line 175 — union_val is + // (Collector ref | SteppableCollector ref) + match TypeAPIQuery.from_position(session, fixture_file, 175, 5) + | let result: TypeAPIResult val => + h.assert_eq[String val]("union", result.type_kind) + + // Both Collector and SteppableCollector have describe() + // with matching signatures — full signature preserved + h.assert_eq[USize](1, result.callable.size()) + try + let m = result.callable(0)? + h.assert_eq[String val]("describe", m.name) + h.assert_eq[String val]("fun", m.kind) + h.assert_eq[String val]("box", m.receiver_cap) + h.assert_true( + m.return_type.contains("String"), + "return type should contain String: " + m.return_type) + h.assert_eq[USize](0, m.params.size()) + h.assert_true( + m.source_type.contains(" | "), + "union method source_type should be the union type") + else + h.fail("expected one callable method in union intersection") + end + + h.assert_false( + _FixtureHelper.has_method(result.callable, "add"), + "add should not be in union intersection") + h.assert_false( + _FixtureHelper.has_method(result.callable, "size"), + "size should not be in union intersection") + h.assert_false( + _FixtureHelper.has_method(result.callable, "step"), + "step should not be in union intersection") + + h.assert_eq[USize](0, result.not_callable.size()) + h.assert_eq[USize](0, result.constructors.size()) + h.assert_eq[USize](0, result.traits.size()) + | let err: String val => + h.fail("type_api union failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiUnionDivergentTest is UnitTest + fun name(): String => "type_api/union_divergent" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // divergent at line 178 is (DivergentA ref | DivergentB ref) + match TypeAPIQuery.from_position(session, fixture_file, 178, 5) + | let result: TypeAPIResult val => + h.assert_eq[String val]("union", result.type_kind) + + // shared() has matching signatures across branches + match _FixtureHelper.find_method(result.callable, "shared") + | let m: MethodInfo val => + h.assert_eq[String val]("box", m.receiver_cap) + h.assert_true( + m.return_type.contains("USize"), + "shared return type should contain USize: " + + m.return_type) + h.assert_eq[USize](0, m.params.size()) + | None => + h.fail("expected 'shared' in union callable") + end + + // info() has divergent signatures (String vs USize return) + match _FixtureHelper.find_method(result.callable, "info") + | let m: MethodInfo val => + h.assert_eq[String val]("", m.receiver_cap) + h.assert_eq[String val]("", m.return_type) + h.assert_eq[USize](0, m.params.size()) + | None => + h.fail("expected 'info' in union callable") + end + | let err: String val => + h.fail("type_api union divergent failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiViewpointTest is UnitTest + fun name(): String => "type_api/viewpoint" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // from_type with a viewpoint-prefixed name strips the prefix + match TypeAPIQuery.from_type(session, "this->Collector", "ref") + | let result: TypeAPIResult val => + h.assert_eq[String val]("class", result.type_kind) + h.assert_true( + _FixtureHelper.has_method(result.callable, "add"), + "Collector should have add method after viewpoint strip") + | let err: String val => + h.fail("type_api viewpoint from_type failed: " + err) + end + + // Also test stripping a type parameter viewpoint prefix + match TypeAPIQuery.from_type(session, "A->Collector", "ref") + | let result: TypeAPIResult val => + h.assert_eq[String val]("class", result.type_kind) + h.assert_true( + _FixtureHelper.has_method(result.callable, "add"), + "Collector should have add after A-> strip") + | let err: String val => + h.fail("type_api A-> viewpoint from_type failed: " + err) + end + + // from_position on vp_result (line 181) exercises the + // tk_arrow handler if the AST produces a viewpoint type + let fixture_file = _FixtureHelper.fixture_path(h) + match TypeAPIQuery.from_position( + session, fixture_file, 181, 5) + | let result: TypeAPIResult val => + h.assert_true( + result.type_name.contains("String"), + "viewpoint result should resolve to String: " + + result.type_name) + | let err: String val => + h.fail("type_api viewpoint from_position failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiEphemeralTest is UnitTest + fun name(): String => "type_api/ephemeral" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + let fixture_file = _FixtureHelper.fixture_path(h) + + // iso_string at line 80 has type String iso + match TypeAPIQuery.from_position(session, fixture_file, 80, 5) + | let result: TypeAPIResult val => + h.assert_eq[String val]("class", result.type_kind) + h.assert_true( + result.type_name.contains("String"), + "ephemeral result should resolve to String: " + + result.type_name) + // At iso cap, only tag methods are callable (non-ephemeral) + // but auto-recovery makes ref methods callable too when + // args are sendable — the callable list should be non-empty + h.assert_true( + result.callable.size() > 0, + "String iso should have callable methods") + | let err: String val => + h.fail("type_api ephemeral from_position failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiGenericTest is UnitTest + fun name(): String => "type_api/generic" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + match TypeAPIQuery.from_type(session, "GenericBox", "ref") + | let result: TypeAPIResult val => + h.assert_eq[String val]("class", result.type_kind) + h.assert_true( + _FixtureHelper.has_method(result.callable, "get"), + "GenericBox should have get method") + | let err: String val => + h.fail("type_api for GenericBox failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _TypeApiErrorTest is UnitTest + fun name(): String => "type_api/error" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Unknown type + match TypeAPIQuery.from_type(session, "NonExistentType", "ref") + | let _: TypeAPIResult val => + h.fail("expected error for unknown type, got result") + | let err: String val => + h.assert_true( + err.contains("not found"), + "error should mention 'not found': " + err) + end + + // Invalid position + let fixture_file = _FixtureHelper.fixture_path(h) + match TypeAPIQuery.from_position(session, fixture_file, 9999, 1) + | let _: TypeAPIResult val => + h.fail("expected error for invalid position, got result") + | let err: String val => + h.assert_true( + err.contains("no AST node"), + "error should mention 'no AST node': " + err) + end + + // Invalid capability + match TypeAPIQuery.from_type(session, "CapMethods", "bogus") + | let _: TypeAPIResult val => + h.fail("expected error for invalid cap, got result") + | let err: String val => + h.assert_true( + err.contains("invalid capability"), + "error should mention invalid capability: " + err) + end + + session.dispose() + +class \nodoc\ iso _CheckCompatibleTest is UnitTest + fun name(): String => "check/compatible" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Nominal subtyping: Collector ref is Describable ref + match CheckQuery(session, "Collector ref", "Describable ref") + | let r: CheckResult val => + h.assert_true(r.compatible, + "Collector ref should be subtype of Describable ref") + h.assert_eq[String val]("Collector ref", r.source_type) + h.assert_eq[String val]("Describable ref", r.target_type) + | let err: String val => + h.fail("check Collector/Describable failed: " + err) + end + + // Structural subtyping: StructuralCountable ref is Countable ref + match CheckQuery(session, "StructuralCountable ref", "Countable ref") + | let r: CheckResult val => + h.assert_true(r.compatible, + "StructuralCountable ref should satisfy Countable ref") + | let err: String val => + h.fail("check StructuralCountable/Countable failed: " + err) + end + + // Self-subtyping: String val is String val + match CheckQuery(session, "String val", "String val") + | let r: CheckResult val => + h.assert_true(r.compatible, + "String val should be subtype of String val") + | let err: String val => + h.fail("check String val/String val failed: " + err) + end + + // Default cap: Collector (defaults to ref) is Describable (defaults to ref) + match CheckQuery(session, "Collector", "Describable") + | let r: CheckResult val => + h.assert_true(r.compatible, + "Collector default cap should be subtype of Describable default cap") + h.assert_true( + r.source_type.contains("ref"), + "source should show default cap ref: " + r.source_type) + | let err: String val => + h.fail("check Collector/Describable default cap failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _CheckIncompatibleTest is UnitTest + fun name(): String => "check/incompatible" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Collector does not implement Countable + match CheckQuery(session, "Collector ref", "Countable ref") + | let r: CheckResult val => + h.assert_false(r.compatible, + "Collector ref should not be subtype of Countable ref") + h.assert_true( + r.reason.size() > 0, + "reason should be non-empty for incompatible types") + | let err: String val => + h.fail("check Collector/Countable failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _CheckCapMismatchTest is UnitTest + fun name(): String => "check/cap_mismatch" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // val is not a subtype of ref + match CheckQuery(session, "String val", "String ref") + | let r: CheckResult val => + h.assert_false(r.compatible, + "String val should not be subtype of String ref") + | let err: String val => + h.fail("check String val/ref failed: " + err) + end + + // ref is a subtype of box + match CheckQuery(session, "CapMethods ref", "CapMethods box") + | let r: CheckResult val => + h.assert_true(r.compatible, + "CapMethods ref should be subtype of CapMethods box") + | let err: String val => + h.fail("check CapMethods ref/box failed: " + err) + end + + // box is not a subtype of ref + match CheckQuery(session, "CapMethods box", "CapMethods ref") + | let r: CheckResult val => + h.assert_false(r.compatible, + "CapMethods box should not be subtype of CapMethods ref") + | let err: String val => + h.fail("check CapMethods box/ref failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _CheckSuggestionsTest is UnitTest + fun name(): String => "check/suggestions" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // ref -> val cap mismatch should generate suggestions + match CheckQuery(session, "CapMethods ref", "CapMethods val") + | let r: CheckResult val => + h.assert_false(r.compatible, + "CapMethods ref should not be subtype of CapMethods val") + h.assert_true( + r.suggestions.size() > 0, + "cap mismatch should produce suggestions") + var has_recover = false + var has_consume = false + for s in r.suggestions.values() do + if s.contains("recover") then has_recover = true end + if s.contains("consume") then has_consume = true end + end + h.assert_true(has_recover, + "suggestions should include recover advice") + h.assert_true(has_consume, + "suggestions should include consume advice") + | let err: String val => + h.fail("check suggestions failed: " + err) + end + + // Incompatible types (different types) should get suggestions too + match CheckQuery(session, "Collector ref", "Countable ref") + | let r: CheckResult val => + h.assert_true( + r.suggestions.size() > 0, + "incompatible different types should produce suggestions") + | let err: String val => + h.fail("check different-type suggestions failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _CheckErrorTest is UnitTest + fun name(): String => "check/error" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Unknown source type + match CheckQuery(session, "NonExistentType ref", "String val") + | let _: CheckResult val => + h.fail("expected error for unknown source type") + | let err: String val => + h.assert_true( + err.contains("not found"), + "error should mention 'not found': " + err) + end + + // Unknown target type + match CheckQuery(session, "String val", "NonExistentType ref") + | let _: CheckResult val => + h.fail("expected error for unknown target type") + | let err: String val => + h.assert_true( + err.contains("not found"), + "error should mention 'not found': " + err) + end + + session.dispose() + +class \nodoc\ iso _ImplementorsNominalTest is UnitTest + fun name(): String => "implementors/nominal" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Describable trait: Collector and SteppableCollector + match ImplementorsQuery(session, "Describable") + | let r: ImplementorsResult val => + h.assert_eq[String val]("Describable", r.type_name) + + var has_collector = false + var has_steppable = false + for n in r.nominal.values() do + if n == "Collector" then has_collector = true end + if n == "SteppableCollector" then has_steppable = true end + end + h.assert_true(has_collector, + "Collector should be nominal implementor of Describable") + h.assert_true(has_steppable, + "SteppableCollector should be nominal implementor of Describable") + + // Results should be sorted + var prev: String val = "" + for n in r.nominal.values() do + h.assert_true(n >= prev, + "nominal results not sorted: '" + prev + "' before '" + n + "'") + prev = n + end + | let err: String val => + h.fail("implementors Describable failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ImplementorsStructuralTest is UnitTest + fun name(): String => "implementors/structural" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Countable interface: CountableItem nominal, StructuralCountable structural + match ImplementorsQuery(session, "Countable") + | let r: ImplementorsResult val => + var has_nominal = false + for n in r.nominal.values() do + if n == "CountableItem" then has_nominal = true end + end + h.assert_true(has_nominal, + "CountableItem should be nominal implementor of Countable") + + var has_structural = false + for n in r.structural.values() do + if n == "StructuralCountable" then has_structural = true end + end + h.assert_true(has_structural, + "StructuralCountable should be structural implementor of Countable") + + // Primitives with matching methods are found structurally + var has_primitive = false + for n in r.structural.values() do + if n == "StructuralPrimitive" then has_primitive = true end + end + h.assert_true(has_primitive, + "StructuralPrimitive should be structural implementor of Countable") + + // StructuralCountable should NOT be in nominal + var structural_in_nominal = false + for n in r.nominal.values() do + if n == "StructuralCountable" then + structural_in_nominal = true + end + end + h.assert_false(structural_in_nominal, + "StructuralCountable should not appear in nominal list") + | let err: String val => + h.fail("implementors Countable failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ImplementorsIntersectionProvidesTest is UnitTest + fun name(): String => "implementors/intersection_provides" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Steppable trait: SteppableCollector provides (Describable & Steppable) + match ImplementorsQuery(session, "Steppable") + | let r: ImplementorsResult val => + var has_sc = false + for n in r.nominal.values() do + if n == "SteppableCollector" then has_sc = true end + end + h.assert_true(has_sc, + "SteppableCollector should be found through intersection provides") + | let err: String val => + h.fail("implementors Steppable failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ImplementorsPrivateFilterTest is UnitTest + fun name(): String => "implementors/private_filter" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Greetable trait: GreetableUser nominal, no private types + match ImplementorsQuery(session, "Greetable") + | let r: ImplementorsResult val => + var has_greetable_user = false + for n in r.nominal.values() do + if n == "GreetableUser" then has_greetable_user = true end + h.assert_false( + try n(0)? == '_' else false end, + "private type should not appear: " + n) + end + h.assert_true(has_greetable_user, + "GreetableUser should be implementor of Greetable") + | let err: String val => + h.fail("implementors Greetable failed: " + err) + end + + session.dispose() + +class \nodoc\ iso _ImplementorsErrorTest is UnitTest + fun name(): String => "implementors/error" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Non-trait type + match ImplementorsQuery(session, "Collector") + | let _: ImplementorsResult val => + h.fail("expected error for non-trait type") + | let err: String val => + h.assert_true( + err.contains("not a trait or interface"), + "error should say not a trait or interface: " + err) + end + + // Unknown type + match ImplementorsQuery(session, "NonExistentType") + | let _: ImplementorsResult val => + h.fail("expected error for unknown type") + | let err: String val => + h.assert_true( + err.contains("not found"), + "error should mention 'not found': " + err) + end + + session.dispose() + +primitive \nodoc\ _FixtureHelper + fun compile(h: TestHelper): (CompileSession, Program val) ? => + let fixture_dir = + Path.join(Path.dir(__loc.file()), "fixture") + let pony_path = + match \exhaustive\ PonyPath(h.env) + | let pp: String => pp + | None => + h.fail("PONYPATH not set") + error + end + + let session = CompileSession( + FilePath(FileAuth(h.env.root), fixture_dir), + pony_path + where limit = PassExpr) + + match \exhaustive\ session.program() + | let prog: Program val => + (session, prog) + | None => + let errs = session.errors() + let msg = + try errs(0)?.msg + else "unknown error" + end + h.fail("fixture compilation failed: " + msg) + session.dispose() + error + end + + fun fixture_path(h: TestHelper): String => + Path.join(Path.dir(__loc.file()), "fixture/main.pony") + + fun find_scope_entry( + result: ScopeResult val, + target: String val) + : (ScopeEntry val | None) + => + for entry in result.entries.values() do + if entry.name == target then return entry end + end + + fun find_export( + result: ExportsResult val, + target: String val) + : (ExportEntry val | None) + => + for entry in result.entries.values() do + if entry.name == target then return entry end + end + + fun find_method( + methods: Array[MethodInfo val] val, + target: String val) + : (MethodInfo val | None) + => + for m in methods.values() do + if m.name == target then return m end + end + + fun has_method( + methods: Array[MethodInfo val] val, + target: String val) + : Bool + => + for m in methods.values() do + if m.name == target then return true end + end + false + + fun has_not_callable( + not_callable: Array[(MethodInfo val, String val)] val, + target: String val) + : Bool + => + for (m, _) in not_callable.values() do + if m.name == target then return true end + end + false + + fun find_type_def( + program: Program val, + type_name: String val) + : (AST box | None) + => + for pkg in program.packages() do + for mod in pkg.modules() do + let mod_ast = mod.ast + var child: (AST box | None) = mod_ast.child() + while true do + match child + | let node: AST box => + if TokenIds.is_entity(node.id()) then + try + match node(0)?.token_value() + | let n: String val if n == type_name => return node + end + end + end + child = node.sibling() + | None => break + end + end + end + end + +class \nodoc\ iso _CapSubtypingBasicTest is UnitTest + fun name(): String => "cap_subtyping/basic" + + fun apply(h: TestHelper) => + // ref <: box <: tag + h.assert_true(CapSubtyping.is_sub("ref", "box"), + "ref should be subtype of box") + h.assert_true(CapSubtyping.is_sub("box", "tag"), + "box should be subtype of tag") + h.assert_true(CapSubtyping.is_sub("ref", "tag"), + "ref should be subtype of tag (transitive)") + + // val <: box <: tag + h.assert_true(CapSubtyping.is_sub("val", "box"), + "val should be subtype of box") + h.assert_true(CapSubtyping.is_sub("val", "tag"), + "val should be subtype of tag") + + // iso <: tag (non-ephemeral iso can only become tag) + h.assert_true(CapSubtyping.is_sub("iso", "tag"), + "iso should be subtype of tag") + + // trn <: box (non-ephemeral trn can only become box) + h.assert_true(CapSubtyping.is_sub("trn", "box"), + "trn should be subtype of box") + + // trn <: tag + h.assert_true(CapSubtyping.is_sub("trn", "tag"), + "trn <: tag") + + // Negative cases + h.assert_false(CapSubtyping.is_sub("box", "ref"), + "box should not be subtype of ref") + h.assert_false(CapSubtyping.is_sub("tag", "val"), + "tag should not be subtype of val") + h.assert_false(CapSubtyping.is_sub("val", "ref"), + "val should not be subtype of ref") + h.assert_false(CapSubtyping.is_sub("box", "val"), + "box should not be subtype of val") + h.assert_false(CapSubtyping.is_sub("ref", "val"), + "ref should not be subtype of val") + h.assert_false(CapSubtyping.is_sub("iso", "ref"), + "non-ephemeral iso should not be subtype of ref") + h.assert_false(CapSubtyping.is_sub("trn", "ref"), + "non-ephemeral trn should not be subtype of ref") + + // Reflexive + h.assert_true(CapSubtyping.is_sub("ref", "ref"), "ref <: ref") + h.assert_true(CapSubtyping.is_sub("val", "val"), "val <: val") + h.assert_true(CapSubtyping.is_sub("box", "box"), "box <: box") + h.assert_true(CapSubtyping.is_sub("tag", "tag"), "tag <: tag") + h.assert_true(CapSubtyping.is_sub("iso", "iso"), "iso <: iso") + h.assert_true(CapSubtyping.is_sub("trn", "trn"), "trn <: trn") + +class \nodoc\ iso _CapSubtypingEphemeralTest is UnitTest + fun name(): String => "cap_subtyping/ephemeral" + + fun apply(h: TestHelper) => + // iso^ can become anything + h.assert_true( + CapSubtyping.is_sub_ephemeral("iso", true, "ref", false), + "iso^ should be subtype of ref") + h.assert_true( + CapSubtyping.is_sub_ephemeral("iso", true, "val", false), + "iso^ should be subtype of val") + h.assert_true( + CapSubtyping.is_sub_ephemeral("iso", true, "box", false), + "iso^ should be subtype of box") + h.assert_true( + CapSubtyping.is_sub_ephemeral("iso", true, "trn", false), + "iso^ should be subtype of trn") + + // trn^ can become ref, val, box + h.assert_true( + CapSubtyping.is_sub_ephemeral("trn", true, "ref", false), + "trn^ should be subtype of ref") + h.assert_true( + CapSubtyping.is_sub_ephemeral("trn", true, "val", false), + "trn^ should be subtype of val") + h.assert_true( + CapSubtyping.is_sub_ephemeral("trn", true, "box", false), + "trn^ should be subtype of box") + + // trn^ cannot become iso + h.assert_false( + CapSubtyping.is_sub_ephemeral("trn", true, "iso", false), + "trn^ should not be subtype of iso") + + // Ephemeral on both sides + h.assert_true( + CapSubtyping.is_sub_ephemeral("iso", true, "ref", true), + "iso^ should be subtype of ref^") + h.assert_true( + CapSubtyping.is_sub_ephemeral("ref", false, "box", true), + "ref should be subtype of box^") + +class \nodoc\ iso _CapSubtypingInvalidTest is UnitTest + fun name(): String => "cap_subtyping/invalid" + + fun apply(h: TestHelper) => + h.assert_false(CapSubtyping.is_sub("bogus", "ref"), + "unrecognized cap should return false") + h.assert_false(CapSubtyping.is_sub("ref", "bogus"), + "unrecognized cap should return false") + h.assert_false(CapSubtyping.is_sub("", "ref"), + "empty cap should return false") + +class \nodoc\ iso _MethodEnumeratorOwnTest is UnitTest + fun name(): String => "method_enumerator/own_methods" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + let type_ast = + match _FixtureHelper.find_type_def(prog, "CapMethods") + | let t: AST box => t + | None => h.fail("CapMethods type not found"); session.dispose(); return + end + + let all = MethodEnumerator.methods(type_ast, "CapMethods") + + h.assert_eq[USize](8, all.size()) + + match _FixtureHelper.find_method(all, "read_only") + | let m: MethodInfo val => + h.assert_eq[String val]("box", m.receiver_cap) + h.assert_eq[String val]("fun", m.kind) + h.assert_eq[String val]("CapMethods", m.source_type) + h.assert_false(m.is_partial, "read_only is not partial") + h.assert_true( + m.return_type.contains("USize"), + "read_only return type should contain USize") + | None => h.fail("expected 'read_only' in methods") + end + + match _FixtureHelper.find_method(all, "mutate") + | let m: MethodInfo val => + h.assert_eq[String val]("ref", m.receiver_cap) + h.assert_eq[String val]("fun", m.kind) + | None => h.fail("expected 'mutate' in methods") + end + + match _FixtureHelper.find_method(all, "immutable_op") + | let m: MethodInfo val => + h.assert_eq[String val]("val", m.receiver_cap) + h.assert_true( + m.return_type.contains("String"), + "immutable_op return type should contain String") + | None => h.fail("expected 'immutable_op' in methods") + end + + match _FixtureHelper.find_method(all, "identity") + | let m: MethodInfo val => + h.assert_eq[String val]("tag", m.receiver_cap) + h.assert_true( + m.return_type.contains("None"), + "identity return type should contain None") + | None => h.fail("expected 'identity' in methods") + end + + match _FixtureHelper.find_method(all, "partial_lookup") + | let m: MethodInfo val => + h.assert_true(m.is_partial, "partial_lookup should be partial") + h.assert_eq[String val]("box", m.receiver_cap) + | None => h.fail("expected 'partial_lookup' in methods") + end + + // Constructors + match _FixtureHelper.find_method(all, "create") + | let m: MethodInfo val => + h.assert_eq[String val]("new", m.kind) + | None => h.fail("expected 'create' constructor") + end + + match _FixtureHelper.find_method(all, "from_value") + | let m: MethodInfo val => + h.assert_eq[String val]("new", m.kind) + | None => h.fail("expected 'from_value' constructor") + end + + session.dispose() + +class \nodoc\ iso _MethodEnumeratorInheritedTest is UnitTest + fun name(): String => "method_enumerator/inherited" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + // Collector is Describable — inherits describe() but defines its own + let collector_ast = + match _FixtureHelper.find_type_def(prog, "Collector") + | let t: AST box => t + | None => + h.fail("Collector type not found"); session.dispose(); return + end + + let collector_methods = + MethodEnumerator.methods(collector_ast, "Collector") + + // describe is defined on Collector itself — source_type is Collector + match _FixtureHelper.find_method(collector_methods, "describe") + | let m: MethodInfo val => + h.assert_eq[String val]("Collector", m.source_type) + | None => h.fail("expected 'describe' in Collector methods") + end + + // SteppableCollector is (Describable & Steppable) + let sc_ast = + match _FixtureHelper.find_type_def(prog, "SteppableCollector") + | let t: AST box => t + | None => + h.fail("SteppableCollector not found"); session.dispose(); return + end + + let sc_methods = + MethodEnumerator.methods(sc_ast, "SteppableCollector") + + // describe comes from SteppableCollector itself + match _FixtureHelper.find_method(sc_methods, "describe") + | let m: MethodInfo val => + h.assert_eq[String val]("SteppableCollector", m.source_type) + | None => h.fail("expected 'describe' in SteppableCollector methods") + end + + // step comes from SteppableCollector itself + match _FixtureHelper.find_method(sc_methods, "step") + | let m: MethodInfo val => + h.assert_eq[String val]("SteppableCollector", m.source_type) + | None => h.fail("expected 'step' in SteppableCollector methods") + end + + // GreetableUser overrides greet() but inherits farewell() + let gu_ast = + match _FixtureHelper.find_type_def(prog, "GreetableUser") + | let t: AST box => t + | None => + h.fail("GreetableUser not found"); session.dispose(); return + end + + let gu_methods = + MethodEnumerator.methods(gu_ast, "GreetableUser") + + match _FixtureHelper.find_method(gu_methods, "greet") + | let m: MethodInfo val => + h.assert_eq[String val]("GreetableUser", m.source_type) + | None => h.fail("expected 'greet' in GreetableUser methods") + end + + // Trait default bodies appear as own methods in the AST + match _FixtureHelper.find_method(gu_methods, "farewell") + | let m: MethodInfo val => + h.assert_eq[String val]("GreetableUser", m.source_type) + | None => h.fail("expected 'farewell' in GreetableUser methods") + end + + session.dispose() + +class \nodoc\ iso _MethodEnumeratorParamsTest is UnitTest + fun name(): String => "method_enumerator/params" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + let type_ast = + match _FixtureHelper.find_type_def(prog, "CapMethods") + | let t: AST box => t + | None => h.fail("CapMethods not found"); session.dispose(); return + end + + let all = MethodEnumerator.methods(type_ast, "CapMethods") + + // with_default has two params, second has a default + match _FixtureHelper.find_method(all, "with_default") + | let m: MethodInfo val => + h.assert_eq[USize](2, m.params.size()) + try + let p1 = m.params(0)? + h.assert_eq[String val]("a", p1.name) + h.assert_true( + p1.param_type.contains("USize"), + "first param type should contain USize") + h.assert_false(p1.has_default, "first param has no default") + else + h.fail("error accessing first param") + end + try + let p2 = m.params(1)? + h.assert_eq[String val]("b", p2.name) + h.assert_true( + p2.param_type.contains("USize"), + "second param type should contain USize") + h.assert_true(p2.has_default, "second param has default") + else + h.fail("error accessing second param") + end + | None => h.fail("expected 'with_default' in methods") + end + + // read_only has no params + match _FixtureHelper.find_method(all, "read_only") + | let m: MethodInfo val => + h.assert_eq[USize](0, m.params.size()) + | None => h.fail("expected 'read_only' in methods") + end + + // mutate has one param + match _FixtureHelper.find_method(all, "mutate") + | let m: MethodInfo val => + h.assert_eq[USize](1, m.params.size()) + try + let p = m.params(0)? + h.assert_eq[String val]("v", p.name) + else + h.fail("error accessing mutate param") + end + | None => h.fail("expected 'mutate' in methods") + end + + session.dispose() + +class \nodoc\ iso _MethodEnumeratorCallableAtTest is UnitTest + fun name(): String => "method_enumerator/callable_at" + + fun apply(h: TestHelper) => + (let session, let prog) = + try _FixtureHelper.compile(h)? + else h.fail("fixture compilation failed"); return + end + + let type_ast = + match _FixtureHelper.find_type_def(prog, "CapMethods") + | let t: AST box => t + | None => h.fail("CapMethods not found"); session.dispose(); return + end + + let all = MethodEnumerator.methods(type_ast, "CapMethods") + + // At val: box and tag methods callable, ref not callable + (let val_callable, let val_not) = + MethodEnumerator.callable_at(all, "val") + + h.assert_true( + _FixtureHelper.has_method(val_callable, "read_only"), + "read_only (box) should be callable at val") + h.assert_true( + _FixtureHelper.has_method(val_callable, "identity"), + "identity (tag) should be callable at val") + h.assert_true( + _FixtureHelper.has_method(val_callable, "immutable_op"), + "immutable_op (val) should be callable at val") + h.assert_true( + _FixtureHelper.has_not_callable(val_not, "mutate"), + "mutate (ref) should not be callable at val") + + // At ref: ref, box, tag callable; val not callable + (let ref_callable, let ref_not) = + MethodEnumerator.callable_at(all, "ref") + + h.assert_true( + _FixtureHelper.has_method(ref_callable, "read_only"), + "read_only (box) should be callable at ref") + h.assert_true( + _FixtureHelper.has_method(ref_callable, "mutate"), + "mutate (ref) should be callable at ref") + h.assert_true( + _FixtureHelper.has_method(ref_callable, "identity"), + "identity (tag) should be callable at ref") + h.assert_true( + _FixtureHelper.has_not_callable(ref_not, "immutable_op"), + "immutable_op (val) should not be callable at ref") + + // At tag: only tag methods callable + (let tag_callable, let tag_not) = + MethodEnumerator.callable_at(all, "tag") + + h.assert_true( + _FixtureHelper.has_method(tag_callable, "identity"), + "identity (tag) should be callable at tag") + h.assert_true( + _FixtureHelper.has_not_callable(tag_not, "read_only"), + "read_only (box) should not be callable at tag") + h.assert_true( + _FixtureHelper.has_not_callable(tag_not, "mutate"), + "mutate (ref) should not be callable at tag") + + // Constructors should not appear in callable or not_callable + h.assert_false( + _FixtureHelper.has_method(val_callable, "create"), + "constructors excluded from callable_at") + h.assert_false( + _FixtureHelper.has_not_callable(val_not, "create"), + "constructors excluded from callable_at") + + // Behaviors are always callable regardless of receiver cap + let worker_ast = + match _FixtureHelper.find_type_def(prog, "Worker") + | let t: AST box => t + | None => h.fail("Worker not found"); session.dispose(); return + end + + let worker_all = MethodEnumerator.methods(worker_ast, "Worker") + (let worker_callable, _) = + MethodEnumerator.callable_at(worker_all, "tag") + h.assert_true( + _FixtureHelper.has_method(worker_callable, "work"), + "behavior 'work' should be callable at tag") + + session.dispose() diff --git a/tools/pony-agent-server/test/test.pony b/tools/pony-agent-server/test/test.pony new file mode 100644 index 0000000000..e422e77a3e --- /dev/null +++ b/tools/pony-agent-server/test/test.pony @@ -0,0 +1,3 @@ +""" +Tests for pony-agent-server. +""" diff --git a/tools/pony-agent-server/type_api_query.pony b/tools/pony-agent-server/type_api_query.pony new file mode 100644 index 0000000000..e89ba92606 --- /dev/null +++ b/tools/pony-agent-server/type_api_query.pony @@ -0,0 +1,496 @@ +use "pony_compiler" + +class val TypeAPIResult + """ + A type's callable API at a given capability: which methods can be + called, which cannot, available constructors, and implemented traits. + """ + let type_name: String val + let type_kind: String val + let callable: Array[MethodInfo val] val + let not_callable: Array[(MethodInfo val, String val)] val + let constructors: Array[MethodInfo val] val + let traits: Array[String val] val + + new val create( + type_name': String val, + type_kind': String val, + callable': Array[MethodInfo val] val, + not_callable': Array[(MethodInfo val, String val)] val, + constructors': Array[MethodInfo val] val, + traits': Array[String val] val) + => + type_name = type_name' + type_kind = type_kind' + callable = callable' + not_callable = not_callable' + constructors = constructors' + traits = traits' + +primitive TypeAPIQuery + """ + Return the callable methods, uncallable methods, constructors, and + traits for a type at a given capability. Accepts either a type name + with explicit capability, or a file position from which the type and + capability are read directly from the compiler's type AST. + """ + + fun from_type( + session: CompileSession box, + type_name: String val, + cap: String val) + : (TypeAPIResult val | String val) + => + """ + Look up a type by name and return its API filtered by `cap`. + For generic types like `Array[String]`, matches the base name. + """ + if not _is_valid_cap(cap) then + return "invalid capability: " + cap + end + + let program = + match \exhaustive\ session.program() + | let p: Program val => p + | None => return "no compiled program available" + end + + let base_name = _base_type_name(type_name) + if base_name.size() == 0 then + return "cannot determine type name from: " + type_name + end + + let type_ast = + match \exhaustive\ ASTHelpers.find_type_def(program, base_name) + | let t: AST box => t + | let err: String val => return err + | None => return "type not found: " + type_name + end + + _build_result(type_ast, type_name, base_name, cap) + + fun from_position( + session: CompileSession box, + file: String val, + line: USize, + column: USize) + : (TypeAPIResult val | String val) + => + """ + Find the AST node at the given position, read its type and + capability from the compiler's type AST, then return that type's + API. + """ + let program = + match \exhaustive\ session.program() + | let p: Program val => p + | None => return "no compiled program available" + end + + let module = + match \exhaustive\ ASTHelpers.find_module(program, file) + | let m: Module val => m + | None => return "file not found in compiled program: " + file + end + + let index = module.create_position_index() + + let node = + match \exhaustive\ index.find_node_at(line, column) + | let n: AST box => n + | None => return "no AST node at position " + + line.string() + ":" + column.string() + end + + let type_ast = + match \exhaustive\ _get_type_ast(node) + | let t: AST box => t + | None => + return "no type information at position " + + line.string() + ":" + column.string() + end + + _result_from_type_ast(type_ast) + + fun _get_type_ast(node: AST box): (AST box | None) => + """ + Get the type AST for a node. Handles the same special cases as + `Types.get_ast_type` but returns the structured AST instead of a + string. + """ + match node.id() + | TokenIds.tk_letref() + | TokenIds.tk_varref() => + let defs = node.definitions() + try defs(0)?.ast_type() end + | TokenIds.tk_nominal() + | TokenIds.tk_uniontype() + | TokenIds.tk_isecttype() => + node + else + node.ast_type() + end + + fun _result_from_type_ast(type_ast: AST box) + : (TypeAPIResult val | String val) + => + match type_ast.id() + | TokenIds.tk_nominal() => + _handle_nominal_type(type_ast) + | TokenIds.tk_uniontype() => + _handle_union_type(type_ast) + | TokenIds.tk_arrow() => + try + let rhs = type_ast(1)? + _result_from_type_ast(rhs) + else + "cannot unwrap viewpoint type" + end + else + "unsupported type kind: " + TokenIds.string(type_ast.id()) + end + + fun _handle_nominal_type(nominal: AST box) + : (TypeAPIResult val | String val) + => + (let def, let base_name, let cap) = + try _nominal_def_and_cap(nominal)? + else return "cannot resolve nominal type" + end + + let display = + match nominal.type_string() + | let s: String val => s + else base_name + " " + cap + end + + _build_result(def, display, base_name, cap) + + fun _handle_union_type(union_ast: AST box) + : (TypeAPIResult val | String val) + => + let type_str = + match union_ast.type_string() + | let s: String val => "(" + s + ")" + else "unknown union" + end + + let branches = Array[(String val, String val, AST box)] + for branch in union_ast.children() do + match branch.id() + | TokenIds.tk_nominal() => + try + (let def, let name, let cap) = + _nominal_def_and_cap(branch)? + branches.push((name, cap, def)) + end + | TokenIds.tk_arrow() => + try + let rhs = branch(1)? + if rhs.id() == TokenIds.tk_nominal() then + (let def, let name, let cap) = + _nominal_def_and_cap(rhs)? + branches.push((name, cap, def)) + end + end + end + end + + if branches.size() == 0 then + return "cannot resolve union branches: " + type_str + end + + if branches.size() == 1 then + try + (let name, let cap, let def) = branches(0)? + return _build_result(def, type_str, name, cap) + end + end + + let branch_callables = Array[Array[MethodInfo val] val] + for (branch_name, branch_cap, branch_ast) in branches.values() do + let all = MethodEnumerator.methods(branch_ast, branch_name) + (let callable, _) = + MethodEnumerator.callable_at(all, branch_cap) + branch_callables.push(callable) + end + + let intersected_names = Array[String val] + try + for m in branch_callables(0)?.values() do + if _in_all_branches(m.name, branch_callables) then + intersected_names.push(m.name) + end + end + end + + let callable_out: Array[MethodInfo val] iso = + recover iso + Array[MethodInfo val](intersected_names.size()) + end + for name in intersected_names.values() do + try + let first_m = + _find_method_by_name(branch_callables(0)?, name)? + if _signatures_match(name, branch_callables) then + callable_out.push( + MethodInfo( + first_m.name, + first_m.receiver_cap, + first_m.params, + first_m.return_type, + first_m.is_partial, + first_m.kind, + type_str)) + else + callable_out.push( + MethodInfo( + first_m.name, + "", + recover val Array[ParamInfo val] end, + "", + false, + first_m.kind, + type_str)) + end + end + end + + TypeAPIResult( + type_str, + "union", + consume callable_out, + recover val Array[(MethodInfo val, String val)] end, + recover val Array[MethodInfo val] end, + recover val Array[String val] end) + + fun _nominal_def_and_cap(nominal: AST box) + : (AST box, String val, String val) ? + => + """ + Extract the definition, base name, and capability from a + TK_NOMINAL type AST node. The definition comes from the + compiler's data pointer (set during the names pass); the + capability comes from child 3. + """ + let defs = nominal.definitions() + let def = defs(0)? + + let base_name = + match nominal(1)?.token_value() + | let s: String val => s + else error + end + + let cap_node = nominal(3)? + let cap = CapSubtyping.cap_string(cap_node.id()) + if cap.size() == 0 then error end + + (def, base_name, cap) + + fun _build_result( + type_ast: AST box, + display_name: String val, + base_name: String val, + cap: String val) + : TypeAPIResult val + => + let all = MethodEnumerator.methods(type_ast, base_name) + (let callable, let not_callable) = + MethodEnumerator.callable_at(all, cap) + let constructors = _extract_constructors(all) + let traits = _extract_traits(type_ast) + let kind = _type_kind(type_ast.id()) + + TypeAPIResult( + display_name, + kind, + callable, + not_callable, + constructors, + traits) + + fun _extract_constructors(all: Array[MethodInfo val] val) + : Array[MethodInfo val] val + => + recover val + let result = Array[MethodInfo val] + for m in all.values() do + if m.kind == "new" then result.push(m) end + end + result + end + + fun _extract_traits(type_ast: AST box): Array[String val] val => + let result = Array[String val] + try + let provides = type_ast(3)? + if provides.id() == TokenIds.tk_provides() then + var child: (AST box | None) = provides.child() + while true do + match \exhaustive\ child + | let c: AST box => + _collect_trait_names(c, result) + child = c.sibling() + | None => break + end + end + end + end + let out = recover iso Array[String val](result.size()) end + for s in result.values() do out.push(s) end + consume out + + fun _collect_trait_names( + node: (AST box | None), + result: Array[String val] ref) + => + match node + | let ast: AST box => + match ast.id() + | TokenIds.tk_nominal() => + match ast.type_string() + | let s: String val => + result.push(s) + else + try + let defs = ast.definitions() + let def = defs(0)? + match def(0)?.token_value() + | let s: String val => result.push(s) + end + end + end + | TokenIds.tk_isecttype() | TokenIds.tk_uniontype() => + var child: (AST box | None) = ast.child() + while true do + match \exhaustive\ child + | let c: AST box => + _collect_trait_names(c, result) + child = c.sibling() + | None => break + end + end + end + end + + fun _base_type_name(type_str: String val): String val => + var s = type_str + + // Strip all viewpoint prefixes ("this->", "A->") + try + let arrow = s.rfind("->")? + s = s.trim((arrow + 2).usize()) + end + + // Union types are handled separately + if try s(0)? == '(' else false end then + return "" + end + + // Base name ends at the first '[' or ' ' + var i: USize = 0 + while i < s.size() do + try + let c = s(i)? + if (c == '[') or (c == ' ') then break end + end + i = i + 1 + end + s.trim(0, i) + + fun _has_method_name( + methods: Array[MethodInfo val] val, + name: String val) + : Bool + => + for m in methods.values() do + if m.name == name then return true end + end + false + + fun _find_method_by_name( + methods: Array[MethodInfo val] val, + name: String val) + : MethodInfo val ? + => + for m in methods.values() do + if m.name == name then return m end + end + error + + fun _in_all_branches( + name: String val, + branch_callables: Array[Array[MethodInfo val] val]) + : Bool + => + var i: USize = 1 + while i < branch_callables.size() do + try + if not _has_method_name(branch_callables(i)?, name) then + return false + end + else + return false + end + i = i + 1 + end + true + + fun _signatures_match( + name: String val, + branch_callables: Array[Array[MethodInfo val] val]) + : Bool + => + if branch_callables.size() <= 1 then return true end + try + let first = + _find_method_by_name(branch_callables(0)?, name)? + var i: USize = 1 + while i < branch_callables.size() do + let other = + _find_method_by_name(branch_callables(i)?, name)? + if first.receiver_cap != other.receiver_cap then + return false + end + if first.return_type != other.return_type then + return false + end + if first.is_partial != other.is_partial then + return false + end + if first.params.size() != other.params.size() then + return false + end + var j: USize = 0 + while j < first.params.size() do + if first.params(j)?.param_type != + other.params(j)?.param_type + then + return false + end + j = j + 1 + end + i = i + 1 + end + true + else + false + end + + fun _is_valid_cap(cap: String val): Bool => + match cap + | "iso" | "trn" | "ref" | "val" | "box" | "tag" => true + else false + end + + fun _type_kind(token_id: TokenId): String val => + match token_id + | TokenIds.tk_class() => "class" + | TokenIds.tk_actor() => "actor" + | TokenIds.tk_primitive() => "primitive" + | TokenIds.tk_trait() => "trait" + | TokenIds.tk_interface() => "interface" + | TokenIds.tk_struct() => "struct" + else "" + end diff --git a/tools/pony-agent-server/version.pony.in b/tools/pony-agent-server/version.pony.in new file mode 100644 index 0000000000..a86b841c14 --- /dev/null +++ b/tools/pony-agent-server/version.pony.in @@ -0,0 +1,9 @@ +// Generated from version.pony.in by CMake — edit this file, not version.pony. + +primitive Version + """ + The pony-agent-server version string. Matches the ponyc compiler + version since pony-agent-server is distributed with ponyc. + """ + fun apply(): String => + "@PONYC_VERSION@"