• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

moonbitlang / x / 867

18 Jun 2026 06:38AM UTC coverage: 88.523% (+1.7%) from 86.827%
867

push

github

myfreess
path: document Node-compatible dispatch

2553 of 2884 relevant lines covered (88.52%)

338.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.08
/path/win32/path.mbt
1
// Copyright 2025 International Digital Economy Academy
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//     http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14

15
///|
16
/// A newtype wrapper provide path operation methods.
17
pub(all) struct Path(String) derive(Eq, Debug)
18

19
///|
20
fn is_ascii_alpha(ch : Char) -> Bool {
21
  match ch {
308✔
22
    'a'..='z' | 'A'..='Z' => true
308✔
23
    _ => false
×
24
  }
25
}
26

27
///|
28
fn is_win_sep(ch : Char) -> Bool {
29
  ch == '\\' || ch == '/'
7,256✔
30
}
31

32
///|
33
fn is_win_sep_at(path : String, index : Int) -> Bool {
34
  match path.get_char(index) {
808✔
35
    Some(ch) => is_win_sep(ch)
808✔
36
    None => false
×
37
  }
38
}
39

40
///|
41
fn has_win_drive(path : String) -> Bool {
42
  if path.length() < 2 || path.get_char(1) != Some(':') {
178✔
43
    false
91✔
44
  } else {
45
    match path.get_char(0) {
96✔
46
      Some(ch) => is_ascii_alpha(ch)
96✔
47
      _ => false
×
48
    }
49
  }
50
}
51

52
///|
53
fn find_next_win_sep(path : String, start : Int) -> Int? {
54
  for i = start; i < path.length(); i = i + 1 {
19✔
55
    if is_win_sep_at(path, i) {
101✔
56
      return Some(i)
16✔
57
    }
58
  }
59
  None
60
}
61

62
///|
63
fn skip_win_separators(path : String, start : Int) -> Int {
64
  let mut index = start
9✔
65
  while index < path.length() && is_win_sep_at(path, index) {
18✔
66
    index += 1
9✔
67
  }
68
  index
69
}
70

71
///|
72
fn win_root_end(path : String) -> Int {
73
  if path.is_empty() {
33✔
74
    return 0
×
75
  }
76
  if has_win_drive(path) {
33✔
77
    if path.length() > 2 && is_win_sep_at(path, 2) {
8✔
78
      return 3
7✔
79
    }
80
    return 2
81
  }
82
  if !is_win_sep_at(path, 0) {
24✔
83
    return 0
10✔
84
  }
85
  if path.length() == 1 || !is_win_sep_at(path, 1) {
13✔
86
    return 1
2✔
87
  }
88
  if path.length() > 2 && is_win_sep_at(path, 2) {
11✔
89
    return 1
1✔
90
  }
91
  let server_start = 2
92
  guard server_start < path.length() else { return 1 }
1✔
93
  match find_next_win_sep(path, server_start) {
10✔
94
    None => 1
1✔
95
    Some(server_end) => {
9✔
96
      let share_start = skip_win_separators(path, server_end)
9✔
97
      guard share_start < path.length() else { return 1 }
9✔
98
      match find_next_win_sep(path, share_start) {
9✔
99
        None => path.length()
2✔
100
        Some(share_end) => share_end + 1
7✔
101
      }
102
    }
103
  }
104
}
105

106
///|
107
fn is_drive_relative(path : String) -> Bool {
108
  has_win_drive(path) && (path.length() == 2 || !is_win_sep_at(path, 2))
4✔
109
}
110

111
///|
112
fn current_dir_for_drive(device : StringView) -> String {
113
  let device = device.to_owned()
5✔
114
  let cwd = match @env.get_env_var("=" + device) {
5✔
115
    Some(dir) => dir
×
116
    None => @env.current_dir().unwrap()
5✔
117
  }
118
  if has_win_drive(cwd) &&
5✔
119
    !cwd[0:2].equal_ignore_ascii_case(device) &&
×
120
    cwd.length() > 2 &&
×
121
    is_win_sep_at(cwd, 2) {
×
122
    device + "\\"
×
123
  } else if !has_win_drive(cwd) && cwd.length() > 0 && is_win_sep_at(cwd, 0) {
5✔
124
    device + cwd
5✔
125
  } else if !has_win_drive(cwd) {
×
126
    device + "\\" + cwd
×
127
  } else {
128
    cwd
×
129
  }
130
}
131

132
///|
133
pub impl Show for Path with fn output(path, logger) {
134
  logger.write_object(path.0)
×
135
}
136

137
///|
138
pub impl Show for Path with fn to_string(path) {
139
  path.0
174✔
140
}
141

