diff --git a/crubit_explorer/BUILD b/crubit_explorer/BUILD index cdf7a15d4..9a67ffdc2 100644 --- a/crubit_explorer/BUILD +++ b/crubit_explorer/BUILD @@ -23,6 +23,7 @@ crubit_rust_binary( name = "server", srcs = ["src/main.rs"], data = [ + "//frontend:build", "@rules_crubit//cc_bindings_from_rs", "@rules_rust//rust/toolchain:current_rustc_files", "@rules_rust//rust/toolchain:current_rustc_lib_files", @@ -48,6 +49,7 @@ crubit_rust_test( size = "small", srcs = ["src/main.rs"], data = [ + "//frontend:build", "@rules_crubit//cc_bindings_from_rs", "@rules_rust//rust/toolchain:current_rustc_files", "@rules_rust//rust/toolchain:current_rustc_lib_files", @@ -59,6 +61,7 @@ crubit_rust_test( deps = [ "@crubit_explorer_crates//:axum", "@crubit_explorer_crates//:base64", + "@crubit_explorer_crates//:googletest", "@crubit_explorer_crates//:serde", "@crubit_explorer_crates//:serde_json", "@crubit_explorer_crates//:tempfile", diff --git a/crubit_explorer/Cargo.lock b/crubit_explorer/Cargo.lock index 7f45dca23..3f7716a3f 100644 --- a/crubit_explorer/Cargo.lock +++ b/crubit_explorer/Cargo.lock @@ -2,12 +2,27 @@ # 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 = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -68,9 +83,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bytes" @@ -90,6 +105,7 @@ version = "0.1.0" dependencies = [ "axum", "base64", + "googletest", "serde", "serde_json", "tempfile", @@ -110,9 +126,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "form_urlencoded" @@ -125,36 +141,36 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-task", @@ -173,6 +189,29 @@ dependencies = [ "r-efi", ] +[[package]] +name = "googletest" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94" +dependencies = [ + "googletest_macro", + "num-traits", + "regex", + "rustversion", +] + +[[package]] +name = "googletest_macro" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "http" version = "1.4.2" @@ -226,9 +265,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -267,9 +306,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" [[package]] name = "linux-raw-sys" @@ -331,6 +370,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -374,18 +422,18 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -405,6 +453,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +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 = "rustix" version = "1.1.4" @@ -418,6 +495,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -432,9 +515,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -442,29 +525,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.2", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -530,9 +613,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -560,9 +654,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -577,20 +671,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -707,6 +801,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crubit_explorer/Cargo.toml b/crubit_explorer/Cargo.toml index 39657e3a3..68c9b7b79 100644 --- a/crubit_explorer/Cargo.toml +++ b/crubit_explorer/Cargo.toml @@ -15,6 +15,7 @@ serde_json = "1.0.150" tempfile = "3.27.0" tokio = { version = "1.52.3", features = ["full"] } tower = { version = "0.5.2", features = ["util"] } +googletest = "0.14.3" [workspace] diff --git a/crubit_explorer/MODULE.bazel b/crubit_explorer/MODULE.bazel index 0298231d1..8aa047dc9 100644 --- a/crubit_explorer/MODULE.bazel +++ b/crubit_explorer/MODULE.bazel @@ -15,6 +15,20 @@ local_path_override( path = "..", ) +bazel_dep(name = "aspect_rules_js", version = "2.1.0") +bazel_dep(name = "rules_nodejs", version = "6.7.5") + +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain(node_version = "20.11.1") +use_repo(node, "nodejs_toolchains") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "npm", + pnpm_lock = "//frontend:pnpm-lock.yaml", +) +use_repo(npm, "npm") + crubit_toolchains = use_extension("@rules_crubit//bazel:extensions.bzl", "crubit_toolchains") crubit_toolchains.configure( llvm_version = "22.1.0", diff --git a/crubit_explorer/frontend/BUILD b/crubit_explorer/frontend/BUILD new file mode 100644 index 000000000..ba5467d2f --- /dev/null +++ b/crubit_explorer/frontend/BUILD @@ -0,0 +1,37 @@ +# Part of the Crubit project, under the Apache License v2.0 with LLVM +# Exceptions. See /LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//frontend:@angular/cli/package_json.bzl", "bin") + +package( + default_applicable_licenses = ["//:license"], +) + +npm_link_all_packages(name = "node_modules") + +bin.ng( + name = "build", + srcs = [ + "angular.json", + "package.json", + "tsconfig.app.json", + "tsconfig.json", + ":node_modules", + ] + glob([ + "public/**/*", + "src/**/*", + ]), + args = [ + "build", + "--configuration", + "production", + ], + chdir = "frontend", + # Angular build with modern application builder outputs to dist/frontend. + out_dirs = [ + "dist/frontend", + ], + visibility = ["//visibility:public"], +) diff --git a/crubit_explorer/frontend/angular.json b/crubit_explorer/frontend/angular.json new file mode 100644 index 000000000..de77f5089 --- /dev/null +++ b/crubit_explorer/frontend/angular.json @@ -0,0 +1,106 @@ +// Part of the Crubit project, under the Apache License v2.0 with LLVM +// Exceptions. See /LICENSE for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "frontend": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineTemplate": true, + "inlineStyle": true, + "skipTests": true + }, + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/frontend", + "index": "src/index.html", + "main": "src/main.ts", + "preserveSymlinks": false, + "polyfills": "zone.js", + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kB", + "maximumError": "4kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "frontend:build:production" + }, + "development": { + "buildTarget": "frontend:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + } + } + } + } +} diff --git a/crubit_explorer/frontend/package.json b/crubit_explorer/frontend/package.json new file mode 100644 index 000000000..3744bd08d --- /dev/null +++ b/crubit_explorer/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development" + }, + "private": true, + "dependencies": { + "@angular/animations": "^18.2.0", + "@angular/common": "^18.2.0", + "@angular/compiler": "^18.2.0", + "@angular/core": "^18.2.0", + "@angular/forms": "^18.2.0", + "@angular/platform-browser": "^18.2.0", + "@angular/platform-browser-dynamic": "^18.2.0", + "@angular/router": "^18.2.0", + "@monaco-editor/loader": "^1.7.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.10" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^18.2.21", + "@angular/cli": "^18.2.21", + "@angular/compiler-cli": "^18.2.0", + "typescript": "~5.5.2" + }, + "pnpm": { + "onlyBuiltDependencies": [] + } +} diff --git a/crubit_explorer/frontend/public/favicon.ico b/crubit_explorer/frontend/public/favicon.ico new file mode 100644 index 000000000..57614f9c9 Binary files /dev/null and b/crubit_explorer/frontend/public/favicon.ico differ diff --git a/crubit_explorer/frontend/src/app/app.component.html b/crubit_explorer/frontend/src/app/app.component.html new file mode 100644 index 000000000..7aa4cf1bb --- /dev/null +++ b/crubit_explorer/frontend/src/app/app.component.html @@ -0,0 +1,50 @@ + +
+

Crubit Explorer

+
+ + + +
+
+ +
+
+
+ +
+ input.rs +
+
+
+
+
+ +
+ +
+
+ +
+ bindings.h +
+
+
+
+
+
diff --git a/crubit_explorer/frontend/src/app/app.component.ts b/crubit_explorer/frontend/src/app/app.component.ts new file mode 100644 index 000000000..293c42ae7 --- /dev/null +++ b/crubit_explorer/frontend/src/app/app.component.ts @@ -0,0 +1,135 @@ +// Part of the Crubit project, under the Apache License v2.0 with LLVM +// Exceptions. See /LICENSE for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +import {CommonModule} from '@angular/common'; +import {HttpClient} from '@angular/common/http'; +import {AfterViewInit, Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, OnDestroy, ViewChild} from '@angular/core'; +import {Subject, Subscription, debounceTime} from 'rxjs'; +import loader from '@monaco-editor/loader'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + templateUrl: './app.component.html', + styleUrls: [] +}) +export class AppComponent implements AfterViewInit, OnDestroy { + @ViewChild('inputEditorContainer', {static: true}) + inputContainer!: ElementRef; + @ViewChild('outputEditorContainer', {static: true}) + outputContainer!: ElementRef; + + inputEditor: any; + outputEditor: any; + monaco: any; + + private inputChangeSubject = new Subject(); + private subscription!: Subscription; + isCompiling = false; + + constructor(private http: HttpClient) {} + + ngAfterViewInit() { + loader.init().then(monaco => { + this.monaco = monaco; + const prefersDark = window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = prefersDark ? 'vs-dark' : 'vs'; + + this.inputEditor = monaco.editor.create(this.inputContainer.nativeElement, { + value: + '// Write Rust code here\npub struct MyStruct {\n pub a: i32,\n}\n\npub extern "C" fn hello() {}\n', + language: 'rust', + theme: theme, + minimap: {enabled: false}, + automaticLayout: true + }); + + this.outputEditor = + monaco.editor.create(this.outputContainer.nativeElement, { + value: '// Output will appear here', + language: 'cpp', + theme: theme, + readOnly: true, + minimap: {enabled: false}, + automaticLayout: true + }); + + this.inputEditor.onDidChangeModelContent(() => { + this.inputChangeSubject.next(this.inputEditor.getValue()); + }); + }); + + this.subscription = + this.inputChangeSubject.pipe(debounceTime(600)).subscribe(content => { + this.compile(content); + }); + } + + compile(content?: string) { + if (!content && this.inputEditor) { + content = this.inputEditor.getValue(); + } + if (!content) return; + + this.isCompiling = true; + + const payload = { + pluginName: 'cc_bindings_from_rs', + enableCodegenTracing: false, + pluginFlags: [], + input: { + files: [{ + name: 'input.rs', + contentsB64: btoa(unescape(encodeURIComponent(content))) + }] + } + }; + + this.http.post('/api/compile', payload).subscribe({ + next: (res) => { + this.isCompiling = false; + if (res.error) { + if (this.outputEditor) { + this.outputEditor.setValue( + '// Error:\n' + (res.error.reason || res.error.text)); + } + } else if ( + res.output && res.output.files && res.output.files.length > 0) { + // Combine all output files for display + let combinedOutput = ''; + for (let file of res.output.files) { + const decoded = decodeURIComponent(escape(atob(file.contentsB64))); + combinedOutput += '// File: ' + file.name + '\n' + decoded + '\n\n'; + } + if (this.outputEditor) { + this.outputEditor.setValue(combinedOutput); + } + } + }, + error: (err) => { + this.isCompiling = false; + const errText = + err.error?.error?.reason || err.message || 'Unknown Error'; + if (this.outputEditor) { + this.outputEditor.setValue('// Error:\n' + errText); + } + } + }); + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + if (this.inputEditor) { + this.inputEditor.dispose(); + } + if (this.outputEditor) { + this.outputEditor.dispose(); + } + } +} diff --git a/crubit_explorer/frontend/src/app/app.config.ts b/crubit_explorer/frontend/src/app/app.config.ts new file mode 100644 index 000000000..a709a8269 --- /dev/null +++ b/crubit_explorer/frontend/src/app/app.config.ts @@ -0,0 +1,16 @@ +// Part of the Crubit project, under the Apache License v2.0 with LLVM +// Exceptions. See /LICENSE for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +import {provideHttpClient} from '@angular/common/http'; +import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core'; +import {provideRouter} from '@angular/router'; + +import {routes} from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes), + provideHttpClient() + ] +}; diff --git a/crubit_explorer/frontend/src/app/app.routes.ts b/crubit_explorer/frontend/src/app/app.routes.ts new file mode 100644 index 000000000..53a619dc7 --- /dev/null +++ b/crubit_explorer/frontend/src/app/app.routes.ts @@ -0,0 +1,7 @@ +// Part of the Crubit project, under the Apache License v2.0 with LLVM +// Exceptions. See /LICENSE for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +import {Routes} from '@angular/router'; + +export const routes: Routes = []; diff --git a/crubit_explorer/frontend/src/index.html b/crubit_explorer/frontend/src/index.html new file mode 100644 index 000000000..6731d9d03 --- /dev/null +++ b/crubit_explorer/frontend/src/index.html @@ -0,0 +1,19 @@ + + + + + + + Crubit Explorer + + + + + + + + diff --git a/crubit_explorer/frontend/src/main.ts b/crubit_explorer/frontend/src/main.ts new file mode 100644 index 000000000..b83f1cd29 --- /dev/null +++ b/crubit_explorer/frontend/src/main.ts @@ -0,0 +1,11 @@ +// Part of the Crubit project, under the Apache License v2.0 with LLVM +// Exceptions. See /LICENSE for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +import {bootstrapApplication} from '@angular/platform-browser'; + +import {AppComponent} from './app/app.component'; +import {appConfig} from './app/app.config'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/crubit_explorer/frontend/src/styles.css b/crubit_explorer/frontend/src/styles.css new file mode 100644 index 000000000..6e84b3a43 --- /dev/null +++ b/crubit_explorer/frontend/src/styles.css @@ -0,0 +1,445 @@ +/* +Part of the Crubit project, under the Apache License v2.0 with LLVM +Exceptions. See /LICENSE for license information. +SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +*/ + +:root { + --theme-color: #ff6d3cff; + --dropdown-color: #c0a0a0ff; +} + +.material-icons { + font-family: 'Google Material Icons'; +} + +.toolbar-flags { + display:none; +} + +.toolbar-codegen-tracing { + display:none; +} + +html { + /* Copied from tailwind */ + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + line-height: 1.5; +} + +@media(prefers-color-scheme: dark) { + html { + /* Matches monaco 'vs-dark' theme's editor-background */ + background: #1e1e1e; + } +} + +button, input, select { + /* Copied from tailwind */ + font-family: inherit; + font-size: 100%; +} + +body { + /* Prevent full-page UI (100vh/100vw) from creating scrollbars). */ + margin: 0; +} + +.cursor-wait, .cursor-wait * { + /* + override *all* cursors during compilation, otherwise the text editor and + interactive button cursors will mask the compilation indicator + */ + cursor: wait !important; +} + +app-root { + display: flex; + flex-direction: column; + + height: 100vh; + width: 100vw; + padding: 0; + margin: 0; +} + +header { + padding: .5rem; + display: flex; + background: oklch(from var(--theme-color) calc(l - .09) calc(c + .018) h); + color: #fff; +} + +header h1 { + margin: 0 .5rem; + font-size: 1.25rem; + font-weight: 700; + flex-grow: 1; +} + +header h1 .build-label { + font-size: .75rem; + font-weight: 300; +} + +header .toolbar { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + padding: 0 .5rem; + align-items: center; + justify-content: flex-end; +} + +header .toolbar > label { + font-size: .875rem; +} + +header .toolbar > label input, +header .toolbar > label select { + margin: 0 0.5rem; + font-size: 1rem; + display: inline-block; +} + +app-shortn { + display: inline-flex; + align-items: center; +} + +app-shortn button { + appearance: none; + cursor: pointer; + background: none; + border: 0; + color: inherit; +} + +app-shortn .shortn-popup { + position: absolute; + top: 1.5rem; + right: 1.5rem; + + display: flex; + flex-direction: row; + align-items: center; + + background: #fff; + color: #000; + border: 2px solid oklch(from var(--theme-color) calc(l - .36) c h); + + font-size: 1.25rem; + padding: .5rem; +} + +.tab-bar { + width: 100%; + display: flex; + color: #fff; + background: oklch(from var(--theme-color) calc(l - .24) calc(c - .008) h); + padding: .5rem 0 0 0; + margin: 0 0 .5rem 0; +} + +.tab-bar app-tabs { + display: inline-block; +} + +.tab-bar app-tabs .tab { + display: inline-block; + cursor: default; + border: 2px solid oklch(from var(--theme-color) calc(l - .27) calc(c - .009) h); + border-bottom-width: 0; + border-top-left-radius: .5rem; + border-top-right-radius: .5rem; + padding: .5rem; + vertical-align: middle; + font-size: .875rem; + background: oklch(from var(--theme-color) calc(l - .24) calc(c - .008) h); +} + +.tab-bar app-tabs .tab.selected { + background: oklch(from var(--theme-color) calc(l - .09) calc(c - .009) h); +} + +.tab-bar app-tabs .tab:hover, +.tab-bar app-tabs .tab.selected:hover { + background: var(--theme-color); +} + +.tab-bar app-tabs .tab span { + display: inline-block; +} + +.tab-bar app-tabs .tab span.icon { + padding-left: .5rem; + cursor: pointer; + vertical-align: middle; + font-size: .875rem; +} + +.dropdown { + display: inline-flex; + align-items: center; + position: relative; +} + +.dropdown button { + appearance: none; + color: inherit; + background: none; + cursor: pointer; + border: 0; + opacity: .5; + padding: 0 .5rem; +} + +.dropdown .dropdown-content { + position: absolute; + z-index: 10; + top: 2rem; + left: 0; + display: none; +} + +.dropdown .dropdown-content > div { + padding: .5rem; + font-size: .875rem; + color: #000; + background: oklch(from var(--dropdown-color) calc(l + .18) calc(c - .018) h); + cursor: pointer; +} + +.dropdown .dropdown-content > div.header { + cursor: default; + font-size: .75rem; + color: var(--dropdown-color); +} + +.dropdown .dropdown-content > div.highlight { + background: oklch(from var(--dropdown-color) calc(l + .21) calc(c - .021) h); +} + +.dropdown:hover .dropdown-content { + display: inline-block; +} + +main { + display: grid; + flex-grow: 1; + grid-template-columns: 4fr 4px 6fr; +} + +main .pane { + display: flex; + flex-direction: column; + min-width: 0; +} + +main .pane > monaco-editor { + display: block; + flex-grow: 1; + /* Prevents monaco editor from resizing beyond the page boundary. */ + position: relative; +} + +main .pane > monaco-editor > .monaco-editor { + position: absolute; +} + +main .resizer { + background: oklch(from var(--theme-color) calc(l + .18) calc(c - .018) h); + cursor: col-resize; +} + +/* Doxygen Section styling */ +.doxygen-section { + height: 250px; + border-top: 1px solid #ccc; + padding: 10px; + background: #f9f9f9; + font-family: monospace; + color: #000; + display: flex; + flex-direction: column; + box-sizing: border-box; + transition: height 0.2s ease-in-out; +} + +.doxygen-section.collapsed { + height: 40px; + overflow: hidden; + padding-bottom: 0; +} + +.doxygen-header-container { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + border-bottom: 1px solid #ddd; + padding-bottom: 5px; + margin-bottom: 10px; +} + +.doxygen-section.collapsed .doxygen-header-container { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.doxygen-header { + margin: 0; + color: #333; + font-size: 1rem; +} + +.doxygen-panel-toggle-icon { + color: #666; + font-size: 20px !important; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.doxygen-content { + flex-grow: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.doxygen-error { + color: red; + padding: 5px; + background: #fee; + border: 1px solid #fdd; + margin-bottom: 10px; +} + +.doxygen-empty { + color: #666; + font-style: italic; +} + +/* Symbol Tree styling */ +.symbol-tree-container { + display: flex; + flex-direction: column; + gap: 2px; +} + +.symbol-tree-row { + display: flex; + align-items: center; + padding: 4px 8px; + cursor: default; + border-radius: 4px; + user-select: none; + transition: background-color 0.1s ease; +} + +.symbol-tree-row.has-children { + cursor: pointer; +} + +.symbol-tree-row:hover { + background-color: #f0f0f0; +} + +.symbol-toggle-icon { + font-size: 16px !important; + margin-right: 4px; + color: #666; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.symbol-kind-badge { + font-size: 0.75rem; + padding: 1px 5px; + border-radius: 3px; + margin-right: 8px; + font-weight: 600; + text-transform: uppercase; + display: inline-block; + min-width: 65px; + text-align: center; +} + +.symbol-name { + color: #111; + font-weight: bold; +} + +.symbol-location { + color: #666; + font-size: 0.85em; + margin-left: auto; + font-family: monospace; +} + +/* Kind specific badge colors */ +.symbol-kind-badge[data-kind="namespace"] { background-color: #e1f5fe; color: #0288d1; } +.symbol-kind-badge[data-kind="class"] { background-color: #efebe9; color: #5d4037; } +.symbol-kind-badge[data-kind="struct"] { background-color: #ede7f6; color: #5e35b1; } +.symbol-kind-badge[data-kind="function"] { background-color: #e8f5e9; color: #2e7d32; } +.symbol-kind-badge[data-kind="variable"] { background-color: #fff3e0; color: #ef6c00; } +.symbol-kind-badge[data-kind="typedef"] { background-color: #fce4ec; color: #c2185b; } +.symbol-kind-badge[data-kind="union"] { background-color: #fffde7; color: #f57f17; } +.symbol-kind-badge[data-kind="enum"] { background-color: #e0f2f1; color: #004d40; } + +/* Dark mode overrides */ +@media(prefers-color-scheme: dark) { + .doxygen-section { + background: #1e1e1e; + border-top: 1px solid #444; + color: #fff; + } + + .doxygen-header-container { + border-bottom: 1px solid #444; + } + + .doxygen-section.collapsed .doxygen-header-container { + border-bottom: none; + } + + .doxygen-header { + color: #eee; + } + + .doxygen-panel-toggle-icon { + color: #aaa; + } + + .doxygen-empty { + color: #aaa; + } + + .symbol-tree-row:hover { + background-color: #2d2d2d; + } + + .symbol-name { + color: #eee; + } + + .symbol-location { + color: #aaa; + } + + .symbol-kind-badge[data-kind="namespace"] { background-color: #0b2f44; color: #4fc3f7; } + .symbol-kind-badge[data-kind="class"] { background-color: #2d2421; color: #d7ccc8; } + .symbol-kind-badge[data-kind="struct"] { background-color: #241933; color: #d1c4e9; } + .symbol-kind-badge[data-kind="function"] { background-color: #1b301c; color: #81c784; } + .symbol-kind-badge[data-kind="variable"] { background-color: #321; color: #ffb74d; } + .symbol-kind-badge[data-kind="typedef"] { background-color: #331420; color: #f48fb1; } + .symbol-kind-badge[data-kind="union"] { background-color: #333111; color: #fff176; } + .symbol-kind-badge[data-kind="enum"] { background-color: #0d2d2a; color: #4db6ac; } +} \ No newline at end of file diff --git a/crubit_explorer/frontend/tsconfig.app.json b/crubit_explorer/frontend/tsconfig.app.json new file mode 100644 index 000000000..43d7ce642 --- /dev/null +++ b/crubit_explorer/frontend/tsconfig.app.json @@ -0,0 +1,19 @@ +// Part of the Crubit project, under the Apache License v2.0 with LLVM +// Exceptions. See /LICENSE for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/crubit_explorer/frontend/tsconfig.json b/crubit_explorer/frontend/tsconfig.json new file mode 100644 index 000000000..9f0f9317a --- /dev/null +++ b/crubit_explorer/frontend/tsconfig.json @@ -0,0 +1,37 @@ +// Part of the Crubit project, under the Apache License v2.0 with LLVM +// Exceptions. See /LICENSE for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/crubit_explorer/src/main.rs b/crubit_explorer/src/main.rs index 2c552b69f..28610b8d8 100644 --- a/crubit_explorer/src/main.rs +++ b/crubit_explorer/src/main.rs @@ -2,17 +2,23 @@ // Exceptions. See /LICENSE for license information. // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +use axum::body::Body; +use axum::extract::State; +use axum::http::{header, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; use axum::{routing, Router}; +use std::error::Error; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; use tokio::net::TcpListener; use runfiles::Runfiles; const CC_BINDINGS_FROM_RS_RLOCATION: &str = - "rules_crubit/cc_bindings_from_rs/cc_bindings_from_rs"; + "rules_crubit/cc_bindings_from_rs/cc_bindings_from_rs"; -fn get_cc_bindings_from_rs_path() -> Result> { +fn get_cc_bindings_from_rs_path() -> Result> { // Environment variable for location to cc_bindings_from_rs binary if let Ok(env_path) = std::env::var("CC_BINDINGS_FROM_RS") && let path = PathBuf::from(env_path) @@ -48,9 +54,10 @@ fn get_cc_bindings_from_rs_path() -> Result> Err("cc_bindings_from_rs binary not found via CC_BINDINGS_FROM_RS env var, Bazel runfiles, adjacent to executable, or in system PATH".into()) } -fn new_cc_bindings_from_rs_command() -> Result> { +#[cfg(test)] +fn new_cc_bindings_from_rs_command() -> Result> { let binary_path = get_cc_bindings_from_rs_path()?; - let mut cmd = std::process::Command::new(binary_path); + let mut cmd = Command::new(binary_path); let mut extra_lib_dirs = Vec::new(); @@ -104,18 +111,99 @@ fn find_in_path(name: &str) -> Option { }) } -fn app() -> Router { - Router::new().route("/", routing::get(|| async { "Hello, World!" })) +fn get_frontend_dist_path() -> Option { + if let Ok(r) = Runfiles::create() { + if let Some(path) = runfiles::rlocation!(r, "crubit_explorer/frontend/dist/frontend") { + if path.exists() { + return Some(path); + } + } + } + + if let Ok(mut exe_path) = std::env::current_exe() { + exe_path.pop(); + let adjacent_path = exe_path.join("frontend/dist/frontend"); + if adjacent_path.exists() { + return Some(adjacent_path); + } + let adjacent_path2 = exe_path.join("dist/frontend"); + if adjacent_path2.exists() { + return Some(adjacent_path2); + } + } + + None +} + +fn app(frontend_path: Option) -> Router { + if let Some(path) = frontend_path { + println!("Serving frontend from {}", path.display()); + Router::new().fallback(serve_frontend).with_state(path) + } else { + println!("Frontend not found, serving Hello World at root"); + Router::new().route("/", routing::get(|| async { "Hello, World!" })) + } +} + +async fn serve_frontend(uri: Uri, State(frontend_path): State) -> impl IntoResponse { + let path = uri.path(); + let relative_path = path.trim_start_matches('/'); + + if relative_path.is_empty() { + return serve_file(&frontend_path.join("index.html")).await; + } + + // Don't allow path traversal + let has_parent_dir = + Path::new(relative_path).components().any(|c| matches!(c, Component::ParentDir)); + + if has_parent_dir { + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + + let file_path = frontend_path.join(relative_path); + if file_path.exists() && file_path.is_file() { + return serve_file(&file_path).await; + } + + serve_file(&frontend_path.join("index.html")).await +} + +async fn serve_file(path: &Path) -> Response { + match tokio::fs::read(path).await { + Ok(content) => { + let mime = mime_guess_fallback(path); + Response::builder() + .header(header::CONTENT_TYPE, mime) + .body(Body::from(content)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +fn mime_guess_fallback(path: &Path) -> &'static str { + match path.extension().and_then(|s| s.to_str()) { + Some("html") => "text/html", + Some("js") => "application/javascript", + Some("css") => "text/css", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } } #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), Box> { match get_cc_bindings_from_rs_path() { Ok(path) => println!("cc_bindings_from_rs found at: {}", path.display()), Err(err) => eprintln!("Error locating cc_bindings_from_rs: {}", err), } - let app = app(); + let frontend_path = get_frontend_dist_path(); + let app = app(frontend_path); let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); println!("Listening on http://{}", addr); @@ -132,32 +220,56 @@ mod tests { body::Body, http::{Request, StatusCode}, }; + use googletest::prelude::*; use tower::ServiceExt; + #[gtest] #[tokio::test] async fn hello_world() { - let app = app(); + let app = app(None); let response = app.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + expect_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - assert_eq!(&body[..], b"Hello, World!"); + expect_eq!(&body[..], b"Hello, World!"); + } + + #[gtest] + #[tokio::test] + async fn test_frontend_serving() { + let path = get_frontend_dist_path(); + if let Some(frontend_path) = path { + let app = app(Some(frontend_path)); + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + expect_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let html_str = String::from_utf8(body.to_vec()).unwrap(); + expect_that!(html_str, contains_substring("")); + } else { + println!("Frontend dist path not found, skipping test."); + } } + #[gtest] #[tokio::test] async fn test_cc_bindings_from_rs_help() { let mut cmd = new_cc_bindings_from_rs_command() .expect("Failed to create cc_bindings_from_rs command"); let output = cmd.arg("--help").output().expect("Failed to execute cc_bindings_from_rs"); - assert!(output.status.success()); + expect_true!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("Generates C++ bindings for a Rust crate")); + expect_that!(stdout, contains_substring("Generates C++ bindings for a Rust crate")); } + #[gtest] #[tokio::test] async fn test_generate_bindings() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); @@ -181,15 +293,15 @@ mod tests { let stderr = String::from_utf8_lossy(&output.stderr); - assert!(output.status.success(), "Command failed with stderr: {}", stderr); - assert!(h_out.exists()); - assert!(rs_out.exists()); + expect_true!(output.status.success(), "Command failed with stderr: {}", stderr); + expect_true!(h_out.exists()); + expect_true!(rs_out.exists()); let h_content = std::fs::read_to_string(&h_out).expect("Failed to read h_out"); let rs_content = std::fs::read_to_string(&rs_out).expect("Failed to read rs_out"); - assert!(!h_content.is_empty()); - assert!(!rs_content.is_empty()); - assert!(h_content.contains("foo")); + expect_false!(h_content.is_empty()); + expect_false!(rs_content.is_empty()); + expect_that!(h_content, contains_substring("foo")); } }