Skip to content

Commit 5de5ced

Browse files
authored
Merge pull request #1573 from harehare/feat-mq-node-4646200248426777754
✨ feat(mq-node): implement mq-node for Node.js using mq-wasm with tests
2 parents 93c28f1 + b7fc31a commit 5de5ced

16 files changed

Lines changed: 1998 additions & 71 deletions

crates/mq-wasm/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ mq-hir = {workspace = true}
2525
mq-check = {workspace = true}
2626
mq-lang = {workspace = true, features = ["ast-json"]}
2727
mq-markdown = {workspace = true}
28-
opfs = {workspace = true}
28+
opfs = {workspace = true, optional = true}
2929
serde = {workspace = true, features = ["derive"]}
3030
serde-wasm-bindgen = {workspace = true}
3131
serde_json = {workspace = true}
@@ -35,5 +35,9 @@ wasm-bindgen-futures = {workspace = true}
3535
[dev-dependencies]
3636
wasm-bindgen-test = {workspace = true}
3737

38+
[features]
39+
default = ["opfs"]
40+
opfs = ["dep:opfs"]
41+
3842
[package.metadata.wasm-pack.profile.release]
3943
wasm-opt = ['-O4', '--enable-simd', '--enable-bulk-memory', '--enable-nontrapping-float-to-int']

crates/mq-wasm/src/script.rs

Lines changed: 86 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
use futures::StreamExt;
21
use itertools::Itertools;
3-
use opfs::{DirectoryHandle, FileHandle};
42
use serde::{Deserialize, Serialize};
5-
use std::{cell::RefCell, collections::HashMap, rc::Rc, str::FromStr};
3+
use std::str::FromStr;
64
use wasm_bindgen::prelude::*;
75

6+
#[cfg(feature = "opfs")]
7+
use futures::StreamExt;
8+
#[cfg(feature = "opfs")]
9+
use opfs::{DirectoryHandle, FileHandle};
10+
#[cfg(feature = "opfs")]
11+
use std::{cell::RefCell, collections::HashMap, rc::Rc};
12+
813
#[wasm_bindgen(typescript_custom_section)]
914
const TS_CUSTOM_SECTION: &'static str = r#"
1015
export type DefinedValueType = 'Function' | 'Variable';
@@ -208,36 +213,30 @@ impl From<ConversionOptions> for mq_markdown::ConversionOptions {
208213
}
209214
}
210215

211-
#[derive(Debug, Clone)]
216+
#[derive(Debug, Clone, Default)]
212217
pub struct WasmModuleResolver {
218+
#[cfg(feature = "opfs")]
213219
/// Cache of preloaded module contents, keyed by module name
214220
cache: Rc<RefCell<HashMap<String, String>>>,
221+
#[cfg(feature = "opfs")]
215222
/// Root directory handle for OPFS access
216223
root_dir: Rc<RefCell<Option<opfs::persistent::DirectoryHandle>>>,
224+
#[cfg(feature = "opfs")]
217225
/// Flag indicating whether OPFS is available
218226
is_available: Rc<RefCell<bool>>,
219227
}
220228