142
///|
143
/// Returns the last path component of the given path.
144
///
145
/// This follows Node `path.win32.basename`: both `/` and `\` are separators,
146
/// trailing separators are ignored, and root-only paths return an empty name.
147
/// 
148
/// ```moonbit check
149
/// test {
150
///   let path : Path = "usr\\local\\"
151
///   inspect(path.basename(), content="local")
152
/// }
153
/// ```
154
pub fn Path::basename(path : Path) -> StringView {
155
  let path = path.0
64✔
156
  let root_end = if has_win_drive(path) { 2 } else { 0 }
16✔
157
  let mut start = root_end
158
  let mut end = -1
159
  let mut matched_sep = true
160
  for i = path.length() - 1; i >= root_end; i = i - 1 {
64✔
161
    if is_win_sep_at(path, i) {
364✔
162
      if !matched_sep {
52✔
163
        start = i + 1
35✔
164
        break
165
      }
166
    } else if end == -1 {
312✔
167
      matched_sep = false
59✔
168
      end = i + 1
169
    }
170
  }
171
  if end == -1 {
172
    ""
5✔
173
  } else {
174
    path[start:end]
59✔
175
  }
176
}
177

178
///|
179
/// Returns the directory portion of the given path.
180
///
181
/// This follows Node `path.win32.dirname`: both separators are recognized and
182
/// the returned directory preserves the original separator spelling.
183
/// 
184
/// ```moonbit check
185
/// test {
186
///   let path : Path = "usr\\local\\"
187
///   inspect(path.dirname(), content="usr")
188
/// }
189
/// ```
190
pub fn Path::dirname(path : Path) -> Path {
191
  let path = path.0
34✔
192
  if path.is_empty() {
34✔
193
    return "."
1✔
194
  }
195
  let root_end = win_root_end(path)
33✔
196
  if root_end >= path.length() {
33✔
197
    return path
7✔
198
  }
199
  let mut end = -1
200
  let mut matched_sep = true
201
  for i = path.length() - 1; i >= root_end; i = i - 1 {
26✔
202
    if is_win_sep_at(path, i) {
83✔
203
      if !matched_sep {
23✔
204
        end = i
15✔
205
        break
206
      }
207
    } else {
208
      matched_sep = false
60✔
209
    }
210
  }
211
  if end == -1 {
212
    if root_end > 0 {
11✔
213
      path[0:root_end].to_owned()
8✔
214
    } else {
215
      "."
3✔
216
    }
217
  } else if end < root_end {
15✔
218
    path[0:root_end].to_owned()
×
219
  } else {
220
    path[0:end].to_owned()
15✔
221
  }
222
}
223

224
///|
225
test {
226
  let path : Path = "\\\\\\"
227
  inspect(path.prefix(), content="\\\\\\")
228
  // inspect(path.0.view)
229
}
230

231
///|
232
/// Returns the extension of the last path component.
233
///
234
/// This follows Node `path.win32.extname` dotfile rules.
235
/// 
236
/// ```moonbit check
237
/// test {
238
///   let path : Path = "archive.tar.gz"
239
///   inspect(path.extname(), content=".gz")
240
/// }
241
/// ```
242
pub fn Path::extname(path : Path) -> StringView {
243
  let basename = Path::basename(path)
34✔
244
  if basename == "." || basename == ".." {
245
    return ""
4✔
246
  }
247
  match basename.rev_find(".") {
30✔
248
    None => ""
7✔
249
    Some(0) => ""
3✔
250
    Some(idx) => basename[idx:]
20✔
251
  }
252
}
253

254
///|
255
/// Returns whether the given path is absolute.
256
/// 
257
/// edge cases: when path is empty, return false
258
/// 
259
/// ```moonbit check
260
/// test {
261
///   let path1 : Path = "C:\\Program Files\\App"
262
///   let path2 : Path = "Program Files\\App"
263
///   json_inspect(path1.is_absolute(), content=true)
264
///   json_inspect(path2.is_absolute(), content=false)
265
/// }
266
/// ```
267
pub fn Path::is_absolute(path : Path) -> Bool {
268
  let path = path.0
115✔
269
  if path.is_empty() {
115✔
270
    false
2✔
271
  } else if is_win_sep_at(path, 0) {
113✔
272
    true
38✔
273
  } else {
274
    has_win_drive(path) && path.length() > 2 && is_win_sep_at(path, 2)
64✔
275
  }
276
}
277

