From ba5971a78887a869d4789c0202ff56e9f77a047c Mon Sep 17 00:00:00 2001 From: AcruxDevelopment Date: Wed, 7 Oct 2026 00:25:24 -0300 Subject: [PATCH] Add project files --- .github/workflows/build.yml | 52 + .gitignore | 3 + CMakeLists.txt | 180 +++ README.md | 362 ++++++ build.sh | 4 + cmake/mingw-w64-x86_64.cmake | 15 + examples/combined/Expr.g4 | 21 + examples/combined/input.txt | 1 + examples/combined/input_bad.txt | 1 + examples/json/JSON.g4 | 31 + examples/json/example.json | 8 + examples/split/CalcLexer.g4 | 18 + examples/split/CalcParser.g4 | 15 + examples/split/input.txt | 3 + src/analysis.hpp | 487 +++++++ src/antlr_tool.hpp | 338 +++++ src/crash.hpp | 351 +++++ src/http.hpp | 394 ++++++ src/interp.hpp | 107 ++ src/isolate.hpp | 329 +++++ src/json.hpp | 277 ++++ src/main.cpp | 775 +++++++++++ src/pipeline.hpp | 57 + src/platform.hpp | 449 +++++++ src/render.hpp | 331 +++++ src/report.hpp | 850 ++++++++++++ src/serve.hpp | 1047 +++++++++++++++ src/types.hpp | 91 ++ src/util.hpp | 239 ++++ src/webui_assets.hpp | 2155 +++++++++++++++++++++++++++++++ tools/embed_web.py | 67 + web/10-core.js | 226 ++++ web/20-editor.js | 476 +++++++ web/30-tabs.js | 470 +++++++ web/40-views.js | 676 ++++++++++ web/app.css | 189 +++ web/index.html | 85 ++ 37 files changed, 11180 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100755 build.sh create mode 100644 cmake/mingw-w64-x86_64.cmake create mode 100644 examples/combined/Expr.g4 create mode 100644 examples/combined/input.txt create mode 100644 examples/combined/input_bad.txt create mode 100644 examples/json/JSON.g4 create mode 100644 examples/json/example.json create mode 100644 examples/split/CalcLexer.g4 create mode 100644 examples/split/CalcParser.g4 create mode 100644 examples/split/input.txt create mode 100644 src/analysis.hpp create mode 100644 src/antlr_tool.hpp create mode 100644 src/crash.hpp create mode 100644 src/http.hpp create mode 100644 src/interp.hpp create mode 100644 src/isolate.hpp create mode 100644 src/json.hpp create mode 100644 src/main.cpp create mode 100644 src/pipeline.hpp create mode 100644 src/platform.hpp create mode 100644 src/render.hpp create mode 100644 src/report.hpp create mode 100644 src/serve.hpp create mode 100644 src/types.hpp create mode 100644 src/util.hpp create mode 100644 src/webui_assets.hpp create mode 100644 tools/embed_web.py create mode 100644 web/10-core.js create mode 100644 web/20-editor.js create mode 100644 web/30-tabs.js create mode 100644 web/40-views.js create mode 100644 web/app.css create mode 100644 web/index.html diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d975403 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +name: build + +on: + push: + pull_request: + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { name: linux-gcc, os: ubuntu-latest, cc: gcc, cxx: g++ } + - { name: linux-clang, os: ubuntu-latest, cc: clang, cxx: clang++ } + - { name: windows-msvc, os: windows-latest } + - { name: windows-mingw, os: windows-latest, generator: MinGW Makefiles, cc: gcc, cxx: g++ } + - { name: macos-clang, os: macos-latest } + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Configure + shell: bash + run: | + args=(-B build -DCMAKE_BUILD_TYPE=Release -DANTLRLAB_DOWNLOAD_JAR=ON) + if [ -n "${{ matrix.generator }}" ]; then args+=(-G "${{ matrix.generator }}"); fi + if [ -n "${{ matrix.cc }}" ]; then args+=(-DCMAKE_C_COMPILER=${{ matrix.cc }} -DCMAKE_CXX_COMPILER=${{ matrix.cxx }}); fi + cmake "${args[@]}" + + - name: Build + run: cmake --build build --config Release --parallel + + - name: Self-test and examples + shell: bash + run: | + BIN=$(find build -type f \( -name antlrlab -o -name antlrlab.exe \) -not -path '*/CMakeFiles/*' | head -n 1) + echo "using $BIN" + "$BIN" --version + "$BIN" --selftest + "$BIN" examples/combined/Expr.g4 -t "1 + 2" --no-color + "$BIN" -g examples/split/CalcLexer.g4 -g examples/split/CalcParser.g4 -i examples/split/input.txt -q --no-color + "$BIN" examples/json/JSON.g4 examples/json/example.json -q --no-color + # a syntax error must give exit status 1 + set +e + "$BIN" examples/combined/Expr.g4 examples/combined/input_bad.txt -q --no-color + test $? -eq 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ab12de --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +antlr-lab-out/ +build/ +*.jar diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..9231ce4 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,180 @@ +cmake_minimum_required(VERSION 3.16) + +# std::filesystem needs macOS 10.15 or newer. Has to be set before project(). +if(APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "Minimum macOS version") +endif() + +project(antlrlab VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +# The ANTLR tool (Java) and the C++ runtime must be the same version, because +# the serialized grammar that the tool writes is read by the runtime. +set(ANTLR_VERSION "4.13.2" CACHE STRING "ANTLR version to build against") + +option(ANTLRLAB_USE_SYSTEM_RUNTIME + "Use an installed ANTLR C++ runtime instead of downloading one" OFF) +option(ANTLRLAB_DOWNLOAD_JAR + "Download antlr-${ANTLR_VERSION}-complete.jar at configure time and use it by default" OFF) +if(MSVC) + option(ANTLRLAB_STATIC_CRT + "Visual C++: link the C++ runtime statically, so the program needs no redistributable" ON) +elseif(MINGW) + option(ANTLRLAB_STATIC_LINK + "MinGW: link libgcc, libstdc++ and winpthread statically, so no DLLs have to ship with the program" ON) +endif() + +find_package(Threads REQUIRED) + +# Visual C++: the runtime and antlrlab have to use the same C runtime library. +if(MSVC) + if(ANTLRLAB_STATIC_CRT) + set(WITH_STATIC_CRT ON CACHE BOOL "" FORCE) # read by the ANTLR runtime's own CMakeLists + set(ANTLRLAB_MSVC_RUNTIME "MultiThreaded$<$:Debug>") + else() + set(WITH_STATIC_CRT OFF CACHE BOOL "" FORCE) + set(ANTLRLAB_MSVC_RUNTIME "MultiThreaded$<$:Debug>DLL") + endif() +endif() + +# --------------------------------------------------------------- runtime ---- +if(ANTLRLAB_USE_SYSTEM_RUNTIME) + find_path(ANTLR4_INCLUDE_DIR antlr4-runtime.h + PATH_SUFFIXES antlr4-runtime include/antlr4-runtime) + find_library(ANTLR4_LIBRARY NAMES antlr4-runtime antlr4-runtime-static) + if(NOT ANTLR4_INCLUDE_DIR OR NOT ANTLR4_LIBRARY) + message(FATAL_ERROR + "ANTLR C++ runtime not found. Install it, set ANTLR4_INCLUDE_DIR and " + "ANTLR4_LIBRARY, or configure without -DANTLRLAB_USE_SYSTEM_RUNTIME=ON.") + endif() + set(ANTLRLAB_RUNTIME_LIBS ${ANTLR4_LIBRARY}) + set(ANTLRLAB_RUNTIME_INCLUDES ${ANTLR4_INCLUDE_DIR}) + set(ANTLRLAB_RUNTIME_DEFS "") +else() + include(FetchContent) + set(ANTLR_BUILD_CPP_TESTS OFF CACHE BOOL "" FORCE) + set(ANTLR_BUILD_SHARED OFF CACHE BOOL "" FORCE) + set(ANTLR_BUILD_STATIC ON CACHE BOOL "" FORCE) + set(WITH_DEMO OFF CACHE BOOL "" FORCE) + set(ANTLR4_INSTALL OFF CACHE BOOL "" FORCE) + + set(_antlr_fetch_args + URL "https://github.com/antlr/antlr4/archive/refs/tags/${ANTLR_VERSION}.tar.gz" + SOURCE_SUBDIR runtime/Cpp) + if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.24) + list(APPEND _antlr_fetch_args DOWNLOAD_EXTRACT_TIMESTAMP TRUE) + endif() + FetchContent_Declare(antlr4 ${_antlr_fetch_args}) + FetchContent_MakeAvailable(antlr4) + + set(ANTLRLAB_RUNTIME_LIBS antlr4_static) + set(ANTLRLAB_RUNTIME_INCLUDES "${antlr4_SOURCE_DIR}/runtime/Cpp/runtime/src") + set(ANTLRLAB_RUNTIME_DEFS ANTLR4CPP_STATIC) +endif() + +# ------------------------------------------------------------------- jar ---- +set(ANTLRLAB_JAR_PATH "") +if(ANTLRLAB_DOWNLOAD_JAR) + set(_jar "${CMAKE_BINARY_DIR}/antlr-${ANTLR_VERSION}-complete.jar") + if(NOT EXISTS "${_jar}") + # antlr.org first, then the copy in the ANTLR website's GitHub repository. + foreach(_url + "https://www.antlr.org/download/antlr-${ANTLR_VERSION}-complete.jar" + "https://raw.githubusercontent.com/antlr/website-antlr4/gh-pages/download/antlr-${ANTLR_VERSION}-complete.jar") + message(STATUS "Downloading ${_url}") + file(DOWNLOAD "${_url}" "${_jar}" STATUS _dl_status SHOW_PROGRESS) + list(GET _dl_status 0 _dl_code) + if(_dl_code EQUAL 0) + break() + endif() + file(REMOVE "${_jar}") + message(STATUS " failed: ${_dl_status}") + endforeach() + if(NOT EXISTS "${_jar}") + message(WARNING "Could not download the ANTLR jar. " + "Pass --antlr-jar or set ANTLR_JAR when running antlrlab.") + endif() + endif() + if(EXISTS "${_jar}") + file(TO_CMAKE_PATH "${_jar}" ANTLRLAB_JAR_PATH) + endif() +endif() + +# ---------------------------------------------------------------- target ---- +add_executable(antlrlab src/main.cpp) +target_include_directories(antlrlab PRIVATE src ${ANTLRLAB_RUNTIME_INCLUDES}) +target_link_libraries(antlrlab PRIVATE ${ANTLRLAB_RUNTIME_LIBS} Threads::Threads) +target_compile_definitions(antlrlab PRIVATE + ${ANTLRLAB_RUNTIME_DEFS} + LAB_VERSION="${PROJECT_VERSION}" + LAB_ANTLR_VERSION="${ANTLR_VERSION}") +if(ANTLRLAB_JAR_PATH) + target_compile_definitions(antlrlab PRIVATE LAB_DEFAULT_ANTLR_JAR="${ANTLRLAB_JAR_PATH}") + # Also put the jar next to the program: antlrlab looks there too, which keeps + # working when the build directory is moved, or its path has unusual characters. + add_custom_command(TARGET antlrlab POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ANTLRLAB_JAR_PATH}" "$") + install(FILES "${ANTLRLAB_JAR_PATH}" DESTINATION bin) +endif() + +# std::filesystem lives in a separate library in GCC 8 and older, and in Clang +# with libc++ 8 and older. +include(CheckCXXSourceCompiles) +set(CMAKE_REQUIRED_FLAGS "-std=c++17") +if(MSVC) + set(CMAKE_REQUIRED_FLAGS "/std:c++17") +endif() +check_cxx_source_compiles("#include +int main() { return std::filesystem::current_path().empty() ? 1 : 0; }" ANTLRLAB_FS_BUILTIN) +unset(CMAKE_REQUIRED_FLAGS) +if(NOT ANTLRLAB_FS_BUILTIN) + foreach(_fslib stdc++fs c++fs) + set(CMAKE_REQUIRED_LIBRARIES ${_fslib}) + check_cxx_source_compiles("#include +int main() { return std::filesystem::current_path().empty() ? 1 : 0; }" ANTLRLAB_FS_${_fslib}) + unset(CMAKE_REQUIRED_LIBRARIES) + if(ANTLRLAB_FS_${_fslib}) + target_link_libraries(antlrlab PRIVATE ${_fslib}) + break() + endif() + endforeach() +endif() + +if(WIN32) + # Winsock (web UI), BCrypt (access token), Shell32 (command line, opening the + # browser), DbgHelp (stack traces in crash reports) + target_link_libraries(antlrlab PRIVATE ws2_32 bcrypt shell32 dbghelp) + target_compile_definitions(antlrlab PRIVATE _WIN32_WINNT=0x0601 NOMINMAX WIN32_LEAN_AND_MEAN) +endif() + +# Crash reports print a stack trace; ENABLE_EXPORTS (-rdynamic) makes it show function names. +if(NOT WIN32) + set_target_properties(antlrlab PROPERTIES ENABLE_EXPORTS ON) +endif() + +# The ANTLR runtime recurses deeply on real grammars. antlrlab runs it on a +# thread with a big stack it creates itself (src/isolate.hpp), so the main +# program only needs a normal stack; 8 MB matches the Linux default for the +# threads of the web server. +if(MSVC) + target_link_options(antlrlab PRIVATE /STACK:8388608) + set_property(TARGET antlrlab PROPERTY MSVC_RUNTIME_LIBRARY "${ANTLRLAB_MSVC_RUNTIME}") + target_compile_options(antlrlab PRIVATE /utf-8 /EHsc /permissive- /W3) + target_compile_definitions(antlrlab PRIVATE _CRT_SECURE_NO_WARNINGS _CRT_NONSTDC_NO_WARNINGS) +else() + target_compile_options(antlrlab PRIVATE -Wall -Wextra) + if(MINGW) + target_link_options(antlrlab PRIVATE -Wl,--stack,8388608) + if(ANTLRLAB_STATIC_LINK) + target_link_options(antlrlab PRIVATE -static -static-libgcc -static-libstdc++) + endif() + endif() +endif() + +install(TARGETS antlrlab RUNTIME DESTINATION bin) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8bad89 --- /dev/null +++ b/README.md @@ -0,0 +1,362 @@ +# antlrlab + +A command line version of [antlr-lab](http://lab.antlr.org/) written in C++. Give it +your ANTLR grammar files and an input file, and it produces the parse tree +diagram, the tree as text, the token list, and a syntax check of the input. + +``` +$ antlrlab examples/combined/Expr.g4 -t "1 + 2" +(prog (expr (expr 1) + (expr 2)) ) +Input is valid (3 tokens, 8 nodes) +Report: antlr-lab-out/report.html +``` + +Open `antlr-lab-out/report.html` for the diagram (pan, zoom, save as SVG or PNG), +the tree, the tokens, the problems and the grammar. Hovering a node or a token +highlights the text it came from in the input. + +Prefer an editor? `antlrlab --serve Expr.g4 input.txt --open` starts a +[web UI](#web-ui) where you edit the grammar and the inputs, save them to disk, +and watch the diagram update. + +## Build + +You need a C++17 compiler, CMake 3.16 or newer, Java 11 or newer (the ANTLR tool +is a Java program), and network access for the first configure. + +The code is standard C++17 with the operating-system parts isolated in +`src/platform.hpp` and `src/crash.hpp`, so it builds with **GCC**, **Clang**, +**MSVC** (also `clang-cl`) and **MinGW-w64** (the *posix* thread model; the +*win32* one has no `std::thread`), on **Linux, Windows and macOS**. Compilers +older than GCC 9 or Clang 9 get `-lstdc++fs` / `-lc++fs` automatically. What has +actually been run is listed under [Limitations](#limitations). + +```sh +cmake -B build -DANTLRLAB_DOWNLOAD_JAR=ON +cmake --build build +./build/antlrlab --version +``` + +On Windows with Visual Studio, in a *Developer Command Prompt* or PowerShell +(any generator works; Visual Studio's is multi-configuration, so name one): + +```bat +cmake -B build -DANTLRLAB_DOWNLOAD_JAR=ON +cmake --build build --config Release +build\Release\antlrlab.exe --selftest +``` + +With MinGW (MSYS2 `mingw-w64-x86_64-gcc` and `cmake`) use +`-G "MinGW Makefiles"` or `-G Ninja`. To cross-compile for Windows from Linux: + +```sh +sudo apt install g++-mingw-w64-x86-64-posix +cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake -DANTLRLAB_DOWNLOAD_JAR=ON +cmake --build build-win +``` + +The configure step downloads the ANTLR C++ runtime (source, built statically) and, +with `ANTLRLAB_DOWNLOAD_JAR=ON`, `antlr-4.13.2-complete.jar` (from antlr.org, or +from the ANTLR website's GitHub repository if that is unreachable). The jar is +also copied next to the program, where antlrlab looks for it. Without that +option, tell antlrlab where the jar is when you run it (see below). + +| CMake option | Default | Meaning | +| --- | --- | --- | +| `ANTLR_VERSION` | `4.13.2` | Version of the runtime (and of the jar to download) | +| `ANTLRLAB_DOWNLOAD_JAR` | `OFF` | Download the tool jar and use it as the default | +| `ANTLRLAB_USE_SYSTEM_RUNTIME` | `OFF` | Link an installed runtime instead of downloading one | +| `ANTLRLAB_STATIC_CRT` | `ON` | MSVC: link the C++ runtime statically (no redistributable needed) | +| `ANTLRLAB_STATIC_LINK` | `ON` | MinGW: link libgcc, libstdc++ and winpthread statically (no DLLs to ship) | + +`.github/workflows/build.yml` builds and self-tests all of this (Linux with GCC +and Clang, Windows with MSVC and MinGW, macOS) on every push. + +The tool and the runtime must be the same version. If you use your own jar or +runtime, use the same release for both (4.10 or newer). + +## Use + +```sh +# one combined grammar +antlrlab Expr.g4 input.txt + +# separate lexer and parser grammars +antlrlab -g CalcLexer.g4 -g CalcParser.g4 -i input.txt + +# re-run on every save, and keep report.html refreshed in your browser +antlrlab Expr.g4 input.txt --watch --open + +# other rules, other input sources +antlrlab Expr.g4 -s expr -t "1 + 2" +echo "1 + 2" | antlrlab Expr.g4 -i - +``` + +| Option | Meaning | +| --- | --- | +| `-g, --grammar FILE` | Grammar file. Repeat for a lexer plus a parser. Files ending in `.g4` given as plain arguments count too. | +| `-i, --input FILE` | Input file, or `-` for standard input. Any non-`.g4` plain argument is also taken as the input. | +| `-t, --text TEXT` | Input given on the command line. | +| `-s, --start RULE` | Start rule. Default: the first parser rule. | +| `-o, --out DIR` | Output directory. Default: `antlr-lab-out`. | +| `-l, --lib DIR` | Directory with imported grammars or `.tokens` files. | +| `--antlr-jar FILE` | The ANTLR tool jar. | +| `-w, --watch` | Re-run when a grammar or the input file changes. | +| `--open` | Open `report.html` in the default browser. | +| `--tokens` | Also print the token list. | +| `--serve` | Start the [web UI](#web-ui) instead of running once. Plain arguments become open tabs. | +| `-p, --port N` | Port for `--serve` (default: the first free port from 8765). | +| `--root DIR` | Directory the web UI may read and write (default: the current directory). | +| `-q, --quiet` | Print only diagnostics and the verdict. | +| `--no-color` | No colors (`NO_COLOR` is honoured too). | +| `--timeout SECS` | Stop a run that takes longer (default 120; `0` = no limit). Not enforced on Windows. | +| `--no-isolate` | Run the ANTLR runtime inside the main process instead of a child process. For debuggers; see [If it crashes](#if-it-crashes). (Windows always does this.) | +| `--selftest` | Check the installation with a built-in grammar, then exit. | + +Exit status: `0` the input is valid, `1` it has syntax errors, `2` a grammar, tool +or usage error. That makes it usable in scripts and CI. + +### Finding the ANTLR tool + +In this order: `--antlr-jar`, the `ANTLR_JAR` environment variable, the jar +downloaded at build time, an `antlr-*-complete.jar` found in the current +directory, next to the program, on `CLASSPATH`, in your home directory or in +`/usr/local/lib`, `/usr/share/java` and the Homebrew directories (on Windows: +`C:\Javalib`, `C:\antlr`, `C:\Program Files\ANTLR`), and finally an `antlr4` or +`antlr` command on `PATH` (a `.exe`, `.cmd` or `.bat` on Windows). Java comes +from `JAVA_HOME` or `PATH`. + +File names and paths may contain any Unicode characters. On Windows they are +converted properly (the Windows ANSI code page is never used), and the Java tool, +which only understands that code page, is given the short 8.3 form of a path +when it contains characters outside it. On Linux, Java needs a UTF-8 locale +(`LANG=C.UTF-8`) to open files with non-ASCII names. + +### Output files + +| File | Content | +| --- | --- | +| `report.html` | The report: a single self-contained page, no network needed | +| `tree.svg` | The diagram as a standalone SVG | +| `tree.txt` | The tree in ANTLR's LISP form | +| `tokens.txt` | Tokens as `[@index,start:stop='text',,line:col]` | +| `problems.txt` | Syntax errors and warnings | +| `generated/` | What the ANTLR tool wrote (the `.interp` files are what matters) | + +## Web UI + +```sh +antlrlab --serve # browse the current directory +antlrlab --serve Expr.g4 input.txt # start with these files open +antlrlab --serve --root ~/grammars --open +``` + +antlrlab prints an address such as `http://127.0.0.1:8765/?token=...`; `--open` +opens it for you. It is a local web app that edits **real files on disk**: + +``` + File Edit View + +------------------------------+ +--------------------------------+ + | Expr.g4 G | ok.txt I | in2 I | | Diagram (ok.txt) [valid] x | + | Start rule [ ] Auto-run Run| | Diagram | Tree | Tokens | + | | | | + | one editor, one tab per | | the parse tree | + | grammar or input file | +--------------------------------+ + | | | Output (Expr) x | + | | | Problems | ANTLR tool | + +------------------------------+ +--------------------------------+ +``` + +- **One editor, many files.** Each grammar and input is a tab. The `G` / `I` + badge says how the file is used; click it to switch (for example to parse a + `.g4` file as input). The diagram follows the active input, the Output panel + follows the active grammar. +- **Run uses what is in the editor**, saved or not, so you can try an edit + before touching the file. With *Auto-run* on (View menu) it re-runs shortly + after you stop typing. The Java tool only runs again when a grammar actually + changed. `Ctrl+Enter` runs immediately. +- **Lexer + parser pairs work.** If the active grammar is a parser with + `tokenVocab=X`, `X.g4` is taken from an open tab or from the same folder; if + it is a lexer, the parser that uses it is found the same way. +- **Real files.** `Ctrl+S` saves in place (line endings and BOM are kept), + *Save as* picks a folder and name inside the workspace, *Open* browses it. + Every two seconds the open files are checked against the disk: a clean tab + reloads silently when another program changes the file; a tab with unsaved + edits shows a banner instead, and saving over a changed file asks first. +- **Errors where they are.** Syntax errors in the input are underlined and + listed under *Problems*; ANTLR tool errors are marked in the grammar at the + reported line. Click a problem to jump to it. Hovering or clicking a node in + the diagram, or a row in *Tokens*, highlights its text in the input. +- **Diagram** with zoom (wheel), pan (drag), *Fit*, and SVG / PNG export. *Tree* + shows the LISP form (indented or on one line, with Copy), *Tokens* the token + table. +- **Editor niceties:** grammar syntax colors, line numbers, auto-indent, + `Tab` / `Shift+Tab` indent, `Ctrl+/` comment, `Ctrl+F` find and replace. +- Panels close with their `x` and come back from the View menu; the splitters + drag. Layout, theme (system / light / dark) and text size are remembered, and + so are the open files per workspace. + +| Shortcut | Action | +| --- | --- | +| `Ctrl+Enter` | Run | +| `Ctrl+S`, `Ctrl+Shift+S` | Save, Save as | +| `Ctrl+O` | Open file | +| `Alt+W` | Close tab | +| `Ctrl+F` | Find and replace | +| `Ctrl+/` | Toggle comment (grammar) | + +### Is it safe to leave running? + +It is built for your own machine, and a page that can write files deserves care: + +- It listens on `127.0.0.1` only, never on the network. +- Every request needs a random token that is generated at start-up and sent in + the printed address (it moves into a cookie and out of the address bar). The + `Host` and `Origin` headers are checked, so other web pages and DNS-rebinding + tricks cannot talk to it. +- Reads and writes are confined to the workspace directory: paths are resolved + (symlinks too) and anything outside is refused. `.git` is off limits, and + hidden files are not listed. +- It refuses to serve the root of the file system. + +Opening a file larger than 5 MB or an input larger than 4 MB is refused. + +### Changing the UI + +The browser code lives in `web/` (plain HTML, CSS and JavaScript, no build +tools or libraries). After editing it, run `python3 tools/embed_web.py` to +regenerate `src/webui_assets.hpp`, which is checked in, so building antlrlab +itself never needs Python. + +## How it works + +The ANTLR C++ runtime can run a grammar but cannot read a `.g4` file, and compiling +generated C++ for every change would be slow. So: + +1. antlrlab runs the ANTLR tool (Java) on your grammars. The tool writes + `.interp` files next to the generated code: the serialized grammar plus the + token and rule names. +2. The `.interp` data is loaded into the C++ runtime's `LexerInterpreter` and + `ParserInterpreter`, which run the grammar directly. No generated code is ever + compiled. +3. The resulting parse tree is copied into a plain tree that the diagram, text and + HTML code work from. + +The runtime part (steps 2 and 3) runs in an isolated worker, described under +[If it crashes](#if-it-crashes). + +Like the online lab, actions and semantic predicates are ignored, and the parser +does not insist on reaching the end of the input: if the start rule stops early +you get a warning (end the rule with `EOF` to require a full match). + +## If it crashes + +The ANTLR C++ runtime is a native library, and a bad grammar, a very deep input +or a bug can crash it. antlrlab is built so that this does not take the program +down: + +- The runtime runs in a **child process**, on a thread with a **512 MB stack** + (falling back to smaller sizes if the system refuses). Deep recursion, which + is the usual cause of crashes in the runtime on real-world grammars, simply + works; and if something still crashes, only the child dies. The web UI keeps + running and shows *Run crashed*; the command line prints the error, writes the + report and exits with status 2 (in `--watch` mode it keeps watching). +- A run longer than `--timeout` seconds (default 120) is stopped. +- The loaded grammar data is checked against the rule names before the + interpreters use it, so an inconsistent `.interp` file is reported as an error + rather than causing an invalid memory access. + +When a crash happens, the terminal shows what it was doing and a stack trace: + +``` +[antlrlab] fatal error: SIGSEGV (invalid memory access, often a stack overflow) +[antlrlab] while: parsing the input (parser) +[antlrlab] address: 0x0 +[antlrlab] stack trace (innermost first): +./antlrlab(_ZN6antlr4...+0x6f1)[0x558f71ae3e17] +... +``` + +Pipe the trace through `c++filt` to demangle the names. + +One hazard in the runtime is worked around on purpose. For some syntax errors +its `ParserInterpreter` creates a token of its own and keeps it in a single +slot; the next such error overwrites the slot and frees the old token, while +the error node of the first error still points at it. Reading that node later is +a use-after-free, which crashes some of the time (typically on inputs with +several syntax errors). antlrlab therefore copies each error node's data while +its token is still valid (`ErrorNodeRecorder` in `src/analysis.hpp`) and never +touches those tokens afterwards. + +**First step: `antlrlab --selftest`.** It compiles a tiny built-in grammar and +parses an input with it. If it passes, the installation is fine and the problem +is specific to your grammar or input. If it fails, the tool and runtime are not +working together: check that the jar and the runtime are the same version +(`antlrlab --version` shows what it was built for) and rebuild from scratch. + +**To report a crash**, send the whole terminal output above, the grammar, the +input (or a smaller input that still crashes), your operating system and +compiler, and the output of `antlrlab --selftest`. + +**To debug it yourself**, run without the child process so that the debugger +sees the crash: + +```sh +gdb --args ./antlrlab --no-isolate Grammar.g4 input.txt # then: run, bt +lldb -- ./antlrlab --no-isolate Grammar.g4 input.txt # then: run, bt +``` + +Windows has no `fork`, so there the runtime runs in the main process, on a +thread with a 512 MB stack (falling back to smaller ones) that antlrlab creates +itself. A crash ends the program with exit status 2, but it is explained first: +the terminal shows the exception, what antlrlab was doing and a stack trace +(function names need debug symbols; without them you get `module+offset`). +`--timeout` and `--no-isolate` have no effect on Windows. + +## Limitations + +- Java is required at run time, because the grammar compiler exists only in Java. +- Tool and runtime versions must match. A mismatch shows up as a message that the + grammar could not be loaded. +- Names that clash with C++ (a rule called `int`, an alternative label `# Int` is + fine but `# int` is not) can make the tool report errors, because it generates + C++. antlrlab carries on when the tool only complains about such clashes and + still wrote its `.interp` files; otherwise rename the rule. +- Very large trees (more than 20,000 nodes) skip the diagram; the tree text and + tokens are still produced. +- A run that takes longer than `--timeout` (default 120 seconds) is stopped + (not on Windows). +- Parse trees deeper than 50,000 levels are cut off (with a warning), and the + indented tree text stops indenting after 40 levels. +- The web UI was exercised in Chromium. Firefox and Safari should work (it uses + only standard features) but have not been tried. +- Tested: Linux with GCC 13 and Clang 18 (the headers also compile with libc++), + and the Windows build cross-compiled with MinGW-w64 GCC 13 and run under Wine + with a Windows JDK (self-test, the examples, Unicode paths, the web UI, crash + reports). **Not yet run:** Visual C++, real Windows and macOS. They are covered + by the CI workflow (`.github/workflows/build.yml`); push to GitHub and read its + results, or build locally as described above. +- On Windows the runtime is not isolated in a child process (see + [If it crashes](#if-it-crashes)), so a crash or an endless run takes antlrlab + with it. Run the web UI with a grammar you trust, or on Linux, if that matters. + +## Layout + +``` +src/platform.hpp everything OS-specific: UTF-8 paths, arguments, processes, console, random +src/main.cpp command line, console output, watch mode +src/antlr_tool.hpp finds and runs the ANTLR tool +src/interp.hpp reads .interp files +src/analysis.hpp the only file that uses the ANTLR runtime +src/render.hpp tree text and SVG layout +src/report.hpp report.html (HTML, CSS and JavaScript) +src/pipeline.hpp verdict and statistics shared by CLI and web UI +src/isolate.hpp runs the runtime in a child process on a big stack, with a time limit +src/crash.hpp crash reports: phase and stack trace (signals on POSIX, SEH + DbgHelp on Windows) +src/http.hpp small loopback HTTP server +src/json.hpp JSON parser for request bodies +src/serve.hpp web UI backend: files, run, security checks +src/webui_assets.hpp generated from web/ by tools/embed_web.py +web/ the web UI (HTML, CSS, JavaScript) +examples/ combined, split and JSON grammars with inputs +``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..934ecac --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +mkdir build +cmake -B build -DANTLRLAB_DOWNLOAD_JAR=ON +cmake --build build diff --git a/cmake/mingw-w64-x86_64.cmake b/cmake/mingw-w64-x86_64.cmake new file mode 100644 index 0000000..7a675f7 --- /dev/null +++ b/cmake/mingw-w64-x86_64.cmake @@ -0,0 +1,15 @@ +# Cross-compiling for 64-bit Windows from Linux with MinGW-w64: +# +# sudo apt install g++-mingw-w64-x86-64-posix +# cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake -DANTLRLAB_DOWNLOAD_JAR=ON +# cmake --build build-win +# +# The "posix" thread model is required: the "win32" one has no std::thread. +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) +set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc-posix) +set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++-posix) +set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) diff --git a/examples/combined/Expr.g4 b/examples/combined/Expr.g4 new file mode 100644 index 0000000..5f378ad --- /dev/null +++ b/examples/combined/Expr.g4 @@ -0,0 +1,21 @@ +// A small expression grammar in one combined file. +// antlrlab examples/combined/Expr.g4 examples/combined/input.txt +grammar Expr; + +prog : expr EOF ; + +expr + : expr op=(MUL | DIV) expr # MulDiv + | expr op=(ADD | SUB) expr # AddSub + | INT # Int + | ID # Id + | '(' expr ')' # Parens + ; + +MUL : '*' ; +DIV : '/' ; +ADD : '+' ; +SUB : '-' ; +INT : [0-9]+ ; +ID : [a-zA-Z_] [a-zA-Z_0-9]* ; +WS : [ \t\r\n]+ -> skip ; diff --git a/examples/combined/input.txt b/examples/combined/input.txt new file mode 100644 index 0000000..2f0ad12 --- /dev/null +++ b/examples/combined/input.txt @@ -0,0 +1 @@ +1 + 2 * (width - 3) diff --git a/examples/combined/input_bad.txt b/examples/combined/input_bad.txt new file mode 100644 index 0000000..12f5738 --- /dev/null +++ b/examples/combined/input_bad.txt @@ -0,0 +1 @@ +1 + * 2 (width diff --git a/examples/json/JSON.g4 b/examples/json/JSON.g4 new file mode 100644 index 0000000..e8fc6a4 --- /dev/null +++ b/examples/json/JSON.g4 @@ -0,0 +1,31 @@ +// JSON, straight from the ANTLR book. Try it on a .json file: +// antlrlab examples/json/JSON.g4 examples/json/example.json +grammar JSON; + +json : value EOF ; + +obj : '{' pair (',' pair)* '}' + | '{' '}' + ; + +pair : STRING ':' value ; + +arr : '[' value (',' value)* ']' + | '[' ']' + ; + +value : STRING | NUMBER | obj | arr | 'true' | 'false' | 'null' ; + +STRING : '"' (ESC | SAFECODEPOINT)* '"' ; + +fragment ESC : '\\' (["\\/bfnrt] | UNICODE) ; +fragment UNICODE : 'u' HEX HEX HEX HEX ; +fragment HEX : [0-9a-fA-F] ; +fragment SAFECODEPOINT : ~["\\\u0000-\u001F] ; + +NUMBER : '-'? INT ('.' [0-9]+)? EXP? ; + +fragment INT : '0' | [1-9] [0-9]* ; +fragment EXP : [Ee] [+\-]? INT ; + +WS : [ \t\n\r]+ -> skip ; diff --git a/examples/json/example.json b/examples/json/example.json new file mode 100644 index 0000000..902e124 --- /dev/null +++ b/examples/json/example.json @@ -0,0 +1,8 @@ +{ + "name": "antlrlab", + "tags": ["parser", "antlr", "diagram"], + "version": 1.0, + "stable": true, + "owner": null, + "nested": { "depth": 2, "items": [1, 2, 3] } +} diff --git a/examples/split/CalcLexer.g4 b/examples/split/CalcLexer.g4 new file mode 100644 index 0000000..09574a6 --- /dev/null +++ b/examples/split/CalcLexer.g4 @@ -0,0 +1,18 @@ +// Lexer and parser in separate files. +// antlrlab examples/split/CalcLexer.g4 examples/split/CalcParser.g4 examples/split/input.txt +lexer grammar CalcLexer; + +PLUS : '+' ; +MINUS : '-' ; +STAR : '*' ; +SLASH : '/' ; +LPAREN : '(' ; +RPAREN : ')' ; +ASSIGN : '=' ; +SEMI : ';' ; + +NUMBER : [0-9]+ ('.' [0-9]+)? ; +IDENT : [a-zA-Z_] [a-zA-Z_0-9]* ; + +LINE_COMMENT : '//' ~[\r\n]* -> channel(HIDDEN) ; +WS : [ \t\r\n]+ -> skip ; diff --git a/examples/split/CalcParser.g4 b/examples/split/CalcParser.g4 new file mode 100644 index 0000000..7c5ef21 --- /dev/null +++ b/examples/split/CalcParser.g4 @@ -0,0 +1,15 @@ +parser grammar CalcParser; + +options { tokenVocab = CalcLexer; } + +program : statement* EOF ; + +statement : IDENT ASSIGN expression SEMI ; + +expression + : expression (STAR | SLASH) expression + | expression (PLUS | MINUS) expression + | LPAREN expression RPAREN + | NUMBER + | IDENT + ; diff --git a/examples/split/input.txt b/examples/split/input.txt new file mode 100644 index 0000000..3d291f9 --- /dev/null +++ b/examples/split/input.txt @@ -0,0 +1,3 @@ +// two statements +width = 4; +area = width * (width + 2.5) - 1; diff --git a/src/analysis.hpp b/src/analysis.hpp new file mode 100644 index 0000000..f348890 --- /dev/null +++ b/src/analysis.hpp @@ -0,0 +1,487 @@ +// The only file that talks to the ANTLR C++ runtime. +// +// It loads the .interp data written by the ANTLR tool, then runs the lexer +// and parser through the runtime's interpreters (LexerInterpreter, +// ParserInterpreter). Everything is copied into plain lab:: types before the +// runtime objects go away. +// +// Written against the ANTLR 4.13 C++ runtime API. +#pragma once + +#include "util.hpp" // must come first: it pulls in before the runtime undefines EOF + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "antlr4-runtime.h" +#include "crash.hpp" +#include "interp.hpp" +#include "types.hpp" + +#ifndef LAB_ANTLR_VERSION +#define LAB_ANTLR_VERSION "4.13.2" +#endif + +namespace lab { + +// ------------------------------------------------------------------ model ---- + +struct Model { + InterpData lexer; + InterpData parser; + bool hasParser = false; + std::unique_ptr lexerAtn; + std::unique_ptr parserAtn; + std::unique_ptr lexVocab; + std::unique_ptr parVocab; + + std::string tokenName(size_t type) const { + if (type == antlr4::Token::EOF) return "EOF"; + if (type == 0) return "invalid"; // Token::INVALID_TYPE, used for error nodes + auto from = [&](const InterpData& d) -> std::string { + if (type < d.symbolicNames.size() && !d.symbolicNames[type].empty()) return d.symbolicNames[type]; + if (type < d.literalNames.size() && !d.literalNames[type].empty()) return d.literalNames[type]; + return ""; + }; + std::string n = from(lexer); + if (n.empty() && hasParser) n = from(parser); + return n.empty() ? std::to_string(type) : n; + } + + std::string channelName(size_t ch) const { + if (ch < lexer.channelNames.size()) return lexer.channelNames[ch]; + return std::to_string(ch); + } +}; + +// What the .interp files say, with no ANTLR runtime object created yet. Safe +// to build and keep in any process; the runtime is only touched by +// buildModel(), which the callers run in an isolated worker (isolate.hpp). +struct ModelSpec { + InterpData lexer; + InterpData parser; + bool hasParser = false; +}; + +// Reads the given .interp files: exactly one lexer, at most one parser. +inline bool loadInterps(const std::vector& interpFiles, ModelSpec& spec, std::string& error) { + std::vector lexers, parsers; + for (const fs::path& f : interpFiles) { + std::string text; + if (!readFile(f, text)) { + error = "cannot read " + pathToUtf8(f); + return false; + } + InterpData d; + d.name = pathToUtf8(f.stem()); + std::string err; + if (!parseInterp(text, d, err)) { + error = pathToUtf8(f.filename()) + ": " + err; + return false; + } + (d.isLexer ? lexers : parsers).push_back(std::move(d)); + } + if (lexers.empty()) { + error = "no lexer found. Give the program a combined grammar, or a lexer grammar together " + "with the parser grammar that uses it."; + return false; + } + if (lexers.size() > 1 || parsers.size() > 1) { + error = "more than one lexer or parser was generated. Pass one grammar, or one lexer plus " + "one parser grammar."; + return false; + } + spec.lexer = std::move(lexers.front()); + spec.hasParser = !parsers.empty(); + if (spec.hasParser) spec.parser = std::move(parsers.front()); + return true; +} + +// Creates the runtime objects (the ATNs) from the spec and checks that they +// agree with the rule names, because the interpreters index one by the other. +inline bool buildModel(const ModelSpec& spec, Model& m, std::string& error) { + try { + m.lexer = spec.lexer; + m.hasParser = spec.hasParser; + if (m.hasParser) m.parser = spec.parser; + + antlr4::atn::ATNDeserializer deserializer; + crash::setPhase("loading the lexer ATN into the ANTLR runtime"); + m.lexerAtn = deserializer.deserialize( + antlr4::atn::SerializedATNView(m.lexer.atn.data(), m.lexer.atn.size())); + if (!m.lexerAtn || m.lexerAtn->grammarType != antlr4::atn::ATNType::LEXER) { + error = m.lexer.name + ".interp does not contain a lexer ATN."; + return false; + } + if (m.lexerAtn->ruleToStartState.size() != m.lexer.ruleNames.size()) { + error = m.lexer.name + ".interp is inconsistent: " + std::to_string(m.lexer.ruleNames.size()) + + " rule names but the ATN has " + std::to_string(m.lexerAtn->ruleToStartState.size()) + " rules."; + return false; + } + m.lexVocab = std::make_unique(m.lexer.literalNames, m.lexer.symbolicNames); + + if (m.hasParser) { + crash::setPhase("loading the parser ATN into the ANTLR runtime"); + m.parserAtn = deserializer.deserialize( + antlr4::atn::SerializedATNView(m.parser.atn.data(), m.parser.atn.size())); + if (!m.parserAtn || m.parserAtn->grammarType != antlr4::atn::ATNType::PARSER) { + error = m.parser.name + ".interp does not contain a parser ATN."; + return false; + } + if (m.parserAtn->ruleToStartState.size() != m.parser.ruleNames.size()) { + error = m.parser.name + ".interp is inconsistent: " + std::to_string(m.parser.ruleNames.size()) + + " rule names but the ATN has " + std::to_string(m.parserAtn->ruleToStartState.size()) + " rules."; + return false; + } + m.parVocab = std::make_unique(m.parser.literalNames, m.parser.symbolicNames); + } + return true; + } catch (const std::exception& e) { + error = std::string("could not load the grammar into the ANTLR runtime: ") + e.what() + + "\n(The ANTLR tool and the C++ runtime must be the same version. This program was " + "built for " LAB_ANTLR_VERSION ".)"; + return false; + } +} + +// --------------------------------------------------------------- analysis ---- + +namespace detail { + +inline int64_t toIndex(size_t v) { + return v == static_cast(-1) ? -1 : static_cast(v); +} + +// Turns (line, column) and offending-symbol information into diagnostics. +class CollectingListener : public antlr4::BaseErrorListener { + public: + CollectingListener(std::vector& out, const LineTable& lines, bool lexer) + : out_(out), lines_(lines), lexer_(lexer) {} + + size_t count() const { return count_; } + + void syntaxError(antlr4::Recognizer* recognizer, antlr4::Token* offendingSymbol, size_t line, + size_t charPositionInLine, const std::string& msg, std::exception_ptr) override { + Diagnostic d; + d.severity = Diagnostic::Severity::Error; + d.source = lexer_ ? "lexer" : "parser"; + d.line = line; + d.col = charPositionInLine; + d.message = msg; + + if (lexer_) { + d.start = lines_.indexOf(line, charPositionInLine); + d.stop = d.start; + if (auto* lx = dynamic_cast(recognizer)) { + if (antlr4::CharStream* cs = lx->getInputStream()) { + int64_t e = static_cast(cs->index()); + e = std::min(e, lines_.total() - 1); + if (d.start >= 0 && e > d.stop) d.stop = e; + } + } + } else if (offendingSymbol != nullptr && offendingSymbol->getType() != antlr4::Token::EOF) { + d.start = toIndex(offendingSymbol->getStartIndex()); + d.stop = toIndex(offendingSymbol->getStopIndex()); + if (d.start < 0 || d.stop < d.start) d.start = d.stop = -1; + } + out_.push_back(std::move(d)); + ++count_; + } + + private: + std::vector& out_; + const LineTable& lines_; + bool lexer_; + size_t count_ = 0; +}; + +// What an error node says, copied out of its token. +struct ErrorNodeInfo { + std::string text; + std::string typeName; + size_t line = 0; + size_t col = 0; + int64_t start = -1; + int64_t stop = -1; +}; + +// Copies every error node's data out of its token while that token is still +// valid, and nothing else may read those tokens afterwards. +// +// Why: for some syntax errors ParserInterpreter creates a token of its own +// and keeps it in a single slot. The next such error overwrites the slot and +// frees the old token, but the error node of the first error still points at +// it. Reading that node's token later is a use-after-free: it often "works" +// and sometimes crashes. The rule that contains an error node always ends +// right after the error, before another one can happen, so the node's token +// is still alive when exitEveryRule() runs for that rule. +// +// Both parameter types are declared for visitTerminal and visitErrorNode +// (without `override`) so that this compiles against runtime versions that +// differ in the exact signature; whichever one the base class declares is the +// one that gets overridden. +class ErrorNodeRecorder : public antlr4::tree::ParseTreeListener { + public: + explicit ErrorNodeRecorder(const Model& m) : m_(m) {} + + void visitTerminal(antlr4::tree::TerminalNode*) {} + void visitTerminal(antlr4::tree::ParseTree*) {} + void visitErrorNode(antlr4::tree::ErrorNode* node) { record(node); } + void visitErrorNode(antlr4::tree::ParseTree* node) { + if (auto* e = dynamic_cast(node)) record(e); + } + void enterEveryRule(antlr4::ParserRuleContext*) {} + void exitEveryRule(antlr4::ParserRuleContext* ctx) { + if (ctx == nullptr) return; + for (antlr4::tree::ParseTree* child : ctx->children) { + if (auto* e = dynamic_cast(child)) record(e); + } + } + + const ErrorNodeInfo* find(const antlr4::tree::ParseTree* node) const { + const auto it = infos_.find(node); + return it == infos_.end() ? nullptr : &it->second; + } + + private: + void record(antlr4::tree::ErrorNode* node) { + const antlr4::tree::ParseTree* key = node; + if (infos_.count(key) != 0) return; // already copied; its token may be gone by now + ErrorNodeInfo info; + if (antlr4::Token* tok = node->getSymbol()) { + info.text = tok->getText(); + info.typeName = m_.tokenName(tok->getType()); + info.line = tok->getLine(); + info.col = tok->getCharPositionInLine(); + info.start = toIndex(tok->getStartIndex()); + info.stop = toIndex(tok->getStopIndex()); + if (info.start < 0 || info.stop < info.start) info.start = info.stop = -1; + } + infos_.emplace(key, std::move(info)); + } + + const Model& m_; + std::unordered_map infos_; +}; + +// Deeper parse trees than this are cut off (and reported). Every recursive +// function in the program is bounded by it. +constexpr size_t kMaxTreeDepth = 50000; + +inline void convertTree(antlr4::tree::ParseTree* t, const Model& m, const ErrorNodeRecorder& errors, Node& out, + size_t& count, size_t depth, bool& truncated) { + ++count; + if (depth > kMaxTreeDepth) { + out.kind = Node::Kind::Terminal; + out.label = "\u2026"; + out.typeName = "depth limit"; + truncated = true; + return; + } + + auto fillToken = [&](antlr4::Token* tok) { + if (tok == nullptr) return; + out.label = tok->getText(); + out.typeName = m.tokenName(tok->getType()); + out.line = tok->getLine(); + out.col = tok->getCharPositionInLine(); + out.start = toIndex(tok->getStartIndex()); + out.stop = toIndex(tok->getStopIndex()); + if (out.start < 0 || out.stop < out.start) out.start = out.stop = -1; + }; + + // ErrorNode derives from TerminalNode, so it has to be tested first. Its + // token must not be touched here (see ErrorNodeRecorder): use the copy. + if (dynamic_cast(t) != nullptr) { + out.kind = Node::Kind::Error; + if (const ErrorNodeInfo* info = errors.find(t)) { + out.label = info->text; + out.typeName = info->typeName; + out.line = info->line; + out.col = info->col; + out.start = info->start; + out.stop = info->stop; + } else { + out.label = ""; + out.typeName = "error"; + } + return; + } + if (auto* term = dynamic_cast(t)) { + out.kind = Node::Kind::Terminal; + fillToken(term->getSymbol()); + return; + } + + out.kind = Node::Kind::Rule; + auto* ctx = dynamic_cast(t); + if (ctx == nullptr) { + out.label = "?"; + return; + } + const size_t rule = ctx->getRuleIndex(); + out.label = rule < m.parser.ruleNames.size() ? m.parser.ruleNames[rule] : "rule" + std::to_string(rule); + + out.children.reserve(ctx->children.size()); + for (antlr4::tree::ParseTree* c : ctx->children) { + out.children.emplace_back(); + convertTree(c, m, errors, out.children.back(), count, depth + 1, truncated); + } + + for (const Node& c : out.children) { // the rule spans its children + if (c.start < 0) continue; + if (out.start < 0 || c.start < out.start) out.start = c.start; + if (c.stop > out.stop) out.stop = c.stop; + } + if (out.start < 0) { // empty or all-error rule: fall back to the context's tokens + const int64_t s = ctx->getStart() ? toIndex(ctx->getStart()->getStartIndex()) : -1; + const int64_t e = ctx->getStop() ? toIndex(ctx->getStop()->getStopIndex()) : -1; + if (s >= 0 && e >= s) { + out.start = s; + out.stop = e; + } + } + if (out.stop < out.start) out.start = out.stop = -1; +} + +} // namespace detail + +// Runs lexer (and parser, when there is one) over `text`, which must already +// be sanitized UTF-8. `startRule` may be empty (= first parser rule). +// Returns false only for problems with the request itself (unknown start rule). +inline bool analyze(const Model& m, const std::string& text, const std::string& startRule, + AnalysisResult& out, std::string& error) { + out = AnalysisResult(); + + size_t startIdx = 0; + if (m.hasParser) { + const auto& names = m.parser.ruleNames; + if (names.empty()) { + error = "the parser grammar has no rules"; + return false; + } + if (!startRule.empty()) { + const auto it = std::find(names.begin(), names.end(), startRule); + if (it == names.end()) { + error = "unknown start rule '" + startRule + "'. Parser rules: "; + for (size_t i = 0; i < names.size(); ++i) error += (i ? ", " : "") + names[i]; + return false; + } + startIdx = static_cast(it - names.begin()); + } + out.startRule = names[startIdx]; + } + + const LineTable lines(text); + using Clock = std::chrono::steady_clock; + auto ms = [](Clock::time_point a, Clock::time_point b) { + return std::chrono::duration(b - a).count(); + }; + + try { + detail::CollectingListener lexErrors(out.diagnostics, lines, true); + detail::CollectingListener parseErrors(out.diagnostics, lines, false); + + crash::setPhase("creating the lexer"); + antlr4::ANTLRInputStream input(text); + antlr4::LexerInterpreter lexer(m.lexer.name, *m.lexVocab, m.lexer.ruleNames, m.lexer.channelNames, + m.lexer.modeNames, *m.lexerAtn, &input); + lexer.removeErrorListeners(); + lexer.addErrorListener(&lexErrors); + antlr4::CommonTokenStream tokens(&lexer); + + crash::setPhase("splitting the input into tokens (lexer)"); + const auto t0 = Clock::now(); + tokens.fill(); + out.lexMs = ms(t0, Clock::now()); + + crash::setPhase("reading the tokens"); + for (antlr4::Token* t : tokens.getTokens()) { + TokenInfo ti; + ti.index = t->getTokenIndex(); + ti.text = t->getText(); + ti.typeName = m.tokenName(t->getType()); + ti.channel = m.channelName(t->getChannel()); + ti.line = t->getLine(); + ti.col = t->getCharPositionInLine(); + ti.isEof = t->getType() == antlr4::Token::EOF; + if (!ti.isEof) { + ti.start = detail::toIndex(t->getStartIndex()); + ti.stop = detail::toIndex(t->getStopIndex()); + } + out.tokens.push_back(std::move(ti)); + } + + if (m.hasParser) { + crash::setPhase("creating the parser"); + detail::ErrorNodeRecorder errorNodes(m); // declared before the parser, destroyed after it + antlr4::ParserInterpreter parser(m.parser.name, *m.parVocab, m.parser.ruleNames, *m.parserAtn, + &tokens); + parser.removeErrorListeners(); + parser.addErrorListener(&parseErrors); + parser.addParseListener(&errorNodes); + tokens.seek(0); + + crash::setPhase("parsing the input (parser)"); + const auto t1 = Clock::now(); + antlr4::ParserRuleContext* root = parser.parse(startIdx); + out.parseMs = ms(t1, Clock::now()); + + if (root != nullptr) { + crash::setPhase("copying the parse tree"); + bool truncated = false; + detail::convertTree(root, m, errorNodes, out.tree, out.nodeCount, 0, truncated); + out.hasTree = true; + if (truncated) { + Diagnostic d; + d.severity = Diagnostic::Severity::Warning; + d.source = "parser"; + d.message = "the parse tree is more than " + std::to_string(detail::kMaxTreeDepth) + + " levels deep; everything below that is not shown."; + out.diagnostics.push_back(std::move(d)); + } + } + + // The lab does not force EOF either: a start rule may stop early. Say so. + if (parseErrors.count() == 0 && tokens.LA(1) != antlr4::Token::EOF) { + antlr4::Token* next = tokens.LT(1); + Diagnostic d; + d.severity = Diagnostic::Severity::Warning; + d.source = "parser"; + if (next != nullptr) { + d.line = next->getLine(); + d.col = next->getCharPositionInLine(); + d.start = detail::toIndex(next->getStartIndex()); + d.stop = detail::toIndex(next->getStopIndex()); + if (d.start < 0 || d.stop < d.start) d.start = d.stop = -1; + } + d.message = "rule '" + out.startRule + "' matched only part of the input" + + (next ? " and stopped at '" + visible(next->getText()) + "'" : "") + + ". End the rule with EOF to require a full match."; + out.diagnostics.push_back(std::move(d)); + } + } + } catch (const std::exception& e) { + Diagnostic d; + d.source = "input"; + d.message = std::string("internal error while running the grammar: ") + e.what(); + out.diagnostics.push_back(std::move(d)); + out.hasTree = false; + } + + crash::setPhase("finishing"); + std::stable_sort(out.diagnostics.begin(), out.diagnostics.end(), + [](const Diagnostic& a, const Diagnostic& b) { + if (a.line != b.line) return a.line < b.line; + return a.col < b.col; + }); + return true; +} + +} // namespace lab diff --git a/src/antlr_tool.hpp b/src/antlr_tool.hpp new file mode 100644 index 0000000..5e1979e --- /dev/null +++ b/src/antlr_tool.hpp @@ -0,0 +1,338 @@ +// Finds the ANTLR tool (a Java program) and runs it on the user's grammars. +// +// The C++ runtime cannot turn a .g4 file into something runnable by itself, so +// we ask the Java tool to "generate" C++ and only keep the .interp files it +// writes next to the generated code. Those files contain everything the +// runtime's interpreters need (see interp.hpp). +#pragma once + +#include +#include +#include +#include +#include + +#include "util.hpp" + +#ifndef LAB_DEFAULT_ANTLR_JAR +#define LAB_DEFAULT_ANTLR_JAR "" +#endif +#ifndef LAB_ANTLR_VERSION +#define LAB_ANTLR_VERSION "4.13.2" +#endif + +namespace lab { + +struct Tool { + bool found = false; + std::vector argv; // program and leading arguments, e.g. {"java", "-jar", "/x/antlr.jar"} + std::string description; // for messages + std::string error; // set when !found +}; + +inline std::string envVar(const char* name) { return getEnv(name); } + +inline bool isRegularFile(const fs::path& p) { + std::error_code ec; + return fs::is_regular_file(p, ec); +} +inline bool isRegularFile(const std::string& utf8Path) { return isRegularFile(pathFromUtf8(utf8Path)); } + +inline bool isDirectory(const fs::path& p) { + std::error_code ec; + return fs::is_directory(p, ec); +} +inline bool isDirectory(const std::string& utf8Path) { return isDirectory(pathFromUtf8(utf8Path)); } + +#ifdef _WIN32 +constexpr char kPathSep = ';'; +#else +constexpr char kPathSep = ':'; +#endif + +inline std::vector splitPathList(const std::string& s) { + std::vector out; + size_t i = 0; + while (i <= s.size()) { + const size_t j = s.find(kPathSep, i); + const std::string part = s.substr(i, j == std::string::npos ? std::string::npos : j - i); + if (!part.empty()) out.push_back(part); + if (j == std::string::npos) break; + i = j + 1; + } + return out; +} + +// Returns the full path of an executable found on PATH, or "". +inline std::string findOnPath(const std::string& name) { + static const char* const kExts[] = { +#ifdef _WIN32 + ".exe", ".cmd", ".bat" // a file without an extension cannot be run on Windows +#else + "" +#endif + }; + for (const std::string& dir : splitPathList(envVar("PATH"))) { + for (const char* ext : kExts) { + const fs::path p = pathFromUtf8(dir) / pathFromUtf8(name + ext); + if (isRegularFile(p)) return pathToUtf8(p); + } + } + return ""; +} + +// "java" from JAVA_HOME or PATH. +inline bool javaCommand(std::string& program, std::string& error) { + const std::string home = envVar("JAVA_HOME"); + if (!home.empty()) { + fs::path p = pathFromUtf8(home) / "bin" / "java"; +#ifdef _WIN32 + p += ".exe"; +#endif + if (isRegularFile(p)) { + program = pathToUtf8(p); + return true; + } + } + const std::string onPath = findOnPath("java"); + if (!onPath.empty()) { + program = onPath; + return true; + } + error = "Java was not found. The ANTLR tool is a Java program: install a JDK/JRE (11 or newer) " + "or set JAVA_HOME."; + return false; +} + +// ------------------------------------------------------------ jar search ---- + +inline std::string lowerCase(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +inline bool looksLikeAntlrJar(const fs::path& p) { + const std::string n = lowerCase(pathToUtf8(p.filename())); + return n.rfind("antlr", 0) == 0 && n.size() > 4 && n.compare(n.size() - 4, 4, ".jar") == 0 && + n.find("complete") != std::string::npos; +} + +inline std::vector versionNumbers(const std::string& s) { + std::vector v; + for (size_t i = 0; i < s.size();) { + if (s[i] >= '0' && s[i] <= '9') { + int n = 0; + while (i < s.size() && s[i] >= '0' && s[i] <= '9') n = n * 10 + (s[i++] - '0'); + v.push_back(n); + } else { + ++i; + } + } + return v; +} + +// Looks in the usual places for antlr-X.Y.Z-complete.jar. Prefers the version +// this program was built against, otherwise the newest one. +inline std::string searchJar() { + std::vector dirs; + std::vector files; + std::error_code ec; + + dirs.push_back(fs::current_path(ec)); + const fs::path exe = executablePath(); + if (!exe.empty()) dirs.push_back(exe.parent_path()); + for (const std::string& e : splitPathList(envVar("CLASSPATH"))) { + const fs::path p = pathFromUtf8(e); + if (isDirectory(p)) dirs.push_back(p); + else if (looksLikeAntlrJar(p)) files.push_back(p); + } + const std::string home = envVar("HOME").empty() ? envVar("USERPROFILE") : envVar("HOME"); + if (!home.empty()) { + dirs.push_back(pathFromUtf8(home)); + dirs.push_back(pathFromUtf8(home) / ".m2"); + } +#ifdef _WIN32 + for (const char* d : {"C:\\Javalib", "C:\\antlr", "C:\\Program Files\\ANTLR", "C:\\Program Files\\Java"}) { + dirs.emplace_back(d); + } +#else + for (const char* d : {"/usr/local/lib", "/usr/lib", "/usr/share/java", "/opt/homebrew/lib", + "/opt/homebrew/opt/antlr/libexec", "/usr/local/opt/antlr/libexec"}) { + dirs.emplace_back(d); + } +#endif + + for (const fs::path& d : dirs) { + if (!isDirectory(d)) continue; + for (fs::directory_iterator it(d, fs::directory_options::skip_permission_denied, ec), end; + !ec && it != end; it.increment(ec)) { + if (it->is_regular_file(ec) && looksLikeAntlrJar(it->path())) files.push_back(it->path()); + } + ec.clear(); + } + if (files.empty()) return ""; + + const std::string want = LAB_ANTLR_VERSION; + auto rank = [&](const fs::path& p) { + return pathToUtf8(p.filename()).find(want) != std::string::npos ? 1 : 0; + }; + std::stable_sort(files.begin(), files.end(), [&](const fs::path& a, const fs::path& b) { + if (rank(a) != rank(b)) return rank(a) > rank(b); + return versionNumbers(pathToUtf8(a.filename())) > versionNumbers(pathToUtf8(b.filename())); + }); + return pathToUtf8(files.front()); +} + +// ---------------------------------------------------------------- locate ---- + +// Order: --antlr-jar, $ANTLR_JAR, compile-time default, jar search, then an +// "antlr4" / "antlr" launcher on PATH. +inline Tool locateTool(const std::string& explicitJar) { + Tool t; + auto useJar = [&](const std::string& jar, const std::string& how) { + std::string java, err; + if (!javaCommand(java, err)) { + t.error = err; + return; + } + t.found = true; + t.argv = {java, "-jar", toolSafePath(jar)}; + t.description = jar + how; + }; + + if (!explicitJar.empty()) { + if (!isRegularFile(explicitJar)) t.error = "ANTLR jar not found: " + explicitJar; + else useJar(explicitJar, ""); + return t; + } + const std::string envJar = envVar("ANTLR_JAR"); + if (!envJar.empty()) { + if (!isRegularFile(envJar)) t.error = "ANTLR_JAR points to a file that does not exist: " + envJar; + else useJar(envJar, " (from ANTLR_JAR)"); + return t; + } + const std::string builtIn = LAB_DEFAULT_ANTLR_JAR; + if (!builtIn.empty() && isRegularFile(builtIn)) { + useJar(builtIn, ""); + return t; + } + const std::string found = searchJar(); + if (!found.empty()) { + useJar(found, ""); + return t; + } + for (const char* name : {"antlr4", "antlr"}) { + const std::string p = findOnPath(name); + if (!p.empty()) { + t.found = true; + t.argv = {p}; + t.description = p; + return t; + } + } + t.error = + "Could not find the ANTLR tool.\n" + " - pass --antlr-jar /path/to/antlr-" LAB_ANTLR_VERSION "-complete.jar, or\n" + " - set the ANTLR_JAR environment variable, or\n" + " - put antlr-" LAB_ANTLR_VERSION "-complete.jar in the current directory, or\n" + " - configure the build with -DANTLRLAB_DOWNLOAD_JAR=ON to fetch it."; + return t; +} + +// --------------------------------------------------------------- compile ---- + +struct CompileResult { + bool ok = false; + int exitCode = 0; + std::string log; // combined stdout + stderr of the tool + std::string command; // what was run, for display + std::vector interps; + std::string summary; // one line for the failure case +}; + +inline std::string trimLine(std::string s) { + while (!s.empty() && (s.back() == '\r' || s.back() == ' ' || s.back() == '\t')) s.pop_back(); + size_t i = 0; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; + return s.substr(i); +} + +// Some "errors" only concern the generated C++ (a rule named "int", say). +// We never compile that code, so they are harmless for interpreting. +inline bool onlyGeneratedCodeErrors(const std::string& log) { + size_t tolerated = 0, other = 0; + std::istringstream in(log); + std::string line; + while (std::getline(in, line)) { + line = trimLine(line); + if (line.rfind("error(", 0) != 0) continue; + if (line.rfind("error(134)", 0) == 0 || line.find("conflicts with generated code") != std::string::npos) { + ++tolerated; + } else { + ++other; + } + } + return tolerated > 0 && other == 0; +} + +inline std::string firstProblemLine(const std::string& log) { + std::istringstream in(log); + std::string line, firstAny; + while (std::getline(in, line)) { + line = trimLine(line); + if (line.empty()) continue; + if (line.rfind("error(", 0) == 0) return line; + if (firstAny.empty()) firstAny = line; + } + return firstAny; +} + +inline CompileResult compileGrammars(const Tool& tool, const std::vector& grammars, + const std::string& libDir, const fs::path& genDir, + const fs::path& logFile) { + CompileResult r; + std::error_code ec; + fs::remove_all(genDir, ec); + fs::create_directories(genDir, ec); + if (ec) { + r.summary = "cannot create " + pathToUtf8(genDir) + ": " + ec.message(); + return r; + } + + std::vector argv = tool.argv; + for (const char* a : {"-Dlanguage=Cpp", "-no-listener", "-no-visitor", "-Xexact-output-dir", "-o"}) argv.emplace_back(a); + argv.push_back(toolSafePath(pathToUtf8(genDir))); + if (!libDir.empty()) { + argv.emplace_back("-lib"); + argv.push_back(toolSafePath(libDir)); + } + for (const fs::path& g : grammars) argv.push_back(toolSafePath(pathToUtf8(g))); + for (const std::string& a : argv) r.command += (r.command.empty() ? "" : " ") + a; + + const ProcessResult pr = runProcess(argv, logFile); + r.exitCode = pr.exitCode; + if (pr.started) readFile(logFile, r.log); + else r.log = "could not start " + argv[0] + ": " + pr.error + "\n"; + + for (fs::recursive_directory_iterator it(genDir, ec), end; !ec && it != end; it.increment(ec)) { + if (it->is_regular_file(ec) && it->path().extension() == ".interp") r.interps.push_back(it->path()); + } + std::sort(r.interps.begin(), r.interps.end()); + + const bool clean = r.exitCode == 0; + if ((clean || onlyGeneratedCodeErrors(r.log)) && !r.interps.empty()) { + r.ok = true; + return r; + } + + r.summary = firstProblemLine(r.log); + if (r.summary.empty()) { + if (!pr.started) r.summary = "could not start the ANTLR tool (" + pr.error + ")"; + else if (r.exitCode == 127 || r.exitCode == -1) r.summary = "could not start the ANTLR tool"; + else if (clean) r.summary = "the ANTLR tool wrote no .interp files (is it older than 4.7?)"; + else r.summary = "the ANTLR tool exited with code " + std::to_string(r.exitCode); + } + return r; +} + +} // namespace lab diff --git a/src/crash.hpp b/src/crash.hpp new file mode 100644 index 0000000..63913b7 --- /dev/null +++ b/src/crash.hpp @@ -0,0 +1,351 @@ +// Crash reporting. If the ANTLR runtime (or this program) dies with a fatal +// signal, say what we were doing and print a stack trace instead of the bare +// "Segmentation fault" the shell gives. +// +// crash::setPhase("parsing the input") -- cheap; call it before risky work +// crash::installHandlers() -- once, at start-up +// crash::AltStack -- one per thread that may overflow +// +// The phase lives in shared memory, so a parent process can still read it +// after a forked child has died (see isolate.hpp). +#pragma once + +#include "platform.hpp" + +#include +#include +#include +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) +#define LAB_POSIX 1 +#include +#include +#include +#if defined(__has_include) +#if __has_include() +#include +#define LAB_HAVE_BACKTRACE 1 +#endif +#endif +#elif defined(_WIN32) +#include // after windows.h (platform.hpp) +#endif + +namespace lab { +namespace crash { + +struct Shared { + char phase[120]; +}; + +inline Shared*& sharedSlot() { + static Shared* p = nullptr; + return p; +} + +inline Shared& shared() { + static Shared local = {}; + Shared* p = sharedSlot(); + return p ? *p : local; +} + +// Call once before forking so that parent and child see the same memory. +inline void initShared() { +#ifdef LAB_POSIX + if (sharedSlot()) return; +#ifndef MAP_ANONYMOUS +#define MAP_ANONYMOUS MAP_ANON +#endif + void* m = ::mmap(nullptr, sizeof(Shared), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); + if (m != MAP_FAILED) { + sharedSlot() = static_cast(m); + std::memset(sharedSlot(), 0, sizeof(Shared)); + } +#endif +} + +inline void setPhase(const char* what) { + Shared& s = shared(); + size_t i = 0; + for (; what[i] != '\0' && i + 1 < sizeof s.phase; ++i) s.phase[i] = what[i]; + s.phase[i] = '\0'; +} + +inline std::string phase() { return std::string(shared().phase); } + +#ifdef LAB_POSIX + +inline const char* signalName(int sig) { + switch (sig) { + case SIGSEGV: return "SIGSEGV (invalid memory access, often a stack overflow)"; + case SIGBUS: return "SIGBUS (bus error)"; + case SIGFPE: return "SIGFPE (arithmetic error)"; + case SIGILL: return "SIGILL (illegal instruction)"; + case SIGABRT: return "SIGABRT (abort, usually an uncaught exception or failed assertion)"; + case SIGKILL: return "SIGKILL (killed, for example by the out-of-memory killer)"; + case SIGTERM: return "SIGTERM"; + case SIGINT: return "SIGINT"; + default: return "a fatal signal"; + } +} + +inline void writeStr(const char* s) { + size_t n = std::strlen(s); + while (n > 0) { + const ssize_t w = ::write(2, s, n); + if (w <= 0) return; + s += w; + n -= static_cast(w); + } +} + +inline void writeHex(unsigned long v) { + char b[24]; + int i = 23; + b[i] = '\0'; + do { + b[--i] = "0123456789abcdef"[v & 15]; + v >>= 4; + } while (v != 0 && i > 2); + b[--i] = 'x'; + b[--i] = '0'; + writeStr(b + i); +} + +extern "C" inline void onFatalSignal(int sig, siginfo_t* info, void*) { + static std::atomic entered{0}; + if (entered.exchange(1) != 0) _exit(128 + sig); + writeStr("\n[antlrlab] fatal error: "); + writeStr(signalName(sig)); + writeStr("\n[antlrlab] while: "); + writeStr(shared().phase[0] ? shared().phase : "(unknown)"); + if (info && (sig == SIGSEGV || sig == SIGBUS)) { + writeStr("\n[antlrlab] address: "); + writeHex(reinterpret_cast(info->si_addr)); + } +#ifdef LAB_HAVE_BACKTRACE + writeStr("\n[antlrlab] stack trace (innermost first):\n"); + void* frames[64]; + const int n = ::backtrace(frames, 64); + ::backtrace_symbols_fd(frames, n, 2); +#endif + writeStr("[antlrlab] This is a bug in antlrlab or in the ANTLR C++ runtime, not in your grammar.\n"); + writeStr("[antlrlab] Please report it with the stack trace above, the grammar and the input.\n"); + // SA_RESETHAND restored the default action: returning re-raises the signal. +} + +inline void installHandlers() { +#ifdef LAB_HAVE_BACKTRACE + void* warm[2]; + ::backtrace(warm, 2); // loads the unwinder now, not inside a crashing process +#endif + struct sigaction sa; + std::memset(&sa, 0, sizeof sa); + sa.sa_sigaction = onFatalSignal; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESETHAND; + for (int sig : {SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGABRT}) ::sigaction(sig, &sa, nullptr); +} + +// A separate stack for the handler, so that it can run after a stack overflow. +// The alternate stack belongs to one thread: create one of these in each +// thread that may overflow (the main thread, workers). +class AltStack { + public: + AltStack() { + stack_t ss; + mem_ = std::malloc(kSize); + if (!mem_) return; + ss.ss_sp = mem_; + ss.ss_size = kSize; + ss.ss_flags = 0; + if (::sigaltstack(&ss, nullptr) != 0) { + std::free(mem_); + mem_ = nullptr; + } + } + ~AltStack() { + if (!mem_) return; + stack_t ss; + std::memset(&ss, 0, sizeof ss); + ss.ss_flags = SS_DISABLE; + ::sigaltstack(&ss, nullptr); + std::free(mem_); + } + AltStack(const AltStack&) = delete; + AltStack& operator=(const AltStack&) = delete; + + private: + static constexpr size_t kSize = 128 * 1024; + void* mem_ = nullptr; +}; + +#elif defined(_WIN32) + +// Windows: no fork, so a crash cannot be survived, but it can be explained: +// what we were doing, the exception, and a stack trace. The process then +// ends with exit status 2. + +inline const char* signalName(int) { return "a fatal error"; } + +namespace detail { + +inline void out(const char* s) { + DWORD written = 0; + ::WriteFile(::GetStdHandle(STD_ERROR_HANDLE), s, static_cast(std::strlen(s)), &written, nullptr); +} + +inline const char* exceptionName(DWORD code) { + switch (code) { + case EXCEPTION_ACCESS_VIOLATION: return "access violation (invalid memory access)"; + case EXCEPTION_STACK_OVERFLOW: return "stack overflow"; + case EXCEPTION_INT_DIVIDE_BY_ZERO: return "integer division by zero"; + case EXCEPTION_ILLEGAL_INSTRUCTION: return "illegal instruction"; + case EXCEPTION_IN_PAGE_ERROR: return "page error (invalid memory access)"; + case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: return "array bounds exceeded"; + case EXCEPTION_PRIV_INSTRUCTION: return "privileged instruction"; + case EXCEPTION_NONCONTINUABLE_EXCEPTION: return "non-continuable exception"; + default: return "an unhandled exception"; + } +} + +inline void printStackTrace(const CONTEXT* faulting) { + HANDLE proc = ::GetCurrentProcess(); + HANDLE thread = ::GetCurrentThread(); + ::SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS | SYMOPT_LOAD_LINES); + if (!::SymInitialize(proc, nullptr, TRUE)) return; + + CONTEXT ctx = *faulting; + STACKFRAME64 sf; + std::memset(&sf, 0, sizeof sf); + DWORD machine = 0; +#if defined(_M_X64) || defined(__x86_64__) + machine = IMAGE_FILE_MACHINE_AMD64; + sf.AddrPC.Offset = ctx.Rip; + sf.AddrFrame.Offset = ctx.Rbp; + sf.AddrStack.Offset = ctx.Rsp; +#elif defined(_M_IX86) || defined(__i386__) + machine = IMAGE_FILE_MACHINE_I386; + sf.AddrPC.Offset = ctx.Eip; + sf.AddrFrame.Offset = ctx.Ebp; + sf.AddrStack.Offset = ctx.Esp; +#elif defined(_M_ARM64) || defined(__aarch64__) + machine = IMAGE_FILE_MACHINE_ARM64; + sf.AddrPC.Offset = ctx.Pc; + sf.AddrFrame.Offset = ctx.Fp; + sf.AddrStack.Offset = ctx.Sp; +#else + return; +#endif + sf.AddrPC.Mode = AddrModeFlat; + sf.AddrFrame.Mode = AddrModeFlat; + sf.AddrStack.Mode = AddrModeFlat; + + alignas(SYMBOL_INFO) char symbuf[sizeof(SYMBOL_INFO) + 256]; + char line[640]; + for (int i = 0; i < 48; ++i) { + if (!::StackWalk64(machine, proc, thread, &sf, &ctx, nullptr, ::SymFunctionTableAccess64, + ::SymGetModuleBase64, nullptr) || + sf.AddrPC.Offset == 0) { + break; + } + const DWORD64 addr = sf.AddrPC.Offset; + SYMBOL_INFO* sym = reinterpret_cast(symbuf); + std::memset(sym, 0, sizeof(SYMBOL_INFO)); + sym->SizeOfStruct = sizeof(SYMBOL_INFO); + sym->MaxNameLen = 255; + DWORD64 disp = 0; + if (::SymFromAddr(proc, addr, &disp, sym)) { + std::snprintf(line, sizeof line, " %s+0x%llx [0x%llx]\n", sym->Name, + static_cast(disp), static_cast(addr)); + } else { + HMODULE mod = nullptr; + char path[MAX_PATH] = "?"; + if (::GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(addr), &mod) && + mod != nullptr) { + ::GetModuleFileNameA(mod, path, MAX_PATH); + } + const char* base = std::strrchr(path, '\\'); + std::snprintf(line, sizeof line, " %s+0x%llx\n", base ? base + 1 : path, + static_cast(addr - reinterpret_cast(mod))); + } + out(line); + } +} + +inline void finish() { + out("[antlrlab] This is a bug in antlrlab or in the ANTLR C++ runtime, not in your grammar.\n"); + out("[antlrlab] Please report it with the stack trace above, the grammar and the input.\n"); + ::TerminateProcess(::GetCurrentProcess(), 2); +} + +inline void header(const char* what) { + out("\n[antlrlab] fatal error: "); + out(what); + out("\n[antlrlab] while: "); + out(shared().phase[0] ? shared().phase : "(unknown)"); +} + +inline LONG WINAPI onException(EXCEPTION_POINTERS* ep) { + static std::atomic entered{0}; + if (entered.exchange(1) != 0) ::TerminateProcess(::GetCurrentProcess(), 2); + const EXCEPTION_RECORD* rec = ep->ExceptionRecord; + header(exceptionName(rec->ExceptionCode)); + char line[96]; + if (rec->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && rec->NumberParameters >= 2) { + std::snprintf(line, sizeof line, "\n[antlrlab] address: 0x%llx", + static_cast(rec->ExceptionInformation[1])); + out(line); + } else if (rec->ExceptionCode != EXCEPTION_STACK_OVERFLOW) { + std::snprintf(line, sizeof line, "\n[antlrlab] exception code: 0x%08lx", static_cast(rec->ExceptionCode)); + out(line); + } + out("\n[antlrlab] stack trace (innermost first):\n"); + printStackTrace(ep->ContextRecord); + finish(); + return EXCEPTION_EXECUTE_HANDLER; +} + +extern "C" inline void onAbort(int) { + header("abort (usually an uncaught exception or a failed assertion)"); + out("\n"); + finish(); +} + +} // namespace detail + +inline void installHandlers() { + ::SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + ::SetUnhandledExceptionFilter(detail::onException); + std::signal(SIGABRT, detail::onAbort); +#ifdef _MSC_VER + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); +#endif +} + +// Keeps some stack in reserve on this thread so that the exception filter +// can still run after a stack overflow. One per thread that may overflow. +class AltStack { + public: + AltStack() { + ULONG bytes = 128 * 1024; + ::SetThreadStackGuarantee(&bytes); + } + AltStack(const AltStack&) = delete; + AltStack& operator=(const AltStack&) = delete; +}; + +#else // some other system without signals: no crash reports + +inline const char* signalName(int) { return "a fatal error"; } +inline void installHandlers() {} +class AltStack {}; + +#endif + +} // namespace crash +} // namespace lab diff --git a/src/http.hpp b/src/http.hpp new file mode 100644 index 0000000..2a10291 --- /dev/null +++ b/src/http.hpp @@ -0,0 +1,394 @@ +// A minimal HTTP/1.1 server for the local web UI. It only listens on the +// loopback interface, answers one request per connection ("Connection: +// close"), and runs each connection on its own thread so that an idle +// browser pre-connection cannot block anything. +// +// POSIX sockets, with a Winsock branch for Windows. +#pragma once + +#include "platform.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "crash.hpp" + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace lab { + +struct Request { + std::string method; + std::string target; // raw request target + std::string path; // decoded path, without the query + std::map query; + std::map headers; // names lower-cased + std::string body; + + std::string header(const std::string& name) const { + const auto it = headers.find(name); + return it == headers.end() ? std::string() : it->second; + } +}; + +struct Response { + int status = 200; + std::string contentType = "text/plain; charset=utf-8"; + std::string body; + std::vector> headers; +}; + +using Handler = std::function; + +namespace http { + +constexpr size_t kMaxHeaderBytes = 64 * 1024; +constexpr size_t kMaxBodyBytes = 24 * 1024 * 1024; + +#ifdef _WIN32 +using Sock = SOCKET; +inline bool validSock(Sock s) { return s != INVALID_SOCKET; } +inline void closeSock(Sock s) { closesocket(s); } +constexpr Sock kBadSock = INVALID_SOCKET; +#else +using Sock = int; +inline bool validSock(Sock s) { return s >= 0; } +inline void closeSock(Sock s) { ::close(s); } +constexpr Sock kBadSock = -1; +#endif + +inline int hexVal(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +inline std::string percentDecode(const std::string& s, bool plusIsSpace) { + std::string o; + o.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '%' && i + 2 < s.size() && hexVal(s[i + 1]) >= 0 && hexVal(s[i + 2]) >= 0) { + o += static_cast(hexVal(s[i + 1]) * 16 + hexVal(s[i + 2])); + i += 2; + } else if (s[i] == '+' && plusIsSpace) { + o += ' '; + } else { + o += s[i]; + } + } + return o; +} + +inline std::string lower(std::string s) { + for (char& c : s) { + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + } + return s; +} + +inline std::string trim(const std::string& s) { + size_t a = 0, b = s.size(); + while (a < b && (s[a] == ' ' || s[a] == '\t')) ++a; + while (b > a && (s[b - 1] == ' ' || s[b - 1] == '\t' || s[b - 1] == '\r')) --b; + return s.substr(a, b - a); +} + +inline const char* statusText(int code) { + switch (code) { + case 100: return "Continue"; + case 200: return "OK"; + case 204: return "No Content"; + case 302: return "Found"; + case 400: return "Bad Request"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 408: return "Request Timeout"; + case 409: return "Conflict"; + case 411: return "Length Required"; + case 413: return "Payload Too Large"; + case 415: return "Unsupported Media Type"; + case 431: return "Request Header Fields Too Large"; + case 500: return "Internal Server Error"; + case 501: return "Not Implemented"; + default: return "OK"; + } +} + +inline bool sendAll(Sock s, const char* data, size_t n) { + size_t sent = 0; + while (sent < n) { +#ifdef _WIN32 + const int r = ::send(s, data + sent, static_cast(n - sent), 0); +#elif defined(MSG_NOSIGNAL) + const ssize_t r = ::send(s, data + sent, n - sent, MSG_NOSIGNAL); +#else + const ssize_t r = ::send(s, data + sent, n - sent, 0); +#endif + if (r <= 0) return false; + sent += static_cast(r); + } + return true; +} + +inline void writeResponse(Sock s, const Response& r) { + std::string head = "HTTP/1.1 " + std::to_string(r.status) + " " + statusText(r.status) + "\r\n"; + head += "Content-Type: " + r.contentType + "\r\n"; + head += "Content-Length: " + std::to_string(r.body.size()) + "\r\n"; + head += "Connection: close\r\n"; + head += "Cache-Control: no-store\r\n"; + head += "X-Content-Type-Options: nosniff\r\n"; + head += "Referrer-Policy: no-referrer\r\n"; + head += "X-Frame-Options: DENY\r\n"; + head += "Cross-Origin-Resource-Policy: same-origin\r\n"; + for (const auto& h : r.headers) head += h.first + ": " + h.second + "\r\n"; + head += "\r\n"; + if (!sendAll(s, head.data(), head.size())) return; + if (!r.body.empty()) sendAll(s, r.body.data(), r.body.size()); +} + +inline Response errorResponse(int code, const std::string& msg) { + Response r; + r.status = code; + r.contentType = "application/json; charset=utf-8"; + r.body = "{\"error\":\"" + msg + "\"}"; + return r; +} + +// Reads and parses one request. Returns 0 on success, otherwise the HTTP +// status to answer with (-1: just drop the connection). +inline int readRequest(Sock s, Request& rq) { + std::string buf; + char chunk[8192]; + size_t headerEnd = std::string::npos; + while (headerEnd == std::string::npos) { + const int n = static_cast(::recv(s, chunk, static_cast(sizeof chunk), 0)); + if (n <= 0) return buf.empty() ? -1 : 408; + buf.append(chunk, static_cast(n)); + headerEnd = buf.find("\r\n\r\n"); + if (headerEnd == std::string::npos && buf.size() > kMaxHeaderBytes) return 431; + } + + const std::string head = buf.substr(0, headerEnd); + std::string body = buf.substr(headerEnd + 4); + + size_t eol = head.find("\r\n"); + const std::string line = head.substr(0, eol); + const size_t sp1 = line.find(' '); + const size_t sp2 = line.rfind(' '); + if (sp1 == std::string::npos || sp2 == sp1) return 400; + rq.method = line.substr(0, sp1); + rq.target = line.substr(sp1 + 1, sp2 - sp1 - 1); + if (line.compare(sp2 + 1, 5, "HTTP/") != 0) return 400; + + while (eol != std::string::npos) { + const size_t start = eol + 2; + eol = head.find("\r\n", start); + const std::string h = head.substr(start, eol == std::string::npos ? std::string::npos : eol - start); + const size_t colon = h.find(':'); + if (colon == std::string::npos || colon == 0) continue; + rq.headers[lower(h.substr(0, colon))] = trim(h.substr(colon + 1)); + } + + const size_t qm = rq.target.find('?'); + rq.path = percentDecode(rq.target.substr(0, qm), false); + if (qm != std::string::npos) { + const std::string qs = rq.target.substr(qm + 1); + size_t i = 0; + while (i <= qs.size()) { + const size_t amp = qs.find('&', i); + const std::string pair = qs.substr(i, amp == std::string::npos ? std::string::npos : amp - i); + if (!pair.empty()) { + const size_t eq = pair.find('='); + const std::string k = percentDecode(pair.substr(0, eq), true); + const std::string v = eq == std::string::npos ? "" : percentDecode(pair.substr(eq + 1), true); + rq.query[k] = v; + } + if (amp == std::string::npos) break; + i = amp + 1; + } + } + + if (!rq.header("transfer-encoding").empty()) return 501; + + size_t want = 0; + const std::string cl = rq.header("content-length"); + if (!cl.empty()) { + for (char c : cl) { + if (c < '0' || c > '9' || cl.size() > 12) return 400; + } + want = static_cast(std::stoull(cl)); + if (want > kMaxBodyBytes) return 413; + } else if (rq.method == "POST" || rq.method == "PUT") { + return 411; + } + + if (want > body.size() && lower(rq.header("expect")) == "100-continue") { + const char* c100 = "HTTP/1.1 100 Continue\r\n\r\n"; + sendAll(s, c100, std::strlen(c100)); + } + while (body.size() < want) { + const int n = static_cast(::recv(s, chunk, static_cast(sizeof chunk), 0)); + if (n <= 0) return 408; + body.append(chunk, static_cast(n)); + } + body.resize(want); + rq.body = std::move(body); + return 0; +} + +#ifdef _WIN32 +struct WinsockInit { + WinsockInit() { + WSADATA d; + WSAStartup(MAKEWORD(2, 2), &d); + } + ~WinsockInit() { WSACleanup(); } +}; +#endif + +} // namespace http + +class HttpServer { + public: + HttpServer() { +#ifdef _WIN32 + static http::WinsockInit init; +#else + ::signal(SIGPIPE, SIG_IGN); +#endif + } + ~HttpServer() { + if (http::validSock(listen_)) http::closeSock(listen_); + } + HttpServer(const HttpServer&) = delete; + HttpServer& operator=(const HttpServer&) = delete; + + // Tries each port in turn (0 = let the system choose). Loopback only. + bool listenOn(const std::vector& ports, std::string& error) { + for (int p : ports) { + http::Sock s = ::socket(AF_INET, SOCK_STREAM, 0); + if (!http::validSock(s)) { + error = "cannot create a socket"; + return false; + } + int yes = 1; +#ifdef _WIN32 + // Without this, another program could bind the same port and share our traffic. + ::setsockopt(s, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast(&yes), sizeof yes); + ::SetHandleInformation(reinterpret_cast(s), HANDLE_FLAG_INHERIT, 0); // not for the Java child +#else + ::setsockopt(s, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&yes), sizeof yes); +#endif + sockaddr_in addr; + std::memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(p)); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (::bind(s, reinterpret_cast(&addr), sizeof addr) != 0 || ::listen(s, 32) != 0) { + http::closeSock(s); + continue; + } + sockaddr_in bound; +#ifdef _WIN32 + int len = sizeof bound; +#else + socklen_t len = sizeof bound; +#endif + ::getsockname(s, reinterpret_cast(&bound), &len); + port_ = ntohs(bound.sin_port); + listen_ = s; + return true; + } + error = ports.size() == 1 ? "port " + std::to_string(ports[0]) + " is not available" + : "no free port found"; + return false; + } + + int port() const { return port_; } + + // Blocks until `stop` becomes true. + void serve(const Handler& handler, const std::atomic& stop) { + while (!stop.load()) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(listen_, &fds); + timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 200 * 1000; +#ifdef _WIN32 + const int ready = ::select(0, &fds, nullptr, nullptr, &tv); +#else + const int ready = ::select(listen_ + 1, &fds, nullptr, nullptr, &tv); +#endif + if (ready <= 0) continue; + http::Sock c = ::accept(listen_, nullptr, nullptr); + if (!http::validSock(c)) continue; +#ifdef _WIN32 + ::SetHandleInformation(reinterpret_cast(c), HANDLE_FLAG_INHERIT, 0); +#endif + active_.fetch_add(1); + std::thread([this, c, &handler]() { + connection(c, handler); + active_.fetch_sub(1); + }).detach(); + } + for (int i = 0; i < 100 && active_.load() > 0; ++i) { // let running requests finish + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + + private: + http::Sock listen_ = http::kBadSock; + int port_ = 0; + std::atomic active_{0}; + + static void connection(http::Sock c, const Handler& handler) { + crash::AltStack altStack; +#ifdef _WIN32 + DWORD ms = 15000; + ::setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&ms), sizeof ms); + ::setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&ms), sizeof ms); +#else + timeval tv; + tv.tv_sec = 15; + tv.tv_usec = 0; + ::setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + ::setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); +#endif + Request rq; + const int rc = http::readRequest(c, rq); + if (rc > 0) { + http::writeResponse(c, http::errorResponse(rc, http::statusText(rc))); + } else if (rc == 0) { + Response r; + try { + r = handler(rq); + } catch (const std::exception& e) { + r = http::errorResponse(500, "internal error"); + (void)e; + } + http::writeResponse(c, r); + } + http::closeSock(c); + } +}; + +} // namespace lab diff --git a/src/interp.hpp b/src/interp.hpp new file mode 100644 index 0000000..274c182 --- /dev/null +++ b/src/interp.hpp @@ -0,0 +1,107 @@ +// Reads the ".interp" files that the ANTLR tool writes next to generated code. +// +// Format (one section per header, sections separated by a blank line): +// +// token literal names: one entry per token type, "null" if none +// token symbolic names: one entry per token type, "null" if none +// rule names: +// channel names: lexer only +// mode names: lexer only +// atn: +// [4, 1, 12, ...] serialized ATN, comma separated integers +// +// The runtime's LexerInterpreter / ParserInterpreter can run a grammar straight +// from this data, which is what makes a "grammar in, tree out" tool possible +// without generating and compiling C++ code for every grammar. +#pragma once + +#include +#include +#include + +namespace lab { + +struct InterpData { + std::string name; // file stem, e.g. "ExprLexer" + bool isLexer = false; + std::vector literalNames; // "" where the file says null + std::vector symbolicNames; // "" where the file says null + std::vector ruleNames; + std::vector channelNames; + std::vector modeNames; + std::vector atn; +}; + +inline bool parseInterp(const std::string& text, InterpData& out, std::string& error) { + enum class Sec { None, Literal, Symbolic, Rules, Channels, Modes, Atn }; + Sec sec = Sec::None; + bool sawAtn = false, sawChannels = false, sawModes = false; + std::string atnText; + + size_t pos = 0; + while (pos <= text.size()) { + const size_t nl = text.find('\n', pos); + std::string line = text.substr(pos, nl == std::string::npos ? std::string::npos : nl - pos); + pos = (nl == std::string::npos) ? text.size() + 1 : nl + 1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + + if (sec == Sec::Atn) { // everything after "atn:" is the serialized ATN + atnText += line; + atnText += ' '; + continue; + } + if (line == "token literal names:") { sec = Sec::Literal; continue; } + if (line == "token symbolic names:") { sec = Sec::Symbolic; continue; } + if (line == "rule names:") { sec = Sec::Rules; continue; } + if (line == "channel names:") { sec = Sec::Channels; sawChannels = true; continue; } + if (line == "mode names:") { sec = Sec::Modes; sawModes = true; continue; } + if (line == "atn:") { sec = Sec::Atn; sawAtn = true; continue; } + if (line.empty()) { sec = Sec::None; continue; } + + switch (sec) { + case Sec::Literal: out.literalNames.push_back(line == "null" ? "" : line); break; + case Sec::Symbolic: out.symbolicNames.push_back(line == "null" ? "" : line); break; + case Sec::Rules: out.ruleNames.push_back(line); break; + case Sec::Channels: out.channelNames.push_back(line); break; + case Sec::Modes: out.modeNames.push_back(line); break; + default: break; + } + } + + if (!sawAtn) { + error = "no \"atn:\" section (is this really an ANTLR .interp file?)"; + return false; + } + + // The serialized ATN is "[4, 1, 12, ...]": pull out every integer. + out.atn.clear(); + for (size_t i = 0; i < atnText.size();) { + const char c = atnText[i]; + const bool digit = c >= '0' && c <= '9'; + if (!digit && !(c == '-' && i + 1 < atnText.size() && atnText[i + 1] >= '0' && atnText[i + 1] <= '9')) { + ++i; + continue; + } + const bool neg = (c == '-'); + if (neg) ++i; + long long v = 0; + while (i < atnText.size() && atnText[i] >= '0' && atnText[i] <= '9') { + v = v * 10 + (atnText[i] - '0'); + ++i; + } + out.atn.push_back(static_cast(neg ? -v : v)); + } + if (out.atn.empty()) { + error = "the \"atn:\" section is empty"; + return false; + } + if (out.atn.front() != 4) { + error = "serialized ATN has version " + std::to_string(out.atn.front()) + + " but this program reads version 4 (written by ANTLR 4.10 and newer)"; + return false; + } + out.isLexer = sawChannels || sawModes; + return true; +} + +} // namespace lab diff --git a/src/isolate.hpp b/src/isolate.hpp new file mode 100644 index 0000000..3cad53f --- /dev/null +++ b/src/isolate.hpp @@ -0,0 +1,329 @@ +// Runs the part of the program that talks to the ANTLR runtime somewhere it +// cannot take everything else down: +// +// * on a thread with a very large stack, because the runtime's prediction +// code and our own tree code recurse deeply on real grammars (default +// stacks are 8 MB on the main thread and only 512 KB on macOS threads); +// * in a forked child process, so a crash (or an endless run) becomes an +// error message and the parent - the web server, the watch loop - lives on; +// * with a time limit. +// +// Windows has no fork: there the work runs in-process, on a thread with a +// large stack. A crash there still gets a report (what the program was doing +// and a stack trace, see crash.hpp) but ends the program, and --timeout is +// not enforced. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "crash.hpp" + +#ifdef LAB_POSIX +#include +#include +#include +#include +#include +#elif defined(_WIN32) +#include +#endif + +namespace lab { + +// Whether the runtime can be run in a child process that may crash or be +// stopped (POSIX), or only in this process (Windows). +#ifdef LAB_POSIX +constexpr bool kHasIsolation = true; +#else +constexpr bool kHasIsolation = false; +#endif + +struct Outcome { + enum class Kind { Ok, Crashed, TimedOut, Cancelled, Failed }; + + Kind kind = Kind::Failed; + std::string payload; // what the work function returned + int exitCode = 0; // what the work function reported through its argument + int signal = 0; // when Crashed + std::string phase; // what the child was doing at the end + std::string message; // for Failed: the reason +}; + +// The work function returns a payload string and may set an exit code. +using Work = std::function; + +// ------------------------------------------------------------ big stack ---- + +#ifdef LAB_POSIX +namespace detail { + +struct StackJob { + const std::function* fn; + std::exception_ptr error; +}; + +inline void* stackTrampoline(void* p) { + StackJob* job = static_cast(p); + crash::AltStack alt; + try { + (*job->fn)(); + } catch (...) { + job->error = std::current_exception(); + } + return nullptr; +} + +} // namespace detail + +// Runs fn on a new thread with a big stack and waits for it. Falls back to a +// smaller stack, and finally to the calling thread, if the system refuses. +inline void runOnBigStack(const std::function& fn) { + detail::StackJob job{&fn, nullptr}; + const size_t MB = 1024 * 1024; + for (size_t mb : {size_t(512), size_t(128), size_t(32)}) { + pthread_attr_t attr; + if (pthread_attr_init(&attr) != 0) break; + if (pthread_attr_setstacksize(&attr, mb * MB) != 0) { + pthread_attr_destroy(&attr); + continue; + } + pthread_t th; + const int rc = pthread_create(&th, &attr, detail::stackTrampoline, &job); + pthread_attr_destroy(&attr); + if (rc != 0) continue; + pthread_join(th, nullptr); + if (job.error) std::rethrow_exception(job.error); + return; + } + fn(); +} +#elif defined(_WIN32) +namespace detail { + +struct StackJob { + const std::function* fn; + std::exception_ptr error; + bool finished = false; // still false after the wait: the thread was killed (stack overflow) +}; + +inline unsigned __stdcall stackTrampoline(void* p) { + StackJob* job = static_cast(p); + crash::AltStack alt; + try { + (*job->fn)(); + } catch (...) { + job->error = std::current_exception(); + } + job->finished = true; + return 0; +} + +} // namespace detail + +// Runs fn on a new thread with a big stack and waits for it. Falls back to a +// smaller stack, and finally to the calling thread, if the system refuses. +inline void runOnBigStack(const std::function& fn) { + detail::StackJob job{&fn, nullptr, false}; + const size_t MB = 1024 * 1024; + for (size_t mb : {size_t(512), size_t(128), size_t(32)}) { + if (sizeof(void*) < 8 && mb > 128) continue; // 32-bit: address space is scarce + unsigned id = 0; + const uintptr_t h = ::_beginthreadex(nullptr, static_cast(mb * MB), detail::stackTrampoline, &job, + STACK_SIZE_PARAM_IS_A_RESERVATION, &id); + if (h == 0) continue; + HANDLE th = reinterpret_cast(h); + ::WaitForSingleObject(th, INFINITE); + ::CloseHandle(th); + if (job.error) std::rethrow_exception(job.error); + if (!job.finished) throw std::runtime_error("the worker thread ended abnormally (stack overflow?)"); + return; + } + fn(); +} +#else +inline void runOnBigStack(const std::function& fn) { fn(); } +#endif + +// ------------------------------------------------------------- isolation ---- + +namespace detail { + +inline Outcome runInline(const Work& fn) { + Outcome o; + try { + runOnBigStack([&] { o.payload = fn(o.exitCode); }); + o.kind = Outcome::Kind::Ok; + } catch (const std::exception& e) { + o.kind = Outcome::Kind::Failed; + o.message = e.what(); + } catch (...) { + o.kind = Outcome::Kind::Failed; + o.message = "unknown exception"; + } + return o; +} + +} // namespace detail + +// One-line description of a failed Outcome, for people. +inline std::string describe(const Outcome& o) { + const std::string where = o.phase.empty() ? std::string() : " while " + o.phase; + switch (o.kind) { + case Outcome::Kind::Crashed: + return std::string("The ANTLR C++ runtime crashed with ") + crash::signalName(o.signal) + where + + ". antlrlab itself is still running. This is a bug in antlrlab or in the runtime rather than in your " + "grammar; the terminal that started antlrlab has a stack trace."; + case Outcome::Kind::TimedOut: + return "Stopped after " + o.message + where + + ". The grammar or input may be extremely ambiguous. Raise the limit with --timeout."; + case Outcome::Kind::Cancelled: + return "Cancelled."; + case Outcome::Kind::Failed: + return "The run failed" + where + ": " + (o.message.empty() ? "unknown error" : o.message); + default: + return ""; + } +} + +// Runs fn in a forked child (POSIX) and returns what it produced, or how it +// died. timeoutSeconds <= 0 means no limit. isolate = false runs in-process. +inline Outcome runIsolated(const Work& fn, int timeoutSeconds, bool isolate = true) { +#ifdef LAB_POSIX + if (!isolate) return detail::runInline(fn); + + int fds[2]; + if (::pipe(fds) != 0) return detail::runInline(fn); + std::fflush(nullptr); + crash::setPhase("starting"); + const pid_t pid = ::fork(); + if (pid < 0) { + ::close(fds[0]); + ::close(fds[1]); + return detail::runInline(fn); + } + + if (pid == 0) { // ---- child + ::close(fds[0]); + ::signal(SIGINT, SIG_DFL); + ::signal(SIGTERM, SIG_DFL); + struct rlimit nocore; + nocore.rlim_cur = nocore.rlim_max = 0; + ::setrlimit(RLIMIT_CORE, &nocore); + + int code = 0; + char tag = 'R'; + std::string body; + try { + runOnBigStack([&] { body = fn(code); }); + } catch (const std::exception& e) { + tag = 'E'; + body = e.what(); + } catch (...) { + tag = 'E'; + body = "unknown exception"; + } + std::fflush(nullptr); + std::string frame; + frame.reserve(body.size() + 5); + frame.push_back(tag); + for (int i = 0; i < 4; ++i) frame.push_back(static_cast((code >> (8 * i)) & 0xFF)); + frame += body; + size_t off = 0; + while (off < frame.size()) { + const ssize_t w = ::write(fds[1], frame.data() + off, frame.size() - off); + if (w < 0 && errno == EINTR) continue; + if (w <= 0) break; + off += static_cast(w); + } + ::close(fds[1]); + ::_exit(0); + } + + // ---- parent + ::close(fds[1]); + std::string data; + bool timedOut = false; + const auto start = std::chrono::steady_clock::now(); + for (;;) { + int waitMs = 500; + if (timeoutSeconds > 0) { + const long long elapsed = + std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); + const long long left = static_cast(timeoutSeconds) * 1000 - elapsed; + if (left <= 0) { + timedOut = true; + break; + } + if (left < waitMs) waitMs = static_cast(left); + } + struct pollfd pfd; + pfd.fd = fds[0]; + pfd.events = POLLIN; + pfd.revents = 0; + const int rc = ::poll(&pfd, 1, waitMs); + if (rc < 0) { + if (errno == EINTR) continue; + break; + } + if (rc == 0) continue; + char buf[65536]; + const ssize_t n = ::read(fds[0], buf, sizeof buf); + if (n > 0) { + data.append(buf, static_cast(n)); + } else if (n == 0) { + break; + } else if (errno != EINTR) { + break; + } + } + ::close(fds[0]); + if (timedOut) ::kill(pid, SIGKILL); + int status = 0; + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + + Outcome o; + o.phase = crash::phase(); + if (timedOut) { + o.kind = Outcome::Kind::TimedOut; + o.message = std::to_string(timeoutSeconds) + " seconds"; + return o; + } + if (WIFSIGNALED(status)) { + o.signal = WTERMSIG(status); + o.kind = (o.signal == SIGINT || o.signal == SIGTERM) ? Outcome::Kind::Cancelled : Outcome::Kind::Crashed; + return o; + } + if (WIFEXITED(status) && WEXITSTATUS(status) == 0 && data.size() >= 5) { + int code = 0; + for (int i = 0; i < 4; ++i) code |= static_cast(static_cast(data[1 + static_cast(i)])) << (8 * i); + o.exitCode = code; + o.payload = data.substr(5); + if (data[0] == 'R') { + o.kind = Outcome::Kind::Ok; + } else { + o.kind = Outcome::Kind::Failed; + o.message = o.payload; + o.payload.clear(); + } + return o; + } + o.kind = Outcome::Kind::Failed; + o.message = "the worker process ended unexpectedly (exit status " + + std::to_string(WIFEXITED(status) ? WEXITSTATUS(status) : -1) + ")"; + return o; +#else + (void)timeoutSeconds; + (void)isolate; + return detail::runInline(fn); +#endif +} + +} // namespace lab diff --git a/src/json.hpp b/src/json.hpp new file mode 100644 index 0000000..6205d87 --- /dev/null +++ b/src/json.hpp @@ -0,0 +1,277 @@ +// A small, strict JSON parser. It only has to read the request bodies that the +// web UI sends, but it validates everything (depth limit, escapes, UTF-16 +// surrogates) because those bodies arrive over a socket. +#pragma once + +#include +#include +#include +#include + +namespace lab { + +struct Json { + enum class Type { Null, Bool, Number, String, Array, Object }; + + Type type = Type::Null; + bool b = false; + double n = 0; + std::string s; + std::vector a; + std::vector> o; + + const Json* find(const std::string& key) const { + if (type != Type::Object) return nullptr; + for (const auto& kv : o) { + if (kv.first == key) return &kv.second; + } + return nullptr; + } + std::string str(const std::string& key, const std::string& def = "") const { + const Json* v = find(key); + return (v && v->type == Type::String) ? v->s : def; + } + bool boolean(const std::string& key, bool def = false) const { + const Json* v = find(key); + return (v && v->type == Type::Bool) ? v->b : def; + } + double number(const std::string& key, double def = 0) const { + const Json* v = find(key); + return (v && v->type == Type::Number) ? v->n : def; + } + const std::vector* array(const std::string& key) const { + const Json* v = find(key); + return (v && v->type == Type::Array) ? &v->a : nullptr; + } +}; + +namespace detail { + +class JsonParser { + public: + explicit JsonParser(const std::string& text) : t_(text) {} + + bool parse(Json& out, std::string& error) { + skipWs(); + if (!value(out, 0)) { + error = err_.empty() ? "invalid JSON" : err_; + return false; + } + skipWs(); + if (pos_ != t_.size()) { + error = "unexpected text after the JSON value"; + return false; + } + return true; + } + + private: + const std::string& t_; + size_t pos_ = 0; + std::string err_; + + bool fail(const char* msg) { + if (err_.empty()) err_ = std::string(msg) + " at offset " + std::to_string(pos_); + return false; + } + void skipWs() { + while (pos_ < t_.size() && (t_[pos_] == ' ' || t_[pos_] == '\t' || t_[pos_] == '\n' || t_[pos_] == '\r')) ++pos_; + } + bool literal(const char* word) { + size_t i = 0; + while (word[i]) { + if (pos_ + i >= t_.size() || t_[pos_ + i] != word[i]) return false; + ++i; + } + pos_ += i; + return true; + } + + static void appendUtf8(std::string& out, uint32_t cp) { + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } + } + + bool hex4(uint32_t& v) { + if (pos_ + 4 > t_.size()) return fail("truncated \\u escape"); + v = 0; + for (int i = 0; i < 4; ++i) { + const char c = t_[pos_++]; + v <<= 4; + if (c >= '0' && c <= '9') v |= static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') v |= static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= static_cast(c - 'A' + 10); + else return fail("bad \\u escape"); + } + return true; + } + + bool string(std::string& out) { + if (pos_ >= t_.size() || t_[pos_] != '"') return fail("expected a string"); + ++pos_; + out.clear(); + while (pos_ < t_.size()) { + const unsigned char c = static_cast(t_[pos_++]); + if (c == '"') return true; + if (c < 0x20) return fail("control character in string"); + if (c != '\\') { + out += static_cast(c); + continue; + } + if (pos_ >= t_.size()) break; + const char e = t_[pos_++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + uint32_t cp = 0; + if (!hex4(cp)) return false; + if (cp >= 0xD800 && cp <= 0xDBFF) { + uint32_t lo = 0; + if (pos_ + 1 < t_.size() && t_[pos_] == '\\' && t_[pos_ + 1] == 'u') { + pos_ += 2; + if (!hex4(lo)) return false; + } + if (lo >= 0xDC00 && lo <= 0xDFFF) cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + else cp = 0xFFFD; + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + cp = 0xFFFD; + } + appendUtf8(out, cp); + break; + } + default: return fail("bad escape"); + } + } + return fail("unterminated string"); + } + + bool number(double& out) { + const size_t start = pos_; + if (pos_ < t_.size() && t_[pos_] == '-') ++pos_; + bool digits = false; + while (pos_ < t_.size() && ((t_[pos_] >= '0' && t_[pos_] <= '9') || t_[pos_] == '.' || t_[pos_] == 'e' || + t_[pos_] == 'E' || t_[pos_] == '+' || t_[pos_] == '-')) { + if (t_[pos_] >= '0' && t_[pos_] <= '9') digits = true; + ++pos_; + } + if (!digits) return fail("bad number"); + try { + out = std::stod(t_.substr(start, pos_ - start)); + } catch (...) { + return fail("bad number"); + } + return true; + } + + bool value(Json& out, int depth) { + if (depth > 32) return fail("nesting too deep"); + if (pos_ >= t_.size()) return fail("unexpected end"); + const char c = t_[pos_]; + if (c == '{') { + ++pos_; + out.type = Json::Type::Object; + skipWs(); + if (pos_ < t_.size() && t_[pos_] == '}') { + ++pos_; + return true; + } + for (;;) { + skipWs(); + std::string key; + if (!string(key)) return false; + skipWs(); + if (pos_ >= t_.size() || t_[pos_] != ':') return fail("expected ':'"); + ++pos_; + skipWs(); + Json v; + if (!value(v, depth + 1)) return false; + out.o.emplace_back(std::move(key), std::move(v)); + skipWs(); + if (pos_ < t_.size() && t_[pos_] == ',') { + ++pos_; + continue; + } + if (pos_ < t_.size() && t_[pos_] == '}') { + ++pos_; + return true; + } + return fail("expected ',' or '}'"); + } + } + if (c == '[') { + ++pos_; + out.type = Json::Type::Array; + skipWs(); + if (pos_ < t_.size() && t_[pos_] == ']') { + ++pos_; + return true; + } + for (;;) { + skipWs(); + Json v; + if (!value(v, depth + 1)) return false; + out.a.push_back(std::move(v)); + skipWs(); + if (pos_ < t_.size() && t_[pos_] == ',') { + ++pos_; + continue; + } + if (pos_ < t_.size() && t_[pos_] == ']') { + ++pos_; + return true; + } + return fail("expected ',' or ']'"); + } + } + if (c == '"') { + out.type = Json::Type::String; + return string(out.s); + } + if (c == 't') { + out.type = Json::Type::Bool; + out.b = true; + return literal("true") || fail("bad literal"); + } + if (c == 'f') { + out.type = Json::Type::Bool; + out.b = false; + return literal("false") || fail("bad literal"); + } + if (c == 'n') { + out.type = Json::Type::Null; + return literal("null") || fail("bad literal"); + } + out.type = Json::Type::Number; + return number(out.n); + } +}; + +} // namespace detail + +inline bool parseJson(const std::string& text, Json& out, std::string& error) { + out = Json(); + detail::JsonParser p(text); + return p.parse(out, error); +} + +} // namespace lab diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..2c665a5 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,775 @@ +// antlrlab - a command line "ANTLR Lab": give it grammar files and an input, +// get a parse tree diagram, tree text, tokens and syntax validation. +// +// antlrlab Expr.g4 input.txt +// antlrlab -g CalcLexer.g4 -g CalcParser.g4 -i input.txt --watch --open +// +// Exit codes: 0 input is valid, 1 syntax errors, 2 grammar / tool / usage error. + +#include "util.hpp" // first of ours: it includes platform.hpp (windows.h, ) before the ANTLR runtime + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "analysis.hpp" +#include "antlr_tool.hpp" +#include "crash.hpp" +#include "isolate.hpp" +#include "pipeline.hpp" +#include "render.hpp" +#include "report.hpp" +#include "serve.hpp" +#include "types.hpp" + +#ifndef LAB_VERSION +#define LAB_VERSION "1.0.0" +#endif + +using namespace lab; + +namespace { + +// ---------------------------------------------------------------- options ---- + +struct Options { + std::vector grammars; + std::vector inputs; // plain arguments and -i; the CLI takes at most one + std::string inputFile; + std::string inputText; + bool hasText = false; + std::string startRule; + std::string outDir = "antlr-lab-out"; + std::string libDir; + std::string jar; + bool watch = false; + bool open = false; + bool quiet = false; + bool tokens = false; + bool color = true; + bool serve = false; + int port = 0; + std::string root; + int timeout = 120; // seconds a run may take; 0 = no limit + bool selftest = false; + bool isolate = true; // run the ANTLR runtime in a child process +}; + +const char* kUsage = + "antlrlab " LAB_VERSION " - parse tree diagram, tokens and validation for ANTLR grammars\n" + "\n" + "Usage:\n" + " antlrlab [options] GRAMMAR.g4 [GRAMMAR2.g4] INPUT\n" + " antlrlab -g Lexer.g4 -g Parser.g4 -i input.txt\n" + " antlrlab --serve [FILES...] web UI: edit and run files on disk\n" + "\n" + "Grammar: one combined grammar, or a lexer grammar plus the parser grammar that\n" + "uses it. Files ending in .g4 are grammars; any other file is the input.\n" + "\n" + "Options:\n" + " -g, --grammar FILE grammar file (repeatable)\n" + " -i, --input FILE input file, or - for standard input\n" + " -t, --text TEXT input given directly on the command line\n" + " -s, --start RULE start rule (default: the first parser rule)\n" + " -o, --out DIR output directory (default: antlr-lab-out)\n" + " -l, --lib DIR directory with imported grammars / .tokens files\n" + " --antlr-jar FILE ANTLR tool jar (default: search, see README)\n" + " -w, --watch re-run whenever a grammar or the input file changes;\n" + " an open report.html refreshes itself\n" + " --open open report.html in the default browser\n" + " --tokens also print the token list\n" + " --serve start the web UI (editor, diagram, output) on 127.0.0.1\n" + " -p, --port N port for --serve (default: first free from 8765)\n" + " --root DIR directory the web UI may read and write (default: current)\n" + " -q, --quiet only print diagnostics and the verdict\n" + " --timeout SECS stop a run that takes longer (default 120, 0 = no limit)\n" + " --no-isolate run the ANTLR runtime inside this process (for debuggers)\n" + " --selftest check the installation with a built-in grammar, then exit\n" + " --no-color no ANSI colors (also honours NO_COLOR)\n" + " -h, --help show this help\n" + " -v, --version show the version\n" + "\n" + "Output files (in the output directory):\n" + " report.html tree.svg tree.txt tokens.txt problems.txt generated/\n" + "\n" + "Exit status: 0 input is valid, 1 syntax errors, 2 grammar, tool or usage error.\n"; + +bool endsWith(const std::string& s, const std::string& suffix) { + return s.size() >= suffix.size() && lowerCase(s.substr(s.size() - suffix.size())) == suffix; +} + +// Returns -1 to carry on, otherwise the exit code. +int parseArgs(const std::vector& args, Options& o) { + const int argc = static_cast(args.size()); + auto fail = [](const std::string& msg) { + std::fprintf(stderr, "antlrlab: %s\nTry 'antlrlab --help'.\n", msg.c_str()); + return 2; + }; + bool onlyPositional = false; + for (int i = 1; i < argc; ++i) { + std::string a = args[static_cast(i)]; + std::string inlineValue; + bool hasInline = false; + + if (!onlyPositional && a.size() > 2 && a[0] == '-' && a[1] == '-') { + const size_t eq = a.find('='); + if (eq != std::string::npos) { + inlineValue = a.substr(eq + 1); + a = a.substr(0, eq); + hasInline = true; + } + } + auto value = [&](std::string& dst) -> bool { + if (hasInline) { + dst = inlineValue; + return true; + } + if (i + 1 >= argc) return false; + dst = args[static_cast(++i)]; + return true; + }; + std::string v; + + if (onlyPositional || a.empty() || a[0] != '-' || a == "-") { + if (endsWith(a, ".g4")) o.grammars.push_back(a); + else o.inputs.push_back(a); + } else if (a == "--") { + onlyPositional = true; + } else if (a == "-h" || a == "--help") { + std::fputs(kUsage, stdout); + return 0; + } else if (a == "-v" || a == "--version") { + std::printf("antlrlab %s (built for ANTLR %s)\n", LAB_VERSION, LAB_ANTLR_VERSION); + return 0; + } else if (a == "-g" || a == "--grammar") { + if (!value(v)) return fail(a + " needs a file name"); + o.grammars.push_back(v); + } else if (a == "-i" || a == "--input") { + if (!value(v)) return fail(a + " needs a file name"); + o.inputs.push_back(v); + } else if (a == "-t" || a == "--text") { + if (!value(v)) return fail(a + " needs some text"); + if (o.hasText) return fail("more than one input given"); + o.inputText = v; + o.hasText = true; + } else if (a == "-s" || a == "--start") { + if (!value(o.startRule)) return fail(a + " needs a rule name"); + } else if (a == "-o" || a == "--out") { + if (!value(o.outDir)) return fail(a + " needs a directory"); + } else if (a == "-l" || a == "--lib") { + if (!value(o.libDir)) return fail(a + " needs a directory"); + } else if (a == "--antlr-jar") { + if (!value(o.jar)) return fail(a + " needs a file name"); + } else if (a == "-w" || a == "--watch") { + o.watch = true; + } else if (a == "--open") { + o.open = true; + } else if (a == "--tokens") { + o.tokens = true; + } else if (a == "--serve") { + o.serve = true; + } else if (a == "-p" || a == "--port") { + if (!value(v)) return fail(a + " needs a port number"); + char* end = nullptr; + const long n = std::strtol(v.c_str(), &end, 10); + if (v.empty() || *end != '\0' || n < 1 || n > 65535) return fail("invalid port: " + v); + o.port = static_cast(n); + } else if (a == "--root") { + if (!value(o.root)) return fail(a + " needs a directory"); + } else if (a == "-q" || a == "--quiet") { + o.quiet = true; + } else if (a == "--no-color") { + o.color = false; + } else if (a == "--no-isolate") { + o.isolate = false; + } else if (a == "--selftest") { + o.selftest = true; + } else if (a == "--timeout") { + if (!value(v)) return fail(a + " needs a number of seconds"); + char* end = nullptr; + const long n = std::strtol(v.c_str(), &end, 10); + if (v.empty() || *end != '\0' || n < 0 || n > 86400) return fail("invalid timeout: " + v); + o.timeout = static_cast(n); + } else { + return fail("unknown option " + a); + } + } + + if (o.selftest) return -1; + if (o.serve) { + if (o.watch || o.hasText) return fail("--serve cannot be combined with --watch or -t"); + return -1; + } + if (o.grammars.empty()) return fail("no grammar given. Pass at least one .g4 file."); + if (o.inputs.size() + (o.hasText ? 1 : 0) > 1) return fail("more than one input given"); + if (!o.inputs.empty()) o.inputFile = o.inputs[0]; + if (o.inputFile.empty() && !o.hasText) { + return fail("no input given. Pass an input file, or use -i FILE or -t TEXT."); + } + if (o.watch && o.inputFile == "-") return fail("--watch cannot be combined with reading standard input"); + if (o.watch && o.hasText) return fail("--watch needs an input file, not -t"); + return -1; +} + +// ---------------------------------------------------------------- console ---- + +struct Console { + bool out = false; // colors on stdout + bool err = false; // colors on stderr + + static std::string paint(bool on, const char* code, const std::string& s) { + return on ? std::string("\x1b[") + code + "m" + s + "\x1b[0m" : s; + } + std::string eBold(const std::string& s) const { return paint(err, "1", s); } + std::string eRed(const std::string& s) const { return paint(err, "1;31", s); } + std::string eYellow(const std::string& s) const { return paint(err, "1;33", s); } + std::string eGreen(const std::string& s) const { return paint(err, "1;32", s); } + std::string eDim(const std::string& s) const { return paint(err, "2", s); } +}; + +std::vector splitLines(const std::string& text) { + std::vector lines(1); + for (char c : text) { + if (c == '\n') lines.emplace_back(); + else lines.back() += c; + } + for (auto& l : lines) { + if (!l.empty() && l.back() == '\r') l.pop_back(); + } + return lines; +} + +// Splits one line of UTF-8 into code points. +std::vector codePoints(const std::string& line) { + std::vector cps; + for (size_t i = 0; i < line.size();) { + size_t len = 1; + const unsigned char c = static_cast(line[i]); + if (c >= 0xF0) len = 4; + else if (c >= 0xE0) len = 3; + else if (c >= 0xC0) len = 2; + cps.push_back(line.substr(i, len)); + i += len; + } + return cps; +} + +void printDiagnostic(const Console& con, const Diagnostic& d, const std::string& name, + const std::vector& lines) { + const bool isErr = d.severity == Diagnostic::Severity::Error; + const std::string kind = isErr ? con.eRed("error") : con.eYellow("warning"); + std::string where = name; + if (d.line > 0) where += ":" + std::to_string(d.line) + ":" + std::to_string(d.col); + std::fprintf(stderr, "%s: %s: %s\n", con.eBold(where).c_str(), kind.c_str(), d.message.c_str()); + + if (d.line == 0 || d.line > lines.size()) return; + const std::vector cps = codePoints(lines[d.line - 1]); + std::string shown = lines[d.line - 1]; + std::string pad; + for (size_t i = 0; i < d.col && i < cps.size(); ++i) pad += (cps[i] == "\t") ? "\t" : " "; + if (d.col > cps.size()) pad += std::string(d.col - cps.size(), ' '); + size_t len = 1; + if (d.start >= 0 && d.stop >= d.start) len = static_cast(d.stop - d.start + 1); + const size_t room = d.col < cps.size() ? cps.size() - d.col : 1; + len = std::max(1, std::min(len, room)); + std::string marks = "^"; + if (len > 1) marks += std::string(len - 1, '~'); + std::fprintf(stderr, " %s\n %s%s\n", shown.c_str(), pad.c_str(), + (isErr ? con.eRed(marks) : con.eYellow(marks)).c_str()); +} + +std::string tokenLine(const TokenInfo& t, int64_t total) { + const int64_t s = t.isEof ? total : t.start; + const int64_t e = t.isEof ? total - 1 : t.stop; + std::string out = "[@" + std::to_string(t.index) + "," + std::to_string(s) + ":" + std::to_string(e) + "='" + + (t.isEof ? std::string("") : visible(t.text)) + "',<" + t.typeName + ">,"; + if (t.channel != "DEFAULT_TOKEN_CHANNEL" && t.channel != "0") out += "channel=" + t.channel + ","; + out += std::to_string(t.line) + ":" + std::to_string(t.col) + "]"; + return out; +} + +// ---------------------------------------------------------------- session ---- + +struct Session { + Options opt; + Tool tool; + fs::path outDir; + fs::path genDir; + + // grammar stage + bool grammarOk = false; + std::string grammarError; + std::string toolLog; + std::vector sources; + ModelSpec spec; // what the .interp files say; the runtime objects are made in the worker + + int version = 0; + bool firstRun = true; + std::string stdinCache; + bool stdinRead = false; +}; + +void removeStaleOutputs(const Session& s) { + std::error_code ec; + for (const char* n : {"tree.svg", "tree.txt", "tokens.txt", "problems.txt"}) fs::remove(s.outDir / n, ec); +} + +// Runs the ANTLR tool and reads the .interp files it wrote. +void compileGrammar(Session& s) { + s.grammarOk = false; + s.grammarError.clear(); + s.toolLog.clear(); + s.sources.clear(); + s.spec = ModelSpec(); + + std::vector paths; + for (const std::string& g : s.opt.grammars) { + std::string text; + if (!readFile(g, text)) { + s.grammarError = "cannot read grammar file: " + g; + return; + } + s.sources.push_back({pathToUtf8(pathFromUtf8(g).filename()), sanitizeUtf8(text)}); + paths.push_back(pathFromUtf8(g)); + } + + const CompileResult r = compileGrammars(s.tool, paths, s.opt.libDir, s.genDir, s.outDir / "antlr-tool.log"); + s.toolLog = r.log; + if (!r.ok) { + s.grammarError = r.summary; + return; + } + std::string err; + if (!loadInterps(r.interps, s.spec, err)) { + s.grammarError = err; + s.spec = ModelSpec(); + return; + } + s.grammarOk = true; +} + +bool readInput(Session& s, std::string& text, std::string& name, std::string& error) { + if (s.opt.hasText) { + text = s.opt.inputText; + name = ""; + return true; + } + if (s.opt.inputFile == "-") { + if (!s.stdinRead) { + std::ostringstream ss; + ss << std::cin.rdbuf(); + s.stdinCache = ss.str(); + s.stdinRead = true; + } + text = s.stdinCache; + name = ""; + return true; + } + if (!readFile(s.opt.inputFile, text)) { + error = "cannot read input file: " + s.opt.inputFile; + return false; + } + name = s.opt.inputFile; + return true; +} + +bool writeOut(const fs::path& p, const std::string& data) { + if (writeFileAtomic(p, data)) return true; + std::fprintf(stderr, "antlrlab: cannot write %s\n", pathToUtf8(p).c_str()); + return false; +} + +void writeReport(Session& s, ReportInput& rin) { + rin.version = s.version; + rin.live = s.opt.watch; + rin.grammars = s.sources; + rin.toolLog = s.toolLog; + writeOut(s.outDir / "report.html", reportHtml(rin)); + if (s.opt.watch) writeOut(s.outDir / "report-data.js", reportDataJs(rin)); +} + +// Everything that touches the ANTLR runtime: build it from the grammar data, +// run the input, write every output file, print the summary. This runs in an +// isolated worker (see runOnce), so a crash in here cannot take the caller down. +int analyzeAndWrite(Session& s, const Console& con, const std::string& text, const std::string& name, + const ReportInput& base) { + std::error_code ec; + ReportInput rin = base; + + Model model; + std::string err; + if (!buildModel(s.spec, model, err)) { + rin.statusKind = "fail"; + rin.statusText = "Grammar has errors"; + rin.statusDetail = err; + rin.noDiagram = "The grammar could not be loaded, so there is nothing to draw."; + writeReport(s, rin); + removeStaleOutputs(s); + std::fflush(stdout); + std::fprintf(stderr, "%s %s\n", con.eRed("Grammar has errors:").c_str(), err.c_str()); + return 2; + } + + // ---- run the grammar + AnalysisResult res; + std::string runErr; + if (!analyze(model, text, s.opt.startRule, res, runErr)) { + rin.statusKind = "fail"; + rin.statusText = "Cannot run the grammar"; + rin.statusDetail = runErr; + rin.noDiagram = runErr; + writeReport(s, rin); + removeStaleOutputs(s); + std::fprintf(stderr, "antlrlab: %s\n", runErr.c_str()); + return 2; + } + + const size_t errors = res.errorCount(); + const size_t warnings = res.warningCount(); + const size_t realTokens = countRealTokens(res); + fillReportStatus(rin, res, model.hasParser); + + // ---- files + crash::setPhase("writing the output files"); + const int64_t total = static_cast(cpCount(text)); + std::string tokText; + for (const TokenInfo& t : res.tokens) tokText += tokenLine(t, total) + "\n"; + writeOut(s.outDir / "tokens.txt", tokText); + + std::string problems; + const std::vector lines = splitLines(text); + for (const Diagnostic& d : res.diagnostics) { + problems += name + ":" + std::to_string(d.line) + ":" + std::to_string(d.col) + ": " + + (d.severity == Diagnostic::Severity::Error ? "error" : "warning") + ": " + d.message + "\n"; + } + writeOut(s.outDir / "problems.txt", problems.empty() ? "No problems.\n" : problems); + + if (res.hasTree) { + writeOut(s.outDir / "tree.txt", lispCompact(res.tree) + "\n"); + if (res.nodeCount <= kMaxDiagramNodes) { + crash::setPhase("drawing the diagram"); + writeOut(s.outDir / "tree.svg", renderSvg(res.tree, true).svg); + } else { + fs::remove(s.outDir / "tree.svg", ec); + } + } else { + fs::remove(s.outDir / "tree.txt", ec); + fs::remove(s.outDir / "tree.svg", ec); + } + crash::setPhase("writing the report"); + writeReport(s, rin); + + // ---- console + if (s.opt.tokens) { + for (const TokenInfo& t : res.tokens) std::printf("%s\n", tokenLine(t, total).c_str()); + } + if (res.hasTree && !s.opt.quiet) std::printf("%s", lispPretty(res.tree).c_str()); + std::fflush(stdout); + + for (const Diagnostic& d : res.diagnostics) printDiagnostic(con, d, name, lines); + + std::string verdict; + if (errors > 0) verdict = con.eRed(plural(errors, "syntax error")); + else if (warnings > 0) verdict = con.eYellow(rin.statusText); + else verdict = con.eGreen(rin.statusText); + std::string tail = "(" + plural(realTokens, "token"); + if (res.hasTree) tail += ", " + plural(res.nodeCount, "node"); + tail += ")"; + std::fprintf(stderr, "%s %s\n", verdict.c_str(), con.eDim(tail).c_str()); + if (!s.opt.quiet) { + std::fprintf(stderr, "%s\n", con.eDim("Report: " + pathToUtf8(s.outDir / "report.html")).c_str()); + } + return errors > 0 ? 1 : 0; +} + +// One full pass: read the input, analyze, write every output, print a summary. +int runOnce(Session& s, const Console& con) { + std::error_code ec; + fs::create_directories(s.outDir, ec); + if (ec) { + std::fprintf(stderr, "antlrlab: cannot create %s: %s\n", pathToUtf8(s.outDir).c_str(), ec.message().c_str()); + return 2; + } + + std::string raw, name, inErr; + ReportInput rin; + if (!readInput(s, raw, name, inErr)) { + std::fprintf(stderr, "antlrlab: %s\n", inErr.c_str()); + return 2; + } + const std::string text = sanitizeUtf8(raw); + ++s.version; + rin.input = text; + rin.hasParser = s.grammarOk && s.spec.hasParser; + + // ---- the grammar did not compile + if (!s.grammarOk) { + rin.statusKind = "fail"; + rin.statusText = "Grammar has errors"; + rin.statusDetail = s.grammarError; + rin.noDiagram = "The grammar could not be compiled, so there is nothing to draw. See the Grammar tab for the ANTLR output."; + rin.meta = {}; + writeReport(s, rin); + removeStaleOutputs(s); + writeOut(s.outDir / "problems.txt", s.grammarError + "\n"); + + std::fflush(stdout); + if (!s.toolLog.empty()) std::fprintf(stderr, "%s", s.toolLog.c_str()); + std::fprintf(stderr, "%s %s\n", con.eRed("Grammar has errors:").c_str(), s.grammarError.c_str()); + return 2; + } + + // ---- the part that uses the ANTLR runtime, in a worker that may crash or time out + const Outcome o = runIsolated( + [&](int& code) -> std::string { + code = analyzeAndWrite(s, con, text, name, rin); + return std::string(); + }, + s.opt.timeout, s.opt.isolate); + if (o.kind == Outcome::Kind::Ok) return o.exitCode; + + const std::string msg = describe(o); + ++s.version; // the worker may have written a report for this run already + rin.statusKind = "fail"; + rin.statusText = o.kind == Outcome::Kind::Crashed ? "The run crashed" + : o.kind == Outcome::Kind::TimedOut ? "The run timed out" + : "The run failed"; + rin.statusDetail = msg; + rin.noDiagram = msg; + writeReport(s, rin); + removeStaleOutputs(s); + std::fflush(stdout); + std::fprintf(stderr, "%s %s\n", con.eRed(rin.statusText + ":").c_str(), msg.c_str()); + return 2; +} + +// --------------------------------------------------------------- watching ---- + +using Stamps = std::map; + +long long stampOf(const fs::path& p) { + std::error_code ec; + const auto t = fs::last_write_time(p, ec); + if (ec) return -1; + return static_cast(t.time_since_epoch().count()); +} + +Stamps grammarStamps(const Session& s) { + Stamps m; + for (const std::string& g : s.opt.grammars) m[g] = stampOf(pathFromUtf8(g)); + if (!s.opt.libDir.empty() && isDirectory(s.opt.libDir)) { + std::error_code ec; + for (fs::directory_iterator it(pathFromUtf8(s.opt.libDir), ec), end; !ec && it != end; it.increment(ec)) { + const std::string ext = lowerCase(pathToUtf8(it->path().extension())); + if (ext == ".g4" || ext == ".tokens") m[pathToUtf8(it->path())] = stampOf(it->path()); + } + } + return m; +} + +Stamps inputStamps(const Session& s) { + Stamps m; + if (!s.opt.hasText && s.opt.inputFile != "-") m[s.opt.inputFile] = stampOf(pathFromUtf8(s.opt.inputFile)); + return m; +} + +void openTarget(const std::string& p) { openInBrowser(p); } + +int watchLoop(Session& s, const Console& con, int firstCode) { + std::fprintf(stderr, "%s\n", con.eDim("Watching for changes. Press Ctrl+C to stop.").c_str()); + Stamps gram = grammarStamps(s), inp = inputStamps(s); + int code = firstCode; + for (;;) { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + Stamps g2 = grammarStamps(s), i2 = inputStamps(s); + if (g2 == gram && i2 == inp) continue; + + // Let editors finish writing (save-and-rename, formatters) before reading. + for (int settle = 0; settle < 8; ++settle) { + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + Stamps g3 = grammarStamps(s), i3 = inputStamps(s); + if (g3 == g2 && i3 == i2) break; + g2 = std::move(g3); + i2 = std::move(i3); + } + + const bool grammarChanged = g2 != gram; + gram = g2; + inp = i2; + std::fprintf(stderr, "\n%s\n", con.eDim(grammarChanged ? "--- grammar changed, recompiling ---" : "--- input changed ---").c_str()); + if (grammarChanged) compileGrammar(s); + code = runOnce(s, con); + } + return code; +} + +// Terminal text of a tree, concatenated: "1+2+3" for the self-test input. +void leafText(const Node& n, std::string& out) { + if (n.kind != Node::Kind::Rule) { + if (n.typeName != "EOF") out += n.label; + return; + } + for (const Node& c : n.children) leafText(c, out); +} + +// Runs a tiny left-recursive grammar through the whole pipeline: the ANTLR +// tool, the .interp files, the runtime's interpreters and the tree copy. +int selfTest(Session& s) { + std::printf("antlrlab %s, built for ANTLR %s\n", LAB_VERSION, LAB_ANTLR_VERSION); +#if defined(__clang_version__) + std::printf(" compiler clang %s\n", __clang_version__); +#elif defined(_MSC_VER) + std::printf(" compiler MSVC %d\n", _MSC_VER); +#elif defined(__VERSION__) + std::printf(" compiler %s\n", __VERSION__); +#endif +#if defined(_WIN32) + const char* platformName = "Windows"; +#elif defined(__APPLE__) + const char* platformName = "macOS"; +#elif defined(__linux__) + const char* platformName = "Linux"; +#else + const char* platformName = "POSIX"; +#endif + std::printf(" platform %s\n", platformName); + if (!kHasIsolation) std::printf(" isolation not available on this platform (the runtime runs in this process)\n"); + else std::printf(" isolation %s\n", s.opt.isolate ? "on (runtime runs in a child process)" : "off"); + std::printf(" ANTLR tool %s\n", s.tool.found ? s.tool.description.c_str() : "NOT FOUND"); + if (!s.tool.found) { + std::printf("\nFAIL: %s\n", s.tool.error.c_str()); + return 2; + } + + const fs::path dir = s.outDir / "selftest"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir, ec); + const fs::path g = dir / "SelfTest.g4"; + writeFile(g, + "grammar SelfTest;\n" + "prog : expr EOF ;\n" + "expr : expr '+' expr | INT ;\n" + "INT : [0-9]+ ;\n" + "WS : [ ]+ -> skip ;\n"); + + const CompileResult r = compileGrammars(s.tool, {g}, "", dir / "gen", dir / "tool.log"); + if (!r.ok) { + std::printf("\nFAIL: the ANTLR tool could not compile the test grammar: %s\n%s", r.summary.c_str(), r.log.c_str()); + return 2; + } + std::printf(" [ok] the ANTLR tool compiled the test grammar\n"); + + std::string err; + if (!loadInterps(r.interps, s.spec, err)) { + std::printf("\nFAIL: %s\n", err.c_str()); + return 2; + } + std::printf(" [ok] read %zu .interp files (%zu lexer rules, %zu parser rules, ATN of %zu + %zu numbers)\n", + r.interps.size(), s.spec.lexer.ruleNames.size(), s.spec.parser.ruleNames.size(), + s.spec.lexer.atn.size(), s.spec.parser.atn.size()); + + const Outcome o = runIsolated( + [&](int& code) -> std::string { + Model model; + std::string e; + if (!buildModel(s.spec, model, e)) { + code = 2; + return "could not load the grammar into the runtime: " + e; + } + AnalysisResult res; + if (!analyze(model, "1 + 2 + 3", "", res, e)) { + code = 2; + return e; + } + std::string leaves; + if (res.hasTree) leafText(res.tree, leaves); + if (res.errorCount() != 0 || !res.hasTree || leaves != "1+2+3") { + code = 1; + return "unexpected result: " + plural(res.errorCount(), "error") + ", tree: " + + (res.hasTree ? lispCompact(res.tree) : std::string("(none)")); + } + code = 0; + return lispCompact(res.tree); + }, + s.opt.timeout, s.opt.isolate); + + if (o.kind != Outcome::Kind::Ok) { + std::printf("\nFAIL: %s\n", describe(o).c_str()); + return 2; + } + if (o.exitCode != 0) { + std::printf("\nFAIL: %s\n", o.payload.c_str()); + return 2; + } + std::printf(" [ok] the runtime parsed \"1 + 2 + 3\": %s\n", o.payload.c_str()); + std::printf("\nPASS: the ANTLR tool and the runtime work together on this machine.\n" + "If a grammar of yours still crashes, the problem is specific to it.\n"); + return 0; +} + +int realMain(const std::vector& args) { + initConsole(); + crash::initShared(); + crash::installHandlers(); + crash::AltStack altStack; + Session s; + const int parsed = parseArgs(args, s.opt); + if (parsed >= 0) return parsed; + if (!envVar("ANTLRLAB_NO_ISOLATE").empty()) s.opt.isolate = false; + + Console con; + const bool noColorEnv = !envVar("NO_COLOR").empty(); + con.out = s.opt.color && !noColorEnv && terminalSupportsColor(stdout); + con.err = s.opt.color && !noColorEnv && terminalSupportsColor(stderr); + + if (s.opt.serve) { + ServeOptions so; + so.root = s.opt.root.empty() ? fs::current_path() : pathFromUtf8(s.opt.root); + so.port = s.opt.port; + so.timeoutSeconds = s.opt.timeout; + so.isolate = s.opt.isolate; + so.open = s.opt.grammars; + so.open.insert(so.open.end(), s.opt.inputs.begin(), s.opt.inputs.end()); + if (s.opt.open) so.openBrowser = [](const std::string& url) { openTarget(url); }; + return runServer(so, locateTool(s.opt.jar)); + } + + s.outDir = pathFromUtf8(s.opt.outDir); + s.genDir = s.outDir / "generated"; + std::error_code ec; + fs::create_directories(s.outDir, ec); + + s.tool = locateTool(s.opt.jar); + if (s.opt.selftest) return selfTest(s); + if (!s.tool.found) { + std::fprintf(stderr, "antlrlab: %s\n", s.tool.error.c_str()); + return 2; + } + + compileGrammar(s); + int code = runOnce(s, con); + s.firstRun = false; + if (s.opt.open) { + std::error_code abs; + openTarget(pathToUtf8(fs::absolute(s.outDir / "report.html", abs))); + } + if (s.opt.watch) return watchLoop(s, con, code); + return code; +} + +} // namespace + +int main(int argc, char** argv) { + try { + return realMain(commandLineArgs(argc, argv)); + } catch (const std::exception& e) { + std::fprintf(stderr, "antlrlab: unexpected error: %s\n", e.what()); + return 2; + } +} diff --git a/src/pipeline.hpp b/src/pipeline.hpp new file mode 100644 index 0000000..da62fbb --- /dev/null +++ b/src/pipeline.hpp @@ -0,0 +1,57 @@ +// Shared by the command line and the web server: turns an AnalysisResult into +// the verdict (status text, detail line, short stats) shown to the user. +#pragma once + +#include + +#include "report.hpp" +#include "types.hpp" +#include "util.hpp" + +namespace lab { + +inline size_t countRealTokens(const AnalysisResult& res) { + if (res.tokens.empty()) return 0; + return res.tokens.size() - (res.tokens.back().isEof ? 1 : 0); +} + +// Fills status, detail, meta and result of `rin` (which points at `res`). `hasParser` says whether the +// grammar has parser rules (a lexer-only grammar only tokenizes). +inline void fillReportStatus(ReportInput& rin, const AnalysisResult& res, bool hasParser) { + const size_t errors = res.errorCount(); + const size_t warnings = res.warningCount(); + + rin.haveResult = true; + rin.hasParser = hasParser; + rin.startRule = res.startRule; + if (errors > 0) { + rin.statusKind = "error"; + rin.statusText = plural(errors, "syntax error"); + } else if (warnings > 0) { + rin.statusKind = "warn"; + rin.statusText = "Input is valid, with " + plural(warnings, "warning"); + } else { + rin.statusKind = "ok"; + rin.statusText = hasParser ? "Input is valid" : "Input tokenized"; + } + + rin.statusDetail.clear(); + for (const Diagnostic& d : res.diagnostics) { + if ((errors > 0) == (d.severity == Diagnostic::Severity::Error)) { + rin.statusDetail = (d.line ? std::to_string(d.line) + ":" + std::to_string(d.col) + " " : "") + d.message; + break; + } + } + if (rin.statusDetail.empty() && !hasParser) { + rin.statusDetail = "This grammar has no parser rules, so there is no tree."; + } + + rin.meta.clear(); + rin.meta.push_back(plural(countRealTokens(res), "token")); + if (res.hasTree) rin.meta.push_back(plural(res.nodeCount, "tree node")); + rin.meta.push_back("lexed in " + fixed1(res.lexMs) + " ms"); + if (hasParser) rin.meta.push_back("parsed in " + fixed1(res.parseMs) + " ms"); + rin.result = &res; // not copied: the caller keeps `res` alive while the report is built +} + +} // namespace lab diff --git a/src/platform.hpp b/src/platform.hpp new file mode 100644 index 0000000..0884cdf --- /dev/null +++ b/src/platform.hpp @@ -0,0 +1,449 @@ +// Everything that differs between operating systems, in one place. +// +// Supported: Linux, macOS and other POSIX systems (GCC, Clang) and Windows +// (MSVC, clang-cl, MinGW-w64 with POSIX threads). +// +// * UTF-8 <-> filesystem paths. On Windows a std::string passed to +// std::filesystem::path is read in the ANSI code page, not as UTF-8, so +// every path that comes from the command line, the browser or the screen +// goes through pathFromUtf8() / pathToUtf8(). +// * command line, environment variables, console set-up, random bytes +// * starting a program (the ANTLR tool, the web browser) without a shell +// +// NOTE: must come before the ANTLR runtime headers (they #undef the +// EOF macro, which only sticks if it was defined already), and windows.h +// must come before them too, because it defines macros (ERROR, ...) that the +// runtime uses as names. Every file includes this one first (via util.hpp). +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include +#include +#ifdef ERROR +#undef ERROR // from wingdi.h; clashes with ParseTreeType::ERROR in the ANTLR runtime +#endif +#ifdef _MSC_VER +#pragma comment(lib, "ws2_32.lib") +#pragma comment(lib, "bcrypt.lib") +#pragma comment(lib, "shell32.lib") +#pragma comment(lib, "dbghelp.lib") +#endif +#else +#ifdef __APPLE__ +#include +#endif +#include +#include +#include +#include +#include +extern char** environ; +#endif + +namespace lab { + +namespace fs = std::filesystem; + +// ----------------------------------------------------------- UTF-8 paths ---- + +#ifdef _WIN32 +inline std::wstring widen(const std::string& s) { + if (s.empty()) return std::wstring(); + const int n = ::MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), nullptr, 0); + if (n <= 0) return std::wstring(); + std::wstring w(static_cast(n), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), &w[0], n); + return w; +} + +inline std::string narrow(const std::wstring& w) { + if (w.empty()) return std::string(); + const int n = ::WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast(w.size()), nullptr, 0, nullptr, nullptr); + if (n <= 0) return std::string(); + std::string s(static_cast(n), '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast(w.size()), &s[0], n, nullptr, nullptr); + return s; +} +#endif + +// UTF-8 text -> path, and back. Use these instead of fs::path(std::string) +// and path.string(), which are wrong for non-ASCII names on Windows. +inline fs::path pathFromUtf8(const std::string& s) { +#ifdef _WIN32 + return fs::path(widen(s)); +#else + return fs::path(s); +#endif +} + +inline std::string pathToUtf8(const fs::path& p) { +#ifdef _WIN32 + return narrow(p.native()); +#else + return p.native(); +#endif +} + +// Same, but with '/' as the separator (what the browser sees). +inline std::string pathToGenericUtf8(const fs::path& p) { +#ifdef _WIN32 + return narrow(p.generic_wstring()); +#else + return p.generic_string(); +#endif +} + +// ------------------------------------------------ command line, environment ---- + +// The program's arguments as UTF-8, whatever the platform's own encoding is. +inline std::vector commandLineArgs(int argc, char** argv) { + std::vector out; +#ifdef _WIN32 + (void)argc; + (void)argv; + int n = 0; + if (LPWSTR* w = ::CommandLineToArgvW(::GetCommandLineW(), &n)) { + for (int i = 0; i < n; ++i) out.push_back(narrow(w[i])); + ::LocalFree(w); + return out; + } +#endif + for (int i = 0; i < argc; ++i) out.emplace_back(argv[i]); + return out; +} + +// Value of an environment variable as UTF-8, "" if unset. +inline std::string getEnv(const char* name) { +#ifdef _WIN32 + const std::wstring wname = widen(name); + const DWORD n = ::GetEnvironmentVariableW(wname.c_str(), nullptr, 0); + if (n == 0) return std::string(); + std::wstring v(n, L'\0'); + const DWORD got = ::GetEnvironmentVariableW(wname.c_str(), &v[0], n); + v.resize(got); + return narrow(v); +#else + const char* v = std::getenv(name); + return v ? v : ""; +#endif +} + +// Full path of the running program, or an empty path if the system will not say. +inline fs::path executablePath() { +#ifdef _WIN32 + std::vector buf(32768); + const DWORD n = ::GetModuleFileNameW(nullptr, buf.data(), static_cast(buf.size())); + if (n == 0 || n >= buf.size()) return fs::path(); + return fs::path(std::wstring(buf.data(), n)); +#elif defined(__APPLE__) + char buf[4096]; + uint32_t size = sizeof buf; + if (::_NSGetExecutablePath(buf, &size) != 0) return fs::path(); + std::error_code ec; + fs::path p = fs::weakly_canonical(fs::path(buf), ec); + return ec ? fs::path(buf) : p; +#else + std::error_code ec; + const fs::path p = fs::read_symlink("/proc/self/exe", ec); // Linux; empty elsewhere + return ec ? fs::path() : p; +#endif +} + +// ---------------------------------------------------------------- console ---- + +#ifdef _WIN32 +namespace detail { +inline UINT& savedConsoleCodePage() { + static UINT cp = 0; + return cp; +} +inline void restoreConsoleCodePage() { + if (savedConsoleCodePage() != 0) ::SetConsoleOutputCP(savedConsoleCodePage()); +} +} // namespace detail +#endif + +// Call once at start-up. Windows: print UTF-8 correctly in the console and +// read standard input as raw bytes (no CRLF translation, no Ctrl+Z). +inline void initConsole() { +#ifdef _WIN32 + const UINT cp = ::GetConsoleOutputCP(); + if (cp != 0 && cp != CP_UTF8) { + detail::savedConsoleCodePage() = cp; + ::SetConsoleOutputCP(CP_UTF8); + std::atexit(detail::restoreConsoleCodePage); + } + _setmode(_fileno(stdin), _O_BINARY); +#endif +} + +// True when f is a terminal that understands ANSI color codes. +inline bool terminalSupportsColor(std::FILE* f) { +#ifdef _WIN32 + if (!_isatty(_fileno(f))) return false; + const HANDLE h = reinterpret_cast(_get_osfhandle(_fileno(f))); + DWORD mode = 0; + if (h == INVALID_HANDLE_VALUE || !::GetConsoleMode(h, &mode)) return false; +#ifndef ENABLE_VIRTUAL_TERMINAL_PROCESSING +#define ENABLE_VIRTUAL_TERMINAL_PROCESSING 0x0004 +#endif + if (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) return true; + return ::SetConsoleMode(h, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; // fails before Windows 10 +#else + return ::isatty(::fileno(f)) != 0; +#endif +} + +// The Java tool reads its arguments in the Windows ANSI code page, so a path +// with characters outside it (say Cyrillic on a Western system) arrives +// garbled. The short 8.3 form of an existing path is plain ASCII. +// Elsewhere (and for ASCII paths) the path is returned unchanged. +inline std::string toolSafePath(const std::string& utf8Path) { +#ifdef _WIN32 + bool ascii = true; + for (unsigned char c : utf8Path) ascii = ascii && c < 0x80; + if (ascii) return utf8Path; + const std::wstring wide = widen(utf8Path); + const DWORD n = ::GetShortPathNameW(wide.c_str(), nullptr, 0); + if (n == 0) return utf8Path; + std::wstring shortPath(n, L'\0'); + const DWORD got = ::GetShortPathNameW(wide.c_str(), &shortPath[0], n); + if (got == 0 || got >= n) return utf8Path; + shortPath.resize(got); + const std::string result = narrow(shortPath); + for (unsigned char c : result) { + if (c >= 0x80) return utf8Path; // 8.3 names are switched off on this volume + } + return result; +#else + return utf8Path; +#endif +} + +// ----------------------------------------------------------------- random ---- + +// Cryptographically secure random bytes (the web UI's access token). +inline bool secureRandomBytes(void* buf, size_t n) { +#ifdef _WIN32 + return BCryptGenRandom(nullptr, static_cast(buf), static_cast(n), + BCRYPT_USE_SYSTEM_PREFERRED_RNG) >= 0; +#else + bool ok = false; + if (std::FILE* f = std::fopen("/dev/urandom", "rb")) { + ok = std::fread(buf, 1, n, f) == n; + std::fclose(f); + } + if (!ok) { + std::random_device rd; + unsigned char* p = static_cast(buf); + for (size_t i = 0; i < n; ++i) p[i] = static_cast(rd()); + ok = true; + } + return ok; +#endif +} + +// -------------------------------------------------------------- processes ---- + +struct ProcessResult { + bool started = false; + int exitCode = 127; // 127: could not be started (the shell's convention) + std::string error; // why it could not be started +}; + +#ifdef _WIN32 +namespace detail { + +// Quotes one argument the way CommandLineToArgvW / the C runtime read it back. +inline std::wstring quoteWinArg(const std::wstring& arg) { + if (!arg.empty() && arg.find_first_of(L" \t\n\v\"") == std::wstring::npos) return arg; + std::wstring out = L"\""; + for (auto it = arg.begin();; ++it) { + size_t backslashes = 0; + while (it != arg.end() && *it == L'\\') { + ++it; + ++backslashes; + } + if (it == arg.end()) { + out.append(backslashes * 2, L'\\'); + break; + } + if (*it == L'"') out.append(backslashes * 2 + 1, L'\\'); + else out.append(backslashes, L'\\'); + out.push_back(*it); + } + out.push_back(L'"'); + return out; +} + +inline bool isBatchFile(const std::string& program) { + if (program.size() < 4) return false; + std::string ext = program.substr(program.size() - 4); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + return ext == ".bat" || ext == ".cmd"; +} + +inline std::string lastErrorText(DWORD code) { + wchar_t* msg = nullptr; + const DWORD n = ::FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, code, 0, reinterpret_cast(&msg), 0, nullptr); + std::string s = "error " + std::to_string(code); + if (n > 0 && msg) { + std::string t = narrow(std::wstring(msg, n)); + while (!t.empty() && (t.back() == '\n' || t.back() == '\r' || t.back() == ' ' || t.back() == '.')) t.pop_back(); + if (!t.empty()) s = t; + } + if (msg) ::LocalFree(msg); + return s; +} + +} // namespace detail +#endif + +// Runs a program without a shell, waits for it, and returns its exit code. +// argv[0] is looked up on PATH. Standard output and standard error go to +// logFile (created or truncated); when logFile is empty they are inherited. +// Standard input is empty. +inline ProcessResult runProcess(const std::vector& argv, const fs::path& logFile) { + ProcessResult r; + if (argv.empty()) { + r.error = "no program given"; + return r; + } +#ifdef _WIN32 + std::wstring cmd; + if (detail::isBatchFile(argv[0])) { + // .bat / .cmd files only run through cmd.exe; /s keeps the outer quotes apart from the inner ones. + cmd = L"cmd.exe /d /s /c \""; + for (size_t i = 0; i < argv.size(); ++i) { + if (i) cmd += L' '; + cmd += detail::quoteWinArg(widen(argv[i])); + } + cmd += L'"'; + } else { + for (size_t i = 0; i < argv.size(); ++i) { + if (i) cmd += L' '; + cmd += detail::quoteWinArg(widen(argv[i])); + } + } + + SECURITY_ATTRIBUTES sa; + sa.nLength = sizeof sa; + sa.lpSecurityDescriptor = nullptr; + sa.bInheritHandle = TRUE; + + HANDLE nul = ::CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + HANDLE log = INVALID_HANDLE_VALUE; + if (!logFile.empty()) { + log = ::CreateFileW(logFile.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (log == INVALID_HANDLE_VALUE) { + r.error = "cannot create " + pathToUtf8(logFile) + ": " + detail::lastErrorText(::GetLastError()); + if (nul != INVALID_HANDLE_VALUE) ::CloseHandle(nul); + return r; + } + } + + STARTUPINFOW si; + std::memset(&si, 0, sizeof si); + si.cb = sizeof si; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = nul; + si.hStdOutput = log != INVALID_HANDLE_VALUE ? log : ::GetStdHandle(STD_OUTPUT_HANDLE); + si.hStdError = log != INVALID_HANDLE_VALUE ? log : ::GetStdHandle(STD_ERROR_HANDLE); + + PROCESS_INFORMATION pi; + std::memset(&pi, 0, sizeof pi); + std::vector buf(cmd.begin(), cmd.end()); + buf.push_back(L'\0'); // CreateProcessW may modify the command line + const BOOL ok = ::CreateProcessW(nullptr, buf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, + &si, &pi); + const DWORD err = ok ? 0 : ::GetLastError(); + if (log != INVALID_HANDLE_VALUE) ::CloseHandle(log); + if (nul != INVALID_HANDLE_VALUE) ::CloseHandle(nul); + if (!ok) { + r.error = detail::lastErrorText(err); + return r; + } + r.started = true; + ::WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 1; + ::GetExitCodeProcess(pi.hProcess, &code); + ::CloseHandle(pi.hThread); + ::CloseHandle(pi.hProcess); + r.exitCode = static_cast(code); + return r; +#else + std::vector args; + for (const std::string& a : argv) args.push_back(const_cast(a.c_str())); + args.push_back(nullptr); + + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0); + const std::string logPath = logFile.native(); + if (!logPath.empty()) { + posix_spawn_file_actions_addopen(&actions, 1, logPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + posix_spawn_file_actions_adddup2(&actions, 1, 2); + } + pid_t pid = 0; + const int rc = posix_spawnp(&pid, args[0], &actions, nullptr, args.data(), environ); + posix_spawn_file_actions_destroy(&actions); + if (rc != 0) { + r.error = std::strerror(rc); + return r; + } + r.started = true; + int status = 0; + while (::waitpid(pid, &status, 0) < 0) { + if (errno != EINTR) { + r.exitCode = -1; + return r; + } + } + if (WIFEXITED(status)) r.exitCode = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) r.exitCode = 128 + WTERMSIG(status); + else r.exitCode = -1; + return r; +#endif +} + +// Opens a file or a URL in the default application (the browser). Does not wait. +inline void openInBrowser(const std::string& target) { +#ifdef _WIN32 + ::ShellExecuteW(nullptr, L"open", widen(target).c_str(), nullptr, nullptr, SW_SHOWNORMAL); +#else +#ifdef __APPLE__ + const char* opener = "open"; +#else + const char* opener = "xdg-open"; +#endif + std::thread([opener, target]() { runProcess({opener, target}, fs::path("/dev/null")); }).detach(); +#endif +} + +} // namespace lab diff --git a/src/render.hpp b/src/render.hpp new file mode 100644 index 0000000..5068a63 --- /dev/null +++ b/src/render.hpp @@ -0,0 +1,331 @@ +// Turns a lab::Node tree into (a) LISP-style text like ANTLR's toStringTree and +// (b) an SVG diagram. Pure C++ - no ANTLR types in here. +#pragma once + +#include +#include +#include +#include + +#include "types.hpp" +#include "util.hpp" + +namespace lab { + +// ------------------------------------------------------------ LISP text ---- + +inline void lispCompact(const Node& n, std::string& out) { + if (n.kind != Node::Kind::Rule) { + out += visible(n.label); + return; + } + if (n.children.empty()) { + out += n.label; + return; + } + out += '('; + out += n.label; + for (const Node& c : n.children) { + out += ' '; + lispCompact(c, out); + } + out += ')'; +} + +inline std::string lispCompact(const Node& n) { + std::string s; + lispCompact(n, s); + return s; +} + +// Indented form: a subtree stays on one line when it fits, otherwise each +// child goes on its own line. +namespace detail { + +struct LispInfo { + size_t len = 0; // length of the one-line form + size_t count = 1; // nodes in the subtree +}; + +// Fills `v` in pre-order, so that a node's entry is at its pre-order index. +inline LispInfo lispInfo(const Node& n, std::vector& v) { + const size_t me = v.size(); + v.emplace_back(); + LispInfo info; + if (n.kind != Node::Kind::Rule) { + info.len = visible(n.label).size(); + } else if (n.children.empty()) { + info.len = n.label.size(); + } else { + info.len = 2 + n.label.size(); // "(" label ... ")" + for (const Node& c : n.children) { + const LispInfo ci = lispInfo(c, v); + info.len += 1 + ci.len; + info.count += ci.count; + } + } + v[me] = info; + return info; +} + +// Indentation stops growing after this many levels. Without a cap a tree that +// is N levels deep (a long right-recursive list, say) prints O(N^2) spaces. +constexpr size_t kMaxIndentLevels = 40; + +inline void lispPrettyRec(const Node& n, size_t depth, const std::vector& v, size_t& next, + std::string& out) { + const size_t me = next++; + const LispInfo info = v[me]; + const size_t pad = std::min(depth, kMaxIndentLevels) * 2; // capped: see below + out.append(pad, ' '); + if (n.kind != Node::Kind::Rule || n.children.empty() || pad + info.len <= 88) { + lispCompact(n, out); + out += '\n'; + next = me + info.count; // skip the descendants, they were printed inline + return; + } + out += '('; + out += n.label; + out += '\n'; + for (const Node& c : n.children) lispPrettyRec(c, depth + 1, v, next, out); + out.pop_back(); // the newline after the last child + out += ")\n"; +} + +} // namespace detail + +inline std::string lispPretty(const Node& n) { + std::vector info; + info.reserve(64); + detail::lispInfo(n, info); + std::string s; + size_t next = 0; + detail::lispPrettyRec(n, 0, info, next, s); + return s; +} + +// ------------------------------------------------------------------ SVG ---- + +// Palette. Light and dark values are used both for the standalone .svg file +// (light, literal colors) and for the HTML report (CSS variables, themeable). +struct SvgColor { + const char* name; + const char* light; + const char* dark; +}; + +inline const std::vector& svgPalette() { + static const std::vector p = { + {"bg", "#fbfbfc", "#0f1b2d"}, {"text", "#1b2a41", "#e8eef7"}, + {"edge", "#9aa7b8", "#4a5d78"}, {"rulefill", "#ffffff", "#132238"}, + {"rulestroke", "#1b2a41", "#9db8e3"}, {"tokfill", "#1b2a41", "#dbe7fb"}, + {"toktext", "#f4f7fb", "#0f1b2d"}, {"toksub", "#a9b8cc", "#4b5e7a"}, + {"errfill", "#d1352b", "#f0625a"}, {"errtext", "#ffffff", "#1a0b0a"}, + }; + return p; +} + +// Variable declarations for the .al-tree element, e.g. "--al-bg:#fff;...". +inline std::string svgVarDecls(bool dark) { + std::string s; + for (const auto& c : svgPalette()) { + s += std::string("--al-") + c.name + ":" + (dark ? c.dark : c.light) + ";"; + } + return s; +} + +inline std::string svgStyle(bool useVars) { + auto col = [&](const char* name) -> std::string { + if (useVars) return std::string("var(--al-") + name + ")"; + for (const auto& c : svgPalette()) { + if (std::string(c.name) == name) return c.light; + } + return "#000"; + }; + std::string s; + s += ".al-tree{font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,'DejaVu Sans Mono','Liberation Mono',monospace}"; + s += ".al-tree .al-bg{fill:" + col("bg") + "}"; + s += ".al-tree .al-edge{fill:none;stroke:" + col("edge") + ";stroke-width:1.4}"; + s += ".al-tree .al-box{stroke-width:1.5}"; + s += ".al-tree .al-rule .al-box{fill:" + col("rulefill") + ";stroke:" + col("rulestroke") + "}"; + s += ".al-tree .al-term .al-box{fill:" + col("tokfill") + ";stroke:" + col("tokfill") + "}"; + s += ".al-tree .al-error .al-box{fill:" + col("errfill") + ";stroke:" + col("errfill") + "}"; + s += ".al-tree .al-label{font-size:13px;fill:" + col("text") + "}"; + s += ".al-tree .al-term .al-label{fill:" + col("toktext") + "}"; + s += ".al-tree .al-error .al-label{fill:" + col("errtext") + "}"; + s += ".al-tree .al-type{font-size:10px;fill:" + col("toksub") + "}"; + s += ".al-tree .al-error .al-type{fill:" + col("errtext") + ";opacity:.8}"; + s += ".al-tree .al-node:hover .al-box{stroke-width:3}"; + return s; +} + +namespace detail { + +struct Item { + const Node* node = nullptr; + int depth = 0; + double w = 0, h = 0; + double subtree = 0, left = 0, cx = 0; + std::vector kids; +}; + +constexpr double kCharW = 7.8; // 13px monospace +constexpr double kTypeCharW = 6.2; // 10px monospace +constexpr double kPadX = 12; +constexpr double kHGap = 14; +constexpr double kMargin = 24; +constexpr double kRuleH = 30; +constexpr double kTermH = 44; +constexpr double kRowPitch = 76; + +inline std::string shownLabel(const Node& n) { + if (n.kind == Node::Kind::Rule) return n.label; + return truncateCp(visible(n.label), 28); +} + +inline void collect(const Node& n, int parent, int depth, std::vector& items) { + const int idx = static_cast(items.size()); + Item it; + it.node = &n; + it.depth = depth; + const double textW = static_cast(cpCount(shownLabel(n))) * kCharW; + if (n.kind == Node::Kind::Rule) { + it.w = textW + 2 * kPadX; + it.h = kRuleH; + } else { + const double typeW = static_cast(cpCount(n.typeName)) * kTypeCharW; + it.w = std::max(textW, typeW) + 2 * kPadX; + it.h = kTermH; + } + it.w = std::max(it.w, 36.0); + items.push_back(std::move(it)); + if (parent >= 0) items[static_cast(parent)].kids.push_back(idx); + for (const Node& c : n.children) collect(c, idx, depth + 1, items); +} + +inline std::string num(double v) { return fixed1(v); } + +} // namespace detail + +struct SvgResult { + std::string svg; + double width = 0; + double height = 0; +}; + +// Tidy top-down layout: every subtree gets a slot as wide as its widest row, +// children sit side by side inside that slot, and a parent is centred over the +// span of its children. +// +// standalone = true -> XML prolog, embedded \n"; + s += "\n"; + } + + // Edges first so that the boxes sit on top of them. + for (size_t p = 0; p < N; ++p) { + const Item& par = items[p]; + for (int k : par.kids) { + const Item& kid = items[static_cast(k)]; + const double px = par.cx, py = yOf(par) + par.h; + const double cx = kid.cx, cy = yOf(kid); + const double my = (py + cy) / 2; + s += "\n"; + } + } + + for (const Item& it : items) { + const Node& n = *it.node; + const double x = it.cx - it.w / 2, y = yOf(it); + const char* cls = n.kind == Node::Kind::Rule ? "al-rule" + : n.kind == Node::Kind::Terminal ? "al-term" + : "al-error"; + s += std::string("= 0 && n.stop >= n.start) { + s += " data-s=\"" + std::to_string(n.start) + "\" data-e=\"" + std::to_string(n.stop) + "\""; + } + s += ">"; + if (n.kind == Node::Kind::Rule) { + s += "" + escapeXml(n.label) + ""; + s += ""; + s += "" + escapeXml(shownLabel(n)) + ""; + } else { + std::string tip = visible(n.label) + "\n" + n.typeName; + if (n.line > 0) tip += " line " + std::to_string(n.line) + ":" + std::to_string(n.col); + if (n.kind == Node::Kind::Error) tip += "\n(error node: token skipped or inserted during recovery)"; + s += "" + escapeXml(tip) + ""; + s += ""; + s += "" + escapeXml(shownLabel(n)) + ""; + s += "" + escapeXml(n.typeName) + ""; + } + s += "\n"; + } + s += "\n"; + return res; +} + +} // namespace lab diff --git a/src/report.hpp b/src/report.hpp new file mode 100644 index 0000000..4939443 --- /dev/null +++ b/src/report.hpp @@ -0,0 +1,850 @@ +// Builds report.html: one self-contained page (no network, no libraries) that +// shows the parse tree diagram, tree text, tokens, problems and grammar. +// +// The page renders from a JSON object (window.LAB_DATA). In watch mode a +// second file, report-data.js, calls window.labUpdate({...}) with fresh data +// and the page polls for it, so it also works when opened from file://. +// +// The CSS and JS live in separate raw string literals that stay below the +// 16 KB literal limit of MSVC. +#pragma once + +#include +#include + +#include "render.hpp" +#include "types.hpp" +#include "util.hpp" + +namespace lab { + +constexpr size_t kMaxDiagramNodes = 20000; + +struct GrammarSource { + std::string name; + std::string text; +}; + +struct ReportInput { + int version = 1; + bool live = false; + std::string statusKind = "ok"; // ok | warn | error | fail + std::string statusText; + std::string statusDetail; + std::vector grammars; + std::string toolLog; + bool hasParser = false; + std::string startRule; + std::string input; // sanitized UTF-8 + std::vector meta; // short stats, rendered as separate spans + bool haveResult = false; + const AnalysisResult* result = nullptr; // not owned; must outlive reportJson() + std::string noDiagram; // explanation shown instead of a diagram +}; + +// ------------------------------------------------------------------ JSON ---- + +inline std::string reportJson(const ReportInput& in) { + std::string j; + j.reserve(in.input.size() * 4 + 4096); + auto str = [&](const std::string& s) { j += jsonString(s); }; + auto key = [&](const char* k) { + j += '"'; + j += k; + j += "\":"; + }; + + j += '{'; + key("version"); j += std::to_string(in.version); + j += ','; key("live"); j += in.live ? "true" : "false"; + j += ','; key("status"); + j += '{'; key("kind"); str(in.statusKind); + j += ','; key("text"); str(in.statusText); + j += ','; key("detail"); str(in.statusDetail); + j += '}'; + + j += ','; key("grammars"); j += '['; + for (size_t i = 0; i < in.grammars.size(); ++i) { + if (i) j += ','; + j += '{'; key("name"); str(in.grammars[i].name); + j += ','; key("text"); str(in.grammars[i].text); + j += '}'; + } + j += ']'; + + j += ','; key("toolLog"); str(in.toolLog); + j += ','; key("startRule"); str(in.startRule); + j += ','; key("hasParser"); j += in.hasParser ? "true" : "false"; + j += ','; key("input"); str(in.input); + j += ','; key("meta"); j += '['; + for (size_t i = 0; i < in.meta.size(); ++i) { + if (i) j += ','; + str(in.meta[i]); + } + j += ']'; + + j += ','; key("diagnostics"); j += '['; + if (in.haveResult && in.result) { + for (size_t i = 0; i < in.result->diagnostics.size(); ++i) { + const Diagnostic& d = in.result->diagnostics[i]; + if (i) j += ','; + j += '{'; key("sev"); str(d.severity == Diagnostic::Severity::Error ? "error" : "warning"); + j += ','; key("src"); str(d.source); + j += ','; key("line"); j += std::to_string(d.line); + j += ','; key("col"); j += std::to_string(d.col); + j += ','; key("msg"); str(d.message); + j += ','; key("s"); j += std::to_string(d.start); + j += ','; key("e"); j += std::to_string(d.stop); + j += '}'; + } + } + j += ']'; + + j += ','; key("tokens"); j += '['; + if (in.haveResult && in.result) { + for (size_t i = 0; i < in.result->tokens.size(); ++i) { + const TokenInfo& t = in.result->tokens[i]; + if (i) j += ','; + j += '{'; key("i"); j += std::to_string(t.index); + j += ','; key("t"); str(t.isEof ? "" : visible(t.text)); + j += ','; key("ty"); str(t.typeName); + j += ','; key("ch"); str(t.channel); + j += ','; key("l"); j += std::to_string(t.line); + j += ','; key("c"); j += std::to_string(t.col); + j += ','; key("s"); j += std::to_string(t.start); + j += ','; key("e"); j += std::to_string(t.stop); + j += '}'; + } + } + j += ']'; + + std::string tree, pretty, svg, noDiagram = in.noDiagram; + if (in.haveResult && in.result && in.result->hasTree) { + tree = lispCompact(in.result->tree); + pretty = lispPretty(in.result->tree); + if (in.result->nodeCount <= kMaxDiagramNodes) { + svg = renderSvg(in.result->tree, false).svg; + } else if (noDiagram.empty()) { + noDiagram = "The tree has " + std::to_string(in.result->nodeCount) + + " nodes, which is too many to draw. The Tree tab and tree.txt still have it."; + } + } else if (noDiagram.empty()) { + noDiagram = in.hasParser ? "No parse tree was produced." : "This grammar has no parser rules, so there is no tree. See the Tokens tab."; + } + j += ','; key("tree"); str(tree); + j += ','; key("treePretty"); str(pretty); + j += ','; key("svg"); str(svg); + j += ','; key("svgStyle"); str(svgStyle(false)); + j += ','; key("noDiagram"); str(noDiagram); + j += '}'; + return j; +} + +// Content of report-data.js (watch mode). +inline std::string reportDataJs(const ReportInput& in) { + return "window.labUpdate && window.labUpdate(" + reportJson(in) + ");\n"; +} + +// ------------------------------------------------------------------- CSS ---- + +namespace web { + +inline std::string lightVars() { + return "--page:#f5f7fa;--pane:#ffffff;--ink:#1b2a41;--muted:#5b6a80;--line:#d8dfe9;" + "--accent:#2456d3;--ok:#1a7f45;--err:#c62f2a;--warn:#a86a06;" + "--okbg:#e5f4ea;--errbg:#fbe7e5;--warnbg:#fbf0d9;--hl:rgba(36,86,211,.2);" + "--dot:#cdd5e1;" + svgVarDecls(false); +} + +inline std::string darkVars() { + return "--page:#0c1626;--pane:#101d31;--ink:#e3ebf7;--muted:#8fa1ba;--line:#223450;" + "--accent:#7aa2ff;--ok:#4cc38a;--err:#ff7b72;--warn:#e3b341;" + "--okbg:#0f2a27;--errbg:#2e1a24;--warnbg:#2b2617;--hl:rgba(122,162,255,.28);" + "--dot:#1d3050;" + svgVarDecls(true); +} + +inline std::string css() { + std::string s; + s += ":root{" + lightVars() + "}\n"; + s += "@media (prefers-color-scheme:dark){:root:not([data-theme=light]){" + darkVars() + "}}\n"; + s += ":root[data-theme=dark]{" + darkVars() + "}\n"; + s += R"CSS( +*{box-sizing:border-box} +[hidden]{display:none!important} +html,body{height:100%} +body{margin:0;display:flex;flex-direction:column;background:var(--page);color:var(--ink); + font:14px/1.45 system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',sans-serif} +pre,code,.mono,td.mono{font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,'DejaVu Sans Mono','Liberation Mono',monospace} +button{font:inherit;color:inherit} +:focus-visible{outline:2px solid var(--accent);outline-offset:2px} + +.top{display:flex;align-items:center;gap:18px;padding:9px 20px;border-bottom:1px solid var(--line);background:var(--pane)} +.brand{font-weight:650;letter-spacing:.01em;white-space:nowrap} +.files{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:13px} +.files span{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +.spacer{flex:1} +.live{color:var(--muted);font-size:12.5px} +.btn{white-space:nowrap;padding:4px 11px;border:1px solid var(--line);background:var(--pane);border-radius:4px;cursor:pointer;line-height:1.4} +.btn:hover{border-color:var(--accent)} +.btn[aria-pressed=true]{background:var(--hl);border-color:var(--accent)} + +.verdict{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 28px;padding:16px 20px 16px 24px; + border-bottom:1px solid var(--line);border-left:6px solid var(--ok);background:var(--okbg)} +.verdict.warn{border-left-color:var(--warn);background:var(--warnbg)} +.verdict.error,.verdict.fail{border-left-color:var(--err);background:var(--errbg)} +.vhead{margin:0;font-size:clamp(1.5rem,2.6vw,2.15rem);font-weight:650;letter-spacing:-.01em;line-height:1.15;color:var(--ok)} +.verdict.warn .vhead{color:var(--warn)} +.verdict.error .vhead,.verdict.fail .vhead{color:var(--err)} +.vdetail{flex:1 1 280px;min-width:0;margin:0;font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.vmeta{display:flex;flex-wrap:wrap;gap:2px 16px;color:var(--muted);font-size:12.5px} + +main{flex:1;min-height:0;display:grid;grid-template-columns:minmax(260px,34%) minmax(0,1fr)} +.left{display:flex;flex-direction:column;min-height:0;border-right:1px solid var(--line);background:var(--pane)} +.right{display:flex;flex-direction:column;min-height:0;min-width:0} +.ptitle{padding:9px 16px;border-bottom:1px solid var(--line);font-weight:600;font-size:13px;color:var(--muted)} +.code{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:auto 1fr;align-content:start} +.code pre{margin:0;padding:12px 0;font-size:13px;line-height:1.65} +#gutter{padding:12px 10px 12px 14px;text-align:right;color:var(--muted);opacity:.7;position:sticky;left:0;background:var(--pane);user-select:none} +#code{padding-left:10px;padding-right:24px} +.mk-err{background:var(--errbg);text-decoration:underline wavy var(--err);text-underline-offset:3px} +.mk-warn{text-decoration:underline wavy var(--warn);text-underline-offset:3px} +.hl{background:var(--hl);border-radius:2px;box-shadow:0 0 0 1px var(--accent)} +.eofmark{margin-left:4px;padding:0 6px;border-radius:9px;background:var(--err);color:#fff;font:11px system-ui,sans-serif} + +.tabs{display:flex;gap:2px;padding:0 10px;border-bottom:1px solid var(--line);background:var(--pane);overflow-x:auto} +.tabs button{background:none;border:0;border-bottom:2px solid transparent;padding:9px 12px;color:var(--muted);cursor:pointer;white-space:nowrap} +.tabs button:hover{color:var(--ink)} +.tabs button[aria-selected=true]{color:var(--ink);border-bottom-color:var(--accent);font-weight:600} +.badge{margin-left:6px;padding:0 6px;border-radius:9px;background:var(--err);color:#fff;font-size:11px;font-weight:600} +.badge.warn{background:var(--warn)} +.panel{flex:1;min-height:0;display:flex;flex-direction:column} +.scroll{flex:1;min-height:0;overflow:auto;padding:14px 18px} +.bar{display:flex;gap:8px;align-items:center;margin-bottom:10px} +.note{margin:0;padding:18px;color:var(--muted)} + +#dgm{position:relative;flex:1;min-height:200px;overflow:hidden;cursor:grab;touch-action:none; + background-color:var(--page);background-image:radial-gradient(var(--dot) 1px,transparent 1px);background-size:22px 22px} +#dgm.drag{cursor:grabbing} +#dgm.empty{cursor:default} +#stage{position:absolute;left:0;top:0;transform-origin:0 0} +#stage svg{display:block;overflow:visible} +#stage .al-node{cursor:pointer} +.tools{position:absolute;top:10px;right:10px;display:flex;gap:6px} +.tools .btn{min-width:32px} +#dnote{position:absolute;left:0;right:0;top:0;padding:24px;color:var(--muted)} + +pre.out{margin:0;font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word} +table{border-collapse:collapse;width:100%;font-size:13px} +th{position:sticky;top:-14px;background:var(--page);text-align:left;font-weight:600;color:var(--muted);padding:6px 10px;border-bottom:1px solid var(--line)} +td{padding:4px 10px;border-bottom:1px solid var(--line);vertical-align:top} +tbody tr:hover{background:var(--hl)} +tr.side td{opacity:.55} +td.num{text-align:right;color:var(--muted)} + +ul.probs{list-style:none;margin:0;padding:0} +.prob{display:grid;grid-template-columns:64px 64px minmax(0,1fr) auto;gap:10px;padding:9px 18px;border-bottom:1px solid var(--line);cursor:pointer} +.prob:hover,.prob[aria-current=true]{background:var(--hl)} +.prob .sev{font-weight:600;color:var(--err)} +.prob.warning .sev{color:var(--warn)} +.prob .loc{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--muted)} +.prob .src{color:var(--muted);font-size:12.5px} +.gram h3{margin:0 0 6px;font-size:13px;color:var(--muted);font-weight:600} +.gram{margin-bottom:22px} + +@media (max-width:900px){ + body{display:block;height:auto} + main{display:block} + .left{border-right:0;border-bottom:1px solid var(--line);max-height:42vh} + .right{height:88vh} + .vdetail{white-space:normal} +} +@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}} +)CSS"; + s += svgStyle(true); + return s; +} + +// ------------------------------------------------------------------ HTML ---- + +inline std::string shellHead() { + return R"HTML( + + + + +ANTLR Lab + + + + +
+ ANTLR Lab +
+ + + +
+
+

