@lithia-js/native 1.0.0-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ //! Utilities to transform filesystem route file paths into normalized HTTP
2
+ //! route paths and regular expressions used by the router.
3
+ //!
4
+ //! This module exposes the `PathTransformer` trait which defines the
5
+ //! transformations required to convert a filesystem-style route (for example
6
+ //! `users/[id]/route.ts`) into a normalized runtime path (`/users/:id/route`),
7
+ //! a route regex and helper utilities. The `NativePathTransformer` implements
8
+ //! the trait with the typical behaviour expected by the framework.
9
+ //!
10
+ //! Public API:
11
+ //! - `PathTransformer` trait: abstraction used across the codebase to convert
12
+ //! and normalize paths.
13
+ //! - `NativePathTransformer`: default implementation used by the native router.
14
+
15
+ use regex::Regex;
16
+
17
+ /// Trait that defines path transformation utilities used by the router.
18
+ /// Implementors convert filesystem file paths into normalized route paths,
19
+ /// detect dynamic segments, and produce regular expressions suitable for
20
+ /// matching incoming requests.
21
+ pub trait PathTransformer {
22
+ /// Transform a filesystem file path into a normalized route-like path.
23
+ ///
24
+ /// Example: `users/[id]/route.ts` -> `users/:id/route`.
25
+ fn transform_file_path(&self, path: &str) -> String;
26
+
27
+ /// Normalize a route path applying a global prefix, ensuring a leading
28
+ /// slash and removing trailing slashes where appropriate.
29
+ ///
30
+ /// Example: `("/users", "/api")` -> `/api/users`.
31
+ fn normalize_path(&self, path: &str, global_prefix: &str) -> String;
32
+
33
+ /// Return true when the supplied path contains dynamic segments (e.g.
34
+ /// `:id`).
35
+ fn is_dynamic_route(&self, path: &str) -> bool;
36
+
37
+ /// Generate a regular expression string from a normalized route path.
38
+ ///
39
+ /// Example: `/users/:id` -> `^/users/([^\/]+)$`.
40
+ fn generate_route_regex(&self, path: &str) -> String;
41
+
42
+ /// Clone the transformer as a boxed trait object.
43
+ fn clone_box(&self) -> Box<dyn PathTransformer>;
44
+ }
45
+
46
+ /// Join `base` and `path`, ensuring there is at most one separator between
47
+ /// them. If `base` is empty, `path` is returned unchanged.
48
+ fn with_base(path: &str, base: &str) -> String {
49
+ if base.is_empty() {
50
+ path.to_string()
51
+ } else {
52
+ format!(
53
+ "{}/{}",
54
+ base.trim_end_matches('/'),
55
+ path.trim_start_matches('/')
56
+ )
57
+ }
58
+ }
59
+
60
+ /// Ensure the provided `path` starts with a leading slash.
61
+ fn with_leading_slash(path: &str) -> String {
62
+ if path.starts_with('/') {
63
+ path.to_string()
64
+ } else {
65
+ format!("/{}", path)
66
+ }
67
+ }
68
+
69
+ /// Remove a trailing slash from `path` except when the path is `/`.
70
+ fn without_trailing_slash(path: &str) -> String {
71
+ if path.ends_with('/') && path.len() > 1 {
72
+ path.trim_end_matches('/').to_string()
73
+ } else {
74
+ path.to_string()
75
+ }
76
+ }
77
+
78
+ /// Default `PathTransformer` implementation used by the native router.
79
+ /// It converts filesystem route file names and patterns (including dynamic
80
+ /// segments like `[id]` and catch-all `[...path]`) into normalized runtime
81
+ /// path templates and regexes.
82
+ #[derive(Clone)]
83
+ pub struct NativePathTransformer {
84
+ remove_ext: Regex,
85
+ remove_groups: Regex,
86
+ catch_all_named: Regex,
87
+ catch_all: Regex,
88
+ dynamic: Regex,
89
+ dynamic_detector: Regex,
90
+ route_param: Regex,
91
+ }
92
+
93
+ impl NativePathTransformer {
94
+ /// Construct a new `NativePathTransformer` with precompiled regular
95
+ /// expressions tuned for route syntax used by the framework.
96
+ pub fn new() -> Self {
97
+ Self {
98
+ remove_ext: Regex::new(r"\.[A-Za-z]+$").unwrap(),
99
+ remove_groups: Regex::new(r"\(([^(/\\]+)\)[/\\]").unwrap(),
100
+ catch_all_named: Regex::new(r"\[\.\.\.(\w+)\]").unwrap(),
101
+ catch_all: Regex::new(r"\[\.\.\.]").unwrap(),
102
+ dynamic: Regex::new(r"\[([^/\]]+)\]").unwrap(),
103
+ dynamic_detector: Regex::new(r":\w+").unwrap(),
104
+ route_param: Regex::new(r":(\w+)").unwrap(),
105
+ }
106
+ }
107
+ }
108
+
109
+ impl PathTransformer for NativePathTransformer {
110
+ fn transform_file_path(&self, path: &str) -> String {
111
+ let mut result = path.to_string();
112
+
113
+ result = self.remove_ext.replace(&result, "").to_string();
114
+ result = self.remove_groups.replace(&result, "").to_string();
115
+ result = self
116
+ .catch_all_named
117
+ .replace_all(&result, "**:$1")
118
+ .to_string();
119
+ result = self.catch_all.replace_all(&result, "**").to_string();
120
+ result = self.dynamic.replace_all(&result, ":$1").to_string();
121
+ result = result.replace('\\', "/");
122
+
123
+ result
124
+ }
125
+
126
+ fn normalize_path(&self, path: &str, global_prefix: &str) -> String {
127
+ let combined = with_base(path, global_prefix);
128
+ let no_trailing = without_trailing_slash(&combined);
129
+ with_leading_slash(&no_trailing)
130
+ }
131
+
132
+ fn is_dynamic_route(&self, path: &str) -> bool {
133
+ self.dynamic_detector.is_match(path)
134
+ }
135
+
136
+ fn generate_route_regex(&self, path: &str) -> String {
137
+ let escaped = path.replace('/', r"\/");
138
+
139
+ let regex_body = self
140
+ .route_param
141
+ .replace_all(&escaped, r"([^\/]+)")
142
+ .to_string();
143
+
144
+ format!("^{}$", regex_body)
145
+ }
146
+
147
+ fn clone_box(&self) -> Box<dyn PathTransformer> {
148
+ Box::new(self.clone())
149
+ }
150
+ }
151
+
152
+ #[cfg(test)]
153
+ mod tests {
154
+ use super::*;
155
+ use regex::Regex;
156
+
157
+ fn transformer() -> NativePathTransformer {
158
+ NativePathTransformer::new()
159
+ }
160
+
161
+ #[test]
162
+ fn transform_file_path_removes_extension() {
163
+ let t = transformer();
164
+ assert_eq!(t.transform_file_path("users/route.ts"), "users/route")
165
+ }
166
+
167
+ #[test]
168
+ fn transform_file_path_removes_route_groups() {
169
+ let t = transformer();
170
+ assert_eq!(t.transform_file_path("(v1)/users/route.ts"), "users/route")
171
+ }
172
+
173
+ #[test]
174
+ fn transform_file_path_dynamic_segments() {
175
+ let t = transformer();
176
+ assert_eq!(
177
+ t.transform_file_path("users/[id]/route.ts"),
178
+ "users/:id/route"
179
+ )
180
+ }
181
+
182
+ #[test]
183
+ fn transform_file_path_multiple_dynamic_segments() {
184
+ let t = transformer();
185
+ assert_eq!(
186
+ t.transform_file_path("users/[userId]/posts/[postId]/route.ts"),
187
+ "users/:userId/posts/:postId/route"
188
+ )
189
+ }
190
+
191
+ #[test]
192
+ fn transform_file_path_catch_all_named() {
193
+ let t = transformer();
194
+ assert_eq!(
195
+ t.transform_file_path("files/[...path]/route.ts"),
196
+ "files/**:path/route"
197
+ )
198
+ }
199
+
200
+ #[test]
201
+ fn transform_file_path_catch_all_unnamed() {
202
+ let t = transformer();
203
+ assert_eq!(
204
+ t.transform_file_path("files/[...]/route.ts"),
205
+ "files/**/route"
206
+ )
207
+ }
208
+
209
+ #[test]
210
+ fn transform_file_path_normalizes_windows_separators() {
211
+ let t = transformer();
212
+ assert_eq!(
213
+ t.transform_file_path(r"users\[id]\route.ts"),
214
+ "users/:id/route"
215
+ )
216
+ }
217
+
218
+ #[test]
219
+ fn normalize_path_applies_global_prefix() {
220
+ let t = transformer();
221
+ assert_eq!(t.normalize_path("/users", "/api"), "/api/users")
222
+ }
223
+
224
+ #[test]
225
+ fn normalize_path_removes_trailing_slash() {
226
+ let t = transformer();
227
+ assert_eq!(t.normalize_path("/users/", ""), "/users")
228
+ }
229
+
230
+ #[test]
231
+ fn normalize_path_ensures_leading_slash() {
232
+ let t = transformer();
233
+ assert_eq!(t.normalize_path("users", ""), "/users")
234
+ }
235
+
236
+ #[test]
237
+ fn detects_dynamic_route() {
238
+ let t = transformer();
239
+ assert!(t.is_dynamic_route("/users/:id"));
240
+ }
241
+
242
+ #[test]
243
+ fn detects_static_route() {
244
+ let t = transformer();
245
+ assert!(!t.is_dynamic_route("/about"));
246
+ }
247
+
248
+ #[test]
249
+ fn generate_route_regex_static() {
250
+ let t = transformer();
251
+ let regex_str = t.generate_route_regex("/about");
252
+ let regex = Regex::new(&regex_str).unwrap();
253
+
254
+ assert!(regex.is_match("/about"));
255
+ assert!(!regex.is_match("/about/us"));
256
+ }
257
+
258
+ #[test]
259
+ fn generate_route_regex_dynamic() {
260
+ let t = transformer();
261
+ let regex_str = t.generate_route_regex("/users/:id");
262
+ let regex = Regex::new(&regex_str).unwrap();
263
+
264
+ assert!(regex.is_match("/users/123"));
265
+ assert!(regex.is_match("/users/abc"));
266
+ assert!(!regex.is_match("/users/"));
267
+ assert!(!regex.is_match("/users/123/profile"));
268
+ }
269
+
270
+ #[test]
271
+ fn generate_route_regex_nested_dynamic() {
272
+ let t = transformer();
273
+ let regex_str = t.generate_route_regex("/users/:userId/posts/:postId");
274
+ let regex = Regex::new(&regex_str).unwrap();
275
+
276
+ assert!(regex.is_match("/users/42/posts/100"));
277
+ assert!(regex.is_match("/users/john/posts/abc"));
278
+ assert!(!regex.is_match("/users/42/posts/"));
279
+ assert!(!regex.is_match("/users/42/posts/100/comments"));
280
+ }
281
+ }
@@ -0,0 +1,345 @@
1
+ //! Utilities for scanning project directories and matching files using glob patterns.
2
+ //!
3
+ //! This module exposes a small, focused file scanner implemented in Rust that is
4
+ //! used by the native builder to discover route files and other project
5
+ //! artifacts. It provides a simple `FileScanner` trait and a `NativeFileScanner`
6
+ //! concrete implementation that walks directories, applies include/ignore glob
7
+ //! patterns and returns structured `FileInfo` results.
8
+ //!
9
+ //! The scanner intentionally keeps behaviour minimal and deterministic:
10
+ //! - Includes are required: if no include patterns are provided, an empty list
11
+ //! is returned.
12
+ //! - Ignore patterns are optional and applied after include matching.
13
+ //! - Returned file paths are sorted by their relative path to ensure stable
14
+ //! ordering across runs.
15
+
16
+ use globset::{Glob, GlobSetBuilder};
17
+ use napi_derive::napi;
18
+
19
+ use std::io;
20
+
21
+ /// Information about a discovered file.
22
+ /// - `path` is the path relative to the scanned directory (using `/` as
23
+ /// separator on all platforms).
24
+ /// - `full_path` is the absolute filesystem path to the file.
25
+ #[napi(object)]
26
+ #[derive(Debug, Clone)]
27
+ pub struct FileInfo {
28
+ /// Relative path from the scanned directory
29
+ pub path: String,
30
+
31
+ /// Absolute path from the filesystem root
32
+ pub full_path: String,
33
+ }
34
+
35
+ /// Options controlling scanning behaviour.
36
+ /// - `include`: list of glob patterns that select files to include. If
37
+ /// omitted or empty, the scanner returns an empty result set.
38
+ /// - `ignore`: optional list of glob patterns used to exclude matching files
39
+ /// from the previously included set.
40
+ #[napi(object)]
41
+ #[derive(Debug, Clone, Default)]
42
+ pub struct ScanOptions {
43
+ /// Glob patterns to include files.
44
+ pub include: Option<Vec<String>>,
45
+
46
+ /// Glob patterns to ignore (applied after include matching).
47
+ pub ignore: Option<Vec<String>>,
48
+ }
49
+
50
+ /// Trait that abstracts a directory scanner used by the native build system.
51
+ /// Implementors must return a list of `FileInfo` entries corresponding to the
52
+ /// files discovered under the provided `path_components` directory. This trait
53
+ /// is intentionally small to make testing and mocking straightforward in
54
+ /// higher-level code.
55
+ pub trait FileScanner {
56
+ /// Scan a directory described by `path_components` and return matching
57
+ /// `FileInfo` entries.
58
+ /// `path_components` is a slice of path segments that will be joined onto
59
+ /// the current working directory to form the target scanning directory.
60
+ /// `options` may contain include/ignore glob patterns.
61
+ fn scan_dir(
62
+ &self,
63
+ path_components: &[String],
64
+ options: Option<ScanOptions>,
65
+ ) -> io::Result<Vec<FileInfo>>;
66
+ }
67
+
68
+ /// Native `FileScanner` implementation that walks the filesystem using
69
+ /// `walkdir` and matches paths against glob patterns using `globset`.
70
+ /// This scanner is fast enough for typical project sizes and deterministic
71
+ /// because it sorts results by relative path before returning them.
72
+ #[derive(Debug, Clone)]
73
+ pub struct NativeFileScanner;
74
+
75
+ impl NativeFileScanner {
76
+ /// Create a new `NativeFileScanner` instance.
77
+ pub fn new() -> Self {
78
+ Self
79
+ }
80
+ }
81
+
82
+ impl FileScanner for NativeFileScanner {
83
+ fn scan_dir(
84
+ &self,
85
+ path_components: &[String],
86
+ options: Option<ScanOptions>,
87
+ ) -> io::Result<Vec<FileInfo>> {
88
+ let cwd = std::env::current_dir()?;
89
+ let mut target_path = cwd;
90
+
91
+ for part in path_components {
92
+ target_path = target_path.join(part);
93
+ }
94
+
95
+ if !target_path.exists() {
96
+ return Err(io::Error::new(
97
+ io::ErrorKind::NotFound,
98
+ format!("Target directory {:?} does not exist", target_path),
99
+ ));
100
+ }
101
+
102
+ let options = options.unwrap_or_default();
103
+
104
+ let include_patterns = match options.include {
105
+ Some(patterns) if !patterns.is_empty() => patterns,
106
+ _ => return Ok(Vec::new()),
107
+ };
108
+
109
+ let mut include_builder = GlobSetBuilder::new();
110
+ for pattern in &include_patterns {
111
+ let glob = Glob::new(pattern).map_err(|e| {
112
+ io::Error::new(
113
+ io::ErrorKind::InvalidInput,
114
+ format!("Invalid include glob pattern '{}': {}", pattern, e),
115
+ )
116
+ })?;
117
+ include_builder.add(glob);
118
+ }
119
+ let include_matcher = include_builder.build().map_err(|e| {
120
+ io::Error::new(
121
+ io::ErrorKind::InvalidInput,
122
+ format!("Failed to build include matcher: {}", e),
123
+ )
124
+ })?;
125
+
126
+ let ignore_matcher = if let Some(ignore_patterns) = options.ignore {
127
+ let mut ignore_builder = GlobSetBuilder::new();
128
+ for pattern in &ignore_patterns {
129
+ let glob = Glob::new(pattern).map_err(|e| {
130
+ io::Error::new(
131
+ io::ErrorKind::InvalidInput,
132
+ format!("Invalid ignore glob pattern '{}': {}", pattern, e),
133
+ )
134
+ })?;
135
+ ignore_builder.add(glob);
136
+ }
137
+ Some(ignore_builder.build().map_err(|e| {
138
+ io::Error::new(
139
+ io::ErrorKind::InvalidInput,
140
+ format!("Failed to build ignore matcher: {}", e),
141
+ )
142
+ })?)
143
+ } else {
144
+ None
145
+ };
146
+
147
+ let mut file_infos: Vec<FileInfo> = Vec::new();
148
+
149
+ for entry in walkdir::WalkDir::new(&target_path)
150
+ .into_iter()
151
+ .filter_map(|e| e.ok())
152
+ .filter(|e| e.file_type().is_file())
153
+ {
154
+ let path = entry.path();
155
+ let relative = path
156
+ .strip_prefix(&target_path)
157
+ .unwrap_or(path)
158
+ .to_string_lossy()
159
+ .replace('\\', "/");
160
+
161
+ if !include_matcher.is_match(&relative) {
162
+ continue;
163
+ }
164
+
165
+ if let Some(ref ignore) = ignore_matcher {
166
+ if ignore.is_match(&relative) {
167
+ continue;
168
+ }
169
+ }
170
+
171
+ file_infos.push(FileInfo {
172
+ full_path: path.to_string_lossy().to_string(),
173
+ path: relative,
174
+ });
175
+ }
176
+
177
+ file_infos.sort_by(|a, b| a.path.cmp(&b.path));
178
+
179
+ Ok(file_infos)
180
+ }
181
+ }
182
+
183
+ #[cfg(test)]
184
+ mod tests {
185
+ use super::*;
186
+ use std::{fs, path::Path};
187
+ use tempfile::TempDir;
188
+
189
+ fn create_test_files(dir: &Path) -> io::Result<()> {
190
+ fs::create_dir_all(dir.join("src"))?;
191
+ fs::create_dir_all(dir.join("dist"))?;
192
+ fs::create_dir_all(dir.join("tests"))?;
193
+
194
+ // TypeScript files
195
+ fs::write(dir.join("src/index.ts"), "export default {}")?;
196
+ fs::write(dir.join("src/utils.ts"), "export const util = 1")?;
197
+
198
+ // JavaScript files
199
+ fs::write(dir.join("src/legacy.js"), "module.exports = {}")?;
200
+
201
+ // Test files
202
+ fs::write(dir.join("tests/index.test.ts"), "test('works', () => {})")?;
203
+
204
+ // JSON files
205
+ fs::write(dir.join("package.json"), "{}")?;
206
+ fs::write(dir.join("tsconfig.json"), "{}")?;
207
+
208
+ // Build output
209
+ fs::write(dir.join("dist/index.js"), "console.log('built')")?;
210
+
211
+ Ok(())
212
+ }
213
+
214
+ #[test]
215
+ fn test_scan_with_default_ts_pattern() {
216
+ let temp_dir = TempDir::new().unwrap();
217
+ create_test_files(temp_dir.path()).unwrap();
218
+
219
+ let scanner = NativeFileScanner::new();
220
+ let result = scanner
221
+ .scan_dir(&[temp_dir.path().to_string_lossy().to_string()], None)
222
+ .unwrap();
223
+
224
+ // Sem include patterns, não deve retornar nada
225
+ assert_eq!(result.len(), 0);
226
+ }
227
+
228
+ #[test]
229
+ fn test_scan_typescript_files() {
230
+ let temp_dir = TempDir::new().unwrap();
231
+ create_test_files(temp_dir.path()).unwrap();
232
+
233
+ let scanner = NativeFileScanner::new();
234
+ let result = scanner
235
+ .scan_dir(
236
+ &[temp_dir.path().to_string_lossy().to_string()],
237
+ Some(ScanOptions {
238
+ include: Some(vec!["**/*.ts".to_string()]),
239
+ ignore: None,
240
+ }),
241
+ )
242
+ .unwrap();
243
+
244
+ // Deve encontrar apenas .ts files
245
+ let paths: Vec<_> = result.iter().map(|f| f.path.as_str()).collect();
246
+ assert_eq!(paths.len(), 3);
247
+ assert!(paths.contains(&"src/index.ts"));
248
+ assert!(paths.contains(&"src/utils.ts"));
249
+ assert!(paths.contains(&"tests/index.test.ts"));
250
+ }
251
+
252
+ #[test]
253
+ fn test_scan_with_custom_include_patterns() {
254
+ let temp_dir = TempDir::new().unwrap();
255
+ create_test_files(temp_dir.path()).unwrap();
256
+
257
+ let scanner = NativeFileScanner::new();
258
+ let result = scanner
259
+ .scan_dir(
260
+ &[temp_dir.path().to_string_lossy().to_string()],
261
+ Some(ScanOptions {
262
+ include: Some(vec!["**/*.ts".to_string(), "**/*.js".to_string()]),
263
+ ignore: None,
264
+ }),
265
+ )
266
+ .unwrap();
267
+
268
+ let paths: Vec<_> = result.iter().map(|f| f.path.as_str()).collect();
269
+ assert_eq!(paths.len(), 5); // 3 .ts + 2 .js files
270
+ assert!(paths.contains(&"src/legacy.js"));
271
+ assert!(paths.contains(&"dist/index.js"));
272
+ }
273
+
274
+ #[test]
275
+ fn test_scan_with_ignore_patterns() {
276
+ let temp_dir = TempDir::new().unwrap();
277
+ create_test_files(temp_dir.path()).unwrap();
278
+
279
+ let scanner = NativeFileScanner::new();
280
+ let result = scanner
281
+ .scan_dir(
282
+ &[temp_dir.path().to_string_lossy().to_string()],
283
+ Some(ScanOptions {
284
+ include: Some(vec!["**/*.ts".to_string(), "**/*.js".to_string()]),
285
+ ignore: Some(vec!["**/*.test.ts".to_string(), "**/dist/**".to_string()]),
286
+ }),
287
+ )
288
+ .unwrap();
289
+
290
+ let paths: Vec<_> = result.iter().map(|f| f.path.as_str()).collect();
291
+ // Should exclude test files and dist folder
292
+ assert_eq!(paths.len(), 3);
293
+ assert!(paths.contains(&"src/index.ts"));
294
+ assert!(paths.contains(&"src/utils.ts"));
295
+ assert!(paths.contains(&"src/legacy.js"));
296
+ assert!(!paths.contains(&"tests/index.test.ts"));
297
+ assert!(!paths.contains(&"dist/index.js"));
298
+ }
299
+
300
+ #[test]
301
+ fn test_scan_json_files_only() {
302
+ let temp_dir = TempDir::new().unwrap();
303
+ create_test_files(temp_dir.path()).unwrap();
304
+
305
+ let scanner = NativeFileScanner::new();
306
+ let result = scanner
307
+ .scan_dir(
308
+ &[temp_dir.path().to_string_lossy().to_string()],
309
+ Some(ScanOptions {
310
+ include: Some(vec!["**/*.json".to_string()]),
311
+ ignore: None,
312
+ }),
313
+ )
314
+ .unwrap();
315
+
316
+ let paths: Vec<_> = result.iter().map(|f| f.path.as_str()).collect();
317
+ assert_eq!(paths.len(), 2);
318
+ assert!(paths.contains(&"package.json"));
319
+ assert!(paths.contains(&"tsconfig.json"));
320
+ }
321
+
322
+ #[test]
323
+ fn test_scan_specific_directory_with_globs() {
324
+ let temp_dir = TempDir::new().unwrap();
325
+ create_test_files(temp_dir.path()).unwrap();
326
+
327
+ let scanner = NativeFileScanner::new();
328
+ let result = scanner
329
+ .scan_dir(
330
+ &[temp_dir.path().to_string_lossy().to_string()],
331
+ Some(ScanOptions {
332
+ include: Some(vec!["src/**/*.ts".to_string()]),
333
+ ignore: None,
334
+ }),
335
+ )
336
+ .unwrap();
337
+
338
+ let paths: Vec<_> = result.iter().map(|f| f.path.as_str()).collect();
339
+ // Only src/ directory .ts files
340
+ assert_eq!(paths.len(), 2);
341
+ assert!(paths.contains(&"src/index.ts"));
342
+ assert!(paths.contains(&"src/utils.ts"));
343
+ assert!(!paths.contains(&"tests/index.test.ts"));
344
+ }
345
+ }