278
///|
279
fn Path::prefix(path : Path) -> StringView {
280
  let path = path.0
1✔
281
  lexmatch path with longest {
282
    (("\\\\\?\\" ("[^\\]+" as _symlink) "(\\)+") as prefix, _rest) => prefix
283
    (
284
      (
285
        "\\\\\?UNC\\"
286
        ("[^\\]+" as _hostname)
287
        "\\"
288
        ("[^\\]+" as _shared_folder)
289
        "(\\)+"
290
      ) as prefix,
291
      _rest
292
    ) => prefix
293
    (
294
      ("\\\\" ("[^\\]+" as _hostname) "\\" ("[^\\]+" as _shared_folder) "(\\)*") as prefix,
295
      _rest
296
    ) => prefix
297
    (("\\\\\?\\Volume[{]" ("[^}]+" as _guid) "[}]") as prefix, _rest) => prefix
298
    (("\\\\\.\\" ("[^\\]+" as _device) "(\\)+") as prefix, _rest) => prefix
299
    (("\\\\\?\\" ("[a-zA-Z]" as _letter) ":(\\)+") as prefix, _rest) => prefix
300
    ("\\+" as prefix, _rest) => prefix
301
    ((("[a-zA-Z]" as _letter) ":(\\)+") as prefix, _rest) => prefix
302
    ((("[a-zA-Z]" as _letter) ":") as prefix, _rest) => prefix
303
    ("[^a-zA-Z\\][^:][^\\]+(\\)+" as prefix, _rest) => prefix
304
    _ => ""
305
  }
306
}
307

308
///|
309
fn join_rest(lhs : StringView, rhs : StringView) -> StringView? {
310
  lexmatch rhs with longest {
×
311
    ("\\\\\?\\" ("[^\\]+" as _symlink) "(\\)+", _rest) => None
312
    (
313
      "\\\\\?UNC\\"
314
      ("[^\\]+" as _hostname)
315
      "\\"
316
      ("[^\\]+" as _shared_folder)
317
      "(\\)+",
318
      _rest
319
    ) => None
320
    (
321
      "\\\\"
322
      ("[^\\]+" as _hostname)
323
      "\\"
324
      ("[^\\]+" as _shared_folder)
325
      "(\\)+",
326
      _rest
327
    ) => None
328
    ("\\\\\?\\Volume[{]" ("[^}]+" as _guid) "[}]", _rest) => None
329
    ("\\\\\.\\" ("[^\\]+" as _device) "(\\)+", _rest) => None
330
    ("\\\\\?\\" ("[a-zA-Z]" as _letter) ":(\\)+", _rest) => None
331
    ("\\", _rest) => None
332
    (("[a-zA-Z]" as _letter) ":(\\)+", _rest) => None
333
    (("[a-zA-Z]" as rhs_letter) ":", rhs_rest) =>
334
      lexmatch lhs with longest {
335
        (("[a-zA-Z]" as lhs_letter) ":", _rest) =>
336
          if lhs_letter == rhs_letter {
337
            Some(rhs_rest)
×
338
          } else {
339
            None
×
340
          }
341
        _ => None
342
      }
343
    ("[^a-zA-Z\\][^:][^\\]+(\\)+", _rest) => None
344
    rhs_rest => Some(rhs_rest)
345
  }
346
}
347

348
///|
349
/// Concatenates two path strings with `\`, then normalizes the result.
350
///
351
/// Absolute right-hand paths and different-drive paths do not override `lhs`.
352
/// 
353
/// ```moonbit check
354
/// test {
355
///   let path1 : Path = "C::\\Program Files"
356
///   let path2 : Path = "App"
357
///   inspect(path1.join(path2), content="C::\\Program Files\\App")
358
/// }
359
/// ```
360
pub fn Path::join(lhs : Path, rhs : Path) -> Path {
361
  let lhs = lhs.0
45✔
362
  let rhs = rhs.0
363
  if lhs.is_empty() && rhs.is_empty() {
3✔
364
    return "."
1✔
365
  }
366
  if lhs.is_empty() {
44✔
367
    return Path::normalize(rhs)
2✔
368
  }
369
  if rhs.is_empty() {
42✔
370
    return Path::normalize(lhs)
1✔
371
  }
372
  let builder = StringBuilder::new()
41✔
373
  builder.write_stringview(lhs)
41✔
374
  builder.write_char('\\')
41✔
375
  builder.write_stringview(rhs)
41✔
376
  Path::normalize(builder.to_string())
41✔
377
}
378

379
///|
380
/// 1. resolve `.` by directly removing it
381
/// 2. resolve `..` by removing the preceding path component if exists, otherwise keep it.
382
/// 3. remove redundant backslashes 
383
/// 4. preserve trailing slash or backslash
384
/// 
385
/// edge cases:
386
///   1. when path is empty, return `.`
387
/// 
388
/// ```moonbit check
389
/// test {
390
///   let path : Path = "C:\\usr\\\\local\\..\\bin\\"
391
///   inspect(path.normalize(), content="C:\\usr\\bin\\")
392
///
393
///   let rooted_path : Path = "/usr//local/../bin/"
394
///   inspect(rooted_path.normalize(), content="\\usr\\bin\\")
395
/// }
396
/// ```
397
pub fn Path::normalize(path : Path) -> Path {
398
  WinPath::parse(path.0).to_string()
173✔
399
}
400