+

+
+
+
+
+
Input
+
+
+
+
+ + + + + +
+
+
+
+ +
+ + + + + +
+
+
+ + + + +
+
+)HTML"; +} + +// ------------------------------------------------------------ JavaScript ---- + +inline std::string jsCore() { + return R"JS( +(function () { + 'use strict'; + var D = window.LAB_DATA || {}; + var cps = []; + var eofError = false; + var hover = null, pin = null; + var seen = -1; + var view = { tab: null, pretty: true, z: 1, x: 0, y: 0, w: 0, h: 0, needFit: true, svg: '' }; + var TABS = ['diagram', 'tree', 'tokens', 'problems', 'grammar']; + var MAX_ROWS = 5000; + + function $(id) { return document.getElementById(id); } + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined && text !== null) e.textContent = text; + return e; + } + function clear(n) { while (n.firstChild) n.removeChild(n.firstChild); } + function plural(n, w) { return n + ' ' + w + (n === 1 ? '' : 's'); } + + /* ---------- verdict ---------- */ + function paintVerdict() { + var st = D.status || { kind: 'ok', text: '', detail: '' }; + $('verdict').className = 'verdict ' + st.kind; + $('vhead').textContent = st.text; + $('vdetail').textContent = st.detail || ''; + $('vdetail').title = st.detail || ''; + var m = $('vmeta'); + clear(m); + (D.meta || []).forEach(function (t) { m.appendChild(el('span', 'meta', t)); }); + document.title = st.text + ' \u2013 ANTLR Lab'; + var f = $('files'); + clear(f); + (D.grammars || []).forEach(function (g) { f.appendChild(el('span', '', g.name)); }); + if (D.startRule) f.appendChild(el('span', '', 'start rule: ' + D.startRule)); + } + + /* ---------- input pane ---------- */ + function flagClass(f) { + var c = []; + if (f & 1) c.push('mk-err'); + if (f & 2) c.push('mk-warn'); + if (f & 4) c.push('hl'); + return c.join(' '); + } + + function paintInput() { + var box = $('code'); + var n = cps.length; + var flags = new Uint8Array(n + 1); + (D.diagnostics || []).forEach(function (d) { + if (d.s < 0) return; + var bit = d.sev === 'warning' ? 2 : 1; + for (var a = d.s; a <= d.e && a < n; a++) flags[a] |= bit; + }); + var r = hover || pin; + if (r) for (var b = r[0]; b <= r[1] && b < n; b++) flags[b] |= 4; + clear(box); + var frag = document.createDocumentFragment(); + var i = 0; + while (i < n) { + var f = flags[i], j = i + 1; + while (j < n && flags[j] === f) j++; + var txt = cps.slice(i, j).join(''); + frag.appendChild(f === 0 ? document.createTextNode(txt) : el('span', flagClass(f), txt)); + i = j; + } + if (n === 0) frag.appendChild(el('span', 'note', '(empty input)')); + if (eofError) frag.appendChild(el('span', 'eofmark', 'end of input')); + box.appendChild(frag); + } + + function paintGutter() { + var lines = 1; + for (var i = 0; i < cps.length; i++) if (cps[i] === '\n') lines++; + var nums = []; + for (var k = 1; k <= lines; k++) nums.push(k); + $('gutter').textContent = nums.join('\n'); + } + + var queued = false; + function repaintSoon() { + if (queued) return; + queued = true; + requestAnimationFrame(function () { queued = false; paintInput(); }); + } + function setHover(s, e) { + hover = (s >= 0 && e >= s) ? [s, e] : null; + repaintSoon(); + } + function setPin(s, e) { + pin = (s >= 0 && e >= s) ? [s, e] : null; + paintInput(); + var h = document.querySelector('#code .hl'); + if (h && h.scrollIntoView) h.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + + /* ---------- tabs ---------- */ + function showTab(t) { + view.tab = t; + TABS.forEach(function (id) { + var on = id === t; + var b = $('t-' + id); + b.setAttribute('aria-selected', on ? 'true' : 'false'); + b.tabIndex = on ? 0 : -1; + $('p-' + id).hidden = !on; + }); + if (t === 'diagram' && view.needFit) fit(); + } + + function initTabs() { + TABS.forEach(function (id, idx) { + var b = $('t-' + id); + b.onclick = function () { showTab(id); }; + b.onkeydown = function (ev) { + var d = ev.key === 'ArrowRight' ? 1 : ev.key === 'ArrowLeft' ? -1 : 0; + if (!d) return; + var next = TABS[(idx + d + TABS.length) % TABS.length]; + showTab(next); + $('t-' + next).focus(); + ev.preventDefault(); + }; + }); + } + + /* ---------- tree text ---------- */ + function paintTree() { + $('treeout').textContent = D.tree ? (view.pretty ? D.treePretty : D.tree) : (D.noDiagram || 'No parse tree.'); + $('b-pretty').setAttribute('aria-pressed', view.pretty ? 'true' : 'false'); + $('b-flat').setAttribute('aria-pressed', view.pretty ? 'false' : 'true'); + } + + function copyText(text, btn) { + var done = function () { + var old = btn.textContent; + btn.textContent = 'Copied'; + setTimeout(function () { btn.textContent = old; }, 1200); + }; + var fallback = function () { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); done(); } catch (e) { /* ignore */ } + ta.remove(); + }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(done, fallback); + } else { + fallback(); + } + } + + /* ---------- tokens ---------- */ + function paintTokens() { + var wrap = $('tokwrap'); + clear(wrap); + var toks = D.tokens || []; + if (!toks.length) { wrap.appendChild(el('p', 'note', 'No tokens.')); return; } + var table = el('table'); + var head = el('tr'); + ['#', 'Text', 'Type', 'Channel', 'Line:col'].forEach(function (h) { head.appendChild(el('th', '', h)); }); + var thead = el('thead'); + thead.appendChild(head); + table.appendChild(thead); + var body = el('tbody'); + toks.slice(0, MAX_ROWS).forEach(function (t) { + var tr = el('tr', t.ch && t.ch !== 'DEFAULT_TOKEN_CHANNEL' && t.ch !== '0' ? 'side' : ''); + tr.appendChild(el('td', 'num', String(t.i))); + tr.appendChild(el('td', 'mono', t.t)); + tr.appendChild(el('td', 'mono', t.ty)); + tr.appendChild(el('td', 'mono', t.ch)); + tr.appendChild(el('td', 'mono', t.l + ':' + t.c)); + tr.onmouseenter = function () { setHover(t.s, t.e); }; + tr.onmouseleave = function () { setHover(-1, -1); }; + body.appendChild(tr); + }); + table.appendChild(body); + wrap.appendChild(table); + if (toks.length > MAX_ROWS) { + wrap.appendChild(el('p', 'note', 'Showing the first ' + MAX_ROWS + ' of ' + toks.length + ' tokens. tokens.txt has all of them.')); + } + } + + /* ---------- problems ---------- */ + function paintProblems() { + var list = $('problist'); + clear(list); + var ds = D.diagnostics || []; + $('noprob').hidden = ds.length > 0; + var errs = 0, warns = 0; + ds.forEach(function (d) { + if (d.sev === 'error') errs++; else warns++; + var li = el('li', 'prob ' + d.sev); + li.tabIndex = 0; + li.appendChild(el('span', 'sev', d.sev)); + li.appendChild(el('span', 'loc', d.line ? d.line + ':' + d.col : '')); + li.appendChild(el('span', 'msg', d.msg)); + li.appendChild(el('span', 'src', d.src)); + var act = function () { + Array.prototype.forEach.call(list.children, function (c) { c.removeAttribute('aria-current'); }); + li.setAttribute('aria-current', 'true'); + setPin(d.s, d.e); + }; + li.onclick = act; + li.onkeydown = function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { act(); ev.preventDefault(); } }; + list.appendChild(li); + }); + var badge = $('badge'); + badge.hidden = !ds.length; + badge.className = 'badge' + (errs ? '' : ' warn'); + badge.textContent = String(errs || warns); + } + + /* ---------- grammar ---------- */ + function paintGrammar() { + var wrap = $('gramwrap'); + clear(wrap); + if (D.toolLog) { + var s0 = el('section', 'gram'); + s0.appendChild(el('h3', '', 'ANTLR tool output')); + s0.appendChild(el('pre', 'out', D.toolLog)); + wrap.appendChild(s0); + } + (D.grammars || []).forEach(function (g) { + var s = el('section', 'gram'); + s.appendChild(el('h3', '', g.name)); + s.appendChild(el('pre', 'out', g.text)); + wrap.appendChild(s); + }); + } +)JS"; +} + +inline std::string jsDiagram() { + return R"JS( + /* ---------- diagram ---------- */ + function applyView() { + $('stage').style.transform = 'translate(' + view.x + 'px,' + view.y + 'px) scale(' + view.z + ')'; + } + + function fit() { + var c = $('dgm'); + var cw = c.clientWidth, ch = c.clientHeight; + if (!view.w || !cw || !ch) return; + var z = Math.min(1, (cw - 40) / view.w, (ch - 40) / view.h); + z = Math.max(z, 0.05); + view.z = z; + view.x = (cw - view.w * z) / 2; + view.y = Math.max(20, (ch - view.h * z) / 2); + view.needFit = false; + applyView(); + } + + function zoomAt(cx, cy, factor) { + var z = Math.min(8, Math.max(0.05, view.z * factor)); + var k = z / view.z; + view.x = cx - (cx - view.x) * k; + view.y = cy - (cy - view.y) * k; + view.z = z; + applyView(); + } + function zoomCentre(factor) { + var c = $('dgm'); + zoomAt(c.clientWidth / 2, c.clientHeight / 2, factor); + } + + function paintDiagram() { + var stage = $('stage'), note = $('dnote'), box = $('dgm'); + var had = !!view.svg; + if (D.svg === view.svg && had) return; // same picture: keep pan and zoom + view.svg = D.svg || ''; + clear(stage); + if (!view.svg) { + note.textContent = D.noDiagram || 'No diagram.'; + note.hidden = false; + box.classList.add('empty'); + view.w = view.h = 0; + view.needFit = true; + return; + } + note.hidden = true; + box.classList.remove('empty'); + stage.innerHTML = view.svg; + var vb = stage.querySelector('svg').getAttribute('viewBox').split(' '); + view.w = parseFloat(vb[2]); + view.h = parseFloat(vb[3]); + if (!had) view.needFit = true; + if (view.tab === 'diagram' && view.needFit) fit(); else applyView(); + } + + function initDiagram() { + var box = $('dgm'), stage = $('stage'); + var drag = null, moved = false; + + box.addEventListener('wheel', function (ev) { + if (!view.w) return; + ev.preventDefault(); + var unit = ev.deltaMode === 1 ? 16 : 1; + var r = box.getBoundingClientRect(); + zoomAt(ev.clientX - r.left, ev.clientY - r.top, Math.exp(-ev.deltaY * unit * (ev.ctrlKey ? 0.01 : 0.0015))); + }, { passive: false }); + + box.addEventListener('pointerdown', function (ev) { + if (ev.button !== 0 || ev.target.closest('.tools') || !view.w) return; + drag = { x: ev.clientX, y: ev.clientY, vx: view.x, vy: view.y }; + moved = false; + box.setPointerCapture(ev.pointerId); + box.classList.add('drag'); + }); + box.addEventListener('pointermove', function (ev) { + if (!drag) return; + var dx = ev.clientX - drag.x, dy = ev.clientY - drag.y; + if (Math.abs(dx) + Math.abs(dy) > 3) moved = true; + view.x = drag.vx + dx; + view.y = drag.vy + dy; + applyView(); + }); + var end = function () { drag = null; box.classList.remove('drag'); }; + box.addEventListener('pointerup', end); + box.addEventListener('pointercancel', end); + box.addEventListener('dblclick', function (ev) { if (!ev.target.closest('.tools')) fit(); }); + + stage.addEventListener('click', function (ev) { + if (moved) return; + var g = ev.target.closest('.al-node'); + if (!g || !g.hasAttribute('data-s')) return; + var s = +g.getAttribute('data-s'), e = +g.getAttribute('data-e'); + if (pin && pin[0] === s && pin[1] === e) setPin(-1, -1); else setPin(s, e); + }); + stage.addEventListener('mouseover', function (ev) { + var g = ev.target.closest('.al-node'); + if (g && g.hasAttribute('data-s')) setHover(+g.getAttribute('data-s'), +g.getAttribute('data-e')); + }); + stage.addEventListener('mouseout', function (ev) { + if (ev.target.closest('.al-node')) setHover(-1, -1); + }); + + box.addEventListener('keydown', function (ev) { + var step = 60; + if (ev.key === '+' || ev.key === '=') zoomCentre(1.25); + else if (ev.key === '-') zoomCentre(0.8); + else if (ev.key === '0') fit(); + else if (ev.key === 'ArrowLeft') { view.x += step; applyView(); } + else if (ev.key === 'ArrowRight') { view.x -= step; applyView(); } + else if (ev.key === 'ArrowUp') { view.y += step; applyView(); } + else if (ev.key === 'ArrowDown') { view.y -= step; applyView(); } + else return; + ev.preventDefault(); + }); + + $('zin').onclick = function () { zoomCentre(1.25); }; + $('zout').onclick = function () { zoomCentre(0.8); }; + $('zfit').onclick = fit; + $('psvg').onclick = function () { if (view.w) download('tree.svg', new Blob([svgMarkup()], { type: 'image/svg+xml' })); }; + $('ppng').onclick = savePng; + window.addEventListener('resize', function () { if (view.tab === 'diagram' && view.needFit) fit(); }); + } + + /* ---------- export ---------- */ + function svgMarkup() { + var NS = 'http://www.w3.org/2000/svg'; + var c = $('stage').querySelector('svg').cloneNode(true); + var st = document.createElementNS(NS, 'style'); + st.textContent = D.svgStyle; + var bg = document.createElementNS(NS, 'rect'); + bg.setAttribute('class', 'al-bg'); + bg.setAttribute('width', '100%'); + bg.setAttribute('height', '100%'); + c.insertBefore(bg, c.firstChild); + c.insertBefore(st, c.firstChild); + return '\n' + new XMLSerializer().serializeToString(c); + } + + function download(name, blob) { + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000); + } + + function savePng() { + if (!view.w) return; + var scale = Math.min(2, 8000 / Math.max(view.w, view.h)); + var img = new Image(); + img.onload = function () { + var c = document.createElement('canvas'); + c.width = Math.max(1, Math.round(view.w * scale)); + c.height = Math.max(1, Math.round(view.h * scale)); + c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); + c.toBlob(function (b) { if (b) download('tree.png', b); }, 'image/png'); + }; + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgMarkup()); + } + + /* ---------- theme ---------- */ + function effectiveTheme() { + var t = document.documentElement.getAttribute('data-theme'); + if (t) return t; + return window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + function paintThemeButton() { + $('theme').textContent = effectiveTheme() === 'dark' ? 'Switch to light' : 'Switch to dark'; + } + function initTheme() { + $('theme').onclick = function () { + var next = effectiveTheme() === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + try { localStorage.setItem('antlr-lab-theme', next); } catch (e) { /* ignore */ } + paintThemeButton(); + }; + if (window.matchMedia) { + var mq = matchMedia('(prefers-color-scheme: dark)'); + var on = function () { paintThemeButton(); }; + if (mq.addEventListener) mq.addEventListener('change', on); else if (mq.addListener) mq.addListener(on); + } + paintThemeButton(); + } + + /* ---------- main ---------- */ + function render() { + seen = D.version; + cps = Array.from(D.input || ''); + eofError = (D.diagnostics || []).some(function (d) { return d.sev === 'error' && d.s < 0 && d.src === 'parser'; }); + hover = null; + if (pin && pin[1] >= cps.length) pin = null; + paintVerdict(); + paintGutter(); + paintInput(); + paintDiagram(); + paintTree(); + paintTokens(); + paintProblems(); + paintGrammar(); + if (view.tab === null) { + var kind = (D.status || {}).kind; + showTab(kind === 'fail' ? 'grammar' : (D.svg ? 'diagram' : (D.tree ? 'tree' : 'tokens'))); + } else if (view.tab === 'diagram' && view.needFit) { + fit(); + } + } + + window.labUpdate = function (data) { + if (!data || data.version === seen) return; + D = data; + window.LAB_DATA = data; + render(); + }; + + initTabs(); + initDiagram(); + initTheme(); + $('b-pretty').onclick = function () { view.pretty = true; paintTree(); }; + $('b-flat').onclick = function () { view.pretty = false; paintTree(); }; + $('b-copy').onclick = function () { copyText(view.pretty ? D.treePretty : D.tree, $('b-copy')); }; + render(); + + if (D.live) { + $('live').hidden = false; + setInterval(function () { + var old = document.getElementById('poll'); + if (old) old.remove(); + var s = document.createElement('script'); + s.id = 'poll'; + s.src = 'report-data.js?t=' + Date.now(); + document.head.appendChild(s); + }, 1000); + } +})(); +)JS"; +} + +} // namespace web + +// ------------------------------------------------------------ the page ---- + +inline std::string reportHtml(const ReportInput& in) { + std::string h; + h += web::shellHead(); + h += web::css(); + h += web::shellBody(); + h += "\n\n\n\n"; + return h; +} + +} // namespace lab diff --git a/src/serve.hpp b/src/serve.hpp new file mode 100644 index 0000000..f45fa40 --- /dev/null +++ b/src/serve.hpp @@ -0,0 +1,1047 @@ +// Backend of the web UI ("antlrlab --serve"). +// +// What it does: +// - serves the single-page editor (web/ -> webui_assets.hpp) +// - lists, reads and saves files inside one workspace directory +// - compiles the grammars the browser sends (unsaved edits included), runs +// the input and returns the result as JSON +// +// Safety, because a web page that can write files is a real attack surface: +// - listens on 127.0.0.1 only and checks the Host header (DNS rebinding) +// - every API call needs a random per-run token (cookie) plus a custom +// header, and a cross-origin Origin is refused +// - every path is resolved (symlinks included) and must stay inside the +// workspace root; .git is off limits +#pragma once + +#include "util.hpp" // first: see the note at the top of util.hpp + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "analysis.hpp" +#include "antlr_tool.hpp" +#include "crash.hpp" +#include "http.hpp" +#include "isolate.hpp" +#include "json.hpp" +#include "pipeline.hpp" +#include "render.hpp" +#include "report.hpp" +#include "webui_assets.hpp" + +#ifndef LAB_VERSION +#define LAB_VERSION "1.0.0" +#endif + +namespace lab { + +struct ServeOptions { + fs::path root; + int port = 0; // 0: try 8765 upwards + int timeoutSeconds = 120; // limit for one run; 0 = none + bool isolate = true; // run the ANTLR runtime in a child process + std::vector open; // files to open as tabs (relative to the cwd) + std::function openBrowser; // empty: do not open +}; + +namespace serve { + +// ---------------------------------------------------------------- basics ---- + +inline std::string randomToken() { + unsigned char buf[16] = {0}; + if (!secureRandomBytes(buf, sizeof buf)) { + std::random_device rd; + for (unsigned char& b : buf) b = static_cast(rd()); + } + static const char* hex = "0123456789abcdef"; + std::string s; + for (unsigned char b : buf) { + s += hex[b >> 4]; + s += hex[b & 15]; + } + return s; +} + +inline bool sameToken(const std::string& a, const std::string& b) { + if (a.size() != b.size() || a.empty()) return false; + unsigned char d = 0; + for (size_t i = 0; i < a.size(); ++i) d |= static_cast(a[i] ^ b[i]); + return d == 0; +} + +inline std::string cookieValue(const std::string& header, const std::string& name) { + size_t i = 0; + while (i < header.size()) { + const size_t semi = header.find(';', i); + const std::string part = http::trim(header.substr(i, semi == std::string::npos ? std::string::npos : semi - i)); + const size_t eq = part.find('='); + if (eq != std::string::npos && part.compare(0, eq, name) == 0) return part.substr(eq + 1); + if (semi == std::string::npos) break; + i = semi + 1; + } + return ""; +} + +inline std::string fnv1a(const std::string& s) { + uint64_t h = 1469598103934665603ULL; + for (unsigned char c : s) { + h ^= c; + h *= 1099511628211ULL; + } + char b[24]; + std::snprintf(b, sizeof b, "%016llx", static_cast(h)); + return b; +} + +inline Response jsonResponse(int status, std::string body) { + Response r; + r.status = status; + r.contentType = "application/json; charset=utf-8"; + r.body = std::move(body); + return r; +} + +inline Response jsonError(int status, const std::string& message, const std::string& extra = "") { + return jsonResponse(status, "{\"error\":" + jsonString(message) + extra + "}"); +} + +// Paths travel to and from the browser as UTF-8 with '/' separators. +inline std::string utf8Of(const fs::path& p) { return pathToGenericUtf8(p); } +inline fs::path pathOf(const std::string& s) { return pathFromUtf8(s); } + +// Do two path components name the same file? Windows and macOS file systems +// ignore case (ASCII only here; real names come from the file system anyway). +inline bool sameComponent(const fs::path& a, const fs::path& b) { +#if defined(_WIN32) || defined(__APPLE__) + return lowerCase(pathToUtf8(a)) == lowerCase(pathToUtf8(b)); +#else + return a == b; +#endif +} + +// Is p the root itself or somewhere below it? Both must be absolute and normalized. +inline bool pathInside(const fs::path& root, const fs::path& p) { + auto a = root.begin(); + auto b = p.begin(); + for (; a != root.end(); ++a, ++b) { + if (b == p.end() || !sameComponent(*a, *b)) return false; + } + return true; +} + +// Files the file browser does not show. +inline bool isHiddenEntry(const fs::path& p, const std::string& name) { + if (name.empty() || name[0] == '.') return true; +#ifdef _WIN32 + const DWORD attrs = ::GetFileAttributesW(p.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && (attrs & (FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM)) != 0; +#else + (void)p; + return false; +#endif +} + +// Windows treats some names specially: "a.txt:stream" is an alternate data +// stream, NUL and COM1 are devices, "x." and "x " mean "x", and "GIT~1" can be +// the short name of ".git". None of that is a plain file in the workspace. +// Returns a message, or "" when the relative path is fine (always, elsewhere). +inline std::string windowsPathProblem(const std::string& rel) { +#ifdef _WIN32 + if (rel.find(':') != std::string::npos) return "invalid path"; + size_t i = 0; + while (i <= rel.size()) { + const size_t j = rel.find('/', i); + const std::string part = rel.substr(i, j == std::string::npos ? std::string::npos : j - i); + i = j == std::string::npos ? rel.size() + 1 : j + 1; + if (part.empty()) continue; + if (part.back() == '.' || part.back() == ' ') { + if (part != "." && part != "..") return "invalid path"; + } + const size_t tilde = part.find('~'); + if (tilde != std::string::npos && tilde + 1 < part.size() && std::isdigit(static_cast(part[tilde + 1]))) { + return "invalid path"; + } + std::string base = lowerCase(part.substr(0, part.find('.'))); + while (!base.empty() && base.back() == ' ') base.pop_back(); + if (base == "con" || base == "prn" || base == "aux" || base == "nul" || base == "conin$" || base == "conout$") { + return "invalid path"; + } + if (base.size() == 4 && (base.compare(0, 3, "com") == 0 || base.compare(0, 3, "lpt") == 0) && base[3] >= '1' && + base[3] <= '9') { + return "invalid path"; + } + } +#else + (void)rel; +#endif + return std::string(); +} + +// "mtime-size": changes whenever the file is rewritten. +inline std::string etagOf(const fs::path& p) { + std::error_code ec; + const auto t = fs::last_write_time(p, ec); + if (ec) return ""; + auto size = fs::file_size(p, ec); + if (ec) size = 0; + const auto ms = std::chrono::duration_cast(t.time_since_epoch()).count(); + return std::to_string(ms) + "-" + std::to_string(size); +} + +// Saves via a temporary file so that a crash cannot leave half a file. +inline bool saveFile(const fs::path& p, const std::string& data) { + std::error_code ec; + fs::path tmp = p; + tmp += ".antlrlab-tmp"; + if (!writeFile(tmp, data)) { + fs::remove(tmp, ec); + return false; + } + if (fs::exists(p, ec)) { + const auto perms = fs::status(p, ec).permissions(); + fs::permissions(tmp, perms, ec); + } + fs::rename(tmp, p, ec); + if (ec) { + std::error_code ec2; + fs::remove(tmp, ec2); + return writeFile(p, data); + } + return true; +} + +// -------------------------------------------------------------- grammars ---- + +struct GrammarHeader { + enum class Kind { Unknown, Lexer, Parser, Combined }; + Kind kind = Kind::Unknown; + std::string name; + std::string tokenVocab; + std::vector imports; +}; + +// Removes comments, but leaves string literals and [char sets] alone (a lexer +// rule may well contain '//' or [/*]). +inline std::string stripComments(const std::string& t) { + std::string o; + o.reserve(t.size()); + size_t i = 0; + const size_t n = t.size(); + while (i < n) { + const char c = t[i]; + if (c == '/' && i + 1 < n && t[i + 1] == '/') { + while (i < n && t[i] != '\n') ++i; + } else if (c == '/' && i + 1 < n && t[i + 1] == '*') { + i += 2; + while (i + 1 < n && !(t[i] == '*' && t[i + 1] == '/')) { + if (t[i] == '\n') o += '\n'; + ++i; + } + i = std::min(n, i + 2); + o += ' '; + } else if (c == '\'' || c == '[') { + const char close = c == '\'' ? '\'' : ']'; + o += c; + ++i; + while (i < n && t[i] != '\n') { + o += t[i]; + if (t[i] == '\\' && i + 1 < n) { + ++i; + o += t[i]; + } else if (t[i] == close) { + ++i; + break; + } + ++i; + } + } else { + o += c; + ++i; + } + } + return o; +} + +// Identifiers and single punctuation characters of a (comment-free) grammar; +// strings, [char sets] and whitespace are skipped. Linear time, no recursion. +inline std::vector headerTokens(const std::string& t) { + std::vector out; + size_t i = 0; + const size_t n = t.size(); + auto idStart = [](char c) { return std::isalpha(static_cast(c)) || c == '_'; }; + auto idChar = [&](char c) { return idStart(c) || std::isdigit(static_cast(c)); }; + while (i < n && out.size() < 20000) { + const char c = t[i]; + if (std::isspace(static_cast(c))) { + ++i; + } else if (idStart(c)) { + size_t j = i + 1; + while (j < n && idChar(t[j])) ++j; + out.push_back(t.substr(i, j - i)); + i = j; + } else if (c == '\'' || c == '[') { + const char close = c == '\'' ? '\'' : ']'; + ++i; + while (i < n && t[i] != '\n') { + if (t[i] == '\\') ++i; + else if (t[i] == close) { + ++i; + break; + } + ++i; + } + out.push_back(""); + } else { + out.push_back(std::string(1, c)); + ++i; + } + } + return out; +} + +inline GrammarHeader parseHeader(const std::string& text) { + GrammarHeader h; + const std::vector tk = headerTokens(stripComments(text.substr(0, 131072))); + + size_t k = 0; + if (k < tk.size() && (tk[k] == "lexer" || tk[k] == "parser")) { + h.kind = tk[k] == "lexer" ? GrammarHeader::Kind::Lexer : GrammarHeader::Kind::Parser; + ++k; + } else { + h.kind = GrammarHeader::Kind::Combined; + } + size_t afterHeader = 0; + if (k + 2 < tk.size() && tk[k] == "grammar" && tk[k + 2] == ";") { + h.name = tk[k + 1]; + afterHeader = k + 3; + } else { + h.kind = GrammarHeader::Kind::Unknown; + } + + for (size_t i = 0; i + 2 < tk.size(); ++i) { + if (tk[i] == "tokenVocab" && tk[i + 1] == "=" && !tk[i + 2].empty() && + (std::isalpha(static_cast(tk[i + 2][0])) || tk[i + 2][0] == '_')) { + h.tokenVocab = tk[i + 2]; + break; + } + } + + // import A, B; import X=Y; (only at the start of a statement) + for (size_t i = afterHeader; i < tk.size(); ++i) { + const bool statementStart = i == afterHeader || tk[i - 1] == ";" || tk[i - 1] == "}"; + if (tk[i] != "import" || !statementStart) continue; + std::string last; + for (size_t j = i + 1; j < tk.size() && tk[j] != ";"; ++j) { + if (tk[j] == ",") { + if (!last.empty()) h.imports.push_back(last); + last.clear(); + } else if (tk[j] != "=" && (std::isalpha(static_cast(tk[j][0])) || tk[j][0] == '_')) { + last = tk[j]; + } + } + if (!last.empty()) h.imports.push_back(last); + break; + } + return h; +} + +inline bool validGrammarFileName(const std::string& n) { + if (n.size() < 4 || n.compare(n.size() - 3, 3, ".g4") != 0) return false; + for (size_t i = 0; i + 3 < n.size(); ++i) { + const char c = n[i]; + const bool ok = std::isalpha(static_cast(c)) || c == '_' || (i > 0 && std::isdigit(static_cast(c))); + if (!ok) return false; + } + return true; +} + +inline std::string stemOf(const std::string& fileName) { + const size_t dot = fileName.rfind('.'); + return dot == std::string::npos ? fileName : fileName.substr(0, dot); +} + +struct Compiled { + std::string sig; + bool ok = false; + std::string error; + std::string log; + std::vector entry; // grammar files given to the tool + ModelSpec spec; // the .interp data; runtime objects live only in the worker + bool modelChecked = false; // the runtime accepted this grammar + std::string modelError; // ... or refused it (or crashed on it) +}; + +inline std::string jsonStringArray(const std::vector& v) { + std::string s = "["; + for (size_t i = 0; i < v.size(); ++i) { + if (i) s += ','; + s += jsonString(v[i]); + } + return s + "]"; +} + +// ------------------------------------------------------------------- app ---- + +class App { + public: + App(fs::path root, Tool tool, int port, std::vector open, fs::path work, int timeoutSeconds, + bool isolate) + : root_(std::move(root)), + tool_(std::move(tool)), + port_(port), + open_(std::move(open)), + work_(std::move(work)), + timeout_(timeoutSeconds), + isolate_(isolate), + token_(randomToken()), + cookie_("antlrlab_" + std::to_string(port)) {} + + const std::string& token() const { return token_; } + + Response handle(const Request& rq) { + const std::string host = rq.header("host"); + const std::string p = std::to_string(port_); + if (host != "127.0.0.1:" + p && host != "localhost:" + p) return textResponse(403, "Forbidden: unexpected Host header."); + if (rq.method != "GET" && rq.method != "POST") return textResponse(405, "Method not allowed."); + + if (rq.method == "GET" && rq.path == "/favicon.ico") { + Response r; + r.status = 204; + return r; + } + if (rq.method == "GET" && rq.path == "/app.css") return assetResponse("text/css; charset=utf-8", css()); + if (rq.method == "GET" && rq.path == "/app.js") return assetResponse("text/javascript; charset=utf-8", webJs()); + + if (rq.method == "GET" && rq.path == "/") { + const auto it = rq.query.find("token"); + if (it != rq.query.end() && sameToken(it->second, token_)) { + Response r; + r.status = 302; + r.headers.emplace_back("Location", "/"); + r.headers.emplace_back("Set-Cookie", cookie_ + "=" + token_ + "; Path=/; HttpOnly; SameSite=Strict"); + return r; + } + if (!hasToken(rq)) { + return textResponse(403, + "antlrlab: open the address that antlrlab printed in the terminal.\n" + "It contains a one-time token that unlocks this page.\n"); + } + Response r = assetResponse("text/html; charset=utf-8", webIndexHtml()); + return r; + } + + if (rq.path.rfind("/api/", 0) == 0) { + if (!hasToken(rq) || rq.header("x-requested-with") != "antlrlab") return jsonError(403, "not authorized"); + const std::string origin = rq.header("origin"); + if (!origin.empty() && origin != "http://" + rq.header("host")) return jsonError(403, "cross-origin request refused"); + return api(rq); + } + return textResponse(404, "Not found."); + } + + private: + fs::path root_; + Tool tool_; + int port_; + std::vector open_; + fs::path work_; + int timeout_; + bool isolate_; + std::string token_; + std::string cookie_; + std::mutex mu_; + Compiled compiled_; + + // ------------------------------------------------------------ plumbing ---- + + static Response textResponse(int status, const std::string& text) { + Response r; + r.status = status; + r.body = text; + return r; + } + + static Response assetResponse(const std::string& type, const std::string& body) { + Response r; + r.contentType = type; + r.body = body; + r.headers.emplace_back( + "Content-Security-Policy", + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; " + "connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"); + return r; + } + + bool hasToken(const Request& rq) const { return sameToken(cookieValue(rq.header("cookie"), cookie_), token_); } + + static std::string css() { + std::string s = webCss(); + s += "\n:root{" + svgVarDecls(false) + "}\n"; + s += "@media (prefers-color-scheme:dark){:root:not([data-theme=light]){" + svgVarDecls(true) + "}}\n"; + s += ":root[data-theme=dark]{" + svgVarDecls(true) + "}\n"; + s += svgStyle(true); + return s; + } + + // ---------------------------------------------------------------- paths ---- + + bool inside(const fs::path& p) const { return pathInside(root_, p); } + + // Workspace-relative path (forward slashes) -> absolute path inside the root. + bool resolve(const std::string& rel, fs::path& out, std::string& err) const { + if (rel.find('\0') != std::string::npos || rel.find('\\') != std::string::npos) { + err = "invalid path"; + return false; + } + if (!rel.empty() && rel[0] == '/') { + err = "absolute paths are not allowed"; + return false; + } + if (rel.size() >= 2 && rel[1] == ':') { + err = "absolute paths are not allowed"; + return false; + } + err = windowsPathProblem(rel); + if (!err.empty()) return false; + std::error_code ec; + const fs::path full = fs::weakly_canonical(root_ / pathOf(rel), ec); + if (ec || !inside(full)) { + err = "that path is outside the workspace"; + return false; + } + const fs::path relPath = fs::relative(full, root_, ec); + for (const auto& part : relPath) { + if (lowerCase(pathToUtf8(part)) == ".git") { + err = "the .git directory is off limits"; + return false; + } + } + out = full; + return true; + } + + std::string relOf(const fs::path& full) const { + std::error_code ec; + std::string r = utf8Of(fs::relative(full, root_, ec)); + return r == "." ? "" : r; + } + + // ----------------------------------------------------------------- API ---- + + Response api(const Request& rq) { + const std::string& p = rq.path; + if (p == "/api/info" && rq.method == "GET") return apiInfo(); + if (p == "/api/dir" && rq.method == "GET") return apiDir(rq); + if (p == "/api/file" && rq.method == "GET") return apiFile(rq); + if (rq.method != "POST") return jsonError(405, "use POST"); + Json body; + std::string err; + if (!parseJson(rq.body, body, err)) return jsonError(400, "bad JSON: " + err); + if (p == "/api/save") return apiSave(body); + if (p == "/api/stat") return apiStat(body); + if (p == "/api/run") return apiRun(body); + return jsonError(404, "unknown endpoint"); + } + + Response apiInfo() { + std::string j = "{"; + j += "\"version\":" + jsonString(LAB_VERSION); + j += ",\"antlrVersion\":" + jsonString(LAB_ANTLR_VERSION); + j += ",\"root\":" + jsonString(utf8Of(root_)); + const std::string rootName = utf8Of(root_.filename()); + j += ",\"rootName\":" + jsonString(rootName.empty() ? utf8Of(root_) : rootName); + j += ",\"tool\":{\"found\":" + std::string(tool_.found ? "true" : "false") + + ",\"description\":" + jsonString(tool_.description) + ",\"error\":" + jsonString(tool_.error) + "}"; + j += ",\"open\":" + jsonStringArray(open_); + return jsonResponse(200, j + "}"); + } + + Response apiDir(const Request& rq) { + const auto it = rq.query.find("path"); + const std::string rel = it == rq.query.end() ? "" : it->second; + fs::path full; + std::string err; + if (!resolve(rel, full, err)) return jsonError(403, err); + std::error_code ec; + if (!fs::is_directory(full, ec)) return jsonError(404, "not a directory"); + + struct Entry { + std::string name; + bool dir; + uintmax_t size; + std::string etag; + }; + std::vector entries; + for (fs::directory_iterator d(full, fs::directory_options::skip_permission_denied, ec), end; !ec && d != end; + d.increment(ec)) { + const std::string name = utf8Of(d->path().filename()); + if (isHiddenEntry(d->path(), name) || name == "node_modules" || name == "antlr-lab-out") continue; + std::error_code e2; + const bool isDir = d->is_directory(e2); + if (!isDir && !d->is_regular_file(e2)) continue; + Entry e{name, isDir, 0, ""}; + if (!isDir) { + e.size = d->file_size(e2); + e.etag = etagOf(d->path()); + } + entries.push_back(std::move(e)); + if (entries.size() >= 5000) break; + } + std::sort(entries.begin(), entries.end(), [](const Entry& a, const Entry& b) { + if (a.dir != b.dir) return a.dir; + return lowerCase(a.name) < lowerCase(b.name); + }); + + std::string j = "{\"path\":" + jsonString(relOf(full)) + ",\"entries\":["; + for (size_t i = 0; i < entries.size(); ++i) { + if (i) j += ','; + j += "{\"name\":" + jsonString(entries[i].name) + ",\"dir\":" + (entries[i].dir ? "true" : "false") + + ",\"size\":" + std::to_string(entries[i].size) + "}"; + } + return jsonResponse(200, j + "]}"); + } + + Response apiFile(const Request& rq) { + const auto it = rq.query.find("path"); + if (it == rq.query.end() || it->second.empty()) return jsonError(400, "missing path"); + fs::path full; + std::string err; + if (!resolve(it->second, full, err)) return jsonError(403, err); + std::error_code ec; + if (!fs::is_regular_file(full, ec)) return jsonError(404, "no such file"); + if (fs::file_size(full, ec) > 5u * 1024u * 1024u) return jsonError(413, "file is larger than 5 MB"); + std::string raw; + if (!readFile(full, raw)) return jsonError(500, "cannot read the file"); + if (raw.substr(0, 8000).find('\0') != std::string::npos) return jsonError(415, "this looks like a binary file"); + + const bool bom = raw.size() >= 3 && static_cast(raw[0]) == 0xEF && + static_cast(raw[1]) == 0xBB && static_cast(raw[2]) == 0xBF; + const std::string clean = sanitizeUtf8(raw); + const bool lossy = clean.size() != raw.size() - (bom ? 3 : 0) || clean != raw.substr(bom ? 3 : 0); + + size_t crlf = 0, lf = 0; + std::string text; + text.reserve(clean.size()); + for (size_t i = 0; i < clean.size(); ++i) { + if (clean[i] == '\r') { + if (i + 1 < clean.size() && clean[i + 1] == '\n') { + ++crlf; + ++i; + } else { + ++lf; + } + text += '\n'; + } else { + if (clean[i] == '\n') ++lf; + text += clean[i]; + } + } + const char* eol = (crlf > 0 && crlf >= lf) ? "crlf" : "lf"; + + std::string j = "{\"path\":" + jsonString(relOf(full)) + ",\"name\":" + jsonString(utf8Of(full.filename())) + + ",\"text\":" + jsonString(text) + ",\"etag\":" + jsonString(etagOf(full)) + + ",\"eol\":\"" + eol + "\",\"bom\":" + (bom ? "true" : "false") + + ",\"lossy\":" + (lossy ? "true" : "false") + "}"; + return jsonResponse(200, j); + } + + Response apiSave(const Json& b) { + const std::string rel = b.str("path"); + if (rel.empty()) return jsonError(400, "missing path"); + fs::path full; + std::string err; + if (!resolve(rel, full, err)) return jsonError(403, err); + if (full == root_) return jsonError(400, "that is the workspace directory"); + + std::error_code ec; + if (fs::is_directory(full, ec)) return jsonError(400, "that path is a directory"); + const bool exists = fs::exists(full, ec); + const bool create = b.boolean("create"); + if (create && exists) return jsonError(409, "file exists", ",\"exists\":true"); + const std::string baseEtag = b.str("baseEtag"); + if (exists && !baseEtag.empty() && !b.boolean("force")) { + const std::string cur = etagOf(full); + if (cur != baseEtag) return jsonError(409, "the file changed on disk", ",\"changed\":true,\"etag\":" + jsonString(cur)); + } + + std::string text = sanitizeUtf8(b.str("text")); + if (text.size() > 8u * 1024u * 1024u) return jsonError(413, "text is too large"); + if (b.str("eol") == "crlf") { + std::string c; + c.reserve(text.size() + text.size() / 20); + for (char ch : text) { + if (ch == '\n') c += '\r'; + c += ch; + } + text = std::move(c); + } + if (b.boolean("bom")) text = "\xEF\xBB\xBF" + text; + + fs::create_directories(full.parent_path(), ec); + if (!saveFile(full, text)) return jsonError(500, "cannot write the file"); + return jsonResponse(200, "{\"path\":" + jsonString(relOf(full)) + ",\"etag\":" + jsonString(etagOf(full)) + "}"); + } + + Response apiStat(const Json& b) { + const auto* paths = b.array("paths"); + std::string j = "{\"etags\":{"; + bool first = true; + if (paths) { + size_t count = 0; + for (const Json& p : *paths) { + if (p.type != Json::Type::String || ++count > 64) continue; + fs::path full; + std::string err; + std::string tag; + std::error_code ec; + if (resolve(p.s, full, err) && fs::is_regular_file(full, ec)) tag = etagOf(full); + if (!first) j += ','; + first = false; + j += jsonString(p.s) + ":" + (tag.empty() ? "null" : jsonString(tag)); + } + } + return jsonResponse(200, j + "}}"); + } + + // ------------------------------------------------------------------ run ---- + + struct Sent { + std::string name, path, text; + GrammarHeader hdr; + }; + + static void collectClosure(const std::vector& gs, size_t idx, std::set& seen, int depth) { + if (!seen.insert(idx).second || depth > 4) return; + for (const std::string& imp : gs[idx].hdr.imports) { + for (size_t k = 0; k < gs.size(); ++k) { + if (stemOf(gs[k].name) == imp) collectClosure(gs, k, seen, depth + 1); + } + } + } + + // Decides which grammar files the tool is run on, makes sure the work + // directory has everything it needs, and (re)compiles when something changed. + const Compiled& ensureCompiled(const std::vector& gs, size_t mainIdx, bool& cached, double& compileMs) { + cached = false; + compileMs = 0; + + fs::path mainDir; + if (!gs[mainIdx].path.empty()) { + fs::path full; + std::string err; + if (resolve(gs[mainIdx].path, full, err)) mainDir = full.parent_path(); + } + + // Neighbours on disk (imports, .tokens files, the other half of a pair). + struct Neighbour { + std::string name; + fs::path path; + std::string etag; + }; + std::vector neighbours; + if (!mainDir.empty()) { + std::error_code ec; + for (fs::directory_iterator d(mainDir, fs::directory_options::skip_permission_denied, ec), end; !ec && d != end; + d.increment(ec)) { + std::error_code e2; + if (!d->is_regular_file(e2)) continue; + const std::string ext = lowerCase(utf8Of(d->path().extension())); + if (ext != ".g4" && ext != ".tokens") continue; + if (d->file_size(e2) > 1024 * 1024) continue; + std::error_code e3; + const fs::path real = fs::weakly_canonical(d->path(), e3); + if (e3 || !inside(real)) continue; // a symlink that leaves the workspace + const std::string name = utf8Of(d->path().filename()); + bool sentToo = false; + for (const Sent& s : gs) sentToo = sentToo || s.name == name; + if (sentToo) continue; + neighbours.push_back({name, d->path(), etagOf(d->path())}); + if (neighbours.size() >= 200) break; + } + } + + // Entry grammars: the main one plus its partner (lexer <-> parser). + std::vector entry{gs[mainIdx].name}; + const GrammarHeader& mh = gs[mainIdx].hdr; + auto addEntry = [&](const std::string& n) { + if (std::find(entry.begin(), entry.end(), n) == entry.end()) entry.push_back(n); + }; + if (mh.kind == GrammarHeader::Kind::Parser && !mh.tokenVocab.empty()) { + bool found = false; + for (const Sent& s : gs) { + if (s.hdr.name == mh.tokenVocab || stemOf(s.name) == mh.tokenVocab) { + addEntry(s.name); + found = true; + break; + } + } + if (!found) { + for (const Neighbour& n : neighbours) { + if (stemOf(n.name) == mh.tokenVocab && lowerCase(utf8Of(n.path.extension())) == ".g4") addEntry(n.name); + } + } + } else if (mh.kind == GrammarHeader::Kind::Lexer) { + bool found = false; + for (const Sent& s : gs) { + if (s.hdr.kind == GrammarHeader::Kind::Parser && s.hdr.tokenVocab == mh.name) { + addEntry(s.name); + found = true; + break; + } + } + if (!found) { + std::vector partners; + for (const Neighbour& n : neighbours) { + if (lowerCase(utf8Of(n.path.extension())) != ".g4") continue; + std::string text; + if (!readFile(n.path, text)) continue; + const GrammarHeader h = parseHeader(text); + if (h.kind == GrammarHeader::Kind::Parser && h.tokenVocab == mh.name) partners.push_back(n.name); + } + if (partners.size() == 1) addEntry(partners[0]); + } + } + + // Signature of everything the compile depends on. + std::string sigSrc = tool_.description + "\n"; + std::set relevant; + for (size_t i = 0; i < gs.size(); ++i) { + if (std::find(entry.begin(), entry.end(), gs[i].name) != entry.end()) collectClosure(gs, i, relevant, 0); + } + for (size_t i : relevant) sigSrc += gs[i].name + '\0' + gs[i].text + '\1'; + for (const Neighbour& n : neighbours) sigSrc += n.name + '\0' + n.etag + '\1'; + for (const std::string& e : entry) sigSrc += "entry:" + e + '\1'; + const std::string sig = fnv1a(sigSrc); + + if (compiled_.sig == sig) { + cached = true; + return compiled_; + } + + const auto t0 = std::chrono::steady_clock::now(); + Compiled c; + c.sig = sig; + c.entry = entry; + + std::error_code ec; + const fs::path src = work_ / "src"; + fs::remove_all(src, ec); + fs::create_directories(src, ec); + for (const Neighbour& n : neighbours) fs::copy_file(n.path, src / pathOf(n.name), fs::copy_options::overwrite_existing, ec); + for (const Sent& s : gs) writeFile(src / pathOf(s.name), s.text); + + std::vector entryPaths; + for (const std::string& e : entry) entryPaths.push_back(src / pathOf(e)); + const CompileResult r = compileGrammars(tool_, entryPaths, pathToUtf8(src), work_ / "gen", work_ / "tool.log"); + c.log = r.log; + if (!r.ok) { + c.error = r.summary; + } else { + std::string merr; + if (loadInterps(r.interps, c.spec, merr)) { + c.ok = true; + } else { + c.error = merr; + c.spec = ModelSpec(); + } + } + compileMs = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + compiled_ = std::move(c); + return compiled_; + } + + Response apiRun(const Json& b) { + if (!tool_.found) { + return jsonResponse(200, "{\"ok\":false,\"grammar\":{\"ok\":false,\"error\":" + jsonString(tool_.error) + + ",\"log\":\"\",\"rules\":[],\"entry\":[]},\"result\":null}"); + } + const auto* garr = b.array("grammars"); + if (!garr || garr->empty()) return jsonError(400, "no grammar given"); + if (garr->size() > 16) return jsonError(400, "too many grammar files"); + + std::vector gs; + for (const Json& g : *garr) { + Sent s; + s.name = g.str("name"); + s.path = g.str("path"); + s.text = sanitizeUtf8(g.str("text")); + if (!validGrammarFileName(s.name)) { + return jsonError(400, "invalid grammar file name: " + s.name + " (use letters, digits and _, ending in .g4)"); + } + if (s.text.size() > 2u * 1024u * 1024u) return jsonError(413, "grammar is too large"); + s.hdr = parseHeader(s.text); + gs.push_back(std::move(s)); + } + size_t mainIdx = 0; + const std::string mainName = b.str("main"); + for (size_t i = 0; i < gs.size(); ++i) { + if (gs[i].name == mainName) mainIdx = i; + } + + const Json* in = b.find("input"); + const bool haveInput = in && in->type == Json::Type::Object; + std::string inputText; + if (haveInput) { + inputText = sanitizeUtf8(in->str("text")); + if (inputText.size() > 4u * 1024u * 1024u) return jsonError(413, "input is larger than 4 MB"); + } + + std::lock_guard lock(mu_); + bool cached = false; + double compileMs = 0; + ensureCompiled(gs, mainIdx, cached, compileMs); + Compiled& c = compiled_; // mu_ is held + + // The runtime part - building the ATNs and running the input - happens in + // an isolated worker: a crash or an endless run there must not take the + // server (and your open session) down with it. + Outcome o; + bool ran = false; + if (c.ok && c.modelError.empty() && (haveInput || !c.modelChecked)) { + const ModelSpec& spec = c.spec; + const std::string start = b.str("start"); + const bool wantRun = haveInput; + ran = true; + o = runIsolated( + [&](int&) -> std::string { + Model model; + std::string err; + if (!buildModel(spec, model, err)) return "G" + err; + if (!wantRun) return "M"; + AnalysisResult res; + if (!analyze(model, inputText, start, res, err)) return "E" + err; + ReportInput rin; + fillReportStatus(rin, res, model.hasParser); + return "R" + reportJson(rin); + }, + timeout_, isolate_); + const bool loadPhase = o.phase.rfind("loading the", 0) == 0 || o.phase == "starting"; + if (o.kind == Outcome::Kind::Ok && !o.payload.empty()) { + if (o.payload[0] == 'G') c.modelError = o.payload.substr(1); + else c.modelChecked = true; + } else if (o.kind != Outcome::Kind::Ok && o.kind != Outcome::Kind::Cancelled && loadPhase) { + c.modelError = describe(o); // do not crash on the same grammar again and again + } + } + + const bool grammarOk = c.ok && c.modelError.empty(); + const std::string grammarError = c.ok ? c.modelError : c.error; + const bool hasParser = grammarOk && c.spec.hasParser; + + std::string j = "{\"ok\":" + std::string(grammarOk ? "true" : "false"); + j += ",\"grammar\":{\"ok\":" + std::string(grammarOk ? "true" : "false"); + j += ",\"name\":" + jsonString(stemOf(gs[mainIdx].name)); + j += ",\"entry\":" + jsonStringArray(c.entry); + j += ",\"cached\":" + std::string(cached ? "true" : "false"); + j += ",\"compileMs\":" + fixed1(compileMs); + j += ",\"error\":" + jsonString(grammarError); + j += ",\"log\":" + jsonString(c.log); + j += ",\"hasParser\":" + std::string(hasParser ? "true" : "false"); + j += ",\"rules\":" + jsonStringArray(hasParser ? c.spec.parser.ruleNames : std::vector()); + j += "}"; + + if (!grammarOk || !haveInput || !ran) return jsonResponse(200, j + ",\"result\":null}"); + + if (o.kind == Outcome::Kind::Ok && !o.payload.empty()) { + if (o.payload[0] == 'R') return jsonResponse(200, j + ",\"result\":" + o.payload.substr(1) + "}"); + return jsonResponse(200, j + ",\"result\":null,\"runError\":" + jsonString(o.payload.substr(1)) + "}"); + } + const char* kind = o.kind == Outcome::Kind::Crashed ? "crashed" + : o.kind == Outcome::Kind::TimedOut ? "timeout" + : o.kind == Outcome::Kind::Cancelled ? "cancelled" + : "failed"; + return jsonResponse(200, j + ",\"result\":null,\"crash\":{\"kind\":\"" + kind + "\",\"signal\":" + + std::to_string(o.signal) + ",\"phase\":" + jsonString(o.phase) + + ",\"message\":" + jsonString(describe(o)) + "}}"); + } +}; + +inline std::atomic& stopFlag() { + static std::atomic flag{false}; + return flag; +} +extern "C" inline void onStopSignal(int) { stopFlag().store(true); } + +} // namespace serve + +// Runs the web UI until Ctrl+C. Returns the process exit code. +inline int runServer(const ServeOptions& so, const Tool& tool) { + crash::initShared(); + crash::installHandlers(); + crash::AltStack altStack; + std::error_code ec; + const fs::path root = fs::canonical(so.root.empty() ? fs::path(".") : so.root, ec); + if (ec || !fs::is_directory(root, ec)) { + std::fprintf(stderr, "antlrlab: workspace directory not found: %s\n", pathToUtf8(so.root).c_str()); + return 2; + } + if (root == root.root_path()) { + std::fprintf(stderr, "antlrlab: refusing to serve the whole file system. Pass --root with a project directory.\n"); + return 2; + } + + std::vector open; + for (const std::string& f : so.open) { + const fs::path full = fs::weakly_canonical(fs::absolute(pathFromUtf8(f), ec), ec); + bool ok = !ec && serve::pathInside(root, full); + if (!ok) { + std::fprintf(stderr, "antlrlab: %s is outside the workspace (%s). Use --root to change it.\n", f.c_str(), + pathToUtf8(root).c_str()); + return 2; + } + if (!fs::is_regular_file(full, ec)) { + std::fprintf(stderr, "antlrlab: cannot open %s\n", f.c_str()); + return 2; + } + open.push_back(serve::utf8Of(fs::relative(full, root, ec))); + } + + HttpServer server; + std::vector ports; + if (so.port > 0) ports.push_back(so.port); + else for (int p = 8765; p < 8785; ++p) ports.push_back(p); + std::string err; + if (!server.listenOn(ports, err)) { + std::fprintf(stderr, "antlrlab: cannot start the server: %s\n", err.c_str()); + return 2; + } + + const fs::path work = fs::temp_directory_path(ec) / ("antlrlab-" + serve::randomToken().substr(0, 12)); + fs::create_directories(work, ec); + if (ec) { + std::fprintf(stderr, "antlrlab: cannot create a work directory: %s\n", ec.message().c_str()); + return 2; + } + + serve::App app(root, tool, server.port(), open, work, so.timeoutSeconds, so.isolate); + std::signal(SIGINT, serve::onStopSignal); + std::signal(SIGTERM, serve::onStopSignal); + + const std::string url = "http://127.0.0.1:" + std::to_string(server.port()) + "/"; + const std::string full = url + "?token=" + app.token(); + std::fprintf(stderr, "antlrlab %s web UI\n", LAB_VERSION); + std::fprintf(stderr, " workspace %s\n", pathToUtf8(root).c_str()); + std::fprintf(stderr, " open %s\n", full.c_str()); + if (!tool.found) std::fprintf(stderr, " warning %s\n", tool.error.c_str()); + std::fprintf(stderr, "Press Ctrl+C to stop.\n"); + if (so.openBrowser) so.openBrowser(full); + + const Handler handler = [&app](const Request& rq) { return app.handle(rq); }; + server.serve(handler, serve::stopFlag()); + + fs::remove_all(work, ec); + std::fprintf(stderr, "\nstopped.\n"); + return 0; +} + +} // namespace lab diff --git a/src/types.hpp b/src/types.hpp new file mode 100644 index 0000000..ec9025e --- /dev/null +++ b/src/types.hpp @@ -0,0 +1,91 @@ +// Plain data types shared by the analysis, rendering and reporting code. +// Nothing in here depends on the ANTLR runtime. +#pragma once + +#include +#include +#include +#include + +namespace lab { + +// One node of the parse tree, copied out of the ANTLR tree so that rendering +// does not need the runtime (and stays valid after the parser is destroyed). +struct Node { + enum class Kind { Rule, Terminal, Error }; + + Kind kind = Kind::Rule; + std::string label; // rule name, or the raw token text + std::string typeName; // token type name (terminals and error nodes only) + int64_t start = -1; // input range as code point indices, inclusive; -1 = none + int64_t stop = -1; + size_t line = 0; // position of a token (1-based line, 0-based column) + size_t col = 0; + std::vector children; + + Node() = default; + Node(const Node&) = default; + Node(Node&&) noexcept = default; + Node& operator=(const Node&) = default; + Node& operator=(Node&&) noexcept = default; + + // The default destructor would recurse once per tree level; a parse tree + // that is thousands of levels deep (a long right-recursive list) could + // overflow the stack just by being freed. Take the tree apart iteratively. + ~Node() { + if (children.empty()) return; + std::vector> pending; + pending.push_back(std::move(children)); + while (!pending.empty()) { + std::vector level = std::move(pending.back()); + pending.pop_back(); + for (Node& c : level) { + if (!c.children.empty()) pending.push_back(std::move(c.children)); + } + } // each `level` dies here with childless nodes, so nothing recurses + } +}; + +struct TokenInfo { + size_t index = 0; + std::string text; // raw text + std::string typeName; + std::string channel; + size_t line = 0; + size_t col = 0; + int64_t start = -1; // code point indices, inclusive; -1 = none (EOF) + int64_t stop = -1; + bool isEof = false; +}; + +struct Diagnostic { + enum class Severity { Error, Warning }; + + Severity severity = Severity::Error; + std::string source; // "lexer", "parser" or "input" + size_t line = 0; + size_t col = 0; + int64_t start = -1; // range to underline in the input, -1 = none + int64_t stop = -1; + std::string message; +}; + +struct AnalysisResult { + std::vector tokens; + std::vector diagnostics; + bool hasTree = false; + Node tree; + std::string startRule; + size_t nodeCount = 0; + double lexMs = 0; + double parseMs = 0; + + size_t errorCount() const { + size_t n = 0; + for (const auto& d : diagnostics) n += d.severity == Diagnostic::Severity::Error; + return n; + } + size_t warningCount() const { return diagnostics.size() - errorCount(); } +}; + +} // namespace lab diff --git a/src/util.hpp b/src/util.hpp new file mode 100644 index 0000000..3bd4560 --- /dev/null +++ b/src/util.hpp @@ -0,0 +1,239 @@ +// Small helpers: files, UTF-8, escaping. +// +// NOTE: keep the standard headers used by the rest of the program in this file +// (or included before it). The ANTLR runtime #undef's the EOF macro from +// , and that only sticks if was already included. platform.hpp +// (included here) takes care of that and of windows.h. +#pragma once + +#include "platform.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lab { + +// ---------------------------------------------------------------- files ---- + +// Paths given as std::string are UTF-8 (see platform.hpp); the fs::path +// overloads are for paths that already are paths. +inline bool readFile(const fs::path& p, std::string& out); +inline bool readFile(const std::string& utf8Path, std::string& out) { + return readFile(pathFromUtf8(utf8Path), out); +} + +inline bool readFile(const fs::path& p, std::string& out) { + std::ifstream in(p, std::ios::binary); + if (!in) return false; + std::ostringstream ss; + ss << in.rdbuf(); + out = ss.str(); + return true; +} + +inline bool writeFile(const fs::path& p, const std::string& data) { + std::ofstream out(p, std::ios::binary | std::ios::trunc); + if (!out) return false; + out.write(data.data(), static_cast(data.size())); + return static_cast(out); +} + +// Write to a temp file and rename, so a browser polling the file never sees a +// half-written version. +inline bool writeFileAtomic(const fs::path& p, const std::string& data) { + fs::path tmp = p; + tmp += ".tmp"; + if (!writeFile(tmp, data)) return false; + std::error_code ec; + fs::rename(tmp, p, ec); + if (ec) { + fs::remove(tmp, ec); + return writeFile(p, data); + } + return true; +} + +// ---------------------------------------------------------------- UTF-8 ---- + +// Replaces invalid UTF-8 with U+FFFD and drops a leading BOM. The sanitized +// text is what gets fed to ANTLR *and* shown in the report, so code point +// indices agree everywhere. +inline std::string sanitizeUtf8(const std::string& in) { + std::string out; + out.reserve(in.size()); + const size_t n = in.size(); + size_t i = 0; + auto byteAt = [&](size_t k) { return static_cast(in[k]); }; + if (n >= 3 && byteAt(0) == 0xEF && byteAt(1) == 0xBB && byteAt(2) == 0xBF) i = 3; + auto cont = [&](size_t k) { return k < n && (byteAt(k) & 0xC0) == 0x80; }; + + while (i < n) { + const uint8_t c = byteAt(i); + size_t len = 0; + if (c < 0x80) { + len = 1; + } else if (c >= 0xC2 && c <= 0xDF) { + len = cont(i + 1) ? 2 : 0; + } else if (c >= 0xE0 && c <= 0xEF) { + if (cont(i + 1) && cont(i + 2)) { + const uint8_t c1 = byteAt(i + 1); + len = ((c == 0xE0 && c1 < 0xA0) || (c == 0xED && c1 > 0x9F)) ? 0 : 3; + } + } else if (c >= 0xF0 && c <= 0xF4) { + if (cont(i + 1) && cont(i + 2) && cont(i + 3)) { + const uint8_t c1 = byteAt(i + 1); + len = ((c == 0xF0 && c1 < 0x90) || (c == 0xF4 && c1 > 0x8F)) ? 0 : 4; + } + } + if (len == 0) { + out += "\xEF\xBF\xBD"; + ++i; + } else { + out.append(in, i, len); + i += len; + } + } + return out; +} + +inline size_t cpCount(const std::string& s) { + size_t n = 0; + for (unsigned char c : s) n += (c & 0xC0) != 0x80; + return n; +} + +// Shorten to at most maxCp code points, ending with an ellipsis. +inline std::string truncateCp(const std::string& s, size_t maxCp) { + if (maxCp < 2 || cpCount(s) <= maxCp) return s; + size_t seen = 0, i = 0; + while (i < s.size()) { + if ((static_cast(s[i]) & 0xC0) != 0x80) { + if (seen == maxCp - 1) break; + ++seen; + } + ++i; + } + return s.substr(0, i) + "\xE2\x80\xA6"; +} + +// Maps ANTLR (line, column) positions to code point indices in the input. +class LineTable { + public: + explicit LineTable(const std::string& utf8) { + starts_.push_back(0); + int64_t cp = 0; + for (unsigned char c : utf8) { + if ((c & 0xC0) == 0x80) continue; + ++cp; + if (c == '\n') starts_.push_back(cp); + } + total_ = cp; + } + int64_t total() const { return total_; } + // line is 1-based, col is 0-based. Returns -1 when the position is outside + // the input (for example at EOF). + int64_t indexOf(size_t line, size_t col) const { + if (line == 0 || line > starts_.size()) return -1; + const int64_t idx = starts_[line - 1] + static_cast(col); + return idx < total_ ? idx : -1; + } + + private: + std::vector starts_; + int64_t total_ = 0; +}; + +// ------------------------------------------------------------- escaping ---- + +// Make control characters visible: "\n" -> \n, other controls -> \xNN. +inline std::string visible(const std::string& s) { + std::string o; + o.reserve(s.size()); + for (unsigned char c : s) { + switch (c) { + case '\n': o += "\\n"; break; + case '\r': o += "\\r"; break; + case '\t': o += "\\t"; break; + default: + if (c < 0x20 || c == 0x7F) { + char b[8]; + std::snprintf(b, sizeof b, "\\x%02X", c); + o += b; + } else { + o += static_cast(c); + } + } + } + return o; +} + +inline std::string escapeXml(const std::string& s) { + std::string o; + o.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '&': o += "&"; break; + case '<': o += "<"; break; + case '>': o += ">"; break; + case '"': o += """; break; + case '\'': o += "'"; break; + default: o += c; + } + } + return o; +} + +// JSON string literal that is also safe to embed inside a + + +)__WEB__"; + return s; +} + +inline std::string webCss() { + std::string s; + s += R"__WEB__(:root{--bg:#e6ecf4;--pane:#ffffff;--edit:#ffffff;--ink:#1b2a41;--muted:#5b6a80;--line:#cbd5e3; +--title:#cfe2f5;--title-ink:#0e2a45;--tab:#eef2f8;--hov:#e3ebf6;--hl:rgba(36,86,211,.2);--hl2:#dbe7fb; +--accent:#2456d3;--onaccent:#ffffff;--ok:#1a7f45;--err:#c62f2a;--warn:#a86a06;--onstatus:#ffffff; +--okbg:#e5f4ea;--errbg:#fbe7e5;--warnbg:#fbf0d9;--findbg:#fff0a8;--curbg:#ffd25e;--dot:#cdd5e1; +--c-com:#6b7c93;--c-str:#1f7a3d;--c-set:#a35a00;--c-act:#6b4fb3;--c-ann:#a02a74;--c-kw:#1c4fc4;--c-tok:#0b7a66;--c-lab:#4a5d78;--fs:13px;--lh:20px} +@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#08111f;--pane:#0f1c30;--edit:#0b1626;--ink:#e4ecf8;--muted:#8a9db8;--line:#233752; +--title:#17476b;--title-ink:#eef6ff;--tab:#13233a;--hov:#1a2e4a;--hl:rgba(110,168,255,.28);--hl2:#1d3a5e; +--accent:#6ea8ff;--onaccent:#07162b;--ok:#4cc38a;--err:#ff7b72;--warn:#e3b341;--onstatus:#08111f; +--okbg:#0f2a27;--errbg:#2e1a24;--warnbg:#2b2617;--findbg:#4d4318;--curbg:#7a5f12;--dot:#1b2d48; +--c-com:#7e90aa;--c-str:#9ad0a0;--c-set:#e6b673;--c-act:#b39ddb;--c-ann:#f0a8d0;--c-kw:#7fb2ff;--c-tok:#7dd3c0;--c-lab:#9db4d6;}} +:root[data-theme=dark]{--bg:#08111f;--pane:#0f1c30;--edit:#0b1626;--ink:#e4ecf8;--muted:#8a9db8;--line:#233752; +--title:#17476b;--title-ink:#eef6ff;--tab:#13233a;--hov:#1a2e4a;--hl:rgba(110,168,255,.28);--hl2:#1d3a5e; +--accent:#6ea8ff;--onaccent:#07162b;--ok:#4cc38a;--err:#ff7b72;--warn:#e3b341;--onstatus:#08111f; +--okbg:#0f2a27;--errbg:#2e1a24;--warnbg:#2b2617;--findbg:#4d4318;--curbg:#7a5f12;--dot:#1b2d48; +--c-com:#7e90aa;--c-str:#9ad0a0;--c-set:#e6b673;--c-act:#b39ddb;--c-ann:#f0a8d0;--c-kw:#7fb2ff;--c-tok:#7dd3c0;--c-lab:#9db4d6;} + +*{box-sizing:border-box} +[hidden]{display:none!important} +html,body{height:100%;margin:0} +body{display:flex;flex-direction:column;background:var(--bg);color:var(--ink);overflow:hidden; + font:13px/1.4 system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',sans-serif} +button,input,select,textarea{font:inherit;color:inherit} +pre,code,.mono,.ta,.hl,.gutter,.out,td.mono,#pos{font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,'DejaVu Sans Mono','Liberation Mono',monospace} +:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.spacer{flex:1} +.muted{color:var(--muted)} + +/* menu bar */ +#menubar{display:flex;align-items:center;gap:2px;height:32px;padding:0 8px;background:var(--pane);border-bottom:1px solid var(--line);flex:none} +.menu-btn{padding:4px 10px;background:none;border:0;border-radius:3px;cursor:pointer} +.menu-btn:hover,.menu-btn[aria-expanded=true]{background:var(--hov)} +#rootname{margin-left:auto;color:var(--muted);font-size:12px;padding-right:6px} +.dropdown{position:fixed;z-index:60;min-width:230px;padding:4px 0;background:var(--pane);border:1px solid var(--line)} +.mi{display:flex;align-items:center;gap:14px;padding:5px 14px 5px 26px;cursor:pointer;position:relative;white-space:nowrap} +.mi:hover,.mi:focus{background:var(--hl2);outline:0} +.mi .k{margin-left:auto;padding-left:24px;color:var(--muted);font-size:12px} +.mi .ck{position:absolute;left:8px} +.mi[aria-disabled=true]{opacity:.45;pointer-events:none} +.sep{height:1px;margin:4px 0;background:var(--line)} + +/* layout */ +main{flex:1;min-height:0;display:grid;grid-template-columns:var(--leftw,50%) 8px minmax(0,1fr);padding:8px} +main.no-right{grid-template-columns:minmax(0,1fr)} +main.no-right #split-v,main.no-right #right{display:none} +#right{display:grid;grid-template-rows:var(--toph,62%) 8px minmax(0,1fr);min-height:0;min-width:0} +#right.one{grid-template-rows:minmax(0,1fr)} +.split{background:none;position:relative} +.split::after{content:"";position:absolute;background:var(--line);opacity:0;transition:opacity .1s} +.split-v::after{left:3px;right:3px;top:0;bottom:0} +.split-h::after{top:3px;bottom:3px;left:0;right:0} +.split:hover::after,.split:focus-visible::after,.split.drag::after{opacity:1;background:var(--accent)} +.split-v{cursor:col-resize} +.split-h{cursor:row-resize} +.pane{display:flex;flex-direction:column;min-height:0;min-width:0;background:var(--pane);border:1px solid var(--line)} +.ptitle{display:flex;align-items:center;gap:10px;flex:none;padding:5px 6px 5px 12px;background:var(--title);color:var(--title-ink);font-weight:600} +.x{background:none;border:0;border-radius:3px;padding:0 6px;font-size:17px;line-height:1.1;cursor:pointer;opacity:.75} +.x:hover{opacity:1;background:rgba(127,127,127,.25)} +.psub{display:flex;align-items:center;gap:8px;flex:none;min-height:36px;padding:4px 8px;border-bottom:1px solid var(--line)} +.seg{display:flex;border:1px solid var(--line);border-radius:4px;overflow:hidden} +.seg button{border:0;background:none;padding:3px 11px;color:var(--muted);cursor:pointer;white-space:nowrap} +.seg button:hover{color:var(--ink)} +.seg button[aria-selected=true]{background:var(--hl2);color:var(--ink);font-weight:600} +.tools{display:flex;gap:5px} +.btn{padding:4px 12px;background:var(--pane);border:1px solid var(--line);border-radius:4px;cursor:pointer;white-space:nowrap} +.btn:hover{border-color:var(--accent)} +.btn.sm{padding:2px 9px} +.btn[aria-pressed=true]{background:var(--hl2);border-color:var(--accent)} +.btn.primary{background:var(--accent);color:var(--onaccent);border-color:var(--accent);font-weight:600} +.view{flex:1;min-height:0;position:relative;overflow:hidden} +.scroll{overflow:auto;padding:10px 14px} +.flush{padding:0} +.note{margin:0;padding:18px;color:var(--muted)} +.out{margin:0;font-size:12.5px;line-height:1.55;white-space:pre-wrap;word-break:break-word} +.chip{padding:0 9px;border-radius:9px;font-size:12px;font-weight:600;background:var(--ok);color:var(--onstatus)} +.chip.warn{background:var(--warn)} +.chip.error,.chip.fail{background:var(--err)} +.chip.stale{opacity:.55} +.badge{margin-left:6px;padding:0 6px;border-radius:9px;background:var(--err);color:var(--onstatus);font-size:11px;font-weight:600} +.badge.warn{background:var(--warn)} + +/* tabs and toolbar */ +#tabs{display:flex;flex:none;overflow-x:auto;background:var(--tab);border-bottom:1px solid var(--line)} +.tab{display:flex;align-items:center;gap:8px;padding:6px 6px 6px 12px;background:var(--tab);color:var(--muted);border-right:1px solid var(--line);cursor:pointer;white-space:nowrap;user-select:none} +.tab:hover{color:var(--ink)} +.tab[aria-selected=true]{background:var(--title);color:var(--title-ink);font-weight:600} +.tab .kind{font:600 11px ui-monospace,Menlo,Consolas,monospace;padding:0 4px;border:1px solid currentColor;border-radius:2px;background:none;color:inherit;opacity:.8;cursor:pointer} +.tab .kind:hover{opacity:1} +.tab .dot{width:8px;height:8px;border-radius:50%;background:var(--warn)} +.tab .tx{background:none;border:0;border-radius:3px;padding:0 5px;font-size:15px;line-height:1.1;cursor:pointer;color:inherit;opacity:.7} +.tab .tx:hover{opacity:1;background:rgba(127,127,127,.25)} +#toolbar{display:flex;align-items:center;gap:14px;flex:none;min-height:36px;padding:4px 8px;border-bottom:1px solid var(--line)} +.tb-item{display:flex;align-items:center;gap:6px;color:var(--muted)} +.tb-item select{background:var(--pane);border:1px solid var(--line);border-radius:4px;padding:2px 4px;max-width:180px} +#pos{font-size:12px} +.runbtn{padding:4px 18px;border:0;border-radius:4px;background:var(--accent);color:var(--onaccent);font-weight:600;cursor:pointer} +.runbtn[data-busy=true]{opacity:.6} +.runbtn:disabled{opacity:.4;cursor:default} + +/* editor */ +#editors{flex:1;min-height:0;position:relative;background:var(--edit)} +.ed{position:absolute;inset:0;display:flex;flex-direction:column} +.banner{display:flex;align-items:center;gap:10px;flex:none;padding:6px 12px;background:var(--warnbg);border-bottom:1px solid var(--line)} +.scroller{flex:1;min-height:0;overflow:auto;position:relative} +.rows{display:grid;grid-template-columns:auto 1fr;width:max-content;min-width:100%;min-height:100%} +.gutter,.hl,.ta{font-size:var(--fs);line-height:var(--lh);tab-size:4;font-variant-ligatures:none;letter-spacing:normal} +.gutter{position:sticky;left:0;z-index:1;margin:0;padding:8px 10px 8px 14px;text-align:right;color:var(--muted);background:var(--edit);border-right:1px solid var(--line);user-select:none} +.content{position:relative;min-width:0} +.hl{margin:0;padding:8px 28px 8px 10px;min-height:100%;white-space:pre;color:var(--ink);pointer-events:none} +.ta{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:8px 28px 8px 10px;border:0;resize:none;overflow:hidden;white-space:pre;background:transparent;color:transparent;caret-color:var(--ink);outline:0} +.ta::selection{background:rgba(110,168,255,.38);color:transparent} +.t-com{color:var(--c-com);font-style:italic} +.t-str{color:var(--c-str)} +.t-set{color:var(--c-set)} +.t-act{color:var(--c-act)} +.t-ann{color:var(--c-ann)} +.t-kw{color:var(--c-kw);font-weight:600} +.t-tok{color:var(--c-tok)} +.t-def{font-weight:700} +.t-tokdef{color:var(--c-tok);font-weight:700} +.t-lab{color:var(--c-lab);font-style:italic} +.t-op{color:var(--muted)} +.mk-err{background:var(--errbg);text-decoration:underline wavy var(--err);text-underline-offset:3px} +.mk-warn{text-decoration:underline wavy var(--warn);text-underline-offset:3px} +.mk-hl{background:var(--hl);box-shadow:0 0 0 1px var(--accent);border-radius:2px} +.mk-find{background:var(--findbg)} +.mk-cur{background:var(--curbg)} +.find{position:absolute;top:6px;right:22px;z-index:4;display:grid;gap:6px;padding:8px;background:var(--pane);border:1px solid var(--line)} +.find .row{display:flex;gap:5px;align-items:center} +.find input{width:190px;padding:3px 6px;background:var(--edit);border:1px solid var(--line);border-radius:3px} +.find .cnt{min-width:58px;color:var(--muted);font-size:12px} +#welcome{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:var(--muted);text-align:center;padding:20px} +#welcome h2{margin:0;color:var(--ink);font-size:18px} +#welcome p{margin:0 0 10px} +.wbtns{display:flex;gap:8px} + +/* diagram */ +#dgm{cursor:grab;touch-action:none;background-color:var(--bg);background-image:radial-gradient(var(--dot) 1px,transparent 1px);background-size:22px 22px} +#dgm.drag{cursor:grabbing} +#dgm.empty{cursor:default} +#dgm.stale #stage{opacity:.45} +#stage{position:absolute;left:0;top:0;transform-origin:0 0} +#stage svg{display:block;overflow:visible} +#stage .al-node{cursor:pointer} +#dnote{position:absolute;left:0;right:0;top:0} + +/* tokens, problems */ +table{border-collapse:collapse;width:100%;font-size:12.5px} +th{position:sticky;top:-10px;background:var(--pane);text-align:left;font-weight:600;color:var(--muted);padding:5px 10px;border-bottom:1px solid var(--line)} +td{padding:3px 10px;border-bottom:1px solid var(--line);vertical-align:top} +tbody tr:hover{background:var(--hl)} +tr.side td{opacity:.55} +td.num{text-align:right;color:var(--muted)} +.probs{list-style:none;margin:0;padding:0} +.prob{display:grid;grid-template-columns:60px 130px minmax(0,1fr) auto;gap:10px;padding:7px 14px;border-bottom:1px solid var(--line);cursor:pointer} +.prob:hover,.prob[aria-current=true]{background:var(--hl)} +.prob .sev{font-weight:600;color:var(--err)} +.prob.warning .sev{color:var(--warn)} +.prob .loc{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--muted);overflow-wrap:anywhere} +.prob .src{color:var(--muted);font-size:12px} + +/* dialogs, toasts */ +.modal-back{position:fixed;inset:0;z-index:80;display:flex;align-items:flex-start;justify-content:center;padding-top:12vh;background:rgba(5,10,20,.5)} +.modal{width:min(560px,92vw);max-height:76vh;display:flex;flex-direction:column;background:var(--pane);border:1px solid var(--line)} +.modal h3{margin:0;padding:10px 14px;font-size:14px;background:var(--title);color:var(--title-ink)} +.mbody{padding:12px 14px;overflow:auto;min-height:0} +.mbody p{margin:0 0 6px} +.mfoot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--line)} +.crumbs{display:flex;flex-wrap:wrap;gap:2px;margin-bottom:8px;font-size:12.5px} +)__WEB__"; + s += R"__WEB__(.crumbs button{background:none;border:0;padding:1px 5px;border-radius:3px;cursor:pointer;color:var(--accent)} +.crumbs button:hover{background:var(--hov)} +.flist{list-style:none;margin:8px 0 0;padding:0;height:260px;overflow:auto;border:1px solid var(--line);background:var(--edit)} +.flist li{display:flex;gap:10px;padding:4px 10px;cursor:pointer} +.flist li:hover{background:var(--hov)} +.flist li[aria-selected=true]{background:var(--hl2)} +.flist .sz{margin-left:auto;color:var(--muted);font-size:12px} +.mbody input[type=text]{width:100%;padding:5px 8px;background:var(--edit);border:1px solid var(--line);border-radius:4px} +#toasts{position:fixed;left:14px;bottom:14px;z-index:90;display:flex;flex-direction:column;gap:6px} +.toast{padding:7px 12px;background:var(--title);color:var(--title-ink);border:1px solid var(--line);max-width:420px} +.toast.error{background:var(--err);color:var(--onstatus)} + +@media (max-width:900px){ + body{overflow:auto} + main{display:block;overflow:visible} + .split{display:none} + #left{height:70vh} + #right{display:block;margin-top:8px} + #right>.panel{height:70vh;margin-bottom:8px} +} +@media (prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}} +)__WEB__"; + return s; +} + +inline std::string webJs() { + std::string s; + s += R"__WEB__((function () { +'use strict'; + +/* ================================================================== helpers */ +var $ = function (id) { return document.getElementById(id); }; +function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined && text !== null) e.textContent = text; + return e; +} +function clear(n) { while (n.firstChild) n.removeChild(n.firstChild); } +function plural(n, w) { return n + ' ' + w + (n === 1 ? '' : 's'); } +function basename(p) { var i = p.lastIndexOf('/'); return i < 0 ? p : p.slice(i + 1); } +function dirname(p) { var i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); } +function joinPath(d, n) { return d ? d + '/' + n : n; } +function stem(n) { var i = n.lastIndexOf('.'); return i <= 0 ? n : n.slice(0, i); } +function isGrammarFile(p) { return /\.g4$/i.test(p); } +function human(n) { return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; } + +var store = { + get: function (k, d) { + try { var v = localStorage.getItem('antlrlab:' + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } + }, + set: function (k, v) { try { localStorage.setItem('antlrlab:' + k, JSON.stringify(v)); } catch (e) { /* ignore */ } } +}; + +function debounce(fn, ms) { + var t = null; + var d = function () { clearTimeout(t); var a = arguments; t = setTimeout(function () { fn.apply(null, a); }, ms); }; + d.cancel = function () { clearTimeout(t); }; + return d; +} + +/* ==================================================================== state */ +var S = { + info: null, + tabs: [], + active: null, // id of the visible tab + lastGrammar: null, // the grammar that Run uses + lastInput: null, // the input that Run uses + results: {}, // input tab id -> { data, rev, grev } + gres: null, // last grammar outcome from the server + crash: null, // set when the last run crashed, timed out or failed in the worker + grev: 0, // bumps on every grammar edit + start: {}, // grammar name -> chosen start rule + prefs: { autorun: true, theme: 'auto', fs: 13, diagram: true, output: true, view: 'diagram', out: 'problems' }, + running: false, + queued: false, + untitled: { grammar: 0, input: 0 } +}; + +/* ====================================================================== API */ +function api(method, path, body) { + var opts = { method: method, credentials: 'same-origin', headers: { 'X-Requested-With': 'antlrlab' } }; + if (body !== undefined) { + opts.body = JSON.stringify(body); + opts.headers['Content-Type'] = 'application/json'; + } + return fetch(path, opts).then(function (r) { + return r.text().then(function (t) { + var data = null; + try { data = JSON.parse(t); } catch (e) { /* not JSON */ } + if (!r.ok) { + var err = new Error((data && data.error) || ('HTTP ' + r.status)); + err.status = r.status; + err.data = data; + throw err; + } + return data; + }); + }); +} + +/* ==================================================================== toast */ +function toast(msg, kind) { + var t = el('div', 'toast' + (kind ? ' ' + kind : ''), msg); + $('toasts').appendChild(t); + setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, kind === 'error' ? 6500 : 2600); +} + +/* ================================================================== dialogs */ +// modal({title, build(ctx) -> Node, buttons: [{label, value, primary}]}) -> Promise(value | null) +function modal(opts) { + return new Promise(function (resolve) { + var root = $('modal-root'); + var prev = document.activeElement; + clear(root); + var back = el('div', 'modal-back'); + var box = el('div', 'modal'); + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-modal', 'true'); + box.appendChild(el('h3', '', opts.title)); + var body = el('div', 'mbody'); + var ctx = { close: close, box: box, accept: null }; + if (opts.message) body.appendChild(el('p', '', opts.message)); + if (opts.build) body.appendChild(opts.build(ctx)); + box.appendChild(body); + var foot = el('div', 'mfoot'); + var primary = null; + (opts.buttons || []).forEach(function (b) { + var btn = el('button', 'btn' + (b.primary ? ' primary' : ''), b.label); + btn.type = 'button'; + btn.onclick = function () { + if (b.check && !b.check()) return; + close(b.value === undefined ? b.label : b.value); + }; + if (b.primary) primary = btn; + foot.appendChild(btn); + }); + box.appendChild(foot); + back.appendChild(box); + root.appendChild(back); + + function onKey(ev) { + if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); close(null); } + else if (ev.key === 'Enter' && primary && ev.target.tagName !== 'BUTTON' && !(ev.target.closest && ev.target.closest('.flist'))) { + ev.preventDefault(); + ev.stopPropagation(); + primary.click(); + } + } + back.addEventListener('mousedown', function (ev) { if (ev.target === back) close(null); }); + document.addEventListener('keydown', onKey, true); + function close(v) { + document.removeEventListener('keydown', onKey, true); + clear(root); + if (prev && prev.focus) prev.focus(); + resolve(v); + } + var first = box.querySelector('[data-focus]') || primary || box.querySelector('button'); + if (first) first.focus(); + }); +} + +function ask(title, message, buttons) { return modal({ title: title, message: message, buttons: buttons }); } + +/* ===================================================================== menus */ +var menus = []; // [{label, items: function() -> [...]}] +var openMenu = -1; +var dropdown = null; + +function defineMenus(list) { + menus = list; + var bar = $('menubar'); + clear(bar); + menus.forEach(function (m, i) { + var b = el('button', 'menu-btn', m.label); + b.type = 'button'; + b.setAttribute('role', 'menuitem'); + b.setAttribute('aria-haspopup', 'true'); + b.setAttribute('aria-expanded', 'false'); + b.onclick = function () { if (openMenu === i) closeMenu(); else showMenu(i, false); }; + b.onmouseenter = function () { if (openMenu >= 0 && openMenu !== i) showMenu(i, false); }; + b.onkeydown = function (ev) { + if (ev.key === 'ArrowDown' || ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); showMenu(i, true); } + else if (ev.key === 'ArrowRight') { ev.preventDefault(); bar.children[(i + 1) % menus.length].focus(); } + else if (ev.key === 'ArrowLeft') { ev.preventDefault(); bar.children[(i + menus.length - 1) % menus.length].focus(); } + }; + bar.appendChild(b); + }); + var rn = el('span', '', ''); + rn.id = 'rootname'; + bar.appendChild(rn); +} + +function closeMenu(refocus) { + if (dropdown) { dropdown.remove(); dropdown = null; } + var bar = $('menubar'); + Array.prototype.forEach.call(bar.querySelectorAll('.menu-btn'), function (b) { b.setAttribute('aria-expanded', 'false'); }); + var was = openMenu; + openMenu = -1; + if (refocus && was >= 0) bar.children[was].focus(); +} + +function showMenu(i, focusFirst) { + closeMenu(); + openMenu = i; + var btn = $('menubar').children[i]; + btn.setAttribute('aria-expanded', 'true'); + var dd = el('div', 'dropdown'); + dd.setAttribute('role', 'menu'); + menus[i].items().forEach(function (it) { + if (it.sep) { dd.appendChild(el('div', 'sep')); return; } + var row = el('div', 'mi'); + row.setAttribute('role', 'menuitem'); + row.tabIndex = -1; + if (it.disabled) row.setAttribute('aria-disabled', 'true'); + if (it.checked) { row.appendChild(el('span', 'ck', '\u2713')); row.setAttribute('aria-checked', 'true'); } + row.appendChild(el('span', '', it.label)); + if (it.key) row.appendChild(el('span', 'k', it.key)); + var run = function () { closeMenu(); setTimeout(it.action, 0); }; + row.onclick = run; + row.onkeydown = function (ev) { + var items = Array.prototype.filter.call(dd.children, function (c) { return c.classList.contains('mi') && c.getAttribute('aria-disabled') !== 'true'; }); + var at = items.indexOf(row); + if (ev.key === 'ArrowDown') { ev.preventDefault(); items[(at + 1) % items.length].focus(); } + else if (ev.key === 'ArrowUp') { ev.preventDefault(); items[(at + items.length - 1) % items.length].focus(); } + else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); run(); } + else if (ev.key === 'Escape') { ev.preventDefault(); closeMenu(true); } + else if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') { + ev.preventDefault(); + var n = (i + (ev.key === 'ArrowRight' ? 1 : menus.length - 1)) % menus.length; + showMenu(n, true); + $('menubar').children[n].focus(); + var first = dropdown && dropdown.querySelector('.mi:not([aria-disabled=true])'); + if (first) first.focus(); + } + }; + dd.appendChild(row); + }); + document.body.appendChild(dd); + var r = btn.getBoundingClientRect(); + dd.style.left = Math.max(0, Math.min(r.left, window.innerWidth - dd.offsetWidth - 4)) + 'px'; + dd.style.top = r.bottom + 'px'; + dropdown = dd; + if (focusFirst) { + var f = dd.querySelector('.mi:not([aria-disabled=true])'); + if (f) f.focus(); + } +} + +document.addEventListener('mousedown', function (ev) { + if (dropdown && !dropdown.contains(ev.target) && !ev.target.closest('.menu-btn')) closeMenu(); +}); +window.addEventListener('blur', function () { closeMenu(); }); + +/* ================================================================== editor + * A plain