Add project files
This commit is contained in:
commit
ba5971a788
37 files changed
+11180
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
antlr-lab-out/
|
||||
build/
|
||||
*.jar
|
||||
+180
@@ -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$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
set(WITH_STATIC_CRT OFF CACHE BOOL "" FORCE)
|
||||
set(ANTLRLAB_MSVC_RUNTIME "MultiThreaded$<$<CONFIG:Debug>: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}" "$<TARGET_FILE_DIR:antlrlab>")
|
||||
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 <filesystem>
|
||||
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 <filesystem>
|
||||
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)
|
||||
@@ -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)) <EOF>)
|
||||
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',<TYPE>,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
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
mkdir build
|
||||
cmake -B build -DANTLRLAB_DOWNLOAD_JAR=ON
|
||||
cmake --build build
|
||||
@@ -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)
|
||||
@@ -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 ;
|
||||
@@ -0,0 +1 @@
|
||||
1 + 2 * (width - 3)
|
||||
@@ -0,0 +1 @@
|
||||
1 + * 2 (width
|
||||
@@ -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 ;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "antlrlab",
|
||||
"tags": ["parser", "antlr", "diagram"],
|
||||
"version": 1.0,
|
||||
"stable": true,
|
||||
"owner": null,
|
||||
"nested": { "depth": 2, "items": [1, 2, 3] }
|
||||
}
|
||||
@@ -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 ;
|
||||
@@ -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
|
||||
;
|
||||
@@ -0,0 +1,3 @@
|
||||
// two statements
|
||||
width = 4;
|
||||
area = width * (width + 2.5) - 1;
|
||||
@@ -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 <cstdio> before the runtime undefines EOF
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#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<antlr4::atn::ATN> lexerAtn;
|
||||
std::unique_ptr<antlr4::atn::ATN> parserAtn;
|
||||
std::unique_ptr<antlr4::dfa::Vocabulary> lexVocab;
|
||||
std::unique_ptr<antlr4::dfa::Vocabulary> 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<fs::path>& interpFiles, ModelSpec& spec, std::string& error) {
|
||||
std::vector<InterpData> 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<antlr4::dfa::Vocabulary>(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<antlr4::dfa::Vocabulary>(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<size_t>(-1) ? -1 : static_cast<int64_t>(v);
|
||||
}
|
||||
|
||||
// Turns (line, column) and offending-symbol information into diagnostics.
|
||||
class CollectingListener : public antlr4::BaseErrorListener {
|
||||
public:
|
||||
CollectingListener(std::vector<Diagnostic>& 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<antlr4::Lexer*>(recognizer)) {
|
||||
if (antlr4::CharStream* cs = lx->getInputStream()) {
|
||||
int64_t e = static_cast<int64_t>(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<Diagnostic>& 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<antlr4::tree::ErrorNode*>(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<antlr4::tree::ErrorNode*>(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<const antlr4::tree::ParseTree*, ErrorNodeInfo> 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<antlr4::tree::ErrorNode*>(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 = "<error>";
|
||||
out.typeName = "error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (auto* term = dynamic_cast<antlr4::tree::TerminalNode*>(t)) {
|
||||
out.kind = Node::Kind::Terminal;
|
||||
fillToken(term->getSymbol());
|
||||
return;
|
||||
}
|
||||
|
||||
out.kind = Node::Kind::Rule;
|
||||
auto* ctx = dynamic_cast<antlr4::ParserRuleContext*>(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<size_t>(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<double, std::milli>(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
|
||||
@@ -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 <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> 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<std::string> splitPathList(const std::string& s) {
|
||||
std::vector<std::string> 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<char>(std::tolower(static_cast<unsigned char>(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<int> versionNumbers(const std::string& s) {
|
||||
std::vector<int> 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<fs::path> dirs;
|
||||
std::vector<fs::path> 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<fs::path> 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<fs::path>& 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<std::string> 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
|
||||
+351
@@ -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 <atomic>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#if defined(__unix__) || defined(__APPLE__)
|
||||
#define LAB_POSIX 1
|
||||
#include <signal.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#if defined(__has_include)
|
||||
#if __has_include(<execinfo.h>)
|
||||
#include <execinfo.h>
|
||||
#define LAB_HAVE_BACKTRACE 1
|
||||
#endif
|
||||
#endif
|
||||
#elif defined(_WIN32)
|
||||
#include <dbghelp.h> // 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<Shared*>(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<size_t>(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<int> 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<unsigned long>(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<DWORD>(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<SYMBOL_INFO*>(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<unsigned long long>(disp), static_cast<unsigned long long>(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<LPCSTR>(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<unsigned long long>(addr - reinterpret_cast<DWORD64>(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<int> 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<unsigned long long>(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<unsigned long>(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
|
||||
+394
@@ -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 <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "crash.hpp"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <signal.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace lab {
|
||||
|
||||
struct Request {
|
||||
std::string method;
|
||||
std::string target; // raw request target
|
||||
std::string path; // decoded path, without the query
|
||||
std::map<std::string, std::string> query;
|
||||
std::map<std::string, std::string> 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<std::pair<std::string, std::string>> headers;
|
||||
};
|
||||
|
||||
using Handler = std::function<Response(const Request&)>;
|
||||
|
||||
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<char>(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<char>(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<int>(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<size_t>(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<int>(::recv(s, chunk, static_cast<int>(sizeof chunk), 0));
|
||||
if (n <= 0) return buf.empty() ? -1 : 408;
|
||||
buf.append(chunk, static_cast<size_t>(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<size_t>(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<int>(::recv(s, chunk, static_cast<int>(sizeof chunk), 0));
|
||||
if (n <= 0) return 408;
|
||||
body.append(chunk, static_cast<size_t>(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<int>& 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<const char*>(&yes), sizeof yes);
|
||||
::SetHandleInformation(reinterpret_cast<HANDLE>(s), HANDLE_FLAG_INHERIT, 0); // not for the Java child
|
||||
#else
|
||||
::setsockopt(s, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&yes), sizeof yes);
|
||||
#endif
|
||||
sockaddr_in addr;
|
||||
std::memset(&addr, 0, sizeof addr);
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(static_cast<uint16_t>(p));
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (::bind(s, reinterpret_cast<sockaddr*>(&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<sockaddr*>(&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<bool>& 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<HANDLE>(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<int> 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<const char*>(&ms), sizeof ms);
|
||||
::setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&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
|
||||
+107
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace lab {
|
||||
|
||||
struct InterpData {
|
||||
std::string name; // file stem, e.g. "ExprLexer"
|
||||
bool isLexer = false;
|
||||
std::vector<std::string> literalNames; // "" where the file says null
|
||||
std::vector<std::string> symbolicNames; // "" where the file says null
|
||||
std::vector<std::string> ruleNames;
|
||||
std::vector<std::string> channelNames;
|
||||
std::vector<std::string> modeNames;
|
||||
std::vector<int32_t> 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<int32_t>(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
|
||||
+329
@@ -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 <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include "crash.hpp"
|
||||
|
||||
#ifdef LAB_POSIX
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/wait.h>
|
||||
#elif defined(_WIN32)
|
||||
#include <process.h>
|
||||
#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<std::string(int& exitCode)>;
|
||||
|
||||
// ------------------------------------------------------------ big stack ----
|
||||
|
||||
#ifdef LAB_POSIX
|
||||
namespace detail {
|
||||
|
||||
struct StackJob {
|
||||
const std::function<void()>* fn;
|
||||
std::exception_ptr error;
|
||||
};
|
||||
|
||||
inline void* stackTrampoline(void* p) {
|
||||
StackJob* job = static_cast<StackJob*>(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<void()>& 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<void()>* 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<StackJob*>(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<void()>& 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<unsigned>(mb * MB), detail::stackTrampoline, &job,
|
||||
STACK_SIZE_PARAM_IS_A_RESERVATION, &id);
|
||||
if (h == 0) continue;
|
||||
HANDLE th = reinterpret_cast<HANDLE>(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<void()>& 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<char>((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<size_t>(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::milliseconds>(std::chrono::steady_clock::now() - start).count();
|
||||
const long long left = static_cast<long long>(timeoutSeconds) * 1000 - elapsed;
|
||||
if (left <= 0) {
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
if (left < waitMs) waitMs = static_cast<int>(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<size_t>(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<int>(static_cast<unsigned char>(data[1 + static_cast<size_t>(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
|
||||
+277
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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<Json> a;
|
||||
std::vector<std::pair<std::string, Json>> 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<Json>* 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<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
out += static_cast<char>(0xC0 | (cp >> 6));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
out += static_cast<char>(0xE0 | (cp >> 12));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (cp >> 18));
|
||||
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(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<uint32_t>(c - '0');
|
||||
else if (c >= 'a' && c <= 'f') v |= static_cast<uint32_t>(c - 'a' + 10);
|
||||
else if (c >= 'A' && c <= 'F') v |= static_cast<uint32_t>(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<unsigned char>(t_[pos_++]);
|
||||
if (c == '"') return true;
|
||||
if (c < 0x20) return fail("control character in string");
|
||||
if (c != '\\') {
|
||||
out += static_cast<char>(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
|
||||
+775
@@ -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, <cstdio>) before the ANTLR runtime
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> grammars;
|
||||
std::vector<std::string> 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<std::string>& args, Options& o) {
|
||||
const int argc = static_cast<int>(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<size_t>(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<size_t>(++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<int>(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<int>(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<std::string> splitLines(const std::string& text) {
|
||||
std::vector<std::string> 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<std::string> codePoints(const std::string& line) {
|
||||
std::vector<std::string> cps;
|
||||
for (size_t i = 0; i < line.size();) {
|
||||
size_t len = 1;
|
||||
const unsigned char c = static_cast<unsigned char>(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<std::string>& 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<std::string> 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<size_t>(d.stop - d.start + 1);
|
||||
const size_t room = d.col < cps.size() ? cps.size() - d.col : 1;
|
||||
len = std::max<size_t>(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("<EOF>") : 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<GrammarSource> 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<fs::path> 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 = "<text>";
|
||||
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 = "<stdin>";
|
||||
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<int64_t>(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<std::string> 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<std::string, long long>;
|
||||
|
||||
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<long long>(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<std::string>& 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <string>
|
||||
|
||||
#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
|
||||
@@ -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: <cstdio> 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 <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <windows.h>
|
||||
#include <bcrypt.h>
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#include <shellapi.h>
|
||||
#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 <mach-o/dyld.h>
|
||||
#endif
|
||||
#include <cerrno>
|
||||
#include <fcntl.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
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<int>(s.size()), nullptr, 0);
|
||||
if (n <= 0) return std::wstring();
|
||||
std::wstring w(static_cast<size_t>(n), L'\0');
|
||||
::MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(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<int>(w.size()), nullptr, 0, nullptr, nullptr);
|
||||
if (n <= 0) return std::string();
|
||||
std::string s(static_cast<size_t>(n), '\0');
|
||||
::WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(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<std::string> commandLineArgs(int argc, char** argv) {
|
||||
std::vector<std::string> 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<wchar_t> buf(32768);
|
||||
const DWORD n = ::GetModuleFileNameW(nullptr, buf.data(), static_cast<DWORD>(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<HANDLE>(_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<PUCHAR>(buf), static_cast<ULONG>(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<unsigned char*>(buf);
|
||||
for (size_t i = 0; i < n; ++i) p[i] = static_cast<unsigned char>(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<char>(std::tolower(static_cast<unsigned char>(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<LPWSTR>(&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<std::string>& 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<wchar_t> 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<int>(code);
|
||||
return r;
|
||||
#else
|
||||
std::vector<char*> args;
|
||||
for (const std::string& a : argv) args.push_back(const_cast<char*>(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
|
||||
+331
@@ -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 <algorithm>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<LispInfo>& 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<LispInfo>& v, size_t& next,
|
||||
std::string& out) {
|
||||
const size_t me = next++;
|
||||
const LispInfo info = v[me];
|
||||
const size_t pad = std::min<size_t>(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<detail::LispInfo> 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<SvgColor>& svgPalette() {
|
||||
static const std::vector<SvgColor> 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<int> 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<Item>& items) {
|
||||
const int idx = static_cast<int>(items.size());
|
||||
Item it;
|
||||
it.node = &n;
|
||||
it.depth = depth;
|
||||
const double textW = static_cast<double>(cpCount(shownLabel(n))) * kCharW;
|
||||
if (n.kind == Node::Kind::Rule) {
|
||||
it.w = textW + 2 * kPadX;
|
||||
it.h = kRuleH;
|
||||
} else {
|
||||
const double typeW = static_cast<double>(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<size_t>(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 <style>, opaque background
|
||||
// standalone = false -> bare <svg> for embedding in the HTML report, which
|
||||
// supplies the CSS (and lets its own canvas show through)
|
||||
inline SvgResult renderSvg(const Node& root, bool standalone) {
|
||||
using namespace detail;
|
||||
std::vector<Item> items;
|
||||
collect(root, -1, 0, items);
|
||||
const size_t N = items.size();
|
||||
|
||||
for (size_t i = N; i-- > 0;) { // children come after parents, so go backwards
|
||||
Item& it = items[i];
|
||||
if (it.kids.empty()) {
|
||||
it.subtree = it.w;
|
||||
} else {
|
||||
double sum = kHGap * static_cast<double>(it.kids.size() - 1);
|
||||
for (int k : it.kids) sum += items[static_cast<size_t>(k)].subtree;
|
||||
it.subtree = std::max(it.w, sum);
|
||||
}
|
||||
}
|
||||
items[0].left = kMargin;
|
||||
for (size_t i = 0; i < N; ++i) {
|
||||
Item& it = items[i];
|
||||
if (it.kids.empty()) continue;
|
||||
double sum = kHGap * static_cast<double>(it.kids.size() - 1);
|
||||
for (int k : it.kids) sum += items[static_cast<size_t>(k)].subtree;
|
||||
double x = it.left + (it.subtree - sum) / 2;
|
||||
for (int k : it.kids) {
|
||||
Item& kid = items[static_cast<size_t>(k)];
|
||||
kid.left = x;
|
||||
x += kid.subtree + kHGap;
|
||||
}
|
||||
}
|
||||
int maxDepth = 0;
|
||||
for (size_t i = N; i-- > 0;) {
|
||||
Item& it = items[i];
|
||||
maxDepth = std::max(maxDepth, it.depth);
|
||||
if (it.kids.empty()) {
|
||||
it.cx = it.left + it.subtree / 2;
|
||||
} else {
|
||||
const double mid = (items[static_cast<size_t>(it.kids.front())].cx +
|
||||
items[static_cast<size_t>(it.kids.back())].cx) / 2;
|
||||
const double lo = it.left + it.w / 2;
|
||||
const double hi = it.left + it.subtree - it.w / 2;
|
||||
it.cx = std::min(std::max(mid, lo), hi);
|
||||
}
|
||||
}
|
||||
|
||||
SvgResult res;
|
||||
res.width = items[0].subtree + 2 * kMargin;
|
||||
res.height = kMargin * 2 + maxDepth * kRowPitch + kTermH;
|
||||
auto yOf = [&](const Item& it) { return kMargin + it.depth * kRowPitch; };
|
||||
|
||||
std::string& s = res.svg;
|
||||
s.reserve(N * 260 + 2048);
|
||||
if (standalone) s += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
||||
s += "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"al-tree\" width=\"" + num(res.width) +
|
||||
"\" height=\"" + num(res.height) + "\" viewBox=\"0 0 " + num(res.width) + " " +
|
||||
num(res.height) + "\" role=\"img\" aria-label=\"Parse tree\">\n";
|
||||
if (standalone) {
|
||||
s += "<style>" + svgStyle(false) + "</style>\n";
|
||||
s += "<rect class=\"al-bg\" x=\"0\" y=\"0\" width=\"100%\" height=\"100%\"/>\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<size_t>(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 += "<path class=\"al-edge\" d=\"M" + num(px) + "," + num(py) + " C" + num(px) + "," +
|
||||
num(my) + " " + num(cx) + "," + num(my) + " " + num(cx) + "," + num(cy) + "\"/>\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("<g class=\"al-node ") + cls + "\"";
|
||||
if (n.start >= 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 += "<title>" + escapeXml(n.label) + "</title>";
|
||||
s += "<rect class=\"al-box\" x=\"" + num(x) + "\" y=\"" + num(y) + "\" width=\"" +
|
||||
num(it.w) + "\" height=\"" + num(it.h) + "\" rx=\"" + num(it.h / 2) + "\"/>";
|
||||
s += "<text class=\"al-label\" x=\"" + num(it.cx) + "\" y=\"" + num(y + it.h / 2 + 4.5) +
|
||||
"\" text-anchor=\"middle\">" + escapeXml(shownLabel(n)) + "</text>";
|
||||
} 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 += "<title>" + escapeXml(tip) + "</title>";
|
||||
s += "<rect class=\"al-box\" x=\"" + num(x) + "\" y=\"" + num(y) + "\" width=\"" +
|
||||
num(it.w) + "\" height=\"" + num(it.h) + "\" rx=\"3\"/>";
|
||||
s += "<text class=\"al-label\" x=\"" + num(it.cx) + "\" y=\"" + num(y + 18) +
|
||||
"\" text-anchor=\"middle\">" + escapeXml(shownLabel(n)) + "</text>";
|
||||
s += "<text class=\"al-type\" x=\"" + num(it.cx) + "\" y=\"" + num(y + 33) +
|
||||
"\" text-anchor=\"middle\">" + escapeXml(n.typeName) + "</text>";
|
||||
}
|
||||
s += "</g>\n";
|
||||
}
|
||||
s += "</svg>\n";
|
||||
return res;
|
||||
}
|
||||
|
||||
} // namespace lab
|
||||
+850
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#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<GrammarSource> grammars;
|
||||
std::string toolLog;
|
||||
bool hasParser = false;
|
||||
std::string startRule;
|
||||
std::string input; // sanitized UTF-8
|
||||
std::vector<std::string> 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 ? "<EOF>" : 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(<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ANTLR Lab</title>
|
||||
<script>try{var t=localStorage.getItem('antlr-lab-theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}</script>
|
||||
<style>
|
||||
)HTML";
|
||||
}
|
||||
|
||||
inline std::string shellBody() {
|
||||
return R"HTML(
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<span class="brand">ANTLR Lab</span>
|
||||
<div class="files" id="files"></div>
|
||||
<span class="spacer"></span>
|
||||
<span class="live" id="live" hidden>Auto-refresh is on</span>
|
||||
<button class="btn" id="theme" type="button">Switch theme</button>
|
||||
</header>
|
||||
<section class="verdict" id="verdict" aria-live="polite">
|
||||
<h1 class="vhead" id="vhead"></h1>
|
||||
<p class="vdetail" id="vdetail"></p>
|
||||
<div class="vmeta" id="vmeta"></div>
|
||||
</section>
|
||||
<main>
|
||||
<section class="left" aria-label="Input">
|
||||
<div class="ptitle">Input</div>
|
||||
<div class="code"><pre id="gutter" aria-hidden="true"></pre><pre id="code"></pre></div>
|
||||
</section>
|
||||
<section class="right">
|
||||
<div class="tabs" role="tablist" aria-label="Results">
|
||||
<button role="tab" id="t-diagram" type="button">Diagram</button>
|
||||
<button role="tab" id="t-tree" type="button">Tree</button>
|
||||
<button role="tab" id="t-tokens" type="button">Tokens</button>
|
||||
<button role="tab" id="t-problems" type="button">Problems<span class="badge" id="badge" hidden></span></button>
|
||||
<button role="tab" id="t-grammar" type="button">Grammar</button>
|
||||
</div>
|
||||
<div class="panel" id="p-diagram" role="tabpanel">
|
||||
<div id="dgm" tabindex="0" aria-label="Parse tree diagram. Drag to pan, scroll to zoom.">
|
||||
<div id="stage"></div>
|
||||
<p id="dnote" hidden></p>
|
||||
<div class="tools">
|
||||
<button class="btn" id="zin" type="button" aria-label="Zoom in">+</button>
|
||||
<button class="btn" id="zout" type="button" aria-label="Zoom out">−</button>
|
||||
<button class="btn" id="zfit" type="button">Fit</button>
|
||||
<button class="btn" id="psvg" type="button">Save SVG</button>
|
||||
<button class="btn" id="ppng" type="button">Save PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel" id="p-tree" role="tabpanel" hidden>
|
||||
<div class="scroll">
|
||||
<div class="bar">
|
||||
<button class="btn" id="b-pretty" type="button">Indented</button>
|
||||
<button class="btn" id="b-flat" type="button">One line</button>
|
||||
<button class="btn" id="b-copy" type="button">Copy</button>
|
||||
</div>
|
||||
<pre class="out" id="treeout"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel" id="p-tokens" role="tabpanel" hidden>
|
||||
<div class="scroll" id="tokwrap"></div>
|
||||
</div>
|
||||
<div class="panel" id="p-problems" role="tabpanel" hidden>
|
||||
<div class="scroll" style="padding:0">
|
||||
<p class="note" id="noprob">No problems found.</p>
|
||||
<ul class="probs" id="problist"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel" id="p-grammar" role="tabpanel" hidden>
|
||||
<div class="scroll" id="gramwrap"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)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 '<?xml version="1.0" encoding="UTF-8"?>\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 += "<script>window.LAB_DATA=" + reportJson(in) + ";</script>\n<script>";
|
||||
h += web::jsCore();
|
||||
h += web::jsDiagram();
|
||||
h += "</script>\n</body>\n</html>\n";
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace lab
|
||||
+1047
File diff suppressed because it is too large.
Load diff
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<Node> 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<std::vector<Node>> pending;
|
||||
pending.push_back(std::move(children));
|
||||
while (!pending.empty()) {
|
||||
std::vector<Node> 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<TokenInfo> tokens;
|
||||
std::vector<Diagnostic> 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
|
||||
+239
@@ -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
|
||||
// <cstdio>, and that only sticks if <cstdio> was already included. platform.hpp
|
||||
// (included here) takes care of that and of windows.h.
|
||||
#pragma once
|
||||
|
||||
#include "platform.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
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<std::streamsize>(data.size()));
|
||||
return static_cast<bool>(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<uint8_t>(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<unsigned char>(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<int64_t>(col);
|
||||
return idx < total_ ? idx : -1;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<int64_t> 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<char>(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 <script> element.
|
||||
inline std::string jsonString(const std::string& s) {
|
||||
std::string o;
|
||||
o.reserve(s.size() + 2);
|
||||
o += '"';
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
const unsigned char c = static_cast<unsigned char>(s[i]);
|
||||
switch (c) {
|
||||
case '"': o += "\\\""; break;
|
||||
case '\\': o += "\\\\"; break;
|
||||
case '\n': o += "\\n"; break;
|
||||
case '\r': o += "\\r"; break;
|
||||
case '\t': o += "\\t"; break;
|
||||
case '<': o += "\\u003c"; break;
|
||||
case '>': o += "\\u003e"; break;
|
||||
case '&': o += "\\u0026"; break;
|
||||
default:
|
||||
if (c < 0x20 || c == 0x7F) {
|
||||
char b[8];
|
||||
std::snprintf(b, sizeof b, "\\u%04x", c);
|
||||
o += b;
|
||||
} else if (c == 0xE2 && i + 2 < s.size() &&
|
||||
static_cast<unsigned char>(s[i + 1]) == 0x80 &&
|
||||
(static_cast<unsigned char>(s[i + 2]) & 0xFE) == 0xA8) {
|
||||
o += (static_cast<unsigned char>(s[i + 2]) == 0xA8) ? "\\u2028" : "\\u2029";
|
||||
i += 2;
|
||||
} else {
|
||||
o += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
o += '"';
|
||||
return o;
|
||||
}
|
||||
|
||||
inline std::string plural(size_t n, const std::string& word) {
|
||||
return std::to_string(n) + " " + word + (n == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
inline std::string fixed1(double v) {
|
||||
char b[48];
|
||||
std::snprintf(b, sizeof b, "%.1f", v);
|
||||
return b;
|
||||
}
|
||||
|
||||
} // namespace lab
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Embeds web/ (the browser UI) into src/webui_assets.hpp.
|
||||
|
||||
Run this after editing anything in web/: python3 tools/embed_web.py
|
||||
|
||||
Each file becomes a few C++ raw string literals of at most ~12 KB, because
|
||||
MSVC rejects longer string literals. The generated header is checked in, so
|
||||
building antlrlab does not need Python.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
WEB = os.path.join(ROOT, "web")
|
||||
OUT = os.path.join(ROOT, "src", "webui_assets.hpp")
|
||||
DELIM = "__WEB__"
|
||||
CHUNK = 12000
|
||||
|
||||
|
||||
def read(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
try:
|
||||
text.encode("ascii")
|
||||
except UnicodeEncodeError as e:
|
||||
sys.exit("%s contains non-ASCII text (%s). Use \\u escapes or entities." % (path, e))
|
||||
if ")" + DELIM + '"' in text:
|
||||
sys.exit("%s contains the raw string terminator" % path)
|
||||
return text
|
||||
|
||||
|
||||
def chunks(text):
|
||||
out, cur = [], ""
|
||||
for line in text.splitlines(keepends=True):
|
||||
if len(cur) + len(line) > CHUNK and cur:
|
||||
out.append(cur)
|
||||
cur = ""
|
||||
cur += line
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def function(name, text):
|
||||
body = "".join(' s += R"%s(%s)%s";\n' % (DELIM, c, DELIM) for c in chunks(text))
|
||||
return "inline std::string %s() {\n std::string s;\n%s return s;\n}\n" % (name, body)
|
||||
|
||||
|
||||
def main():
|
||||
js = "".join(read(p) for p in sorted(glob.glob(os.path.join(WEB, "[0-9]*.js"))))
|
||||
parts = [
|
||||
"// GENERATED by tools/embed_web.py from web/ - do not edit by hand.\n",
|
||||
"#pragma once\n\n#include <string>\n\nnamespace lab {\n\n",
|
||||
function("webIndexHtml", read(os.path.join(WEB, "index.html"))),
|
||||
"\n",
|
||||
function("webCss", read(os.path.join(WEB, "app.css"))),
|
||||
"\n",
|
||||
function("webJs", js),
|
||||
"\n} // namespace lab\n",
|
||||
]
|
||||
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("".join(parts))
|
||||
print("wrote %s (%d bytes of JS, %d files)" % (OUT, len(js), len(glob.glob(os.path.join(WEB, "[0-9]*.js")))))
|
||||
|
||||
|
||||
main()
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
(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(); });
|
||||
@@ -0,0 +1,476 @@
|
||||
|
||||
/* ================================================================== editor
|
||||
* A plain <textarea> sits on top of a <pre> that paints the same text with
|
||||
* syntax colors and marks. Both have identical font metrics and size, so the
|
||||
* textarea provides the caret, selection, IME and native undo while the <pre>
|
||||
* provides the colors. One editor (and so one undo history) per tab. */
|
||||
|
||||
var CLS = ['', 't-com', 't-str', 't-set', 't-act', 't-ann', 't-kw', 't-tok', 't-def', 't-lab', 't-op', 't-tokdef'];
|
||||
var MKCLS = [[1, 'mk-err'], [2, 'mk-warn'], [4, 'mk-hl'], [8, 'mk-find'], [16, 'mk-cur']];
|
||||
var KW = { grammar: 1, lexer: 1, parser: 1, fragment: 1, options: 1, tokens: 1, channels: 1, 'import': 1, mode: 1, returns: 1,
|
||||
locals: 1, throws: 1, 'catch': 1, 'finally': 1, skip: 1, more: 1, pushMode: 1, popMode: 1, type: 1, channel: 1 };
|
||||
|
||||
function isIdStart(c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_'; }
|
||||
function isIdChar(c) { return isIdStart(c) || (c >= '0' && c <= '9'); }
|
||||
|
||||
// One class id per UTF-16 unit, for ANTLR grammar syntax.
|
||||
function g4Classes(t) {
|
||||
var n = t.length, cls = new Uint8Array(n), i = 0, prevKw = '';
|
||||
function paint(a, b, c) { for (var k = a; k < b; k++) cls[k] = c; }
|
||||
function quoted(from, close) {
|
||||
var j = from + 1;
|
||||
while (j < n && t.charAt(j) !== '\n') {
|
||||
if (t.charAt(j) === '\\') { j += 2; continue; }
|
||||
if (t.charAt(j) === close) { j++; break; }
|
||||
j++;
|
||||
}
|
||||
return Math.min(j, n);
|
||||
}
|
||||
while (i < n) {
|
||||
var c = t.charAt(i), j;
|
||||
if (c === '/' && t.charAt(i + 1) === '/') {
|
||||
j = t.indexOf('\n', i); if (j < 0) j = n;
|
||||
paint(i, j, 1); i = j;
|
||||
} else if (c === '/' && t.charAt(i + 1) === '*') {
|
||||
j = t.indexOf('*/', i + 2); j = j < 0 ? n : j + 2;
|
||||
paint(i, j, 1); i = j;
|
||||
} else if (c === "'") {
|
||||
j = quoted(i, "'"); paint(i, j, 2); i = j;
|
||||
} else if (c === '[') {
|
||||
j = quoted(i, ']'); paint(i, j, 3); i = j;
|
||||
} else if (c === '{') {
|
||||
var depth = 0; j = i;
|
||||
while (j < n) {
|
||||
var d = t.charAt(j);
|
||||
if (d === "'" || d === '"') { j = quoted(j, d); continue; }
|
||||
if (d === '{') depth++;
|
||||
else if (d === '}' && --depth === 0) { j++; break; }
|
||||
j++;
|
||||
}
|
||||
paint(i, j, 4); i = j;
|
||||
} else if (c === '@' || c === '#') {
|
||||
j = i + 1;
|
||||
while (j < n && (t.charAt(j) === ' ' || t.charAt(j) === '\t') && c === '#') j++;
|
||||
while (j < n && (isIdChar(t.charAt(j)) || t.charAt(j) === ':')) j++;
|
||||
paint(i, j, 5); i = j;
|
||||
} else if (isIdStart(c)) {
|
||||
j = i + 1;
|
||||
while (j < n && isIdChar(t.charAt(j))) j++;
|
||||
var word = t.slice(i, j);
|
||||
var k = j;
|
||||
while (k < n && (t.charAt(k) === ' ' || t.charAt(k) === '\t' || t.charAt(k) === '\n' || t.charAt(k) === '\r')) k++;
|
||||
var next = t.charAt(k), after = t.charAt(k + 1);
|
||||
var ls = t.lastIndexOf('\n', i - 1) + 1;
|
||||
var first = /^[ \t]*$/.test(t.slice(ls, i));
|
||||
var def = next === ':' && after !== ':' && (first || prevKw === 'fragment');
|
||||
var c0 = word.charAt(0);
|
||||
var up = c0 >= 'A' && c0 <= 'Z';
|
||||
var id;
|
||||
if (KW[word] === 1 && !def && next !== ':') id = 6;
|
||||
else if (def) id = up ? 11 : 8;
|
||||
else if ((next === '=' && after !== '=') || (next === '+' && after === '=')) id = 9;
|
||||
else id = up ? 7 : 0;
|
||||
paint(i, j, id);
|
||||
prevKw = word;
|
||||
i = j;
|
||||
} else {
|
||||
if ('|:;()*+?~.,<>=-'.indexOf(c) >= 0) cls[i] = 10;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return cls;
|
||||
}
|
||||
|
||||
// code point index -> UTF-16 index
|
||||
function cpMap(text) {
|
||||
if (!/[\ud800-\udbff]/.test(text)) return function (i) { return i; };
|
||||
var map = [];
|
||||
for (var i = 0; i < text.length;) {
|
||||
map.push(i);
|
||||
var c = text.charCodeAt(i);
|
||||
i += (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) ? 2 : 1;
|
||||
}
|
||||
map.push(text.length);
|
||||
return function (k) { return k < map.length ? map[k] : text.length; };
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- line helpers */
|
||||
function lineStarts(ed) {
|
||||
if (!ed.ls) {
|
||||
var t = ed.ta.value, a = [0];
|
||||
for (var i = t.indexOf('\n'); i >= 0; i = t.indexOf('\n', i + 1)) a.push(i + 1);
|
||||
ed.ls = a;
|
||||
}
|
||||
return ed.ls;
|
||||
}
|
||||
function lineOf(ed, idx) {
|
||||
var a = lineStarts(ed), lo = 0, hi = a.length - 1;
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi + 1) >> 1;
|
||||
if (a[mid] <= idx) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
// 1-based line + 0-based column -> unit index (column clamped to the line)
|
||||
function unitAt(ed, line, col) {
|
||||
var a = lineStarts(ed);
|
||||
if (line < 1) return 0;
|
||||
if (line > a.length) return ed.ta.value.length;
|
||||
var s = a[line - 1], e = line < a.length ? a[line] - 1 : ed.ta.value.length;
|
||||
return Math.min(s + col, e);
|
||||
}
|
||||
|
||||
var metricCache = {};
|
||||
function metrics(ed) {
|
||||
var key = S.prefs.fs;
|
||||
if (metricCache[key]) return metricCache[key];
|
||||
var probe = el('span', '', '0000000000000000000000000000000000000000');
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.whiteSpace = 'pre';
|
||||
probe.className = 'hl';
|
||||
probe.style.padding = '0';
|
||||
probe.style.minHeight = '0';
|
||||
ed.hl.parentNode.appendChild(probe);
|
||||
var w = probe.getBoundingClientRect().width / 40;
|
||||
probe.remove();
|
||||
var lh = parseFloat(getComputedStyle(ed.ta).lineHeight) || 20;
|
||||
metricCache[key] = { cw: w || 8, lh: lh };
|
||||
return metricCache[key];
|
||||
}
|
||||
|
||||
// Scrolls the editor so that the given unit index is visible.
|
||||
function revealUnit(ed, idx) {
|
||||
var m = metrics(ed), sc = ed.scroller;
|
||||
var line = lineOf(ed, idx);
|
||||
var ls = lineStarts(ed)[line];
|
||||
var col = 0, pre = ed.ta.value.slice(ls, idx);
|
||||
for (var i = 0; i < pre.length; i++) col = pre.charAt(i) === '\t' ? (Math.floor(col / 4) + 1) * 4 : col + 1;
|
||||
var y = 8 + line * m.lh;
|
||||
var gw = ed.gutter.offsetWidth;
|
||||
var x = gw + 10 + col * m.cw;
|
||||
if (y < sc.scrollTop + m.lh) sc.scrollTop = Math.max(0, y - m.lh * 2);
|
||||
else if (y + m.lh * 2 > sc.scrollTop + sc.clientHeight) sc.scrollTop = y + m.lh * 3 - sc.clientHeight;
|
||||
if (x < sc.scrollLeft + gw + 30) sc.scrollLeft = Math.max(0, x - gw - 60);
|
||||
else if (x + 40 > sc.scrollLeft + sc.clientWidth) sc.scrollLeft = x + 80 - sc.clientWidth;
|
||||
}
|
||||
|
||||
function caretInfo(ed) {
|
||||
var p = ed.ta.selectionEnd, line = lineOf(ed, p);
|
||||
return { line: line + 1, col: p - lineStarts(ed)[line] + 1 };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- painting */
|
||||
function paintEditor(ed) {
|
||||
var t = ed.ta.value, n = t.length;
|
||||
var big = n > 800000;
|
||||
var cls = (ed.tab.kind === 'grammar' && !big) ? g4Classes(t) : null;
|
||||
var mk = new Uint8Array(n + 1);
|
||||
function mark(list, bit) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var r = list[i];
|
||||
for (var k = Math.max(0, r.s); k < Math.min(n, r.e); k++) mk[k] |= (r.bit || bit);
|
||||
}
|
||||
}
|
||||
if (!big) {
|
||||
mark(ed.diag, 1);
|
||||
if (ed.find) {
|
||||
for (var f = 0; f < ed.find.matches.length && f < 5000; f++) {
|
||||
var fm = ed.find.matches[f];
|
||||
for (var q = fm[0]; q < fm[1]; q++) mk[q] |= (f === ed.find.cur ? 16 : 8);
|
||||
}
|
||||
}
|
||||
var rng = ed.hover || ed.pin;
|
||||
if (rng) for (var h = Math.max(0, rng[0]); h < Math.min(n, rng[1]); h++) mk[h] |= 4;
|
||||
}
|
||||
|
||||
var frag = document.createDocumentFragment();
|
||||
var i = 0;
|
||||
while (i < n) {
|
||||
var key = (cls ? cls[i] : 0) | (mk[i] << 4), j = i + 1;
|
||||
while (j < n && ((cls ? cls[j] : 0) | (mk[j] << 4)) === key) j++;
|
||||
var txt = t.slice(i, j);
|
||||
if (key === 0) {
|
||||
frag.appendChild(document.createTextNode(txt));
|
||||
} else {
|
||||
var names = [];
|
||||
if (key & 15) names.push(CLS[key & 15]);
|
||||
for (var m = 0; m < MKCLS.length; m++) if ((key >> 4) & MKCLS[m][0]) names.push(MKCLS[m][1]);
|
||||
frag.appendChild(el('span', names.join(' '), txt));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
frag.appendChild(document.createTextNode('\n')); // matches the textarea's trailing line
|
||||
clear(ed.hl);
|
||||
ed.hl.appendChild(frag);
|
||||
|
||||
var lines = lineStarts(ed).length;
|
||||
if (lines !== ed.lines) {
|
||||
var nums = [];
|
||||
for (var L = 1; L <= lines; L++) nums.push(L);
|
||||
ed.gutter.textContent = nums.join('\n') + '\n';
|
||||
ed.lines = lines;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- editing */
|
||||
function replaceRange(ed, a, b, text) {
|
||||
var ta = ed.ta;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(a, b);
|
||||
var ok = false;
|
||||
// execCommand keeps the undo history but is very slow for big multi-line
|
||||
// strings in Chrome, so large edits take the direct route.
|
||||
if (text.length + (b - a) <= 20000) {
|
||||
try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
|
||||
}
|
||||
if (!ok) {
|
||||
ta.setRangeText(text, a, b, 'end');
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function lineSpan(t, a, b) {
|
||||
if (b > a && t.charAt(b - 1) === '\n') b--;
|
||||
var s = t.lastIndexOf('\n', a - 1) + 1;
|
||||
var e = t.indexOf('\n', b);
|
||||
if (e < 0) e = t.length;
|
||||
return [s, e];
|
||||
}
|
||||
|
||||
function indentSelection(ed, outdent) {
|
||||
var ta = ed.ta, t = ta.value, a = ta.selectionStart, b = ta.selectionEnd;
|
||||
var unit = ed.tab.kind === 'grammar' ? ' ' : '\t';
|
||||
if (a === b && !outdent) {
|
||||
if (unit === '\t') { replaceRange(ed, a, b, '\t'); return; }
|
||||
var col = a - (t.lastIndexOf('\n', a - 1) + 1);
|
||||
replaceRange(ed, a, b, unit.slice(0, 4 - (col % 4)));
|
||||
return;
|
||||
}
|
||||
var sp = lineSpan(t, a, b);
|
||||
var lines = t.slice(sp[0], sp[1]).split('\n').map(function (l) {
|
||||
if (!outdent) return l.length ? unit + l : l;
|
||||
if (l.charAt(0) === '\t') return l.slice(1);
|
||||
var m = /^ {1,4}/.exec(l);
|
||||
return m ? l.slice(m[0].length) : l;
|
||||
});
|
||||
var out = lines.join('\n');
|
||||
replaceRange(ed, sp[0], sp[1], out);
|
||||
ed.ta.setSelectionRange(sp[0], sp[0] + out.length);
|
||||
}
|
||||
|
||||
function toggleComment(ed) {
|
||||
if (ed.tab.kind !== 'grammar') return;
|
||||
var ta = ed.ta, t = ta.value, sp = lineSpan(t, ta.selectionStart, ta.selectionEnd);
|
||||
var lines = t.slice(sp[0], sp[1]).split('\n');
|
||||
var live = lines.filter(function (l) { return l.trim().length; });
|
||||
var all = live.length && live.every(function (l) { return /^\s*\/\//.test(l); });
|
||||
var indent = Math.min.apply(null, live.map(function (l) { return /^\s*/.exec(l)[0].length; }).concat([1e9]));
|
||||
var out = lines.map(function (l) {
|
||||
if (!l.trim().length) return l;
|
||||
return all ? l.replace(/^(\s*)\/\/ ?/, '$1') : l.slice(0, indent) + '// ' + l.slice(indent);
|
||||
}).join('\n');
|
||||
replaceRange(ed, sp[0], sp[1], out);
|
||||
ta.setSelectionRange(sp[0], sp[0] + out.length);
|
||||
}
|
||||
|
||||
function editorKeys(ed, ev) {
|
||||
var ta = ed.ta, k = ev.key, mod = ev.ctrlKey || ev.metaKey;
|
||||
if (k === 'Tab' && !mod && !ev.altKey) { ev.preventDefault(); indentSelection(ed, ev.shiftKey); return; }
|
||||
if (k === 'Enter' && !mod && !ev.altKey && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
var a = ta.selectionStart, t = ta.value, ls = t.lastIndexOf('\n', a - 1) + 1;
|
||||
var lead = /^[ \t]*/.exec(t.slice(ls, a))[0];
|
||||
var before = t.slice(ls, a).replace(/\s+$/, '');
|
||||
var extra = (ed.tab.kind === 'grammar' && /[:({]$/.test(before)) ? ' ' : '';
|
||||
replaceRange(ed, a, ta.selectionEnd, '\n' + lead + extra);
|
||||
return;
|
||||
}
|
||||
if (mod && k === '/') { ev.preventDefault(); toggleComment(ed); return; }
|
||||
if (mod && (k === 'f' || k === 'F' || k === 'h' || k === 'H')) { ev.preventDefault(); openFind(ed); return; }
|
||||
if (k === 'Escape' && ed.findBar && !ed.findBar.hidden) { ev.preventDefault(); closeFind(ed); }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- find */
|
||||
function buildFind(ed) {
|
||||
var bar = el('div', 'find');
|
||||
bar.setAttribute('role', 'search');
|
||||
var r1 = el('div', 'row'), r2 = el('div', 'row');
|
||||
var q = el('input'); q.type = 'text'; q.placeholder = 'Find'; q.setAttribute('aria-label', 'Find');
|
||||
var cnt = el('span', 'cnt');
|
||||
var bPrev = el('button', 'btn sm', '\u2191'); bPrev.title = 'Previous (Shift+Enter)';
|
||||
var bNext = el('button', 'btn sm', '\u2193'); bNext.title = 'Next (Enter)';
|
||||
var bCase = el('button', 'btn sm', 'Aa'); bCase.title = 'Match case'; bCase.setAttribute('aria-pressed', 'false');
|
||||
var bX = el('button', 'btn sm', '\u00d7'); bX.title = 'Close (Esc)';
|
||||
var rep = el('input'); rep.type = 'text'; rep.placeholder = 'Replace'; rep.setAttribute('aria-label', 'Replace with');
|
||||
var bRep = el('button', 'btn sm', 'Replace');
|
||||
var bAll = el('button', 'btn sm', 'All');
|
||||
[bPrev, bNext, bCase, bX, bRep, bAll].forEach(function (b) { b.type = 'button'; });
|
||||
[q, bPrev, bNext, bCase, cnt, bX].forEach(function (n) { r1.appendChild(n); });
|
||||
[rep, bRep, bAll].forEach(function (n) { r2.appendChild(n); });
|
||||
bar.appendChild(r1); bar.appendChild(r2);
|
||||
ed.root.appendChild(bar);
|
||||
ed.findBar = bar;
|
||||
ed.fq = q; ed.fr = rep; ed.fcnt = cnt; ed.fcase = false;
|
||||
|
||||
q.oninput = function () { runFind(ed, true); };
|
||||
q.onkeydown = function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); stepFind(ed, ev.shiftKey ? -1 : 1); }
|
||||
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(ed); }
|
||||
};
|
||||
rep.onkeydown = function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); replaceOne(ed); }
|
||||
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(ed); }
|
||||
};
|
||||
bPrev.onclick = function () { stepFind(ed, -1); };
|
||||
bNext.onclick = function () { stepFind(ed, 1); };
|
||||
bCase.onclick = function () {
|
||||
ed.fcase = !ed.fcase;
|
||||
bCase.setAttribute('aria-pressed', ed.fcase ? 'true' : 'false');
|
||||
runFind(ed, true);
|
||||
};
|
||||
bX.onclick = function () { closeFind(ed); };
|
||||
bRep.onclick = function () { replaceOne(ed); };
|
||||
bAll.onclick = function () { replaceAll(ed); };
|
||||
}
|
||||
|
||||
function openFind(ed) {
|
||||
if (!ed.findBar) buildFind(ed);
|
||||
ed.findBar.hidden = false;
|
||||
var sel = ed.ta.value.slice(ed.ta.selectionStart, ed.ta.selectionEnd);
|
||||
if (sel && sel.indexOf('\n') < 0) ed.fq.value = sel;
|
||||
ed.fq.focus();
|
||||
ed.fq.select();
|
||||
runFind(ed, false);
|
||||
}
|
||||
|
||||
function closeFind(ed) {
|
||||
if (!ed.findBar) return;
|
||||
ed.findBar.hidden = true;
|
||||
ed.find = null;
|
||||
paintEditor(ed);
|
||||
ed.ta.focus();
|
||||
}
|
||||
|
||||
function findMatches(ed) {
|
||||
var q = ed.fq.value, out = [];
|
||||
if (!q) return out;
|
||||
var hay = ed.fcase ? ed.ta.value : ed.ta.value.toLowerCase();
|
||||
var needle = ed.fcase ? q : q.toLowerCase();
|
||||
for (var i = hay.indexOf(needle); i >= 0 && out.length < 5000; i = hay.indexOf(needle, i + Math.max(1, needle.length))) {
|
||||
out.push([i, i + needle.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function runFind(ed, jump) {
|
||||
var ms = findMatches(ed), cur = -1;
|
||||
if (ms.length) {
|
||||
var from = ed.ta.selectionStart;
|
||||
for (var i = 0; i < ms.length; i++) { if (ms[i][0] >= from) { cur = i; break; } }
|
||||
if (cur < 0) cur = 0;
|
||||
}
|
||||
ed.find = ms.length || ed.fq.value ? { matches: ms, cur: cur } : null;
|
||||
ed.fcnt.textContent = ed.fq.value ? (ms.length ? (cur + 1) + ' of ' + ms.length : 'No results') : '';
|
||||
paintEditor(ed);
|
||||
if (jump && cur >= 0) selectMatch(ed, ms[cur]);
|
||||
}
|
||||
|
||||
function selectMatch(ed, m) {
|
||||
ed.ta.setSelectionRange(m[0], m[1]);
|
||||
revealUnit(ed, m[0]);
|
||||
}
|
||||
|
||||
function stepFind(ed, dir) {
|
||||
if (!ed.find || !ed.find.matches.length) { runFind(ed, true); return; }
|
||||
var n = ed.find.matches.length;
|
||||
ed.find.cur = (ed.find.cur + dir + n) % n;
|
||||
ed.fcnt.textContent = (ed.find.cur + 1) + ' of ' + n;
|
||||
paintEditor(ed);
|
||||
selectMatch(ed, ed.find.matches[ed.find.cur]);
|
||||
}
|
||||
|
||||
function replaceOne(ed) {
|
||||
if (!ed.find || ed.find.cur < 0) { runFind(ed, true); return; }
|
||||
var m = ed.find.matches[ed.find.cur];
|
||||
replaceRange(ed, m[0], m[1], ed.fr.value);
|
||||
ed.fq.focus();
|
||||
runFind(ed, true);
|
||||
}
|
||||
|
||||
function replaceAll(ed) {
|
||||
var ms = findMatches(ed);
|
||||
if (!ms.length) return;
|
||||
var t = ed.ta.value, out = '', last = 0;
|
||||
ms.forEach(function (m) { out += t.slice(last, m[0]) + ed.fr.value; last = m[1]; });
|
||||
out += t.slice(last);
|
||||
replaceRange(ed, 0, t.length, out);
|
||||
ed.fq.focus();
|
||||
runFind(ed, false);
|
||||
toast('Replaced ' + plural(ms.length, 'match'));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ editor */
|
||||
function makeEditor(tab) {
|
||||
var root = el('div', 'ed');
|
||||
root.hidden = true;
|
||||
var banner = el('div', 'banner');
|
||||
banner.hidden = true;
|
||||
var scroller = el('div', 'scroller');
|
||||
var rows = el('div', 'rows');
|
||||
var gutter = el('pre', 'gutter');
|
||||
gutter.setAttribute('aria-hidden', 'true');
|
||||
var content = el('div', 'content');
|
||||
var hl = el('pre', 'hl');
|
||||
hl.setAttribute('aria-hidden', 'true');
|
||||
var ta = el('textarea', 'ta');
|
||||
ta.spellcheck = false;
|
||||
ta.setAttribute('wrap', 'off');
|
||||
ta.setAttribute('autocomplete', 'off');
|
||||
ta.setAttribute('autocorrect', 'off');
|
||||
ta.setAttribute('autocapitalize', 'off');
|
||||
ta.setAttribute('aria-label', 'Editor');
|
||||
content.appendChild(hl);
|
||||
content.appendChild(ta);
|
||||
rows.appendChild(gutter);
|
||||
rows.appendChild(content);
|
||||
scroller.appendChild(rows);
|
||||
root.appendChild(banner);
|
||||
root.appendChild(scroller);
|
||||
|
||||
var ed = { tab: tab, root: root, banner: banner, scroller: scroller, gutter: gutter, hl: hl, ta: ta,
|
||||
diag: [], hover: null, pin: null, find: null, ls: null, lines: 0, findBar: null, onInput: null, onCaret: null };
|
||||
|
||||
ta.addEventListener('input', function () {
|
||||
ed.ls = null;
|
||||
ed.diag = [];
|
||||
ed.pin = null;
|
||||
if (ed.find) runFind(ed, false); else paintEditor(ed);
|
||||
revealUnit(ed, ta.selectionEnd);
|
||||
if (ed.onInput) ed.onInput(ed);
|
||||
if (ed.onCaret) ed.onCaret(ed);
|
||||
});
|
||||
ta.addEventListener('keydown', function (ev) { editorKeys(ed, ev); });
|
||||
['keyup', 'mouseup', 'focus'].forEach(function (name) {
|
||||
ta.addEventListener(name, function (ev) {
|
||||
if (name === 'keyup' && /^(Arrow|Page|Home|End)/.test(ev.key)) revealUnit(ed, ta.selectionEnd);
|
||||
if (ed.onCaret) ed.onCaret(ed);
|
||||
});
|
||||
});
|
||||
return ed;
|
||||
}
|
||||
|
||||
function setEditorText(ed, text) {
|
||||
ed.ta.value = text;
|
||||
ed.ls = null;
|
||||
ed.diag = [];
|
||||
ed.pin = null;
|
||||
ed.find = null;
|
||||
if (ed.findBar) { ed.findBar.hidden = true; }
|
||||
paintEditor(ed);
|
||||
}
|
||||
|
||||
function setEditorRange(ed, kind, range) { // kind: 'hover' | 'pin'; range [s,e) in units or null
|
||||
ed[kind] = range;
|
||||
paintEditor(ed);
|
||||
}
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
|
||||
/* ==================================================================== tabs */
|
||||
var nextTabId = 1;
|
||||
var NEW_GRAMMAR = 'grammar MyGrammar;\n\nprog : expr EOF ;\n\nexpr : term (\'+\' term)* ;\n\nterm : INT | ID | \'(\' expr \')\' ;\n\nINT : [0-9]+ ;\nID : [a-zA-Z_] [a-zA-Z_0-9]* ;\nWS : [ \\t\\r\\n]+ -> skip ;\n';
|
||||
|
||||
function grammarName(text) {
|
||||
var m = /^\s*(?:lexer\s+|parser\s+)?grammar\s+([A-Za-z_]\w*)\s*;/m.exec(text.slice(0, 20000));
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
function getTab(id) {
|
||||
for (var i = 0; i < S.tabs.length; i++) if (S.tabs[i].id === id) return S.tabs[i];
|
||||
return null;
|
||||
}
|
||||
function tabTitle(t) {
|
||||
if (t.path) return basename(t.path);
|
||||
if (t.kind === 'grammar') { var n = grammarName(t.ed.ta.value); return (n || 'Untitled') + '.g4'; }
|
||||
return 'Input ' + t.num;
|
||||
}
|
||||
function firstOfKind(kind) {
|
||||
for (var i = 0; i < S.tabs.length; i++) if (S.tabs[i].kind === kind) return S.tabs[i];
|
||||
return null;
|
||||
}
|
||||
function ensureLast() {
|
||||
var g = getTab(S.lastGrammar), i = getTab(S.lastInput);
|
||||
if (!g || g.kind !== 'grammar') { g = firstOfKind('grammar'); S.lastGrammar = g ? g.id : null; }
|
||||
if (!i || i.kind !== 'input') { i = firstOfKind('input'); S.lastInput = i ? i.id : null; }
|
||||
}
|
||||
|
||||
function addTab(o) {
|
||||
var t = { id: 't' + (nextTabId++), kind: o.kind, path: o.path || '', num: 0, saved: o.text, etag: o.etag || '',
|
||||
eol: o.eol || 'lf', bom: !!o.bom, dirty: false, rev: 0, banner: '', diskEtag: '', shown: '' };
|
||||
if (!t.path) t.num = ++S.untitled[t.kind];
|
||||
t.ed = makeEditor(t);
|
||||
t.ed.onInput = function () { onEdit(t); };
|
||||
t.ed.onCaret = updatePos;
|
||||
setEditorText(t.ed, o.text);
|
||||
$('editors').appendChild(t.ed.root);
|
||||
S.tabs.push(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
function onEdit(t) {
|
||||
t.rev++;
|
||||
if (t.kind === 'grammar') S.grev++;
|
||||
t.dirty = t.ed.ta.value !== t.saved;
|
||||
var sig = (t.dirty ? '*' : '') + tabTitle(t);
|
||||
if (sig !== t.shown) renderTabs();
|
||||
refreshPanels();
|
||||
if (t.kind === 'grammar' || t.id === S.lastInput) scheduleRun(t.kind === 'grammar' ? 800 : 500);
|
||||
}
|
||||
|
||||
function activate(id, focus) {
|
||||
var t = getTab(id);
|
||||
if (!t) return;
|
||||
S.active = id;
|
||||
if (t.kind === 'grammar') S.lastGrammar = id; else S.lastInput = id;
|
||||
ensureLast();
|
||||
S.tabs.forEach(function (x) { x.ed.root.hidden = x.id !== id; });
|
||||
$('welcome').hidden = true;
|
||||
renderTabs();
|
||||
updateBanner(t);
|
||||
updatePos();
|
||||
refreshPanels();
|
||||
if (focus !== false) t.ed.ta.focus();
|
||||
rememberSession();
|
||||
if (S.prefs.autorun) {
|
||||
var it = getTab(S.lastInput), r = it ? S.results[it.id] : null;
|
||||
if (S.lastGrammar && it && (!r || r.rev !== it.rev || r.grev !== S.grev || r.main !== S.lastGrammar)) scheduleRun(120);
|
||||
}
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
S.active = null;
|
||||
$('welcome').hidden = false;
|
||||
renderTabs();
|
||||
updatePos();
|
||||
refreshPanels();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
var bar = $('tabs');
|
||||
clear(bar);
|
||||
var activeEl = null;
|
||||
S.tabs.forEach(function (t) {
|
||||
var title = tabTitle(t);
|
||||
t.shown = (t.dirty ? '*' : '') + title;
|
||||
var on = t.id === S.active;
|
||||
var d = el('div', 'tab');
|
||||
d.setAttribute('role', 'tab');
|
||||
d.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
d.tabIndex = on ? 0 : -1;
|
||||
d.title = t.path || 'Not saved to disk yet';
|
||||
if (t.dirty) { var dot = el('span', 'dot'); dot.title = 'Unsaved changes'; d.appendChild(dot); }
|
||||
d.appendChild(el('span', '', title));
|
||||
var kind = el('button', 'kind', t.kind === 'grammar' ? 'G' : 'I');
|
||||
kind.type = 'button';
|
||||
kind.title = (t.kind === 'grammar' ? 'Grammar' : 'Input') + ' - click to treat this file as ' + (t.kind === 'grammar' ? 'an input' : 'a grammar');
|
||||
kind.onclick = function (ev) { ev.stopPropagation(); toggleKind(t); };
|
||||
d.appendChild(kind);
|
||||
var x = el('button', 'tx', '\u00d7');
|
||||
x.type = 'button';
|
||||
x.setAttribute('aria-label', 'Close ' + title);
|
||||
x.onclick = function (ev) { ev.stopPropagation(); closeTab(t.id); };
|
||||
d.appendChild(x);
|
||||
d.onclick = function () { activate(t.id); };
|
||||
d.onauxclick = function (ev) { if (ev.button === 1) { ev.preventDefault(); closeTab(t.id); } };
|
||||
d.onkeydown = function (ev) {
|
||||
var i = S.tabs.indexOf(t), n = S.tabs.length;
|
||||
if (ev.key === 'ArrowRight') { ev.preventDefault(); activate(S.tabs[(i + 1) % n].id, false); $('tabs').querySelector('[aria-selected=true]').focus(); }
|
||||
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); activate(S.tabs[(i + n - 1) % n].id, false); $('tabs').querySelector('[aria-selected=true]').focus(); }
|
||||
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); activate(t.id); }
|
||||
};
|
||||
bar.appendChild(d);
|
||||
if (on) activeEl = d;
|
||||
});
|
||||
if (activeEl && activeEl.scrollIntoView) activeEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
|
||||
function toggleKind(t) {
|
||||
t.kind = t.kind === 'grammar' ? 'input' : 'grammar';
|
||||
delete S.results[t.id];
|
||||
S.grev++;
|
||||
ensureLast();
|
||||
paintEditor(t.ed);
|
||||
if (t.id === S.active) activate(t.id, false); else renderTabs();
|
||||
refreshPanels();
|
||||
scheduleRun(200);
|
||||
}
|
||||
|
||||
function closeTab(id, force) {
|
||||
var t = getTab(id);
|
||||
if (!t) return Promise.resolve(true);
|
||||
function proceed() {
|
||||
var idx = S.tabs.indexOf(t);
|
||||
S.tabs.splice(idx, 1);
|
||||
t.ed.root.remove();
|
||||
delete S.results[id];
|
||||
if (t.kind === 'grammar') S.grev++;
|
||||
if (S.lastGrammar === id) S.lastGrammar = null;
|
||||
if (S.lastInput === id) S.lastInput = null;
|
||||
ensureLast();
|
||||
if (S.active === id) {
|
||||
var next = S.tabs[Math.min(idx, S.tabs.length - 1)];
|
||||
S.active = null;
|
||||
if (next) activate(next.id); else showEmpty();
|
||||
} else {
|
||||
renderTabs();
|
||||
refreshPanels();
|
||||
}
|
||||
rememberSession();
|
||||
scheduleRun(300);
|
||||
}
|
||||
if (!t.dirty || force) { proceed(); return Promise.resolve(true); }
|
||||
return ask('Unsaved changes', '"' + tabTitle(t) + '" has unsaved changes.',
|
||||
[{ label: 'Save', value: 'save', primary: true }, { label: "Don't save", value: 'discard' }, { label: 'Cancel', value: null }])
|
||||
.then(function (v) {
|
||||
if (v === 'discard') { proceed(); return true; }
|
||||
if (v === 'save') return saveTab(t).then(function (ok) { if (ok) proceed(); return ok; });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePos() {
|
||||
var t = getTab(S.active);
|
||||
var p = $('pos');
|
||||
if (!t) { p.textContent = ''; return; }
|
||||
var c = caretInfo(t.ed);
|
||||
p.textContent = 'Ln ' + c.line + ', Col ' + c.col;
|
||||
}
|
||||
|
||||
function rememberSession() {
|
||||
if (!S.info) return;
|
||||
var active = getTab(S.active);
|
||||
store.set('session:' + S.info.root, {
|
||||
open: S.tabs.filter(function (t) { return t.path; }).map(function (t) { return { path: t.path, kind: t.kind }; }),
|
||||
active: active ? active.path : ''
|
||||
});
|
||||
}
|
||||
|
||||
/* ========================================================== files on disk */
|
||||
function openFile(path, o) {
|
||||
var ex = null;
|
||||
S.tabs.forEach(function (t) { if (t.path === path) ex = t; });
|
||||
if (ex) { activate(ex.id); return Promise.resolve(ex); }
|
||||
return api('GET', '/api/file?path=' + encodeURIComponent(path)).then(function (f) {
|
||||
var t = addTab({ kind: (o && o.kind) || (isGrammarFile(f.path) ? 'grammar' : 'input'), path: f.path, text: f.text,
|
||||
etag: f.etag, eol: f.eol, bom: f.bom });
|
||||
S.lastDir = dirname(f.path);
|
||||
if (f.lossy) toast(basename(f.path) + ' contains bytes that are not valid UTF-8; they were replaced.', 'error');
|
||||
activate(t.id, !(o && o.noFocus));
|
||||
return t;
|
||||
}).catch(function (e) {
|
||||
toast('Cannot open ' + path + ': ' + e.message, 'error');
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function saveError(e) {
|
||||
toast('Save failed: ' + e.message, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
function doSave(t, path, extra) {
|
||||
var text = t.ed.ta.value;
|
||||
var body = { path: path, text: text, eol: t.eol, bom: t.bom };
|
||||
Object.keys(extra || {}).forEach(function (k) { body[k] = extra[k]; });
|
||||
return api('POST', '/api/save', body).then(function (r) {
|
||||
t.path = r.path;
|
||||
t.saved = text;
|
||||
t.etag = r.etag;
|
||||
t.dirty = t.ed.ta.value !== text;
|
||||
t.banner = '';
|
||||
S.lastDir = dirname(r.path);
|
||||
renderTabs();
|
||||
updateBanner(t);
|
||||
rememberSession();
|
||||
toast('Saved ' + basename(r.path));
|
||||
var gn = t.kind === 'grammar' ? grammarName(text) : '';
|
||||
if (gn && gn !== stem(basename(r.path))) {
|
||||
toast('ANTLR expects the file to be called ' + gn + '.g4 (the grammar is named ' + gn + ').', 'error');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function saveTab(t) {
|
||||
if (!t.path) return saveAs(t);
|
||||
return doSave(t, t.path, { baseEtag: t.etag }).catch(function (e) {
|
||||
if (e.status === 409 && e.data && e.data.changed) {
|
||||
return ask('File changed on disk', basename(t.path) + ' was modified outside this editor after you opened it. Saving now overwrites those changes.',
|
||||
[{ label: 'Overwrite', value: 'o', primary: true }, { label: 'Cancel', value: null }]).then(function (v) {
|
||||
if (v !== 'o') return false;
|
||||
return doSave(t, t.path, { force: true }).catch(saveError);
|
||||
});
|
||||
}
|
||||
return saveError(e);
|
||||
});
|
||||
}
|
||||
|
||||
function saveAs(t) {
|
||||
var suggest = t.path ? basename(t.path) : (t.kind === 'grammar' ? tabTitle(t) : 'input' + t.num + '.txt');
|
||||
return fileDialog({ title: 'Save as', mode: 'save', dir: t.path ? dirname(t.path) : S.lastDir, name: suggest, okLabel: 'Save' }).then(function (path) {
|
||||
if (!path) return false;
|
||||
return doSave(t, path, { create: true }).catch(function (e) {
|
||||
if (e.status === 409 && e.data && e.data.exists) {
|
||||
return ask('Replace file?', path + ' already exists. Replace it?', [{ label: 'Replace', value: 'r', primary: true }, { label: 'Cancel', value: null }])
|
||||
.then(function (v) { return v === 'r' ? doSave(t, path, {}).catch(saveError) : false; });
|
||||
}
|
||||
return saveError(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveAll() {
|
||||
var chain = Promise.resolve(true);
|
||||
S.tabs.filter(function (t) { return t.dirty; }).forEach(function (t) { chain = chain.then(function () { return saveTab(t); }); });
|
||||
return chain;
|
||||
}
|
||||
|
||||
function reloadTab(t, quiet) {
|
||||
if (!t.path) return Promise.resolve(false);
|
||||
return api('GET', '/api/file?path=' + encodeURIComponent(t.path)).then(function (f) {
|
||||
var caret = Math.min(t.ed.ta.selectionStart, f.text.length);
|
||||
setEditorText(t.ed, f.text);
|
||||
t.ed.ta.setSelectionRange(caret, caret);
|
||||
t.saved = f.text;
|
||||
t.etag = f.etag;
|
||||
t.eol = f.eol;
|
||||
t.bom = f.bom;
|
||||
t.dirty = false;
|
||||
t.banner = '';
|
||||
t.rev++;
|
||||
if (t.kind === 'grammar') S.grev++;
|
||||
renderTabs();
|
||||
updateBanner(t);
|
||||
refreshPanels();
|
||||
toast((quiet ? 'Reloaded ' + basename(t.path) + ' (changed on disk)' : 'Reloaded ' + basename(t.path)));
|
||||
if (t.kind === 'grammar' || t.id === S.lastInput) scheduleRun(300);
|
||||
return true;
|
||||
}).catch(function (e) {
|
||||
toast('Cannot reload ' + t.path + ': ' + e.message, 'error');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function reloadActive() {
|
||||
var t = getTab(S.active);
|
||||
if (!t || !t.path) return;
|
||||
if (!t.dirty) { reloadTab(t, false); return; }
|
||||
ask('Reload from disk', 'Discard your unsaved changes to ' + tabTitle(t) + ' and reload it?', [{ label: 'Reload', value: 'r', primary: true }, { label: 'Cancel', value: null }])
|
||||
.then(function (v) { if (v === 'r') reloadTab(t, false); });
|
||||
}
|
||||
|
||||
function newGrammar() {
|
||||
var t = addTab({ kind: 'grammar', text: NEW_GRAMMAR });
|
||||
activate(t.id);
|
||||
scheduleRun(300);
|
||||
}
|
||||
function newInput() {
|
||||
var t = addTab({ kind: 'input', text: '' });
|
||||
activate(t.id);
|
||||
}
|
||||
|
||||
function updateBanner(t) {
|
||||
var b = t.ed.banner;
|
||||
clear(b);
|
||||
if (!t.banner) { b.hidden = true; return; }
|
||||
b.hidden = false;
|
||||
function btn(label, fn) { var x = el('button', 'btn sm', label); x.type = 'button'; x.onclick = fn; return x; }
|
||||
if (t.banner === 'deleted') {
|
||||
b.appendChild(el('span', '', 'This file was deleted on disk.'));
|
||||
b.appendChild(btn('Keep editing', function () { t.banner = ''; t.etag = ''; updateBanner(t); }));
|
||||
} else {
|
||||
b.appendChild(el('span', '', 'This file changed on disk.'));
|
||||
b.appendChild(btn('Reload', function () { reloadTab(t, false); }));
|
||||
b.appendChild(btn('Keep my version', function () { t.etag = t.diskEtag; t.banner = ''; updateBanner(t); }));
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ external change polling */
|
||||
var polling = false;
|
||||
function pollDisk() {
|
||||
if (polling || document.hidden) return;
|
||||
var paths = S.tabs.filter(function (t) { return t.path; }).map(function (t) { return t.path; });
|
||||
if (!paths.length) return;
|
||||
polling = true;
|
||||
api('POST', '/api/stat', { paths: paths }).then(function (r) {
|
||||
S.tabs.forEach(function (t) {
|
||||
if (!t.path || !(t.path in r.etags)) return;
|
||||
var tag = r.etags[t.path];
|
||||
if (tag === t.etag) return;
|
||||
if (tag === null) {
|
||||
if (t.banner !== 'deleted' && t.etag) { t.banner = 'deleted'; updateBanner(t); }
|
||||
return;
|
||||
}
|
||||
if (!t.dirty) { reloadTab(t, true); return; }
|
||||
if (t.diskEtag !== tag || t.banner !== 'changed') { t.diskEtag = tag; t.banner = 'changed'; updateBanner(t); }
|
||||
});
|
||||
}).catch(function () { /* server gone or busy: try again later */ }).then(function () { polling = false; });
|
||||
}
|
||||
setInterval(pollDisk, 2000);
|
||||
window.addEventListener('focus', pollDisk);
|
||||
window.addEventListener('beforeunload', function (ev) {
|
||||
if (S.tabs.some(function (t) { return t.dirty; })) { ev.preventDefault(); ev.returnValue = ''; }
|
||||
});
|
||||
|
||||
/* ======================================================== open / save dialog */
|
||||
function fileDialog(o) {
|
||||
var cur = o.dir !== undefined && o.dir !== null ? o.dir : (S.lastDir || '');
|
||||
var chosen = '';
|
||||
var entries = [];
|
||||
var crumbs = el('div', 'crumbs');
|
||||
var list = el('ul', 'flist');
|
||||
list.setAttribute('role', 'listbox');
|
||||
var filter = null, nameIn = null, ctxRef = null;
|
||||
|
||||
function load(dir) {
|
||||
api('GET', '/api/dir?path=' + encodeURIComponent(dir)).then(function (d) {
|
||||
cur = d.path;
|
||||
entries = d.entries;
|
||||
chosen = '';
|
||||
drawCrumbs();
|
||||
drawList();
|
||||
}).catch(function (e) {
|
||||
toast(e.message, 'error');
|
||||
if (dir) load('');
|
||||
});
|
||||
}
|
||||
function crumb(label, path) {
|
||||
var b = el('button', '', label);
|
||||
b.type = 'button';
|
||||
b.onclick = function () { load(path); };
|
||||
return b;
|
||||
}
|
||||
function drawCrumbs() {
|
||||
clear(crumbs);
|
||||
crumbs.appendChild(crumb(S.info.rootName || 'workspace', ''));
|
||||
var acc = '';
|
||||
(cur ? cur.split('/') : []).forEach(function (p) {
|
||||
acc = joinPath(acc, p);
|
||||
crumbs.appendChild(el('span', 'muted', '/'));
|
||||
crumbs.appendChild(crumb(p, acc));
|
||||
});
|
||||
}
|
||||
function drawList() {
|
||||
clear(list);
|
||||
var q = filter ? filter.value.toLowerCase() : '';
|
||||
var shown = entries.filter(function (e) { return !q || e.name.toLowerCase().indexOf(q) >= 0; });
|
||||
if (!shown.length) list.appendChild(el('li', 'muted', entries.length ? 'No matches' : 'Empty folder'));
|
||||
shown.forEach(function (e) {
|
||||
var li = el('li');
|
||||
li.setAttribute('role', 'option');
|
||||
li.tabIndex = 0;
|
||||
li.appendChild(el('span', '', (e.dir ? '\u25b8 ' : '') + e.name));
|
||||
if (!e.dir) li.appendChild(el('span', 'sz', human(e.size)));
|
||||
var path = joinPath(cur, e.name);
|
||||
function select() {
|
||||
Array.prototype.forEach.call(list.children, function (c) { c.removeAttribute('aria-selected'); });
|
||||
li.setAttribute('aria-selected', 'true');
|
||||
chosen = path;
|
||||
if (nameIn) nameIn.value = e.name;
|
||||
}
|
||||
li.onclick = function () { if (e.dir) load(path); else select(); };
|
||||
li.ondblclick = function () { if (!e.dir) { select(); ctxRef.close('ok'); } };
|
||||
li.onkeydown = function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); ev.stopPropagation(); if (e.dir) load(path); else { select(); ctxRef.close('ok'); } }
|
||||
else if (ev.key === ' ') { ev.preventDefault(); if (e.dir) load(path); else select(); }
|
||||
else if (ev.key === 'ArrowDown' && li.nextSibling) { ev.preventDefault(); li.nextSibling.focus(); }
|
||||
else if (ev.key === 'ArrowUp' && li.previousSibling) { ev.preventDefault(); li.previousSibling.focus(); }
|
||||
};
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
return modal({
|
||||
title: o.title,
|
||||
build: function (ctx) {
|
||||
ctxRef = ctx;
|
||||
var wrap = el('div');
|
||||
wrap.appendChild(crumbs);
|
||||
if (o.mode === 'open') {
|
||||
filter = el('input');
|
||||
filter.type = 'text';
|
||||
filter.placeholder = 'Filter';
|
||||
filter.setAttribute('aria-label', 'Filter files');
|
||||
filter.setAttribute('data-focus', '1');
|
||||
filter.oninput = drawList;
|
||||
filter.onkeydown = function (ev) { if (ev.key === 'ArrowDown' && list.firstChild) { ev.preventDefault(); list.firstChild.focus(); } };
|
||||
wrap.appendChild(filter);
|
||||
}
|
||||
wrap.appendChild(list);
|
||||
if (o.mode === 'save') {
|
||||
var lab = el('p', 'muted', 'File name');
|
||||
lab.style.margin = '10px 0 4px';
|
||||
wrap.appendChild(lab);
|
||||
nameIn = el('input');
|
||||
nameIn.type = 'text';
|
||||
nameIn.value = o.name || '';
|
||||
nameIn.setAttribute('aria-label', 'File name');
|
||||
nameIn.setAttribute('data-focus', '1');
|
||||
wrap.appendChild(nameIn);
|
||||
setTimeout(function () { nameIn.focus(); nameIn.select(); }, 0);
|
||||
}
|
||||
load(cur);
|
||||
return wrap;
|
||||
},
|
||||
buttons: [
|
||||
{ label: 'Cancel', value: null },
|
||||
{ label: o.okLabel || 'Open', value: 'ok', primary: true, check: function () {
|
||||
if (o.mode === 'open' && !chosen) { toast('Select a file first'); return false; }
|
||||
if (o.mode === 'save') {
|
||||
var n = nameIn.value.trim();
|
||||
if (!n || /[\/\\]/.test(n)) { toast('Enter a file name without slashes'); return false; }
|
||||
}
|
||||
return true;
|
||||
} }
|
||||
]
|
||||
}).then(function (v) {
|
||||
if (v !== 'ok') return null;
|
||||
S.lastDir = cur;
|
||||
return o.mode === 'open' ? chosen : joinPath(cur, nameIn.value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
var t = getTab(S.active);
|
||||
fileDialog({ title: 'Open file', mode: 'open', dir: t && t.path ? dirname(t.path) : S.lastDir, okLabel: 'Open' }).then(function (p) {
|
||||
if (p) openFile(p);
|
||||
});
|
||||
}
|
||||
+676
@@ -0,0 +1,676 @@
|
||||
|
||||
/* ============================================================== preferences */
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function savePrefs() { store.set('prefs', S.prefs); }
|
||||
|
||||
function applyPrefs() {
|
||||
var root = document.documentElement;
|
||||
if (S.prefs.theme === 'auto') root.removeAttribute('data-theme'); else root.setAttribute('data-theme', S.prefs.theme);
|
||||
root.style.setProperty('--fs', S.prefs.fs + 'px');
|
||||
root.style.setProperty('--lh', Math.round(S.prefs.fs * 1.55) + 'px');
|
||||
metricCache = {};
|
||||
$('autorun').checked = !!S.prefs.autorun;
|
||||
}
|
||||
|
||||
function applyLayout() {
|
||||
var d = S.prefs.diagram, o = S.prefs.output;
|
||||
$('p-diagram').hidden = !d;
|
||||
$('p-output').hidden = !o;
|
||||
$('split-h').hidden = !(d && o);
|
||||
$('right').classList.toggle('one', !(d && o));
|
||||
$('main').classList.toggle('no-right', !(d || o));
|
||||
var root = document.documentElement;
|
||||
root.style.setProperty('--leftw', (clamp(S.prefs.lw || 0.5, 0.2, 0.8) * 100) + '%');
|
||||
root.style.setProperty('--toph', (clamp(S.prefs.th || 0.62, 0.15, 0.85) * 100) + '%');
|
||||
setTimeout(function () { if (!V.userMoved) fit(); }, 0);
|
||||
}
|
||||
|
||||
function initSplit(id, vertical) {
|
||||
var sp = $(id);
|
||||
sp.addEventListener('pointerdown', function (ev) {
|
||||
if (ev.button) return;
|
||||
sp.setPointerCapture(ev.pointerId);
|
||||
sp.classList.add('drag');
|
||||
});
|
||||
sp.addEventListener('pointermove', function (ev) {
|
||||
if (!sp.classList.contains('drag')) return;
|
||||
if (vertical) {
|
||||
var r = $('main').getBoundingClientRect();
|
||||
S.prefs.lw = clamp((ev.clientX - r.left - 8) / (r.width - 16), 0.2, 0.8);
|
||||
} else {
|
||||
var r2 = $('right').getBoundingClientRect();
|
||||
S.prefs.th = clamp((ev.clientY - r2.top) / r2.height, 0.15, 0.85);
|
||||
}
|
||||
applyLayout();
|
||||
});
|
||||
var end = function () { if (sp.classList.contains('drag')) { sp.classList.remove('drag'); savePrefs(); } };
|
||||
sp.addEventListener('pointerup', end);
|
||||
sp.addEventListener('pointercancel', end);
|
||||
sp.addEventListener('keydown', function (ev) {
|
||||
var step = ev.key === (vertical ? 'ArrowRight' : 'ArrowDown') ? 0.02 : ev.key === (vertical ? 'ArrowLeft' : 'ArrowUp') ? -0.02 : 0;
|
||||
if (!step) return;
|
||||
ev.preventDefault();
|
||||
if (vertical) S.prefs.lw = clamp((S.prefs.lw || 0.5) + step, 0.2, 0.8); else S.prefs.th = clamp((S.prefs.th || 0.62) + step, 0.15, 0.85);
|
||||
applyLayout();
|
||||
savePrefs();
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================== results */
|
||||
function currentInput() { return getTab(S.lastInput); }
|
||||
function currentResult() {
|
||||
var it = currentInput();
|
||||
return it ? (S.results[it.id] || null) : null;
|
||||
}
|
||||
function isStale() {
|
||||
var it = currentInput(), r = currentResult();
|
||||
if (!it || !r) return false;
|
||||
return r.rev !== it.rev || r.grev !== S.grev || r.main !== S.lastGrammar || !!(S.gres && !S.gres.ok) || !!S.crash;
|
||||
}
|
||||
|
||||
function parseToolLog(log) {
|
||||
var out = [];
|
||||
String(log || '').split(/\r?\n/).forEach(function (line) {
|
||||
var m = /^(error|warning)\((\d+)\): (.*?):(\d+):(\d+): (.*)$/.exec(line);
|
||||
if (m) { out.push({ sev: m[1], code: m[2], file: basename(m[3].replace(/\\/g, '/')), line: +m[4], col: +m[5], msg: m[6] }); return; }
|
||||
var m2 = /^(error|warning)\((\d+)\): (.*)$/.exec(line);
|
||||
if (m2) out.push({ sev: m2[1], code: m2[2], file: '', line: 0, col: 0, msg: m2[3] });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function setRange(kind, s, e) {
|
||||
var it = currentInput();
|
||||
if (!it) return;
|
||||
var r = S.results[it.id];
|
||||
if (s < 0 || !r || r.rev !== it.rev) { setEditorRange(it.ed, kind, null); return; }
|
||||
var m = cpMap(it.ed.ta.value);
|
||||
setEditorRange(it.ed, kind, [m(s), m(e + 1)]);
|
||||
}
|
||||
function togglePin(s, e) {
|
||||
var it = currentInput();
|
||||
if (!it || s < 0) return;
|
||||
if (S.active !== it.id) activate(it.id, false);
|
||||
var cur = it.ed.pin, m = cpMap(it.ed.ta.value), a = m(s), b = m(e + 1);
|
||||
if (cur && cur[0] === a && cur[1] === b) { setEditorRange(it.ed, 'pin', null); return; }
|
||||
setEditorRange(it.ed, 'pin', [a, b]);
|
||||
revealUnit(it.ed, a);
|
||||
}
|
||||
|
||||
function applyMarks() {
|
||||
// input editor: syntax problems of the latest run
|
||||
S.tabs.forEach(function (t) {
|
||||
if (t.kind === 'input') {
|
||||
var r = S.results[t.id], list = [];
|
||||
if (r && r.rev === t.rev) {
|
||||
var m = cpMap(t.ed.ta.value);
|
||||
r.data.diagnostics.forEach(function (d) {
|
||||
if (d.s >= 0) list.push({ s: m(d.s), e: m(d.e + 1), bit: d.sev === 'warning' ? 2 : 1 });
|
||||
});
|
||||
}
|
||||
t.ed.diag = list;
|
||||
paintEditor(t.ed);
|
||||
} else {
|
||||
var g = S.gres, marks = [];
|
||||
if (g && g.snap && g.snap.revs[t.id] === t.rev) {
|
||||
parseToolLog(g.log).forEach(function (p) {
|
||||
if (p.file !== tabTitle(t) || p.line < 1) return;
|
||||
var s = unitAt(t.ed, p.line, p.col), e = s, v = t.ed.ta.value;
|
||||
while (e < v.length && isIdChar(v.charAt(e))) e++;
|
||||
if (e === s) e = Math.min(v.length, s + 1);
|
||||
marks.push({ s: s, e: e, bit: p.sev === 'warning' ? 2 : 1 });
|
||||
});
|
||||
}
|
||||
t.ed.diag = marks;
|
||||
paintEditor(t.ed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function diagramNote() {
|
||||
if (!getTab(S.lastGrammar)) return 'Open or create a grammar (File menu) to see a diagram.';
|
||||
if (!currentInput()) return 'Open or create an input file (File menu). Its parse tree appears here.';
|
||||
if (S.gres && !S.gres.ok) return 'The grammar has errors. See the Output panel.';
|
||||
if (S.crash) return S.crash.message;
|
||||
var r = currentResult();
|
||||
if (r && r.data.noDiagram) return r.data.noDiagram;
|
||||
if (S.running) return 'Running...';
|
||||
return 'Press Run to parse this input.';
|
||||
}
|
||||
|
||||
var shownData = null; // result data currently drawn in the heavy views
|
||||
|
||||
function refreshPanels() {
|
||||
var it = currentInput(), gt = getTab(S.lastGrammar), r = currentResult();
|
||||
$('dtitle').textContent = 'Diagram' + (it ? ' (' + tabTitle(it) + ')' : '');
|
||||
$('otitle').textContent = 'Output' + (gt ? ' (' + stem(tabTitle(gt)) + ')' : '');
|
||||
|
||||
var chip = $('chip'), kind = null, text = '';
|
||||
if (S.gres && !S.gres.ok) { kind = 'fail'; text = 'Grammar has errors'; }
|
||||
else if (S.crash) { kind = 'fail'; text = S.crash.kind === 'timeout' ? 'Run timed out' : S.crash.kind === 'crashed' ? 'Run crashed' : 'Run failed'; }
|
||||
else if (r) { kind = r.data.status.kind; text = r.data.status.text; }
|
||||
chip.hidden = !kind;
|
||||
if (kind) {
|
||||
chip.className = 'chip ' + kind + (isStale() ? ' stale' : '');
|
||||
chip.textContent = text;
|
||||
chip.title = S.crash ? S.crash.message : (r && r.data.status.detail ? r.data.status.detail : '');
|
||||
}
|
||||
$('dgm').classList.toggle('stale', isStale());
|
||||
|
||||
var data = r ? r.data : null;
|
||||
if (data !== shownData) { shownData = data; paintResultViews(data); }
|
||||
else if (!data) paintResultViews(null);
|
||||
|
||||
paintProblems();
|
||||
paintLog();
|
||||
paintStartRule();
|
||||
var busy = $('run');
|
||||
busy.setAttribute('data-busy', S.running ? 'true' : 'false');
|
||||
busy.textContent = S.running ? 'Running' : 'Run';
|
||||
busy.disabled = !getTab(S.lastGrammar);
|
||||
$('o-problems').setAttribute('aria-selected', S.prefs.out === 'problems' ? 'true' : 'false');
|
||||
$('o-log').setAttribute('aria-selected', S.prefs.out === 'log' ? 'true' : 'false');
|
||||
$('probview').hidden = S.prefs.out !== 'problems';
|
||||
$('logview').hidden = S.prefs.out !== 'log';
|
||||
}
|
||||
|
||||
function paintResultViews(data) {
|
||||
paintDiagram(data);
|
||||
var tree = $('treeout');
|
||||
tree.textContent = data && data.tree ? (V.pretty ? data.treePretty : data.tree) : diagramNote();
|
||||
paintTokens(data);
|
||||
showView(S.prefs.view);
|
||||
}
|
||||
|
||||
function showView(v) {
|
||||
S.prefs.view = v;
|
||||
['diagram', 'tree', 'tokens'].forEach(function (id) {
|
||||
$('v-' + id).setAttribute('aria-selected', id === v ? 'true' : 'false');
|
||||
});
|
||||
$('dgm').hidden = v !== 'diagram';
|
||||
$('treeview').hidden = v !== 'tree';
|
||||
$('tokview').hidden = v !== 'tokens';
|
||||
$('dtools').hidden = v !== 'diagram';
|
||||
$('ttools').hidden = v !== 'tree';
|
||||
$('b-pretty').setAttribute('aria-pressed', V.pretty ? 'true' : 'false');
|
||||
$('b-flat').setAttribute('aria-pressed', V.pretty ? 'false' : 'true');
|
||||
if (v === 'diagram' && V.needFit) fit();
|
||||
}
|
||||
|
||||
function paintStartRule() {
|
||||
var wrap = $('startwrap'), sel = $('startrule'), g = S.gres;
|
||||
var show = !!(g && g.ok && g.hasParser && g.rules.length);
|
||||
wrap.hidden = !show;
|
||||
if (!show) return;
|
||||
var sig = g.name + '|' + g.rules.join(',');
|
||||
var chosen = S.start[g.name] || g.rules[0];
|
||||
if (sel.getAttribute('data-sig') !== sig) {
|
||||
clear(sel);
|
||||
g.rules.forEach(function (n) { var o = el('option', '', n); o.value = n; sel.appendChild(o); });
|
||||
sel.setAttribute('data-sig', sig);
|
||||
}
|
||||
sel.value = g.rules.indexOf(chosen) >= 0 ? chosen : g.rules[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- problems */
|
||||
function jumpToRange(tab, s, e) {
|
||||
if (!tab) return;
|
||||
activate(tab.id, true);
|
||||
tab.ed.ta.setSelectionRange(s, e);
|
||||
revealUnit(tab.ed, s);
|
||||
}
|
||||
|
||||
function paintProblems() {
|
||||
var list = $('problist');
|
||||
clear(list);
|
||||
var items = [], g = S.gres, r = currentResult(), it = currentInput();
|
||||
if (g) {
|
||||
var parsed = parseToolLog(g.log);
|
||||
parsed.forEach(function (p) {
|
||||
items.push({ src: 'grammar', sev: p.sev, loc: p.line ? p.file + ' ' + p.line + ':' + p.col : p.file, msg: p.msg, go: function () {
|
||||
var t = null;
|
||||
S.tabs.forEach(function (x) { if (x.kind === 'grammar' && tabTitle(x) === p.file) t = x; });
|
||||
t = t || getTab(S.lastGrammar);
|
||||
if (t && p.line) { var s = unitAt(t.ed, p.line, p.col); jumpToRange(t, s, s); }
|
||||
} });
|
||||
});
|
||||
if (!g.ok && !parsed.some(function (p) { return p.sev === 'error'; })) {
|
||||
items.push({ src: 'grammar', sev: 'error', loc: '', msg: g.error || 'The grammar could not be compiled.', go: null });
|
||||
}
|
||||
}
|
||||
if (S.crash && !(g && !g.ok)) {
|
||||
items.push({ src: 'runtime', sev: 'error', loc: S.crash.phase ? S.crash.phase : '', msg: S.crash.message, go: null });
|
||||
}
|
||||
if (r && it && !(g && !g.ok) && !S.crash) {
|
||||
r.data.diagnostics.forEach(function (d) {
|
||||
items.push({ src: d.src, sev: d.sev, loc: d.line ? d.line + ':' + d.col : '', msg: d.msg, go: function () {
|
||||
if (r.rev !== it.rev) return;
|
||||
var m = cpMap(it.ed.ta.value);
|
||||
if (d.s >= 0) { jumpToRange(it, m(d.s), m(d.e + 1)); setEditorRange(it.ed, 'pin', [m(d.s), m(d.e + 1)]); }
|
||||
else { var u = unitAt(it.ed, d.line, d.col); jumpToRange(it, u, u); }
|
||||
} });
|
||||
});
|
||||
}
|
||||
$('noprob').hidden = items.length > 0;
|
||||
var errs = 0, warns = 0;
|
||||
items.forEach(function (p) {
|
||||
if (p.sev === 'error') errs++; else warns++;
|
||||
var li = el('li', 'prob ' + p.sev);
|
||||
li.tabIndex = 0;
|
||||
li.appendChild(el('span', 'sev', p.sev));
|
||||
li.appendChild(el('span', 'loc', p.loc));
|
||||
li.appendChild(el('span', 'msg', p.msg));
|
||||
li.appendChild(el('span', 'src', p.src));
|
||||
if (p.go) {
|
||||
li.onclick = function () {
|
||||
Array.prototype.forEach.call(list.children, function (c) { c.removeAttribute('aria-current'); });
|
||||
li.setAttribute('aria-current', 'true');
|
||||
p.go();
|
||||
};
|
||||
li.onkeydown = function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); li.click(); } };
|
||||
}
|
||||
list.appendChild(li);
|
||||
});
|
||||
var badge = $('badge');
|
||||
badge.hidden = !items.length;
|
||||
badge.className = 'badge' + (errs ? '' : ' warn');
|
||||
badge.textContent = String(errs || warns);
|
||||
}
|
||||
|
||||
function paintLog() {
|
||||
var g = S.gres, text = '';
|
||||
if (g) {
|
||||
text = g.log && g.log.trim() ? g.log : (g.ok ? 'The ANTLR tool reported nothing.' : (g.error || ''));
|
||||
if (g.ok) text += (text.slice(-1) === '\n' ? '' : '\n') + '\nGrammar files: ' + (g.entry || []).join(', ') + (g.cached ? ' (unchanged, reused)' : ' (compiled in ' + g.compileMs + ' ms)');
|
||||
} else {
|
||||
text = 'Nothing has run yet.';
|
||||
}
|
||||
$('logout').textContent = text;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- tokens */
|
||||
function paintTokens(data) {
|
||||
var wrap = $('tokview');
|
||||
clear(wrap);
|
||||
var toks = data ? data.tokens : [];
|
||||
if (!toks.length) { wrap.appendChild(el('p', 'note', data ? 'No tokens.' : diagramNote())); return; }
|
||||
var table = el('table'), head = el('tr');
|
||||
['#', 'Text', 'Type', 'Channel', 'Line:col'].forEach(function (h) { head.appendChild(el('th', '', h)); });
|
||||
var th = el('thead');
|
||||
th.appendChild(head);
|
||||
table.appendChild(th);
|
||||
var body = el('tbody');
|
||||
toks.slice(0, 5000).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 () { setRange('hover', t.s, t.e); };
|
||||
tr.onmouseleave = function () { setRange('hover', -1, -1); };
|
||||
tr.onclick = function () { togglePin(t.s, t.e); };
|
||||
body.appendChild(tr);
|
||||
});
|
||||
table.appendChild(body);
|
||||
wrap.appendChild(table);
|
||||
if (toks.length > 5000) wrap.appendChild(el('p', 'note', 'Showing the first 5000 of ' + toks.length + ' tokens.'));
|
||||
}
|
||||
|
||||
/* ================================================================== diagram */
|
||||
var V = { z: 1, x: 0, y: 0, w: 0, h: 0, key: '', owner: '', needFit: true, userMoved: false, pretty: true };
|
||||
|
||||
function applyView() { $('stage').style.transform = 'translate(' + V.x + 'px,' + V.y + 'px) scale(' + V.z + ')'; }
|
||||
|
||||
function fit() {
|
||||
var c = $('dgm'), cw = c.clientWidth, ch = c.clientHeight;
|
||||
if (!V.w || !cw || !ch) return;
|
||||
var z = Math.max(0.05, Math.min(1.35, (cw - 40) / V.w, (ch - 40) / V.h));
|
||||
V.z = z;
|
||||
V.x = (cw - V.w * z) / 2;
|
||||
V.y = Math.max(20, (ch - V.h * z) / 2);
|
||||
V.needFit = false;
|
||||
V.userMoved = false;
|
||||
applyView();
|
||||
}
|
||||
|
||||
function zoomAt(cx, cy, f) {
|
||||
var z = clamp(V.z * f, 0.05, 8), k = z / V.z;
|
||||
V.x = cx - (cx - V.x) * k;
|
||||
V.y = cy - (cy - V.y) * k;
|
||||
V.z = z;
|
||||
V.userMoved = true;
|
||||
applyView();
|
||||
}
|
||||
function zoomCentre(f) { var c = $('dgm'); zoomAt(c.clientWidth / 2, c.clientHeight / 2, f); }
|
||||
|
||||
function paintDiagram(data) {
|
||||
var stage = $('stage'), note = $('dnote'), box = $('dgm');
|
||||
var svg = data && data.svg ? data.svg : '';
|
||||
var owner = S.lastInput || '';
|
||||
if (owner !== V.owner) { V.owner = owner; V.needFit = true; V.userMoved = false; V.key = '\u0000'; }
|
||||
if (svg && svg === V.key) return;
|
||||
V.key = svg;
|
||||
clear(stage);
|
||||
if (!svg) {
|
||||
note.textContent = diagramNote();
|
||||
note.hidden = false;
|
||||
box.classList.add('empty');
|
||||
V.w = V.h = 0;
|
||||
V.needFit = true;
|
||||
return;
|
||||
}
|
||||
note.hidden = true;
|
||||
box.classList.remove('empty');
|
||||
stage.innerHTML = svg;
|
||||
var vb = stage.querySelector('svg').getAttribute('viewBox').split(' ');
|
||||
V.w = parseFloat(vb[2]);
|
||||
V.h = parseFloat(vb[3]);
|
||||
if (V.needFit || !V.userMoved) fit(); else applyView();
|
||||
}
|
||||
|
||||
function svgMarkup(data) {
|
||||
var NS = 'http://www.w3.org/2000/svg';
|
||||
var c = $('stage').querySelector('svg').cloneNode(true);
|
||||
var st = document.createElementNS(NS, 'style');
|
||||
st.textContent = data.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 '<?xml version="1.0" encoding="UTF-8"?>\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 exportName(ext) {
|
||||
var it = currentInput();
|
||||
return (it ? stem(tabTitle(it)) : 'tree') + '-tree.' + ext;
|
||||
}
|
||||
|
||||
function savePng(data) {
|
||||
var scale = Math.min(2, 8000 / Math.max(V.w, V.h));
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
var c = document.createElement('canvas');
|
||||
c.width = Math.max(1, Math.round(V.w * scale));
|
||||
c.height = Math.max(1, Math.round(V.h * scale));
|
||||
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
||||
c.toBlob(function (b) { if (b) download(exportName('png'), b); }, 'image/png');
|
||||
};
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgMarkup(data));
|
||||
}
|
||||
|
||||
function initDiagram() {
|
||||
var box = $('dgm'), stage = $('stage'), drag = null, moved = false;
|
||||
box.addEventListener('wheel', function (ev) {
|
||||
if (!V.w) return;
|
||||
ev.preventDefault();
|
||||
var r = box.getBoundingClientRect(), unit = ev.deltaMode === 1 ? 16 : 1;
|
||||
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 || !V.w) return;
|
||||
drag = { x: ev.clientX, y: ev.clientY, vx: V.x, vy: V.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;
|
||||
V.x = drag.vx + dx;
|
||||
V.y = drag.vy + dy;
|
||||
V.userMoved = true;
|
||||
applyView();
|
||||
});
|
||||
var end = function () { drag = null; box.classList.remove('drag'); };
|
||||
box.addEventListener('pointerup', end);
|
||||
box.addEventListener('pointercancel', end);
|
||||
box.addEventListener('dblclick', fit);
|
||||
stage.addEventListener('click', function (ev) {
|
||||
if (moved) return;
|
||||
var g = ev.target.closest('.al-node');
|
||||
if (g && g.hasAttribute('data-s')) togglePin(+g.getAttribute('data-s'), +g.getAttribute('data-e'));
|
||||
});
|
||||
stage.addEventListener('mouseover', function (ev) {
|
||||
var g = ev.target.closest('.al-node');
|
||||
if (g && g.hasAttribute('data-s')) setRange('hover', +g.getAttribute('data-s'), +g.getAttribute('data-e'));
|
||||
});
|
||||
stage.addEventListener('mouseout', function (ev) { if (ev.target.closest('.al-node')) setRange('hover', -1, -1); });
|
||||
box.addEventListener('keydown', function (ev) {
|
||||
var k = ev.key;
|
||||
if (k === '+' || k === '=') zoomCentre(1.25);
|
||||
else if (k === '-') zoomCentre(0.8);
|
||||
else if (k === '0') fit();
|
||||
else if (k === 'ArrowLeft') { V.x += 60; V.userMoved = true; applyView(); }
|
||||
else if (k === 'ArrowRight') { V.x -= 60; V.userMoved = true; applyView(); }
|
||||
else if (k === 'ArrowUp') { V.y += 60; V.userMoved = true; applyView(); }
|
||||
else if (k === 'ArrowDown') { V.y -= 60; V.userMoved = true; applyView(); }
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
});
|
||||
$('zin').onclick = function () { zoomCentre(1.25); };
|
||||
$('zout').onclick = function () { zoomCentre(0.8); };
|
||||
$('zfit').onclick = fit;
|
||||
$('psvg').onclick = function () { var r = currentResult(); if (r && V.w) download(exportName('svg'), new Blob([svgMarkup(r.data)], { type: 'image/svg+xml' })); };
|
||||
$('ppng').onclick = function () { var r = currentResult(); if (r && V.w) savePng(r.data); };
|
||||
if (window.ResizeObserver) new ResizeObserver(function () { if (!V.userMoved && V.w) fit(); }).observe(box);
|
||||
|
||||
['diagram', 'tree', 'tokens'].forEach(function (id) {
|
||||
$('v-' + id).onclick = function () { showView(id); savePrefs(); };
|
||||
});
|
||||
$('b-pretty').onclick = function () { V.pretty = true; shownData = null; refreshPanels(); };
|
||||
$('b-flat').onclick = function () { V.pretty = false; shownData = null; refreshPanels(); };
|
||||
$('b-copy').onclick = function () {
|
||||
var text = $('treeout').textContent, b = $('b-copy');
|
||||
var done = function () { var old = b.textContent; b.textContent = 'Copied'; setTimeout(function () { b.textContent = old; }, 1200); };
|
||||
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, function () {});
|
||||
else {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); done(); } catch (e) { /* ignore */ }
|
||||
ta.remove();
|
||||
}
|
||||
};
|
||||
$('o-problems').onclick = function () { S.prefs.out = 'problems'; savePrefs(); refreshPanels(); };
|
||||
$('o-log').onclick = function () { S.prefs.out = 'log'; savePrefs(); refreshPanels(); };
|
||||
}
|
||||
|
||||
/* ====================================================================== run */
|
||||
var runTimer = null;
|
||||
function scheduleRun(ms) {
|
||||
clearTimeout(runTimer);
|
||||
if (!S.prefs.autorun) return;
|
||||
runTimer = setTimeout(runNow, ms);
|
||||
}
|
||||
|
||||
function runNow() {
|
||||
clearTimeout(runTimer);
|
||||
ensureLast();
|
||||
var main = getTab(S.lastGrammar), inp = getTab(S.lastInput);
|
||||
if (!main) { S.gres = null; refreshPanels(); return; }
|
||||
if (S.running) { S.queued = true; return; }
|
||||
S.running = true;
|
||||
var snap = { revs: {}, inputRev: inp ? inp.rev : 0, grev: S.grev, inputId: inp ? inp.id : null };
|
||||
var grammars = S.tabs.filter(function (t) { return t.kind === 'grammar'; });
|
||||
grammars.forEach(function (t) { snap.revs[t.id] = t.rev; });
|
||||
var name = stem(tabTitle(main));
|
||||
var body = {
|
||||
grammars: grammars.map(function (t) { return { name: tabTitle(t), path: t.path, text: t.ed.ta.value }; }),
|
||||
main: tabTitle(main),
|
||||
start: S.start[name] || ''
|
||||
};
|
||||
if (inp) body.input = { name: tabTitle(inp), text: inp.ed.ta.value };
|
||||
refreshPanels();
|
||||
|
||||
api('POST', '/api/run', body).then(function (r) {
|
||||
var g = r.grammar;
|
||||
S.gres = { ok: !!g.ok, name: g.name, log: g.log || '', error: g.error || '', rules: g.rules || [], hasParser: !!g.hasParser,
|
||||
entry: g.entry || [], cached: !!g.cached, compileMs: g.compileMs, snap: snap };
|
||||
if (r.result && inp) S.results[inp.id] = { data: r.result, rev: snap.inputRev, grev: snap.grev, main: main.id };
|
||||
var wasCrash = !!S.crash;
|
||||
S.crash = r.crash || null;
|
||||
if (S.crash && !wasCrash) toast(S.crash.kind === 'timeout' ? 'The run took too long and was stopped.' : 'The ANTLR runtime failed on this input. See Problems.', 'error');
|
||||
if (r.runError) toast(r.runError, 'error');
|
||||
if (!g.ok && S.prefs.out !== 'problems') { S.prefs.out = 'problems'; }
|
||||
}).catch(function (e) {
|
||||
S.gres = { ok: false, name: name, log: '', error: e.message, rules: [], hasParser: false, entry: [], cached: false, compileMs: 0, snap: snap };
|
||||
toast('Run failed: ' + e.message, 'error');
|
||||
}).then(function () {
|
||||
S.running = false;
|
||||
shownData = null;
|
||||
applyMarks();
|
||||
refreshPanels();
|
||||
if (S.queued) { S.queued = false; runNow(); }
|
||||
});
|
||||
}
|
||||
|
||||
/* =================================================================== wiring */
|
||||
function withEditor(fn) {
|
||||
var t = getTab(S.active);
|
||||
if (t) fn(t);
|
||||
}
|
||||
function changeFont(delta) {
|
||||
S.prefs.fs = delta === 0 ? 13 : clamp(S.prefs.fs + delta, 10, 24);
|
||||
applyPrefs();
|
||||
savePrefs();
|
||||
}
|
||||
function setPref(key, value) {
|
||||
S.prefs[key] = value;
|
||||
applyPrefs();
|
||||
applyLayout();
|
||||
savePrefs();
|
||||
}
|
||||
|
||||
function buildMenus() {
|
||||
var hasTab = function () { return !!getTab(S.active); };
|
||||
defineMenus([
|
||||
{ label: 'File', items: function () {
|
||||
var t = getTab(S.active);
|
||||
return [
|
||||
{ label: 'New grammar', action: newGrammar },
|
||||
{ label: 'New input', action: newInput },
|
||||
{ sep: true },
|
||||
{ label: 'Open\u2026', key: 'Ctrl+O', action: openDialog },
|
||||
{ sep: true },
|
||||
{ label: 'Save', key: 'Ctrl+S', disabled: !t, action: function () { withEditor(saveTab); } },
|
||||
{ label: 'Save as\u2026', key: 'Ctrl+Shift+S', disabled: !t, action: function () { withEditor(saveAs); } },
|
||||
{ label: 'Save all', disabled: !S.tabs.some(function (x) { return x.dirty; }), action: saveAll },
|
||||
{ sep: true },
|
||||
{ label: 'Reload from disk', disabled: !(t && t.path), action: reloadActive },
|
||||
{ label: 'Close tab', key: 'Alt+W', disabled: !t, action: function () { withEditor(function (x) { closeTab(x.id); }); } }
|
||||
];
|
||||
} },
|
||||
{ label: 'Edit', items: function () {
|
||||
return [
|
||||
{ label: 'Undo', key: 'Ctrl+Z', disabled: !hasTab(), action: function () { withEditor(function (t) { t.ed.ta.focus(); document.execCommand('undo'); }); } },
|
||||
{ label: 'Redo', key: 'Ctrl+Y', disabled: !hasTab(), action: function () { withEditor(function (t) { t.ed.ta.focus(); document.execCommand('redo'); }); } },
|
||||
{ sep: true },
|
||||
{ label: 'Find and replace', key: 'Ctrl+F', disabled: !hasTab(), action: function () { withEditor(function (t) { openFind(t.ed); }); } },
|
||||
{ label: 'Toggle comment', key: 'Ctrl+/', disabled: !(getTab(S.active) && getTab(S.active).kind === 'grammar'), action: function () { withEditor(function (t) { t.ed.ta.focus(); toggleComment(t.ed); }); } }
|
||||
];
|
||||
} },
|
||||
{ label: 'View', items: function () {
|
||||
return [
|
||||
{ label: 'Diagram panel', checked: S.prefs.diagram, action: function () { setPref('diagram', !S.prefs.diagram); } },
|
||||
{ label: 'Output panel', checked: S.prefs.output, action: function () { setPref('output', !S.prefs.output); } },
|
||||
{ sep: true },
|
||||
{ label: 'Auto-run', checked: S.prefs.autorun, action: function () { setPref('autorun', !S.prefs.autorun); if (S.prefs.autorun) scheduleRun(100); } },
|
||||
{ sep: true },
|
||||
{ label: 'Theme: match system', checked: S.prefs.theme === 'auto', action: function () { setPref('theme', 'auto'); } },
|
||||
{ label: 'Theme: light', checked: S.prefs.theme === 'light', action: function () { setPref('theme', 'light'); } },
|
||||
{ label: 'Theme: dark', checked: S.prefs.theme === 'dark', action: function () { setPref('theme', 'dark'); } },
|
||||
{ sep: true },
|
||||
{ label: 'Larger text', action: function () { changeFont(1); } },
|
||||
{ label: 'Smaller text', action: function () { changeFont(-1); } },
|
||||
{ label: 'Reset text size', action: function () { changeFont(0); } }
|
||||
];
|
||||
} }
|
||||
]);
|
||||
}
|
||||
|
||||
function initShortcuts() {
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if ($('modal-root').firstChild) return;
|
||||
var mod = ev.ctrlKey || ev.metaKey, k = ev.key;
|
||||
if (mod && k === 'Enter') { ev.preventDefault(); runNow(); }
|
||||
else if (mod && (k === 's' || k === 'S')) { ev.preventDefault(); withEditor(ev.shiftKey ? saveAs : saveTab); }
|
||||
else if (mod && (k === 'o' || k === 'O')) { ev.preventDefault(); openDialog(); }
|
||||
else if (ev.altKey && (k === 'w' || k === 'W')) { ev.preventDefault(); withEditor(function (t) { closeTab(t.id); }); }
|
||||
else if (k === 'Escape') closeMenu(true);
|
||||
});
|
||||
}
|
||||
|
||||
function fatal(e) {
|
||||
var m = $('main');
|
||||
clear(m);
|
||||
m.style.display = 'block';
|
||||
var box = el('div', 'note');
|
||||
box.appendChild(el('h2', '', 'Cannot reach antlrlab'));
|
||||
box.appendChild(el('p', '', (e && e.message ? e.message + '. ' : '') + 'The server may have stopped, or this page was opened without its token. Restart antlrlab and open the address it prints.'));
|
||||
m.appendChild(box);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
var saved = store.get('prefs', null);
|
||||
if (saved) Object.keys(saved).forEach(function (k) { S.prefs[k] = saved[k]; });
|
||||
buildMenus();
|
||||
applyPrefs();
|
||||
applyLayout();
|
||||
initDiagram();
|
||||
initSplit('split-v', true);
|
||||
initSplit('split-h', false);
|
||||
initShortcuts();
|
||||
$('run').onclick = runNow;
|
||||
$('autorun').onchange = function () { setPref('autorun', this.checked); if (S.prefs.autorun) scheduleRun(100); };
|
||||
$('startrule').onchange = function () {
|
||||
var g = S.gres;
|
||||
if (g) S.start[g.name] = this.value;
|
||||
runNow();
|
||||
};
|
||||
$('x-diagram').onclick = function () { setPref('diagram', false); };
|
||||
$('x-output').onclick = function () { setPref('output', false); };
|
||||
$('w-open').onclick = openDialog;
|
||||
$('w-grammar').onclick = newGrammar;
|
||||
$('w-input').onclick = newInput;
|
||||
showView(S.prefs.view || 'diagram');
|
||||
refreshPanels();
|
||||
|
||||
api('GET', '/api/info').then(function (info) {
|
||||
S.info = info;
|
||||
$('rootname').textContent = info.rootName;
|
||||
$('rootname').title = info.root;
|
||||
if (!info.tool.found) toast(info.tool.error, 'error');
|
||||
var session = store.get('session:' + info.root, null);
|
||||
var fromCli = info.open.length > 0;
|
||||
var list = fromCli ? info.open.map(function (p) { return { path: p }; }) : (session ? session.open : []);
|
||||
var chain = Promise.resolve();
|
||||
list.forEach(function (f) { chain = chain.then(function () { return openFile(f.path, { noFocus: true, kind: fromCli ? undefined : f.kind }); }); });
|
||||
return chain.then(function () {
|
||||
if (!S.tabs.length) { showEmpty(); return; }
|
||||
var pick = null;
|
||||
if (!fromCli && session && session.active) S.tabs.forEach(function (t) { if (t.path === session.active) pick = t; });
|
||||
pick = pick || firstOfKind('input') || S.tabs[0];
|
||||
activate(pick.id, true);
|
||||
if (firstOfKind('grammar')) { S.lastGrammar = firstOfKind('grammar').id; }
|
||||
scheduleRun(50);
|
||||
});
|
||||
}).catch(fatal);
|
||||
}
|
||||
|
||||
boot();
|
||||
})();
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
: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}
|
||||
.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}}
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>antlrlab</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2317476b'/%3E%3Cpath d='M8 3.5v3.5M4 12.5V9.5h8v3M8 7v2.5' stroke='%23fff' fill='none' stroke-width='1.4'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="menubar" role="menubar" aria-label="Main menu"></header>
|
||||
<main id="main">
|
||||
<section id="left" class="pane" aria-label="Editor">
|
||||
<div id="tabs" role="tablist" aria-label="Open files"></div>
|
||||
<div id="toolbar">
|
||||
<label class="tb-item" id="startwrap" hidden>Start rule
|
||||
<select id="startrule" aria-label="Start rule"></select>
|
||||
</label>
|
||||
<label class="tb-item"><input type="checkbox" id="autorun"> Auto-run</label>
|
||||
<span class="spacer"></span>
|
||||
<span id="pos" class="muted"></span>
|
||||
<button id="run" class="runbtn" type="button" title="Run (Ctrl+Enter)">Run</button>
|
||||
</div>
|
||||
<div id="editors">
|
||||
<div id="welcome" hidden>
|
||||
<h2>No file open</h2>
|
||||
<p>Open a grammar and an input file from disk, or start with new ones.</p>
|
||||
<div class="wbtns">
|
||||
<button type="button" class="btn" id="w-open">Open…</button>
|
||||
<button type="button" class="btn" id="w-grammar">New grammar</button>
|
||||
<button type="button" class="btn" id="w-input">New input</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="split-v" class="split split-v" role="separator" aria-orientation="vertical" aria-label="Resize editor and results" tabindex="0"></div>
|
||||
<section id="right">
|
||||
<section id="p-diagram" class="pane panel" aria-label="Diagram">
|
||||
<div class="ptitle"><span id="dtitle">Diagram</span><span id="chip" class="chip" hidden></span><span class="spacer"></span><button class="x" id="x-diagram" type="button" aria-label="Close diagram panel">×</button></div>
|
||||
<div class="psub">
|
||||
<div class="seg" role="tablist" aria-label="Result view">
|
||||
<button type="button" role="tab" id="v-diagram">Diagram</button>
|
||||
<button type="button" role="tab" id="v-tree">Tree</button>
|
||||
<button type="button" role="tab" id="v-tokens">Tokens</button>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<span id="dtools" class="tools">
|
||||
<button class="btn sm" id="zin" type="button" aria-label="Zoom in">+</button>
|
||||
<button class="btn sm" id="zout" type="button" aria-label="Zoom out">−</button>
|
||||
<button class="btn sm" id="zfit" type="button">Fit</button>
|
||||
<button class="btn sm" id="psvg" type="button">SVG</button>
|
||||
<button class="btn sm" id="ppng" type="button">PNG</button>
|
||||
</span>
|
||||
<span id="ttools" class="tools" hidden>
|
||||
<button class="btn sm" id="b-pretty" type="button">Indented</button>
|
||||
<button class="btn sm" id="b-flat" type="button">One line</button>
|
||||
<button class="btn sm" id="b-copy" type="button">Copy</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="dgm" class="view" tabindex="0" aria-label="Parse tree diagram. Drag to pan, scroll to zoom.">
|
||||
<div id="stage"></div>
|
||||
<p id="dnote" class="note" hidden></p>
|
||||
</div>
|
||||
<div id="treeview" class="view scroll" hidden><pre id="treeout" class="out"></pre></div>
|
||||
<div id="tokview" class="view scroll" hidden></div>
|
||||
</section>
|
||||
<div id="split-h" class="split split-h" role="separator" aria-orientation="horizontal" aria-label="Resize diagram and output" tabindex="0"></div>
|
||||
<section id="p-output" class="pane panel" aria-label="Output">
|
||||
<div class="ptitle"><span id="otitle">Output</span><span class="spacer"></span><button class="x" id="x-output" type="button" aria-label="Close output panel">×</button></div>
|
||||
<div class="psub">
|
||||
<div class="seg" role="tablist" aria-label="Output view">
|
||||
<button type="button" role="tab" id="o-problems">Problems<span class="badge" id="badge" hidden></span></button>
|
||||
<button type="button" role="tab" id="o-log">ANTLR tool</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="probview" class="view scroll flush"><p id="noprob" class="note">No problems.</p><ul id="problist" class="probs"></ul></div>
|
||||
<div id="logview" class="view scroll" hidden><pre id="logout" class="out"></pre></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<div id="modal-root"></div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user