diff --git a/.github/workflows/wasm_exports.yml b/.github/workflows/wasm_exports.yml new file mode 100644 index 00000000..19c9c6e5 --- /dev/null +++ b/.github/workflows/wasm_exports.yml @@ -0,0 +1,36 @@ +name: Check WASM Exports + +on: + pull_request: + paths: + - lib/include/tree_sitter/api.h + - lib/binding_web/** + push: + branches: [master] + paths: + - lib/include/tree_sitter/api.h + - lib/binding_rust/bindings.rs + - lib/CMakeLists.txt + +jobs: + check-wasm-exports: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up stable Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Install wasm-objdump + run: sudo apt-get update -y && sudo apt-get install -y wabt + + - name: Build C library (make) + run: make -j CFLAGS="$CFLAGS" + env: + CFLAGS: -g -Werror -Wall -Wextra -Wshadow -Wpedantic -Werror=incompatible-pointer-types + + - name: Check WASM exports + run: cargo xtask check-wasm-exports diff --git a/xtask/src/check_wasm_exports.rs b/xtask/src/check_wasm_exports.rs new file mode 100644 index 00000000..3d0cab40 --- /dev/null +++ b/xtask/src/check_wasm_exports.rs @@ -0,0 +1,127 @@ +use std::{ + collections::HashSet, + io::BufRead, + process::{Command, Stdio}, +}; + +use anyhow::{anyhow, Result}; + +use crate::{bail_on_err, build_wasm::run_wasm, BuildWasm}; + +const EXCLUDES: [&str; 28] = [ + // Unneeded because the JS side has its own way of implementing it + "ts_node_child_by_field_name", + "ts_node_edit", + // Precomputed and stored in the JS side + "ts_node_type", + "ts_node_grammar_type", + "ts_node_eq", + "ts_tree_cursor_current_field_name", + "ts_lookahead_iterator_current_symbol_name", + // Deprecated + "ts_node_child_containing_descendant", + // Not used in wasm + "ts_init", + "ts_set_allocator", + "ts_parser_set_cancellation_flag", + "ts_parser_cancellation_flag", + "ts_parser_print_dot_graphs", + "ts_tree_print_dot_graph", + "ts_parser_set_wasm_store", + "ts_parser_take_wasm_store", + "ts_parser_language", + "ts_node_language", + "ts_tree_language", + "ts_lookahead_iterator_language", + "ts_parser_logger", + "ts_parser_parse_string", + "ts_parser_parse_string_encoding", + // Query cursor is not managed by user in web bindings + "ts_query_cursor_delete", + "ts_query_cursor_timeout_micros", + "ts_query_cursor_match_limit", + "ts_query_cursor_remove_match", + "ts_query_cursor_timeout_micros", +]; + +pub fn run() -> Result<()> { + // Build the wasm module with debug symbols for wasm-objdump + run_wasm(&BuildWasm { + debug: true, + verbose: false, + docker: false, + })?; + + let mut wasm_exports = include_str!("../../lib/binding_web/exports.txt") + .lines() + .map(|s| s.replace("_wasm", "").replace("byte", "index")) + // remove leading and trailing quotes, trailing comma + .map(|s| s[1..s.len() - 2].to_string()) + .collect::>(); + + // Run wasm-objdump to see symbols used internally in binding.c but not exposed in any way. + let wasm_objdump = Command::new("wasm-objdump") + .args([ + "--details", + "lib/binding_web/tree-sitter.wasm", + "--section", + "Name", + ]) + .output() + .expect("Failed to run wasm-objdump"); + bail_on_err(&wasm_objdump, "Failed to run wasm-objdump")?; + + wasm_exports.extend( + wasm_objdump + .stdout + .lines() + .map_while(Result::ok) + .skip_while(|line| !line.contains("- func")) + .filter_map(|line| { + if line.contains("func") { + if let Some(function) = line.split_whitespace().nth(2).map(String::from) { + let trimmed = function.trim_start_matches('<').trim_end_matches('>'); + if trimmed.starts_with("ts") && !trimmed.contains("__") { + return Some(trimmed.to_string()); + } + } + } + None + }), + ); + + let nm_child = Command::new("nm") + .arg("-W") + .arg("-U") + .arg("libtree-sitter.so") + .stdout(Stdio::piped()) + .output() + .expect("Failed to run nm"); + bail_on_err(&nm_child, "Failed to run nm")?; + let export_reader = nm_child + .stdout + .lines() + .map_while(Result::ok) + .filter(|line| line.contains(" T ")); + + let exports = export_reader + .filter_map(|line| line.split_whitespace().nth(2).map(String::from)) + .filter(|symbol| !EXCLUDES.contains(&symbol.as_str())) + .collect::>(); + + let mut missing = exports + .iter() + .filter(|&symbol| !wasm_exports.contains(symbol)) + .map(|symbol| symbol.as_str()) + .collect::>(); + missing.sort_unstable(); + + if !missing.is_empty() { + Err(anyhow!(format!( + "Unmatched wasm exports:\n{}", + missing.join("\n") + )))?; + } + + Ok(()) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 52cbe55c..a1333d65 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,6 +1,7 @@ mod benchmark; mod build_wasm; mod bump; +mod check_wasm_exports; mod clippy; mod fetch; mod generate; @@ -28,6 +29,8 @@ enum Commands { BuildWasmStdlib, /// Bumps the version of the workspace. BumpVersion(BumpVersion), + /// Checks that WASM exports are synced. + CheckWasmExports, /// Runs `cargo clippy`. Clippy(Clippy), /// Fetches emscripten. @@ -204,6 +207,7 @@ fn run() -> Result<()> { Commands::BuildWasm(build_wasm_options) => build_wasm::run_wasm(&build_wasm_options)?, Commands::BuildWasmStdlib => build_wasm::run_wasm_stdlib()?, Commands::BumpVersion(bump_options) => bump::run(bump_options)?, + Commands::CheckWasmExports => check_wasm_exports::run()?, Commands::Clippy(clippy_options) => clippy::run(&clippy_options)?, Commands::FetchEmscripten => fetch::run_emscripten()?, Commands::FetchFixtures => fetch::run_fixtures()?,