221-
impl Default for WasmModuleResolver {
222-
fn default() -> Self {
223-
Self::new()
224-
}
225-
}
226-
227229
impl WasmModuleResolver {
228230
pub fn new() -> Self {
229-
Self {
230-
cache: Rc::new(RefCell::new(HashMap::new())),
231-
root_dir: Rc::new(RefCell::new(None)),
232-
is_available: Rc::new(RefCell::new(false)),
233-
}
231+
Self::default()
234232
}
235233

236234
/// Initializes the OPFS root directory handle
237235
///
238236
/// If OPFS is not available, this method will silently fail and the resolver
239237
/// will operate as a NoOp resolver (only using manually added modules via `add_module`).
240238
pub async fn initialize(&self) {
239+
#[cfg(feature = "opfs")]
241240
match opfs::persistent::app_specific_dir().await {
242241
Ok(root) => {
243242
*self.root_dir.borrow_mut() = Some(root);
@@ -257,52 +256,55 @@ impl WasmModuleResolver {
257256
///
258257
/// If OPFS is not available, this method returns immediately without error.
259258
pub async fn preload_modules(&self) {
260-
// Skip if OPFS is not available
261-
if !*self.is_available.borrow() {
262-
return;
263-
}
264-
265-
let root = match self.root_dir.borrow().as_ref() {
266-
Some(r) => r.clone(),
267-
None => return, // Should not happen if is_available is true, but be defensive
268-
};
259+
#[cfg(feature = "opfs")]
260+
{
261+
// Skip if OPFS is not available
262+
if !*self.is_available.borrow() {
263+
return;
264+
}
269265

270-
let mut entries = match root.entries().await {
271-
Ok(e) => e,
272-
Err(_) => return, // Failed to get directory entries
273-
};
266+
let root = match self.root_dir.borrow().as_ref() {
267+
Some(r) => r.clone(),
268+
None => return, // Should not happen if is_available is true, but be defensive
269+
};
274270

275-
while let Some(result) = entries.next().await {
276-
let (name, entry) = match result {
271+
let mut entries = match root.entries().await {
277272
Ok(e) => e,
278-
Err(_) => continue, // Skip entries that fail to read
273+
Err(_) => return, // Failed to get directory entries
279274
};
280275

281-
match entry {
282-
opfs::DirectoryEntry::File(file_handle) => {
283-
// Only process .mq files
284-
if !name.ends_with(".mq") {
276+
while let Some(result) = entries.next().await {
277+
let (name, entry) = match result {
278+
Ok(e) => e,
279+
Err(_) => continue, // Skip entries that fail to read
280+
};
281+
282+
match entry {
283+
opfs::DirectoryEntry::File(file_handle) => {
284+
// Only process .mq files
285+
if !name.ends_with(".mq") {
286+
continue;
287+
}
288+
289+
// Read file contents
290+
let data = match file_handle.read().await {
291+
Ok(d) => d,
292+
Err(_) => continue, // Skip files that fail to read
293+
};
294+
295+
let contents = match String::from_utf8(data) {
296+
Ok(c) => c,
297+
Err(_) => continue, // Skip files that are not valid UTF-8
298+
};
299+
300+
// Store with module name (without .mq extension)
301+
let module_name = name.strip_suffix(".mq").unwrap_or(&name);
302+
self.cache.borrow_mut().insert(module_name.to_string(), contents);
303+
}
304+
opfs::DirectoryEntry::Directory(_) => {
305+
// Skip directories for now
285306
continue;
286307
}
287-
288-
// Read file contents
289-
let data = match file_handle.read().await {
290-
Ok(d) => d,
291-
Err(_) => continue, // Skip files that fail to read
292-
};
293-
294-
let contents = match String::from_utf8(data) {
295-
Ok(c) => c,
296-
Err(_) => continue, // Skip files that are not valid UTF-8
297-
};
298-
299-
// Store with module name (without .mq extension)
300-
let module_name = name.strip_suffix(".mq").unwrap_or(&name);
301-
self.cache.borrow_mut().insert(module_name.to_string(), contents);
302-
}
303-
opfs::DirectoryEntry::Directory(_) => {
304-
// Skip directories for now
305-
continue;
306308
}
307309
}
308310
}
@@ -311,24 +313,32 @@ impl WasmModuleResolver {
311313
/// Manually adds a module to the cache
312314
///
313315
/// This is useful for injecting module contents without using OPFS
314-
pub fn add_module(&self, module_name: &str, content: String) {
315-
self.cache.borrow_mut().insert(module_name.to_string(), content);
316+
pub fn add_module(&self, _module_name: &str, _content: String) {
317+
#[cfg(feature = "opfs")]
318+
self.cache.borrow_mut().insert(_module_name.to_string(), _content);
316319
}
317320

318321
/// Clears the module cache
319322
pub fn clear_cache(&self) {
323+
#[cfg(feature = "opfs")]
320324
self.cache.borrow_mut().clear();
321325
}
322326
}
323327

324328
impl mq_lang::ModuleResolver for WasmModuleResolver {
325329
fn resolve(&self, module_name: &str) -> Result<String, mq_lang::ModuleError> {
326-
self.cache.borrow().get(module_name).cloned().ok_or_else(|| {
330+
#[cfg(feature = "opfs")]
331+
return self.cache.borrow().get(module_name).cloned().ok_or_else(|| {
327332
mq_lang::ModuleError::NotFound(std::borrow::Cow::Owned(format!(
328333
"Module '{}' not found in cache. Use preload_modules() to load it first.",
329334
module_name
330335
)))
331-
})
336+
});
337+
#[cfg(not(feature = "opfs"))]
338+
return Err(mq_lang::ModuleError::NotFound(std::borrow::Cow::Owned(format!(
339+
"Module '{}' not found. Module resolution is not supported in this environment.",
340+
module_name
341+
))));
332342
}
333343

334344
fn get_path(&self, module_name: &str) -> Result<String, mq_lang::ModuleError> {
@@ -600,7 +610,6 @@ pub async fn defined_values(code: &str, module: Option<String>) -> Result<JsValu
600610
#[cfg(test)]
601611
mod tests {
602612
use super::*;
603-
use mq_lang::ModuleResolver;
604613
use wasm_bindgen_test::*;
605614
wasm_bindgen_test_configure!(run_in_browser);
606615

@@ -760,9 +769,14 @@ mod tests {
760769
resolver.add_module("test", "def foo(x): x | upcase();".to_string());
761770

762771
// Should be able to resolve it
763-
let result = resolver.resolve("test");
764-
assert!(result.is_ok());
765-
assert_eq!(result.unwrap(), "def foo(x): x | upcase();");
772+
let result = mq_lang::ModuleResolver::resolve(&resolver, "test");
773+
#[cfg(feature = "opfs")]
774+
{
775+
assert!(result.is_ok());
776+
assert_eq!(result.unwrap(), "def foo(x): x | upcase();");
777+
}
778+
#[cfg(not(feature = "opfs"))]
779+
assert!(result.is_err());
766780
}
767781

768782
#[allow(unused)]
@@ -771,7 +785,7 @@ mod tests {
771785
let resolver = WasmModuleResolver::new();
772786

773787
// Should fail when module is not in cache
774-
let result = resolver.resolve("nonexistent");
788+
let result = mq_lang::ModuleResolver::resolve(&resolver, "nonexistent");
775789
assert!(result.is_err());
776790
}
777791

@@ -782,15 +796,17 @@ mod tests {
782796

783797
// Add a module
784798
resolver.add_module("test", "content".to_string());
785-
assert!(resolver.resolve("test").is_ok());
799+
#[cfg(feature = "opfs")]
800+
assert!(mq_lang::ModuleResolver::resolve(&resolver, "test").is_ok());
786801

787802
// Clear cache
788803
resolver.clear_cache();
789804

790805
// Should no longer be resolvable
791-
assert!(resolver.resolve("test").is_err());
806+
assert!(mq_lang::ModuleResolver::resolve(&resolver, "test").is_err());
792807
}
793808

809+
#[cfg(feature = "opfs")]
794810
#[allow(unused)]
795811
#[wasm_bindgen_test]
796812
async fn test_opfs_create_and_import_module() {
@@ -842,9 +858,8 @@ mod tests {
842858
resolver.preload_modules().await;
843859

844860
// Verify the module was loaded into cache
845-
let resolved_content = resolver
846-
.resolve("test_module")
847-
.expect("Module should be found in cache");
861+
let resolved_content =
862+
mq_lang::ModuleResolver::resolve(&resolver, "test_module").expect("Module should be found in cache");
848863
assert_eq!(resolved_content, module_content);
849864

850865
// Test using the imported module in code execution
@@ -868,6 +883,7 @@ mod tests {
868883
assert_eq!(output.join(""), "HELLO WORLD!");
869884
}
870885

886+
#[cfg(feature = "opfs")]
871887
#[allow(unused)]
872888
#[wasm_bindgen_test]
873889
async fn test_opfs_multiple_modules() {
@@ -920,8 +936,8 @@ mod tests {
920936
resolver.preload_modules().await;
921937

922938
// Verify all modules are loaded
923-
assert!(resolver.resolve("math").is_ok());
924-
assert!(resolver.resolve("string").is_ok());
939+
assert!(mq_lang::ModuleResolver::resolve(&resolver, "math").is_ok());
940+
assert!(mq_lang::ModuleResolver::resolve(&resolver, "string").is_ok());
925941

926942
// Test using multiple imported modules
927943
let code = r#"

justfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ build-wasm:
5252
build-web: build-wasm
5353
pnpm run build
5454

55+
# Build @mqlang/node package
56+
[working-directory: 'crates/mq-wasm']
57+
build-node-wasm:
58+
wasm-pack build --release --target nodejs --out-dir ../../packages/mq-node/mq-wasm -- --no-default-features
59+
rm ../../packages/mq-node/mq-wasm/README.md
60+
rm ../../packages/mq-node/mq-wasm/package.json
61+
62+
# Build @mqlang/node package
63+
[working-directory: 'packages/mq-node']
64+
build-node: build-node-wasm
65+
pnpm run build
66+
5567
# Run formatting
5668
fmt:
5769
cargo fmt --all -- --check

packages/mq-node/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mq-wasm*

packages/mq-node/package.json

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@mqlang/node",
3+
"description": "A jq-like command-line tool for Markdown processing for Node.js",
4+
"version": "0.5.25",
5+
"author": "harehare",
6+
"license": "MIT",
7+
"bugs": {
8+
"url": "https://github.com/harehare/mq/issues"
9+
},
10+
"devDependencies": {
11+
"@types/node": "^25.6.0",
12+
"oxlint": "^1.31.0",
13+
"tsup": "^8.5.1",
14+
"typescript": "^6.0.2",
15+
"vitest": "^3.0.0"
16+
},
17+
"files": [
18+
"dist",
19+
"mq-wasm"
20+
],
21+
"homepage": "https://mqlang.org",
22+
"keywords": [
23+
"filter",
24+
"jq",
25+
"markdown",
26+
"transform",
27+
"wasm",
28+
"webassembly",
29+
"nodejs"
30+
],
31+
"main": "dist/index.js",
32+
"repository": {
33+
"type": "git",
34+
"url": "git+https://github.com/harehare/mq.git",
35+
"directory": "packages/mq-node"
36+
},
37+
"publishConfig": {
38+
"access": "public"
39+
},
40+
"scripts": {
41+
"build": "tsup",
42+
"dev": "tsup --watch",
43+
"type-check": "tsc --noEmit",
44+
"lint": "oxlint .",
45+
"test": "vitest run"
46+
},
47+
"packageManager": "pnpm@10.33.0",
48+
"pnpm": {
49+
"onlyBuiltDependencies": [
50+
"esbuild"
51+
]
52+
},
53+
"sideEffects": false,
54+
"types": "dist/index.d.ts"
55+
}

0 commit comments

Comments
 (0)