401
///|
402
fn is_regular_win_root(path : WinPath) -> Bool {
403
  if path.components.is_empty() {
73✔
404
    match path.prefix {
9✔
405
      Root | VolumeLetterRoot(_) | UNC(_) => true
6✔
406
      _ => false
3✔
407
    }
408
  } else {
409
    false
64✔
410
  }
411
}
412

413
///|
414
fn trim_resolved_trailing_separators(path : Path) -> Path {
415
  let parsed = WinPath::parse(path.0)
73✔
416
  if is_regular_win_root(parsed) {
73✔
417
    return path
6✔
418
  }
419
  let raw = path.0
420
  if raw is [.. rest, lastchar] && is_win_sep(lastchar) {
67✔
421
    Path(rest.to_owned())
9✔
422
  } else {
423
    path
58✔
424
  }
425
}
426

427
///|
428
/// Return the `to` relative path when `from` is the current working directory.
429
/// 
430
/// property: `@path.join(from,@path.relative(from,to)) == @path.normalize(to)`
431
/// 
432
/// edge cases: 
433
///  1. when `@path.normalize(from) == @path.normalize(to)`, return empty string
434
///  2. when roots differ, return the resolved target path
435
/// 
436
/// Warning: cwd is already resolve symbolic link and normalized, but path doesn't.
437
/// 
438
/// ```moonbit check
439
/// test {
440
///   let path : Path = "C:\\Users\\Alice\\Documents\\Project"
441
///   let base : Path = "C:\\Users\\Alice"
442
///   inspect(path.relative(base~), content="Documents\\Project")
443
///
444
///   let base : Path = "/usr/local/bin"
445
///   let path : Path = "/usr/local/share/doc"
446
///   inspect(path.relative(base~), content="..\\share\\doc")
447
/// }
448
/// ```
449
pub fn Path::relative(path : Path, base~ : Path) -> Path {
450
  let resolved_from = Path::resolve(base)
24✔
451
  let resolved_to = Path::resolve(path)
24✔
452
  let from = WinPath::parse(resolved_from.0)
24✔
453
  let to = WinPath::parse(resolved_to.0)
24✔
454
  if from.prefix.to_string().equal_ignore_ascii_case(to.prefix.to_string()) {
24✔
455
    let mut cnt = 0
22✔
456
    // remove the common path component prefix
457
    for left = from.components[:], right = to.components[:] {
458
      match (left, right) {
55✔
459
        ([x, .. xs], [y, .. ys]) if x.equal_ignore_ascii_case(y) => {
33✔
460
          cnt += 1
461
          continue xs, ys
462
        }
463
        _ => break
22✔
464
      }
465
    }
466
    let components : Array[StringView] = []
467
    let goto_parent_cnt = from.components.length() - cnt
22✔
468
    for _ in 0..<goto_parent_cnt {
469
      components.push("..")
14✔
470
    }
471
    let rest_path = to.components[cnt:]
472
    for comp in rest_path {
473
      components.push(comp)
15✔
474
    }
475
    if components.is_empty() {
22✔
476
      ""
6✔
477
    } else {
478
      let path = WinPath::{
16✔
479
        prefix: None,
480
        trailing_separator: false,
481
        components,
482
      }
483
      path.to_string()
16✔
484
    }
485
  } else {
486
    resolved_to
2✔
487
  }
488
}
489

490
///|
491
/// 1. if path is absolute, return path normalized.
492
/// 2. if path is relative, join current working directory and path, and then normalize it.
493
///
494
/// Warning: cwd is already resolve symbolic link and normalized, but path doesn't.
495
pub fn Path::resolve(path : Path) -> Path {
496
  let raw_path = path.0
73✔
497
  let resolved = if Path::is_absolute(path) {
73✔
498
    Path::normalize(path)
68✔
499
  } else if is_drive_relative(raw_path) {
5✔
500
    let base = Path::normalize(current_dir_for_drive(raw_path[0:2]))
5✔
501
    let tail = raw_path[2:]
502
    if tail.is_empty() {
5✔
503
      base
1✔
504
    } else {
505
      Path::join(base, tail.to_owned()) |> Path::normalize
4✔
506
    }
507
  } else {
508
    Path::join(@env.current_dir().unwrap(), path) |> Path::normalize
×
509
  }
510
  trim_resolved_trailing_separators(resolved)
73✔
511
}
512

513
///|
514
/// OS platform specific path delimiter for environment variables. e.g., PATH
515
pub let delimiter : Char = if true { ';' } else { ':' }
1✔
516

517
///|
518
/// OS platform specific path component separator.
519
pub let sep : Char = if true { '\\' } else { '/' }
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc