From 89d1780f9f424144dc368e501da6a4aeb111c073 Mon Sep 17 00:00:00 2001 From: Mark S Date: Fri, 26 Jun 2026 09:47:53 -0400 Subject: [PATCH 1/4] feat(plugins): add Rust and Python native-AST plugins (syn / ruff) WASM plugins on the codesight per-kind ABI (contractVersion, describe, alloc/dealloc, parseRoutes/Schemas/Imports) targeting wasm32-unknown-unknown. Rust (syn): actix/Rocket/axum routes, struct schemas, diesel/sea-orm/sqlx. Python (ruff): FastAPI/Flask/Django routes, SQLAlchemy/Django/pydantic/SQLModel schemas. Shared cargo workspace; Cargo.lock committed for reproducible builds. --- .gitignore | 2 + plugins/ast/Cargo.lock | 779 ++++++++++++++++++++++++++++++++++ plugins/ast/Cargo.toml | 41 ++ plugins/ast/python/Cargo.toml | 20 + plugins/ast/python/src/lib.rs | 686 ++++++++++++++++++++++++++++++ plugins/ast/rust/Cargo.toml | 19 + plugins/ast/rust/src/lib.rs | 495 +++++++++++++++++++++ 7 files changed, 2042 insertions(+) create mode 100644 plugins/ast/Cargo.lock create mode 100644 plugins/ast/Cargo.toml create mode 100644 plugins/ast/python/Cargo.toml create mode 100644 plugins/ast/python/src/lib.rs create mode 100644 plugins/ast/rust/Cargo.toml create mode 100644 plugins/ast/rust/src/lib.rs diff --git a/.gitignore b/.gitignore index b947fcb..4877ce9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ dist/ .DS_Store docs/superpowers/ package-lock.json +plugins/**/*.wasm +plugins/**/target/ tests/fixtures/**/.codesight/ tests/fixtures/**/CODESIGHT.md # Fully test-generated fixture directories diff --git a/plugins/ast/Cargo.lock b/plugins/ast/Cargo.lock new file mode 100644 index 0000000..e480339 --- /dev/null +++ b/plugins/ast/Cargo.lock @@ -0,0 +1,779 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codesight-python-ast" +version = "0.0.1" +dependencies = [ + "ruff_python_ast", + "ruff_python_parser", + "serde", + "serde_json", +] + +[[package]] +name = "codesight-rust-ast" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "get-size-derive2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1da24fbda09ec01bca7cfa1797c0e520e75123bccb01dcdf9041f8aa65183bc2" +dependencies = [ + "attribute-derive", + "quote", + "syn", +] + +[[package]] +name = "get-size2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "823645bc6404ae2915707777061a47d3a031a9ee0bff51b34ec973df3d8d2990" +dependencies = [ + "compact_str", + "get-size-derive2", + "hashbrown", + "ordermap", + "smallvec", + "thin-vec", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "ordermap" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7476a5b122ff1fce7208e7ee9dccd0a516e835f5b8b19b8f3c98a34cf757c1" +dependencies = [ + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ruff_cache" +version = "0.0.2" +source = "git+https://github.com/astral-sh/ruff.git?rev=e1198ba76ed1211f503a7bfb390344eaf820c256#e1198ba76ed1211f503a7bfb390344eaf820c256" +dependencies = [ + "filetime", + "glob", + "globset", + "itertools", + "regex", + "seahash", +] + +[[package]] +name = "ruff_python_ast" +version = "0.0.2" +source = "git+https://github.com/astral-sh/ruff.git?rev=e1198ba76ed1211f503a7bfb390344eaf820c256#e1198ba76ed1211f503a7bfb390344eaf820c256" +dependencies = [ + "aho-corasick", + "arrayvec", + "bitflags", + "compact_str", + "get-size2", + "is-macro", + "memchr", + "ruff_cache", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "serde", + "thin-vec", + "thiserror", +] + +[[package]] +name = "ruff_python_parser" +version = "0.0.2" +source = "git+https://github.com/astral-sh/ruff.git?rev=e1198ba76ed1211f503a7bfb390344eaf820c256#e1198ba76ed1211f503a7bfb390344eaf820c256" +dependencies = [ + "bitflags", + "bstr", + "compact_str", + "get-size2", + "memchr", + "ruff_python_ast", + "ruff_python_trivia", + "ruff_text_size", + "rustc-hash", + "static_assertions", + "thin-vec", + "unicode-ident", + "unicode-normalization", + "unicode_names2", +] + +[[package]] +name = "ruff_python_trivia" +version = "0.0.2" +source = "git+https://github.com/astral-sh/ruff.git?rev=e1198ba76ed1211f503a7bfb390344eaf820c256#e1198ba76ed1211f503a7bfb390344eaf820c256" +dependencies = [ + "itertools", + "ruff_source_file", + "ruff_text_size", + "unicode-ident", +] + +[[package]] +name = "ruff_source_file" +version = "0.0.2" +source = "git+https://github.com/astral-sh/ruff.git?rev=e1198ba76ed1211f503a7bfb390344eaf820c256#e1198ba76ed1211f503a7bfb390344eaf820c256" +dependencies = [ + "memchr", + "ruff_text_size", +] + +[[package]] +name = "ruff_text_size" +version = "0.0.2" +source = "git+https://github.com/astral-sh/ruff.git?rev=e1198ba76ed1211f503a7bfb390344eaf820c256#e1198ba76ed1211f503a7bfb390344eaf820c256" +dependencies = [ + "get-size2", + "serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" +dependencies = [ + "serde", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/plugins/ast/Cargo.toml b/plugins/ast/Cargo.toml new file mode 100644 index 0000000..f22578b --- /dev/null +++ b/plugins/ast/Cargo.toml @@ -0,0 +1,41 @@ +[workspace] +resolver = "3" +exclude = [] +members = [ + "rust", + "python" +] + +[profile.release] +lto = true +strip = true +opt-level = "z" +panic = "abort" +codegen-units = 1 + +[workspace.package] +version = "0.0.1" +authors = [ + "Kailesk Khumar ", + "Mark S. ", +] +edition = "2024" +license = "MIT" +publish = false +repository = "https://github.com/Houseofmvps/codesight" + +[workspace.lints.rust] +warnings = "deny" +unsafe_code = "allow" +unknown_lints = "allow" +unexpected_cfgs = "allow" +rust_2018_idioms = { level = "deny", priority = -1 } + +[workspace.dependencies] +# Astral's ruff crates are not published to crates.io, so +# we pin to a specific revision of `main` for reproducibility. +ruff_python_parser = { git = "https://github.com/astral-sh/ruff.git", rev = "e1198ba76ed1211f503a7bfb390344eaf820c256", default-features = false } +ruff_python_ast = { git = "https://github.com/astral-sh/ruff.git", rev = "e1198ba76ed1211f503a7bfb390344eaf820c256", default-features = false } +serde = { version = "1", default-features = false, features = ["alloc", "derive"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +syn = { version = "2", default-features = false } diff --git a/plugins/ast/python/Cargo.toml b/plugins/ast/python/Cargo.toml new file mode 100644 index 0000000..21ab682 --- /dev/null +++ b/plugins/ast/python/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codesight-python-ast" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +repository = { workspace = true } + +[lib] +crate-type = ["cdylib"] + +[lints] +workspace = true + +[dependencies] +ruff_python_parser = { workspace = true } +ruff_python_ast = { workspace = true, features = ["serde"] } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/plugins/ast/python/src/lib.rs b/plugins/ast/python/src/lib.rs new file mode 100644 index 0000000..0a62cbf --- /dev/null +++ b/plugins/ast/python/src/lib.rs @@ -0,0 +1,686 @@ +//! `codesight` plugin for Python AST parsing using Astral's [`ruff_python_ast`](ruff_python_ast). +//! +//! Implements the codesight WASM plugin ABI (see docs/wasm-plugins.md): +//! alloc(len) -> ptr | dealloc(ptr, len) +//! contractVersion() -> i32 +//! parseRoutes(srcPtr, srcLen) -> i64 (FastAPI/Flask decorators: @x.get("/p"), @app.route(...)) +//! parseSchemas(srcPtr, srcLen) -> i64 (model classes: annotated fields, Optional -> nullable) +//! parseImports(srcPtr, srcLen) -> i64 (import / from-import targets) +//! +//! No imports (compiles for wasm32-unknown-unknown, panic = "abort"). + +#![allow(non_snake_case)] + +use core::alloc::Layout as MemoryLayout; + +use ruff_python_ast::{ + Arguments, Expr as Expression, ExprAttribute as AttributeExpression, + ExprCall as CallExpression, ExprName as NameExpression, ExprStringLiteral as StringLiteral, + Identifier, Stmt as Statement, StmtAnnAssign as AnnotationAssignment, + StmtAssign as Assignment, StmtClassDef as ClassDef, +}; +use ruff_python_parser::{Parsed, parse_module}; + +/// ABI contract version this plugin implements (must match the host's). +#[unsafe(no_mangle)] +pub extern "C" fn contractVersion() -> i32 { + 1 +} + +/// Self-description consumed by the host (languageId + extensions; frameworks carried). +#[unsafe(no_mangle)] +pub extern "C" fn describe() -> u64 { + let meta = serde_json::json!({ + "languageId": "python", + "extensions": [ + ".py", + ], + "frameworks": [ + "flask", + "django", + "fastapi", + "sqlmodel", + "pydantic", + "sqlalchemy", + ], + }); + + unsafe { report(meta.to_string().as_bytes()) } +} + +// ─── ABI: memory ─── + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(length: usize) -> *mut u8 { + if length == 0 { + return core::ptr::null_mut(); + } + + unsafe { std::alloc::alloc(MemoryLayout::from_size_align_unchecked(length, 1)) } +} + +/// # Safety +/// `ptr`/`len` must come from a prior `alloc` call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn dealloc(pointer: *mut u8, length: usize) { + if pointer.is_null() || length == 0 { + return; + } + + unsafe { + std::alloc::dealloc(pointer, MemoryLayout::from_size_align_unchecked(length, 1)); + } +} + +// ─── ABI: per-kind parse entry points ─── + +/// # Safety +/// `src_ptr`/`src_len` describe a host-owned UTF-8 buffer (the host frees it). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn parseRoutes(source: *const u8, length: usize) -> u64 { + unsafe { + parse_and_then(source, length, |body| { + let mut routes = Vec::new(); + + collect_routes(body, &mut routes); + + routes + }) + } +} + +/// # Safety +/// See [`parseRoutes`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn parseSchemas(source: *const u8, length: usize) -> u64 { + unsafe { + parse_and_then(source, length, extract_schemas) + } +} + +/// # Safety +/// See [`parseRoutes`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn parseImports(source: *const u8, length: usize) -> u64 { + unsafe { + parse_and_then(source, length, extract_imports) + } +} + +/// Decode UTF-8 source, parse with [`parse_module`], run `extract`, serialize, and +/// `report`. Returns 0 on invalid UTF-8, a syntax error, or an empty result (host +/// falls back). +unsafe fn parse_and_then( + source: *const u8, + length: usize, + extract: impl Fn(&[Statement]) -> Vec, +) -> u64 { + let bytes = unsafe { + core::slice::from_raw_parts(source, length) + }; + + let Ok(source) = core::str::from_utf8(bytes) else { + return 0; + }; + + let Ok(parsed) = parse_module(source).map(Parsed::into_syntax) else { + return 0; + }; + + let Ok(extracted) = serde_json::to_string(&extract(&parsed.body)) else { + return 0; + }; + + if extracted.is_empty() + || extracted + .split_ascii_whitespace() + .collect::>() + .join("") + == "[]" + { + return 0; + } + + unsafe { + report(extracted.as_bytes()) + } +} + +/// Copy `bytes` into a fresh host-owned allocation and pack (ptr << 32) | len. +unsafe fn report(bytes: &[u8]) -> u64 { + let length = bytes.len(); + let pointer = alloc(length); + + unsafe { + core::ptr::copy_nonoverlapping(bytes.as_ptr(), pointer, length); + } + + ((pointer as u64) << 32) | (length as u64) +} + +// ─── output shapes ─── + +#[derive(serde::Serialize)] +struct Route { + method: String, + path: String, +} + +#[derive(serde::Serialize)] +struct Field { + name: String, + r#type: String, + flags: Vec, +} + +#[derive(serde::Serialize)] +struct Model { + orm: String, + name: String, + fields: Vec, + relations: Vec, +} + +// ─── helpers ─── + +fn expr_string(expression: &Expression) -> Option { + if let Expression::StringLiteral(StringLiteral { value, .. }) = expression { + Some(value.to_string()) + } else { + None + } +} + +// ─── routes ─── + +const ROUTE_VERBS: &[&str] = &["get", "post", "put", "patch", "delete", "options", "head"]; + +fn collect_routes(body: &[Statement], routes: &mut Vec) { + for statement in body { + match statement { + Statement::ClassDef(class) => collect_routes(&class.body, routes), + Statement::FunctionDef(function) => { + for decorator in &function.decorator_list { + routes_from_decorator(&decorator.expression, routes); + } + } + // Django URLConf: `urlpatterns = [path("p/", view), re_path(...), ...]`. + Statement::Assign(Assignment { targets, value, .. }) => { + let is_urlpatterns = targets.iter().any(|t| { + matches!(t, Expression::Name(NameExpression { id, .. }) if id.as_str() == "urlpatterns") + }); + + if is_urlpatterns { + if let Expression::List(list) = &**value { + for element in &list.elts { + routes_from_urlconf(element, routes); + } + } + } + } + _ => {} + } + } +} + +/// Extract a route from a Django URLConf entry: `path("p/", view)`, `re_path(r"^p$", view)`, +/// or legacy `url(...)`. Django doesn't bind a method at the URL layer, so we emit "ALL". +fn routes_from_urlconf(expression: &Expression, routes: &mut Vec) { + let Expression::Call(CallExpression { + func, arguments, .. + }) = expression + else { + return; + }; + + if !matches!(call_leaf(func).as_deref(), Some("path" | "re_path" | "url")) { + return; + } + + if let Some(raw) = arguments.args.first().and_then(expr_string) { + routes.push(Route { + method: "ALL".into(), + path: normalize_route(&raw), + }); + } +} + +/// Normalize a URLConf pattern to a leading-slash path, stripping regex anchors. +fn normalize_route(raw: &str) -> String { + let trimmed = raw.trim_start_matches('^').trim_end_matches('$'); + + if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + } +} + +fn routes_from_decorator(expression: &Expression, routes: &mut Vec) { + let Expression::Call(CallExpression { + arguments: Arguments { args, keywords, .. }, + func, + .. + }) = expression + else { + return; + }; + + let Expression::Attribute(AttributeExpression { + attr: Identifier { id, .. }, + .. + }) = &**func + else { + return; + }; + + let method = id.as_str(); + + let Some(path) = args.first().and_then(expr_string) else { + return; + }; + + if ROUTE_VERBS.contains(&method) { + routes.push(Route { + path, + method: method.to_uppercase().into(), + }); + + return; + } + + if method == "route" || method == "api_route" { + // methods=[...] keyword, else default GET + if let Some(keyword) = keywords + .iter() + .find(|keyword| keyword.arg.as_ref().map(|id| id.as_str()) == Some("methods")) + { + if let Expression::List(list) = &keyword.value { + let mut any = false; + + for method in list.elts.iter().filter_map(expr_string) { + routes.push(Route { + path: path.clone(), + method: method.to_uppercase(), + }); + + any = true; + } + + if any { + return; + } + } + } + + routes.push(Route { + path, + method: "GET".into(), + }); + } +} + +// ─── imports ─── + +fn extract_imports(body: &[Statement]) -> Vec { + let mut imports = Vec::new(); + + for statement in body { + match statement { + Statement::Import(import) => { + for alias in &import.names { + imports.push(alias.name.to_string()); + } + } + + Statement::ImportFrom(import) => { + let module = import + .module + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); + + for alias in &import.names { + if module.is_empty() { + imports.push(alias.name.to_string()); + } else { + imports.push(format!("{}.{}", module, alias.name)); + } + } + } + _ => {} + } + } + + imports +} + +// ─── schemas ─── + +fn extract_schemas(body: &[Statement]) -> Vec { + let mut schemas = Vec::new(); + + for statement in body { + let Statement::ClassDef(ClassDef { + name, + body, + arguments, + .. + }) = statement + else { + continue; + }; + + // Heuristic: a model has at least one base class (Base/BaseModel/models.Model/...). + let bases: Vec = arguments + .as_deref() + .map(|args| args.args.iter().filter_map(dotted).collect()) + .unwrap_or_default(); + + if bases.is_empty() { + continue; + } + + let mut fields = Vec::new(); + let mut relations = Vec::new(); + let mut orm: Option<&'static str> = None; + + for member in body { + match member { + // Annotated: `id: Mapped[int] = mapped_column(primary_key=True)`, + // `name: str` (pydantic), `email: str = Field(unique=True)` (SQLModel). + Statement::AnnAssign(AnnotationAssignment { + target, + annotation, + value, + .. + }) => { + let Expression::Name(NameExpression { id, .. }) = &**target else { + continue; + }; + + let field_name = id.to_string(); + + if field_name.starts_with("__") { + continue; + } + + let (mut r#type, nullable) = annotation_type(annotation); + let mut flags = Vec::new(); + + if let Some(call) = value.as_deref().and_then(analyze_call) { + orm = orm.or(call.orm); + + if let Some(target) = call.relation { + relations.push(target); + continue; + } + + flags.extend(call.flags); + + if let Some(ctor_type) = call.ctor_type { + if r#type.is_empty() || r#type == "unknown" { + r#type = ctor_type; + } + } + } + + if nullable && !flags.iter().any(|flag| flag == "nullable") { + flags.push("nullable".to_string()); + } + + fields.push(Field { + name: field_name, + r#type, + flags, + }); + } + + // Classic / Django: `name = Column(String)`, `email = models.EmailField()`, + // `author = relationship("User")`, `team = models.ForeignKey(Team)`. + Statement::Assign(Assignment { targets, value, .. }) => { + let [Expression::Name(NameExpression { id, .. })] = &targets[..] else { + continue; + }; + + let field_name = id.to_string(); + + if field_name.starts_with("__") { + continue; + } + + let Some(call) = analyze_call(value) else { + continue; + }; + + orm = orm.or(call.orm); + + if let Some(target) = call.relation { + relations.push(target); + continue; + } + + fields.push(Field { + name: field_name, + r#type: call.ctor_type.unwrap_or_else(|| "unknown".to_string()), + flags: call.flags, + }); + } + + _ => {} + } + } + + if fields.is_empty() && relations.is_empty() { + continue; + } + + schemas.push(Model { + orm: orm.unwrap_or_else(|| orm_from_bases(&bases)).to_string(), + name: name.to_string(), + fields, + relations, + }); + } + + schemas +} + +/// What an ORM field constructor (`Column(...)`, `models.CharField(...)`, ...) tells us. +#[derive(Default)] +struct CallInfo { + orm: Option<&'static str>, + flags: Vec, + relation: Option, + ctor_type: Option, +} + +/// Inspect a field's right-hand-side call for ORM hints, flags, relations, and type. +fn analyze_call(expression: &Expression) -> Option { + let Expression::Call(CallExpression { + func, arguments, .. + }) = expression + else { + return None; + }; + + let leaf = call_leaf(func)?; + let module = call_module(func); + let mut info = CallInfo::default(); + + if module.as_deref() == Some("models") { + info.orm = Some("django"); + } else if matches!(leaf.as_str(), "Column" | "mapped_column" | "relationship") { + info.orm = Some("sqlalchemy"); + } + + // Relationship / foreign-key constructors yield a relation, not a scalar column. + const RELATIONS: &[&str] = &[ + "relationship", + "ForeignKey", + "ManyToManyField", + "OneToOneField", + ]; + + if RELATIONS.contains(&leaf.as_str()) { + info.relation = arguments.args.first().and_then(relation_target); + + if leaf != "relationship" { + info.orm = Some("django"); + } + + return Some(info); + } + + // Flags from truthy boolean keywords (primary_key/unique/nullable/null). + for keyword in &arguments.keywords { + let Some(arg) = keyword.arg.as_ref().map(Identifier::as_str) else { + continue; + }; + + if !matches!(&keyword.value, Expression::BooleanLiteral(literal) if literal.value) { + continue; + } + + match arg { + "primary_key" => info.flags.push("pk".to_string()), + "unique" => info.flags.push("unique".to_string()), + "nullable" | "null" => info.flags.push("nullable".to_string()), + _ => {} + } + } + + // Column type: Django field leaf, or SQLAlchemy `Column(Type, ...)` first positional. + if module.as_deref() == Some("models") { + info.ctor_type = Some(django_type(&leaf)); + } else if leaf == "Column" || leaf == "mapped_column" { + info.ctor_type = arguments.args.first().and_then(sqlalchemy_type); + } + + Some(info) +} + +/// Last dotted segment of a call target: `Column` -> "Column", `models.CharField` -> "CharField". +fn call_leaf(func: &Expression) -> Option { + match func { + Expression::Name(NameExpression { id, .. }) => Some(id.to_string()), + Expression::Attribute(AttributeExpression { attr, .. }) => Some(attr.as_str().to_string()), + _ => None, + } +} + +/// Leading dotted segment: `models.CharField` -> "models", bare `Column` -> None. +fn call_module(func: &Expression) -> Option { + if let Expression::Attribute(AttributeExpression { value, .. }) = func { + if let Expression::Name(NameExpression { id, .. }) = &**value { + return Some(id.to_string()); + } + } + + None +} + +/// Render a dotted name expression: `models.Model` -> "models.Model", `Base` -> "Base". +fn dotted(expression: &Expression) -> Option { + match expression { + Expression::Name(NameExpression { id, .. }) => Some(id.to_string()), + Expression::Attribute(AttributeExpression { value, attr, .. }) => { + Some(format!("{}.{}", dotted(value)?, attr.as_str())) + } + _ => None, + } +} + +/// Resolve a relationship/foreign-key target to a model name (`"User"`, `User`, `app.User`). +fn relation_target(expression: &Expression) -> Option { + match expression { + Expression::StringLiteral(StringLiteral { value, .. }) => Some(value.to_string()), + Expression::Name(_) | Expression::Attribute(_) => dotted(expression), + _ => None, + } +} + +/// Map a base class to its ORM when the field constructors didn't already reveal it. +fn orm_from_bases(bases: &[String]) -> &'static str { + for base in bases { + let leaf = base.rsplit('.').next().unwrap_or(base); + + match leaf { + "Model" if base.starts_with("models.") => return "django", + "SQLModel" => return "sqlmodel", + "BaseModel" => return "pydantic", + "DeclarativeBase" | "Base" => return "sqlalchemy", + "Model" => return "sqlalchemy", // db.Model (Flask-SQLAlchemy) + _ => {} + } + } + + "unknown" +} + +/// Normalize a Django field constructor to a simple type name. +fn django_type(field: &str) -> String { + let normalized = match field { + "CharField" | "TextField" | "SlugField" | "EmailField" | "URLField" | "FileField" + | "ImageField" | "UUIDField" => "str", + "IntegerField" | "BigIntegerField" | "SmallIntegerField" | "PositiveIntegerField" + | "AutoField" | "BigAutoField" => "int", + "FloatField" => "float", + "DecimalField" => "decimal", + "BooleanField" | "NullBooleanField" => "bool", + "DateTimeField" => "datetime", + "DateField" => "date", + "TimeField" => "time", + "JSONField" => "json", + other => other, + }; + + normalized.to_string() +} + +/// Normalize a SQLAlchemy `Column` type argument (`Integer`, `String(50)`, `db.Boolean`). +fn sqlalchemy_type(expression: &Expression) -> Option { + let name = match expression { + Expression::Name(NameExpression { id, .. }) => id.to_string(), + Expression::Attribute(AttributeExpression { attr, .. }) => attr.as_str().to_string(), + Expression::Call(CallExpression { func, .. }) => call_leaf(func)?, + _ => return None, + }; + + let normalized = match name.as_str() { + "Integer" | "BigInteger" | "SmallInteger" => "int", + "String" | "Text" | "Unicode" | "VARCHAR" | "CHAR" => "str", + "Boolean" => "bool", + "Float" | "Numeric" => "float", + "DateTime" => "datetime", + "Date" => "date", + "Time" => "time", + "JSON" => "json", + _ => return Some(name), + }; + + Some(normalized.to_string()) +} + +/// Render an annotation to (type, nullable). `Optional[T]` / `Mapped[T]` unwrap. +fn annotation_type(expression: &Expression) -> (String, bool) { + match expression { + Expression::Name(NameExpression { id, .. }) => (id.to_string(), false), + Expression::Subscript(value) => { + let Expression::Name(NameExpression { id, .. }) = &*value.value else { + return (String::new(), false); + }; + + match id.as_str() { + "Mapped" => annotation_type(&value.slice), + "Optional" => { + let (inner, _) = annotation_type(&value.slice); + (inner, true) + } + value => (value.to_string(), false), + } + } + _ => (String::from("unknown"), false), + } +} + diff --git a/plugins/ast/rust/Cargo.toml b/plugins/ast/rust/Cargo.toml new file mode 100644 index 0000000..5771b6b --- /dev/null +++ b/plugins/ast/rust/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codesight-rust-ast" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +repository = { workspace = true } + +[lib] +crate-type = ["cdylib"] + +[lints] +workspace = true + +[dependencies] +syn = { workspace = true, features = ["full", "parsing", "printing", "visit"] } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/plugins/ast/rust/src/lib.rs b/plugins/ast/rust/src/lib.rs new file mode 100644 index 0000000..3e6443a --- /dev/null +++ b/plugins/ast/rust/src/lib.rs @@ -0,0 +1,495 @@ +//! `codesight` plugin for Rust AST parsing using [`syn`](syn). +//! +//! Implements the codesight WASM plugin ABI (see docs/wasm-plugins.md): +//! alloc(len) -> ptr | dealloc(ptr, len) +//! contractVersion() -> i32 +//! parseRoutes(srcPtr, srcLen) -> i64 (actix attrs + axum `.route("/p", get(...))`) +//! parseSchemas(srcPtr, srcLen) -> i64 (named-field structs; Option -> nullable, `id` -> pk) +//! parseImports(srcPtr, srcLen) -> i64 (`use` paths, flattened to leaf paths) +//! +//! No imports (compiles for wasm32-unknown-unknown, panic = "abort"). + +#![allow(non_snake_case)] // exports are camelCase to match the ABI (parseRoutes, contractVersion) + +use core::alloc::Layout as MemoryLayout; + +use syn::{ + Attribute, Expr, ExprCall as CallExpression, ExprLit, ExprMethodCall as MethodCall, + ExprPath as PathExpression, Field as StructField, Fields, File, GenericArgument, Item, ItemFn, + ItemStruct, ItemUse, Lit, Path, PathArguments, Type, TypePath, TypeReference, UseGroup, UseName, + UsePath, UseRename, UseTree, punctuated::Punctuated, visit::{self, Visit}, +}; + +/// ABI contract version this plugin implements (must match the host's). +#[unsafe(no_mangle)] +pub extern "C" fn contractVersion() -> i32 { + 1 +} + +/// Self-description consumed by the host (languageId + extensions; frameworks carried). +#[unsafe(no_mangle)] +pub extern "C" fn describe() -> u64 { + let meta = serde_json::json!({ + "languageId": "rust", + "extensions": [ + ".rs", + ], + "frameworks": [ + "axum", + "sqlx", + "actix", + "rocket", + "diesel", + "sea-orm", + ], + }); + + unsafe { report(meta.to_string().as_bytes()) } +} + +// ─── ABI: memory ─── + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(length: usize) -> *mut u8 { + if length == 0 { + return core::ptr::null_mut(); + } + + unsafe { std::alloc::alloc(MemoryLayout::from_size_align_unchecked(length, 1)) } +} + +/// # Safety +/// `ptr`/`len` must come from a prior `alloc` call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn dealloc(pointer: *mut u8, length: usize) { + if pointer.is_null() || length == 0 { + return; + } + + unsafe { + std::alloc::dealloc(pointer, MemoryLayout::from_size_align_unchecked(length, 1)); + } +} + +// ─── ABI: per-kind extraction functions ─── + +/// # Safety +/// `source`/`length` describe a host-owned UTF-8 buffer (the host frees it). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn parseRoutes(source: *const u8, length: usize) -> u64 { + unsafe { + parse_and_then(source, length, extract_routes) + } +} + +/// # Safety +/// See [`parseRoutes`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn parseSchemas(source: *const u8, length: usize) -> u64 { + unsafe { + parse_and_then(source, length, extract_schemas) + } +} + +/// # Safety +/// See [`parseRoutes`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn parseImports(source: *const u8, length: usize) -> u64 { + unsafe { + parse_and_then(source, length, extract_imports) + } +} + +/// Decode UTF-8 source, parse with [`parse_file`](syn::parse_file), run `extract`, +/// serialize, and `report`. Returns 0 on invalid UTF-8, a syntax error, or an +/// empty result (host falls back). +unsafe fn parse_and_then( + source: *const u8, + length: usize, + extract: impl Fn(&File) -> Vec, +) -> u64 { + let bytes = unsafe { + core::slice::from_raw_parts(source, length) + }; + + let Ok(source) = core::str::from_utf8(bytes) else { + return 0; + }; + + let Ok(parsed) = syn::parse_file(source) else { + return 0; + }; + + let Ok(extracted) = serde_json::to_string(&extract(&parsed)) else { + return 0; + }; + + if extracted.is_empty() + || extracted + .split_ascii_whitespace() + .collect::>() + .join("") + == "[]" + { + return 0; + } + + unsafe { + report(extracted.as_bytes()) + } +} + +/// Copy `bytes` into a fresh host-owned allocation and pack (ptr << 32) | len. +unsafe fn report(bytes: &[u8]) -> u64 { + let length = bytes.len(); + let pointer = alloc(length); + + unsafe { + core::ptr::copy_nonoverlapping(bytes.as_ptr(), pointer, length); + } + + ((pointer as u64) << 32) | (length as u64) +} + +// ─── Output shapes (match docs/wasm-plugins.md) ─── + +#[derive(serde::Serialize)] +struct Route { + path: String, + method: String, +} + +#[derive(serde::Serialize)] +struct Field { + name: String, + r#type: String, + flags: Vec, +} + +#[derive(serde::Serialize)] +struct Model { + orm: String, + name: String, + fields: Vec, + relations: Vec, +} + +// ─── Route Extraction ─── + +fn verb_for(attribute: impl ToString) -> Option { + let attribute = attribute.to_string().to_uppercase().replace("ROUTE", "ALL"); + + match attribute.as_str() { + "ALL" | "GET" | "PUT" | "HEAD" | "POST" | "PATCH" | "TRACE" | "DELETE" | "OPTIONS" + | "CONNECT" => Some(attribute), + _ => None, + } +} + +fn extract_routes(file: &File) -> Vec { + let mut routes = file + .items + .iter() + .filter_map(|item| { + if let Item::Fn(ItemFn { + attrs: attributes, .. + }) = item + { + Some(attributes) + } else { + None + } + }) + .flatten() + .filter_map(|attribute| { + // actix-web / Rocket: verb attribute macros on functions, e.g. + // #[get("/path")], #[post("/path", data = "
")]. + let method = attribute + .path() + .segments + .last() + .and_then(|segment| verb_for(&segment.ident))?; + + let path = first_str_arg(attribute)?; + + Some(Route { path, method }) + }) + .collect::>(); + + // axum: `.route("/path", get(handler).post(handler))` + AxumVisitor(&mut routes).visit_file(file); + + routes +} + +/// First string-literal argument of an attribute — the route path in +/// `#[get("/p")]`, `#[post("/p", data = "")]`, or `#[route("/p", method = "GET")]`. +fn first_str_arg(attribute: &Attribute) -> Option { + attribute + .parse_args_with(Punctuated::::parse_terminated) + .ok()? + .into_iter() + .find_map(|expr| match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(literal), + .. + }) => Some(literal.value()), + _ => None, + }) +} + +struct AxumVisitor<'ast>(&'ast mut Vec); + +impl<'ast> Visit<'ast> for AxumVisitor<'ast> { + fn visit_expr_method_call(&mut self, node: &'ast MethodCall) { + if node.method == "route" { + let mut args = node.args.iter(); + + if let Some(Expr::Lit(ExprLit { + lit: Lit::Str(argument), + .. + })) = args.next() + { + if let Some(handler) = args.next() { + let mut verbs = Vec::new(); + + collect_verbs(handler, &mut verbs); + + if verbs.is_empty() { + verbs.push("ALL".into()); + } + + self.0.extend(verbs.into_iter().map(|method| Route { + method, + path: argument.value(), + })); + } + } + } + + visit::visit_expr_method_call(self, node); + } +} + +/// Collect HTTP verbs from an `axum` routing expression like `get(h).post(h2)`. +fn collect_verbs(expr: &Expr, verbs: &mut Vec) { + match expr { + Expr::MethodCall(MethodCall { + method, receiver, .. + }) => { + if let Some(method) = verb_for(method) { + verbs.push(method); + } + + collect_verbs(receiver, verbs); + } + Expr::Call(CallExpression { func: function, .. }) => { + if let Expr::Path(PathExpression { + path: Path { segments, .. }, + .. + }) = &**function + { + if let Some(method) = segments.last().and_then(|segment| verb_for(&segment.ident)) { + verbs.push(method); + } + } + } + _ => {} + } +} + +// ─── Schema Extraction ─── +fn extract_schemas(file: &File) -> Vec { + file.items + .iter() + .filter_map(|item| { + let Item::Struct(ItemStruct { + ident, + fields: Fields::Named(fields), + attrs, + .. + }) = item + else { + return None; + }; + + let fields = fields + .named + .iter() + .filter_map( + |StructField { + ident, + ty: r#type, + attrs, + .. + }| { + let (name, (r#type, nullable)) = ( + ident.as_ref().map(ToString::to_string)?, + type_to_string(r#type), + ); + + let mut flags = field_flags(attrs); + + if nullable && !flags.iter().any(|flag| flag == "nullable") { + flags.push("nullable".to_string()); + } + + if name == "id" && !flags.iter().any(|flag| flag == "pk") { + flags.push("pk".to_string()); + } + + Some(Field { + name, + flags, + r#type, + }) + }, + ) + .collect::>(); + + // Skip structs whose fields all resolved to None. + if fields.is_empty() { + return None; + } + + Some(Model { + orm: struct_orm(attrs).to_string(), + name: ident.to_string(), + fields, + relations: Vec::new(), + }) + }) + .collect() +} + +/// Identify the ORM from a struct's derives/attributes (else "unknown"). +fn struct_orm(attrs: &[Attribute]) -> &'static str { + let derives = derives(attrs); + let derived = |name: &str| derives.iter().any(|derive| derive == name); + + if derived("DeriveEntityModel") || attrs.iter().any(|attr| attr.path().is_ident("sea_orm")) { + "sea-orm" + } else if derived("Queryable") || derived("Insertable") || derived("Selectable") + || derived("Identifiable") + { + "diesel" + } else if derived("FromRow") { + "sqlx" + } else { + "unknown" + } +} + +/// Collect the identifiers listed in every `#[derive(...)]` on an item. +fn derives(attrs: &[Attribute]) -> Vec { + let mut out = Vec::new(); + + for attr in attrs.iter().filter(|attr| attr.path().is_ident("derive")) { + let _ = attr.parse_nested_meta(|meta| { + if let Some(ident) = meta.path.get_ident() { + out.push(ident.to_string()); + } + + Ok(()) + }); + } + + out +} + +/// Field-level flags from `#[sea_orm(primary_key)]` / `#[sea_orm(unique)]`. +fn field_flags(attrs: &[Attribute]) -> Vec { + let mut flags = Vec::new(); + + for attr in attrs.iter().filter(|attr| attr.path().is_ident("sea_orm")) { + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("primary_key") { + flags.push("pk".to_string()); + } + + if meta.path.is_ident("unique") { + flags.push("unique".to_string()); + } + + Ok(()) + }); + } + + flags +} + +/// Returns (rendered type, nullable). `Option` unwraps to T + nullable. +fn type_to_string(r#type: &Type) -> (String, bool) { + match r#type { + Type::Reference(TypeReference { elem: r#type, .. }) => { + return type_to_string(r#type); + } + Type::Path(TypePath { + path: Path { segments, .. }, + .. + }) => { + if let Some(segment) = segments.last() { + let id = segment.ident.to_string(); + + if id == "Option" { + if let PathArguments::AngleBracketed(brackets) = &segment.arguments { + if let Some(GenericArgument::Type(inner)) = brackets.args.first() { + let (string, _) = type_to_string(inner); + return (string, true); + } + } + } + + return (id, false); + } + } + _ => {} + }; + + ("unknown".into(), false) +} + +// ─── Import Extraction ─── + +fn extract_imports(File { items, .. }: &File) -> Vec { + items + .iter() + .filter_map(|item| { + if let Item::Use(ItemUse { tree, .. }) = item { + Some(tree) + } else { + None + } + }) + .fold(Vec::new(), |mut imports, tree| { + flatten_use(tree, "", &mut imports); + imports + }) +} + +fn flatten_use(tree: &UseTree, prefix: impl Clone + core::fmt::Display, out: &mut Vec) { + match tree { + UseTree::Glob(_) => out.push(join(prefix, "*")), + UseTree::Path(UsePath { tree, ident, .. }) => { + flatten_use(tree, join(prefix, ident.to_string()), out) + } + UseTree::Name(UseName { ident, .. }) | UseTree::Rename(UseRename { ident, .. }) => { + out.push(join(prefix, ident)) + } + UseTree::Group(UseGroup { items, .. }) => { + for item in items { + flatten_use(item, prefix.clone(), out); + } + } + } +} + +fn join(prefix: impl core::fmt::Display, suffix: impl core::fmt::Display) -> String { + let prefix = prefix.to_string(); + + if prefix.is_empty() { + suffix.to_string() + } else { + format!("{prefix}::{suffix}") + } +} From f1dbca3dcf86349cd9458f65be13aa4eb1743081 Mon Sep 17 00:00:00 2001 From: Mark S Date: Fri, 26 Jun 2026 09:48:27 -0400 Subject: [PATCH 2/4] feat(plugins): add Go native-AST plugin (go/parser) WASI reactor (//go:wasmexport, GOOS=wasip1 -buildmode=c-shared) on the same per-kind ABI: Gin/Echo/Fiber/Chi method routes with router-group prefixes and net/http mux (incl. Go 1.22 "VERB /p" patterns); GORM and Ent schemas. --- plugins/ast/golang/go.mod | 3 + plugins/ast/golang/main.go | 510 +++++++++++++++++++++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 plugins/ast/golang/go.mod create mode 100644 plugins/ast/golang/main.go diff --git a/plugins/ast/golang/go.mod b/plugins/ast/golang/go.mod new file mode 100644 index 0000000..1c44aee --- /dev/null +++ b/plugins/ast/golang/go.mod @@ -0,0 +1,3 @@ +module codesight-go-ast + +go 1.24 diff --git a/plugins/ast/golang/main.go b/plugins/ast/golang/main.go new file mode 100644 index 0000000..2bd8f48 --- /dev/null +++ b/plugins/ast/golang/main.go @@ -0,0 +1,510 @@ +// codesight native-AST plugin for Go, parsing with the stdlib go/parser. +// +// Implements the codesight WASM plugin ABI (see docs/wasm-plugins.md) as a +// WASI *reactor*: built with `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared`, +// exporting functions via //go:wasmexport. Unlike the Rust/AssemblyScript +// plugins it is NOT a no-imports module — the Go runtime imports +// wasi_snapshot_preview1 for init (clock/random/exit/stderr), which the host +// supplies via a minimal, no-filesystem WASI environment. +// +// Exports: contractVersion, describe, alloc, dealloc, parseRoutes, parseSchemas, parseImports. +// routes : Gin/Echo (`.GET("/p")`), Fiber/Chi (`.Get("/p")`) method calls, +// router groups (`r.Group("/api")` prefixes), and net/http mux +// (`HandleFunc`/`Handle`, incl. Go 1.22 `"GET /p"` method patterns) +// schemas : GORM structs (gorm.Model embed or `gorm:` tags) and Ent schemas +// (`func (T) Fields() []ent.Field`) +// imports : import paths +package main + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "strconv" + "strings" + "unsafe" +) + +func main() {} + +// ─── ABI: version + self-description ─── + +//go:wasmexport contractVersion +func contractVersion() int32 { return 1 } + +//go:wasmexport describe +func describe() uint64 { + return report([]byte(`{"languageId":"go","extensions":[".go"],"frameworks":["gin","echo","fiber","chi","net/http","gorm","ent"]}`)) +} + +// ─── ABI: memory ─── +// +// The host writes source into a buffer we hand it via alloc, and reads our JSON +// output from a buffer we return. Go's wasm GC is non-moving, so a pointer into +// a live []byte stays valid; we keep buffers alive in `keep` until dealloc. + +var keep = map[uint32][]byte{} + +//go:wasmexport alloc +func alloc(n uint32) uint32 { + if n == 0 { + return 0 + } + b := make([]byte, n) + ptr := uint32(uintptr(unsafe.Pointer(&b[0]))) + keep[ptr] = b + return ptr +} + +//go:wasmexport dealloc +func dealloc(ptr uint32, _ uint32) { + delete(keep, ptr) +} + +// ─── ABI: per-kind parse entry points ─── + +//go:wasmexport parseRoutes +func parseRoutes(srcPtr, srcLen uint32) uint64 { + return parseAndThen(srcPtr, srcLen, func(f *ast.File) any { return extractRoutes(f) }) +} + +//go:wasmexport parseSchemas +func parseSchemas(srcPtr, srcLen uint32) uint64 { + return parseAndThen(srcPtr, srcLen, func(f *ast.File) any { return extractSchemas(f) }) +} + +//go:wasmexport parseImports +func parseImports(srcPtr, srcLen uint32) uint64 { + return parseAndThen(srcPtr, srcLen, func(f *ast.File) any { return extractImports(f) }) +} + +func parseAndThen(srcPtr, srcLen uint32, extract func(*ast.File) any) uint64 { + src := readSource(srcPtr, srcLen) + fileSet := token.NewFileSet() + + file, err := parser.ParseFile(fileSet, "input.go", src, 0) + + if err != nil { + return 0 // not parseable -> "nothing", host falls back + } + + out, err := json.Marshal(extract(file)) + + if err != nil { + return 0 + } + + s := string(out) + + if s == "[]" || s == "null" || len(s) == 0 { + return 0 + } + + return report(out) +} + +func readSource(ptr, length uint32) string { + if length == 0 { + return "" + } + return string(unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length)) +} + +func report(b []byte) uint64 { + n := uint32(len(b)) + ptr := alloc(n) + dst := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), n) + copy(dst, b) + return (uint64(ptr) << 32) | uint64(n) +} + +// ─── output shapes ─── + +type Route struct { + Path string `json:"path"` + Method string `json:"method"` +} + +type Field struct { + Name string `json:"name"` + Type string `json:"type"` + Flags []string `json:"flags"` +} + +type Model struct { + Orm string `json:"orm"` + Name string `json:"name"` + Fields []Field `json:"fields"` + Relations []string `json:"relations"` +} + +// ─── routes ─── + +var verbs = map[string]string{ + "GET": "GET", "POST": "POST", "PUT": "PUT", "PATCH": "PATCH", + "DELETE": "DELETE", "OPTIONS": "OPTIONS", "HEAD": "HEAD", + "Get": "GET", "Post": "POST", "Put": "PUT", "Patch": "PATCH", + "Delete": "DELETE", "Options": "OPTIONS", "Head": "HEAD", +} + +func extractRoutes(f *ast.File) []Route { + out := []Route{} + prefixes := groupPrefixes(f) + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || len(call.Args) == 0 { + return true + } + + // net/http: HandleFunc / Handle, with optional Go 1.22 "VERB /path" pattern. + if sel.Sel.Name == "HandleFunc" || sel.Sel.Name == "Handle" { + if pat, ok := stringLit(call.Args[0]); ok { + if method, path, ok := splitPattern(pat); ok { + out = append(out, Route{Method: method, Path: path}) + } + } + return true + } + + // Framework method calls: r.GET("/p"), g.Get("/p"), etc. + method, ok := verbs[sel.Sel.Name] + if !ok { + return true + } + if path, ok := stringLit(call.Args[0]); ok && strings.HasPrefix(path, "/") { + out = append(out, Route{Method: method, Path: joinPath(prefixOf(sel.X, prefixes), path)}) + } + return true + }) + return out +} + +// groupPrefixes resolves router-group variables to their accumulated path prefix, +// e.g. `api := r.Group("/api"); v1 := api.Group("/v1")` => {api:/api, v1:/api/v1}. +func groupPrefixes(f *ast.File) map[string]string { + type assign struct { + lhs string + recv ast.Expr + lit string + } + var items []assign + ast.Inspect(f, func(n ast.Node) bool { + as, ok := n.(*ast.AssignStmt) + if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 { + return true + } + lhs, ok := as.Lhs[0].(*ast.Ident) + if !ok { + return true + } + lit, recv, ok := groupCall(as.Rhs[0]) + if !ok { + return true + } + items = append(items, assign{lhs.Name, recv, lit}) + return true + }) + + prefixes := map[string]string{} + // Iterate to a fixpoint so out-of-order / nested groups resolve. + for pass := 0; pass <= len(items); pass++ { + changed := false + for _, it := range items { + base := "" + if id, ok := it.recv.(*ast.Ident); ok { + base = prefixes[id.Name] + } + val := joinPath(base, it.lit) + if prefixes[it.lhs] != val { + prefixes[it.lhs] = val + changed = true + } + } + if !changed { + break + } + } + return prefixes +} + +// groupCall returns (prefixLiteral, receiverExpr, ok) for a `recv.Group("/p")` call. +func groupCall(e ast.Expr) (string, ast.Expr, bool) { + call, ok := e.(*ast.CallExpr) + if !ok { + return "", nil, false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Group" || len(call.Args) == 0 { + return "", nil, false + } + lit, ok := stringLit(call.Args[0]) + if !ok { + return "", nil, false + } + return lit, sel.X, true +} + +// prefixOf resolves the path prefix for a route call's receiver — a group +// variable (`api.GET(...)`) or an inline group chain (`r.Group("/api").GET(...)`). +func prefixOf(recv ast.Expr, prefixes map[string]string) string { + switch r := recv.(type) { + case *ast.Ident: + return prefixes[r.Name] + case *ast.CallExpr: + if lit, base, ok := groupCall(r); ok { + parent := "" + if id, ok := base.(*ast.Ident); ok { + parent = prefixes[id.Name] + } + return joinPath(parent, lit) + } + } + return "" +} + +func joinPath(prefix, path string) string { + if prefix == "" { + return path + } + prefix = strings.TrimRight(prefix, "/") + if path == "" || path == "/" { + return prefix + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return prefix + path +} + +// splitPattern parses a net/http mux pattern. Go 1.22 allows a leading method +// (and optional host): "GET /items/{id}" => (GET, /items/{id}). A bare pattern +// matches any method => "ALL". Returns ok=false for non-path patterns. +func splitPattern(pat string) (string, string, bool) { + pat = strings.TrimSpace(pat) + method := "ALL" + if i := strings.IndexByte(pat, ' '); i > 0 { + if v, ok := verbs[pat[:i]]; ok { + method = v + pat = strings.TrimSpace(pat[i+1:]) + } + } + // Strip an optional host component preceding the path. + if j := strings.IndexByte(pat, '/'); j > 0 { + pat = pat[j:] + } + if !strings.HasPrefix(pat, "/") { + return "", "", false + } + return method, pat, true +} + +func stringLit(e ast.Expr) (string, bool) { + if b, ok := e.(*ast.BasicLit); ok && b.Kind == token.STRING { + if s, err := strconv.Unquote(b.Value); err == nil { + return s, true + } + } + return "", false +} + +// ─── schemas (GORM + Ent) ─── + +func extractSchemas(f *ast.File) []Model { + out := extractGorm(f) + out = append(out, extractEnt(f)...) + return out +} + +func extractGorm(f *ast.File) []Model { + out := []Model{} + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + fields := []Field{} + isModel := false + for _, fld := range st.Fields.List { + typeStr := exprString(fld.Type) + if len(fld.Names) == 0 { + // embedded field, e.g. gorm.Model + if typeStr == "gorm.Model" { + isModel = true + fields = append(fields, + Field{Name: "ID", Type: "uint", Flags: []string{"pk"}}, + Field{Name: "CreatedAt", Type: "time.Time", Flags: []string{}}, + Field{Name: "UpdatedAt", Type: "time.Time", Flags: []string{}}, + ) + } + continue + } + flags := []string{} + if fld.Tag != nil { + tag := fld.Tag.Value + if strings.Contains(tag, "gorm:") { + isModel = true + } + if strings.Contains(tag, "primaryKey") || strings.Contains(tag, "primarykey") { + flags = append(flags, "pk") + } + if strings.Contains(tag, "unique") { + flags = append(flags, "unique") + } + if strings.Contains(tag, "not null") { + flags = append(flags, "required") + } + } + for _, name := range fld.Names { + if !name.IsExported() { + continue + } + fields = append(fields, Field{Name: name.Name, Type: typeStr, Flags: flags}) + } + } + if isModel && len(fields) > 0 { + out = append(out, Model{Name: ts.Name.Name, Fields: fields, Relations: []string{}, Orm: "gorm"}) + } + } + } + return out +} + +// extractEnt parses Ent schemas: `func (T) Fields() []ent.Field { return +// []ent.Field{ field.String("sku").Optional(), field.Int("price") } }`. +func extractEnt(f *ast.File) []Model { + out := []Model{} + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "Fields" || fn.Recv == nil || len(fn.Recv.List) == 0 || fn.Body == nil { + continue + } + name := recvTypeName(fn.Recv.List[0].Type) + if name == "" { + continue + } + fields := []Field{} + ast.Inspect(fn.Body, func(n ast.Node) bool { + cl, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + for _, elt := range cl.Elts { + if fld, ok := entField(elt); ok { + fields = append(fields, fld) + } + } + return true + }) + if len(fields) > 0 { + out = append(out, Model{Name: name, Fields: fields, Relations: []string{}, Orm: "ent"}) + } + } + return out +} + +// entField walks an Ent builder chain `field.String("name").Optional().Unique()`, +// collecting modifiers on the way down to the base `field.TYPE("name")` call. +func entField(e ast.Expr) (Field, bool) { + flags := []string{} + cur := e + for { + call, ok := cur.(*ast.CallExpr) + if !ok { + return Field{}, false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return Field{}, false + } + if base, ok := sel.X.(*ast.Ident); ok && base.Name == "field" { + if len(call.Args) == 0 { + return Field{}, false + } + name, ok := stringLit(call.Args[0]) + if !ok { + return Field{}, false + } + return Field{Name: name, Type: entType(sel.Sel.Name), Flags: flags}, true + } + switch sel.Sel.Name { + case "Optional", "Nillable": + flags = append(flags, "nullable") + case "Unique": + flags = append(flags, "unique") + } + cur = sel.X + } +} + +func entType(ctor string) string { + switch ctor { + case "String", "Text", "UUID", "Enum": + return "string" + case "Int", "Int8", "Int16", "Int32", "Int64": + return "int" + case "Uint", "Uint8", "Uint16", "Uint32", "Uint64": + return "uint" + case "Float", "Float32": + return "float64" + case "Bool": + return "bool" + case "Time": + return "time.Time" + case "Bytes": + return "[]byte" + case "JSON": + return "json" + } + return strings.ToLower(ctor) +} + +func recvTypeName(e ast.Expr) string { + switch t := e.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return recvTypeName(t.X) + } + return "" +} + +func exprString(e ast.Expr) string { + switch t := e.(type) { + case *ast.Ident: + return t.Name + case *ast.SelectorExpr: + return exprString(t.X) + "." + t.Sel.Name + case *ast.StarExpr: + return exprString(t.X) + case *ast.ArrayType: + return "[]" + exprString(t.Elt) + default: + return "unknown" + } +} + +// ─── imports ─── + +func extractImports(f *ast.File) []string { + out := []string{} + for _, imp := range f.Imports { + if p, err := strconv.Unquote(imp.Path.Value); err == nil { + out = append(out, p) + } + } + return out +} From d8d53fd0d0743f9e806bac3c82d2ae26e4cabf3c Mon Sep 17 00:00:00 2001 From: Mark S Date: Fri, 26 Jun 2026 09:48:27 -0400 Subject: [PATCH 3/4] ci: build, smoke-test, and release the native-AST plugins Compiles all three plugins (cargo --locked + Go), shrinks them with wasm-opt, round-trips each through the host ABI, and publishes .wasm + SHA256SUMS on plugins-v* tags. PR runs are path-filtered to plugins/ast so JS-only changes don't trigger them. --- .github/workflows/ast-plugins.yml | 186 ++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 .github/workflows/ast-plugins.yml diff --git a/.github/workflows/ast-plugins.yml b/.github/workflows/ast-plugins.yml new file mode 100644 index 0000000..e0236a5 --- /dev/null +++ b/.github/workflows/ast-plugins.yml @@ -0,0 +1,186 @@ +--- +name: ast-plugins + +# Builds the native-AST WASM plugins (Rust/syn, Python/ruff, Go/go-parser) from +# the source under plugins/ast, smoke-tests each against the host ABI, and — on a +# `plugins-v*` tag — publishes the .wasm files (+ SHA256SUMS) as release assets. +# +# The plugins are a self-contained, polyglot "closed box": the Node/TS package +# neither builds nor depends on them (they're excluded from the npm tarball). +# This workflow is the ONLY thing that compiles them, so it runs on every PR that +# touches plugins/ast to guard against source rot. wasm is platform-independent, +# so one artifact per plugin serves every OS/arch — no build matrix needed. +# +# Compatibility is governed by the ABI `contractVersion()`, not `codesight`'s npm +# version, so plugins are released independently via their own `plugins-v*` tags. +# +# Note: Cargo.lock is committed and `cargo build --locked` enforces it, so Rust +# builds are reproducible (the ruff git rev is pinned; the lockfile freezes all +# transitive deps) and CI fails if the lockfile is stale. + +on: + pull_request: + paths: + - plugins/ast/** + - .github/workflows/ast-plugins.yml + + push: + tags: + - plugins-v* + + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build & smoke-test plugins + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust (wasm32-unknown-unknown) + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache cargo build + uses: swatinem/rust-cache@v2 + with: + workspaces: plugins/ast + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache: false # stdlib-only module, no go.sum to key a dependency cache on + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Node deps & build host + run: | + pnpm install + pnpm build + + # warnings = "deny" is wired into each crate, so any warning fails here. + - name: Build Rust plugins (syn / ruff → wasm) + working-directory: plugins/ast + run: cargo build --locked --release --target wasm32-unknown-unknown + + # -ldflags="-s -w" drops the symbol table and DWARF (Go has no `strip = true` + # equivalent); the wasm-opt pass below shrinks all three further. + - name: Build Go plugin (go/parser → wasm) + working-directory: plugins/ast/golang + run: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o codesight-go-ast.wasm . + + # Stage under the host's discovery filename template (^codesight--ast.wasm$). + # cargo emits underscores (codesight_rust_ast.wasm) and forbids hyphens in lib + # target names, so the rename lives here in CI — never in the repo build. + - name: Stage artifacts + run: >- + mkdir -p dist-plugins; + cp + plugins/ast/target/wasm32-unknown-unknown/release/codesight_rust_ast.wasm + dist-plugins/codesight-rust-ast.wasm; + cp + plugins/ast/target/wasm32-unknown-unknown/release/codesight_python_ast.wasm + dist-plugins/codesight-python-ast.wasm; + cp + plugins/ast/golang/codesight-go-ast.wasm + dist-plugins/codesight-go-ast.wasm; + ls -la dist-plugins + + - name: Install wasm-opt (Binaryen) + run: sudo apt-get update && sudo apt-get install -y binaryen + + # WASM-specific whole-module size pass. -Oz optimizes for size. The toolchains + # emit no target_features section but do use bulk-memory (memory.copy/fill), so + # we enable exactly the post-MVP features they need — all long supported by the + # Node host's V8. The smoke test below runs against these optimized binaries, + # so we validate exactly what ships. + - name: Optimize wasm (wasm-opt -Oz) + working-directory: dist-plugins + run: | + for f in *.wasm; do + before=$(wc -c < "$f") + wasm-opt -Oz \ + --enable-bulk-memory --enable-sign-ext \ + --enable-mutable-globals --enable-nontrapping-float-to-int \ + "$f" -o "$f" + echo "$f: ${before} -> $(wc -c < "$f") bytes" + done + + # Load each optimized plugin through the real host and round-trip a fixture to + # catch host<->plugin drift bidirectionally (mirrors the reference-plugin guard). + - name: Smoke-test ABI against the host + env: + PLUGIN_DIR: dist-plugins + run: | + cat > "${RUNNER_TEMP}/smoke.mjs" <<'JS' + import assert from 'node:assert/strict'; + import { pathToFileURL } from 'node:url'; + + const host = pathToFileURL(`${process.cwd()}/dist/wasm/plugin-host.js`).href; + const { loadPlugin } = await import(host); + const dir = process.env.PLUGIN_DIR; + + const fixtures = { + rust: '#[get("/health")]\nfn handler() {}', + python: 'from fastapi import FastAPI\napp = FastAPI()\n@app.get("/health")\ndef handler(): ...', + go: 'package main\nfunc register(r *gin.Engine) { r.GET("/health", nil) }', + }; + + let failed = 0; + for (const [lang, src] of Object.entries(fixtures)) { + try { + const plugin = loadPlugin(lang, [dir]); + assert.ok(plugin, `${lang}: failed to load`); + assert.equal(plugin.metadata?.languageId, lang, `${lang}: describe() languageId mismatch`); + const routes = plugin.routes?.(src) ?? []; + assert.ok(routes.length > 0, `${lang}: expected at least one route from the fixture`); + console.log(`ok: ${lang} — ${routes.length} route(s), languageId=${plugin.metadata.languageId}`); + } catch (error) { + console.error(`FAIL: ${error.message}`); + failed = 1; + } + } + process.exit(failed); + JS + node "${RUNNER_TEMP}/smoke.mjs" + + - name: Generate checksums + working-directory: dist-plugins + run: sha256sum *.wasm | tee SHA256SUMS + + - uses: actions/upload-artifact@v4 + with: + name: ast-plugins + path: dist-plugins/ + if-no-files-found: error + + release: + name: Publish release assets + needs: build + if: startsWith(github.ref, 'refs/tags/plugins-v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: ast-plugins + path: dist-plugins + + - name: Publish to the GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + dist-plugins/*.wasm + dist-plugins/SHA256SUMS From 118bfcc44fa345e59f395a601ba3f1f138a07ea2 Mon Sep 17 00:00:00 2001 From: Mark S Date: Fri, 26 Jun 2026 09:48:27 -0400 Subject: [PATCH 4/4] docs: document prebuilt plugin releases Add a Releases section to the WASM plugin contract: the published artifacts, independent plugins-v* versioning, and download/verify/install steps. --- docs/wasm-plugins.md | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/wasm-plugins.md b/docs/wasm-plugins.md index 57819fc..95aa096 100644 --- a/docs/wasm-plugins.md +++ b/docs/wasm-plugins.md @@ -18,6 +18,7 @@ This document is the contract a plugin must satisfy. It covers: - [Fallback & strict semantics](#fallback--strict-semantics) - [Plugin skeleton](#plugin-skeleton) - [Building & testing locally](#building--testing-locally) +- [Releases](#releases) - [Versioning](#versioning) --- @@ -373,6 +374,57 @@ function trapped on that file. --- +## Releases + +`codesight`'s npm package ships **no** plugins — but the project maintains a small +set of reference implementations under [`plugins/ast/`](../plugins/ast) and publishes +them as **prebuilt release assets**, so you don't have to set up a Rust/Go toolchain +to use them: + +| Asset | Language | Parser | +|------------------------------|----------|-------------------------------------------------------------------| +| `codesight-rust-ast.wasm` | Rust | [`syn`](https://docs.rs/syn) | +| `codesight-python-ast.wasm` | Python | [`ruff`](https://github.com/astral-sh/ruff) (`ruff_python_ast`) | +| `codesight-go-ast.wasm` | Go | stdlib `go/parser` | + +They are optional and opt-in; with none installed, `codesight` behaves exactly as +its built-in extractors always have. + +**Built in CI, not in this package.** The [`ast-plugins`](../.github/workflows/ast-plugins.yml) +workflow compiles each plugin from source, smoke-tests it against the host ABI, and +attaches the `.wasm` files plus a `SHA256SUMS` manifest to a GitHub Release. Because +WebAssembly is platform-independent, one artifact per plugin runs on every OS and +architecture — there are no per-platform downloads. + +**Independent versioning.** Plugins are released on their own `plugins-v*` tags, +decoupled from `codesight`'s npm version. Compatibility is governed solely by the ABI +[`contractVersion()`](#versioning): any plugin built for the host's contract works, +and one built for an older contract is cleanly skipped. + +**Installing a released plugin:** + +```bash +mkdir -p ~/.codesight/plugins +cd ~/.codesight/plugins + +# Download the asset(s) you want from the GitHub Release — already named to match the +# discovery template (no renaming needed) — plus the checksum manifest. +curl -LO https://github.com/Houseofmvps/codesight/releases/download//codesight-rust-ast.wasm +curl -LO https://github.com/Houseofmvps/codesight/releases/download//SHA256SUMS + +# Verify integrity before trusting the binary. +sha256sum --ignore-missing -c SHA256SUMS + +# Enable it (an explicit language list is authoritative for that language's files). +codesight --native-ast=rust ./my-project +``` + +Dropped into any directory on the [discovery waterfall](#discovery--naming), the +plugin is picked up automatically; `--plugin-dir ` points at an arbitrary +location instead. + +--- + ## Versioning The current contract version is **1**. Breaking changes to the ABI or JSON shapes