Skip to content
Open

Paths #154

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/dang-language/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Load the reference file that matches the question:
| `reference/types.md` | built-in types, the `!` nullability sigil, list nullability matrix, null propagation, `::` type hints/casts, coercion rules, **flow-sensitive narrowing** (and its gaps), **enums**, **custom scalars** |
| `reference/objects.md` | **fields** & `let`, **functions** & `&fn` refs, **blocks** & control-flow handoff, **`type` objects** & constructors (`new`), `self`, computed fields, **mutation / copy-on-write**, **interfaces** & **unions** + variance |
| `reference/control-flow.md` | `if`/`else`, `case` (value + type patterns), `loop`, `break`/`continue`/`return`, **errors** (`try`/`catch`/`raise`, the `Error` interface, when to raise vs. return null) |
| `reference/stdlib.md` | top-level builtins (`assert`/`print`/`loop`/`toString`), **`String!` methods** (incl. regex/`Match`, base64), **list `[T]!` methods**, **`JSON`/`YAML`/`TOML` codec namespaces** (`encode`/`decode`), `Random`, `UUID`, error types |
| `reference/stdlib.md` | top-level builtins (`assert`/`print`/`loop`/`toString`), **`String!` methods** (incl. regex/`Match`, base64), **list `[T]!` methods**, **`JSON`/`YAML`/`TOML` codec namespaces** (`encode`/`decode`), `Path`, `Random`, `UUID`, error types |
| `reference/graphql.md` | **GraphQL interop** (selection, inline fragments, laziness/forcing, mutations), **modules** & directory modules, **`dang.toml`**, `import`, shadowing, **directives** |
| `reference/cli.md` | the `dang` CLI, `dang fmt`, the REPL and its `:` commands, exit codes, LSP/editor integration |

Expand Down
15 changes: 14 additions & 1 deletion .agents/skills/dang-language/reference/stdlib.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The built-in surface of Dang itself (everything here is available without any

`print` and `assert` return `null` — there is no `Void` type.

JSON/YAML/TOML conversions are **not** top-level functions — they live in the `JSON`/`YAML`/`TOML` codec namespaces (`.encode` / `.decode`; see [JSON, YAML, and TOML](#json-yaml-and-toml) below). base64 lives on `String!` (`.toBase64` / `.fromBase64`).
JSON/YAML/TOML conversions are **not** top-level functions — they live in the `JSON`/`YAML`/`TOML` codec namespaces (`.encode` / `.decode`; see [JSON, YAML, and TOML](#json-yaml-and-toml) below). Slash-separated path helpers live under `Path`. base64 lives on `String!` (`.toBase64` / `.fromBase64`).

## `String!` methods

Expand Down Expand Up @@ -119,6 +119,19 @@ let cfg: Settings! = TOML.decode("count = 1") # TOML's top level is always a t
- wrong type for field → raises
- invalid enum value → `<path>: invalid enum value "X" for <Enum>`

## `Path` module
- Mirrors Go's slash-separated `path` package, not OS-specific filesystem paths.
- `Path.base(path: String!) -> String!`
- `Path.clean(path: String!) -> String!`
- `Path.contains(base: String!, path: String!) -> Boolean!` — inclusive containment after cleaning; mixed absolute/relative paths are false
- `Path.dir(path: String!) -> String!`
- `Path.ext(path: String!) -> String!`
- `Path.isAbs(path: String!) -> Boolean!`
- `Path.join(elements: [String!]!) -> String!` — list form of Go's variadic `Join`
- `Path.match(pattern: String!, name: String!) -> Boolean!` — raises if the pattern syntax is invalid
- `Path.rel(base: String!, path: String!) -> String!` — `filepath.Rel`-style lexical relative path, but slash-separated
- `Path.split(path: String!) -> Path.Split!` — returns `.dir` and `.file` fields

## `Random` module
- `Random.int(min: Int!, max: Int!) -> Int!` — `min` inclusive, `max` exclusive (errors if `min >= max`)
- `Random.float -> Float!` — `[0.0, 1.0)`
Expand Down
10 changes: 5 additions & 5 deletions docs/go/stdlib.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import (
)

// The standard library reference is generated straight from the builtin
// registry in pkg/dang (see stdlib.go, stdlib_random.go, stdlib_regexp.go,
// assert.go). Each entry's signature comes from the registered parameter,
// block, and return types; its description comes from the builtin's .Doc(...),
// and its pre-seeded REPL from the builtin's .Example(...). Editing a builtin
// updates this page — there is nothing to hand-maintain.
// registry in pkg/dang (see stdlib*.go and assert.go). Each entry's signature
// comes from the registered parameter, block, and return types; its description
// comes from the builtin's .Doc(...), and its pre-seeded REPL from the builtin's
// .Example(...). Editing a builtin updates this page — there is nothing to
// hand-maintain.
//
// The layout mirrors vito/bass's stdlib page: every definition becomes an
// anchored card titled by its signature, and a long group is preceded by a
Expand Down
12 changes: 11 additions & 1 deletion docs/lit/reference/stdlib.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Meta: alphabetical reference, not a tutorial. Each entry: signature, one-line description, one tiny example. Group by module/receiver. Cross-link to the conceptual page that introduces the API.

> This page is generated from the builtin registry in `pkg/dang` (`stdlib.go`, `stdlib_random.go`, `stdlib_regexp.go`, `assert.go`). Each entry's signature, one-line description, and example come straight from the builtin's definition, so the reference can't drift from the implementation — to change an entry, edit the builtin's `.Doc(...)` or `.Example(...)`. Each example is a live REPL: press **Run** to evaluate it (and then keep typing — state carries across entries, just like the CLI).
> This page is generated from the builtin registry in `pkg/dang` (`stdlib*.go`, `assert.go`). Each entry's signature, one-line description, and example come straight from the builtin's definition, so the reference can't drift from the implementation — to change an entry, edit the builtin's `.Doc(...)` or `.Example(...)`. Each example is a live REPL: press **Run** to evaluate it (and then keep typing — state carries across entries, just like the CLI).

\table-of-contents

Expand Down Expand Up @@ -50,6 +50,16 @@

\stdlib-statics{TOML}

## `Path` module

> Slash-separated lexical path manipulation. Most functions match Go's `path` package rather than OS-specific filesystem paths; `Path.rel` follows `filepath.Rel` semantics with slash separators. `Path.contains` is inclusive (equal paths count), `Path.join` takes a list because Dang has no variadic arguments, and `Path.split` returns a `Path.Split!` object with `.dir` and `.file` fields.

\stdlib-statics{Path}

## `Path.Split!` methods

\stdlib-methods{Split}

## `Random` module

\stdlib-statics{Random}
Expand Down
16 changes: 16 additions & 0 deletions pkg/dang/builtins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ func TestBuiltinRegistryClassifiesDefinitions(t *testing.T) {
}
}

pathMethods := map[string]bool{}
ForEachStaticMethod(PathModule, func(def BuiltinDef) {
if !def.IsStatic || def.HostModule != PathModule {
t.Fatalf("path static iterator returned wrong builtin: %+v", def)
}
pathMethods[def.Name] = true
})
for _, name := range []string{"base", "clean", "contains", "dir", "ext", "isAbs", "join", "match", "rel", "split"} {
if !pathMethods[name] {
t.Fatalf("Path.%s was not registered", name)
}
if functionNames[name] {
t.Fatalf("Path.%s leaked into top-level functions", name)
}
}

randomIndex, uuidIndex := -1, -1
for i, module := range StaticModules() {
switch module {
Expand Down
2 changes: 2 additions & 0 deletions pkg/dang/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,10 @@ func init() {
// Install built-in modules (as both objects and values)
Prelude.AddObject("Random", RandomModule)
Prelude.AddObject("UUID", UUIDModule)
Prelude.AddObject("Path", PathModule)
Prelude.Add("Random", hm.NewScheme(nil, hm.NonNullType{Type: RandomModule}))
Prelude.Add("UUID", hm.NewScheme(nil, hm.NonNullType{Type: UUIDModule}))
Prelude.Add("Path", hm.NewScheme(nil, hm.NonNullType{Type: PathModule}))

// Install regex types so user code can refer to them by name.
Prelude.AddObject("Regexp", RegexpType)
Expand Down
1 change: 1 addition & 0 deletions pkg/dang/stdlib.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
// This is called from init() in env.go after type definitions are set up
func registerStdlib() {
registerRandomAndUUID()
registerPath()
registerCodecs()
registerAssert()
registerRegexp()
Expand Down
236 changes: 236 additions & 0 deletions pkg/dang/stdlib_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package dang

import (
"context"
"fmt"
stdpath "path"
"strings"
)

// PathModule is the "Path" namespace for slash-separated path manipulation.
var PathModule = NewType("Path", ObjectKind)

// PathSplitType is the object returned by Path.split.
var PathSplitType = NewType("Split", ObjectKind)

func registerPath() {
PathModule.SetTypeDocString("functions for manipulating slash-separated paths")
PathSplitType.SetTypeDocString("the directory and file parts returned by Path.split")
PathModule.AddObject("Split", PathSplitType)
registerPathSplitMethods()

// Path.base(path: String!) -> String!
StaticMethod(PathModule, "base").
Doc("returns the last element of path, after removing trailing slashes").
Example(`Path.base("/usr/local/bin")`).
Params("path", NonNull(StringType)).
Returns(NonNull(StringType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
return ToValue(stdpath.Base(args.GetString("path")))
})

// Path.clean(path: String!) -> String!
StaticMethod(PathModule, "clean").
Doc("returns the shortest slash-separated path name equivalent to path").
Example(`Path.clean("/a/b/../c//")`).
Params("path", NonNull(StringType)).
Returns(NonNull(StringType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
return ToValue(stdpath.Clean(args.GetString("path")))
})

// Path.contains(base: String!, path: String!) -> Boolean!
StaticMethod(PathModule, "contains").
Doc("reports whether path is equal to base or contained within base after cleaning both paths").
Example(`Path.contains("/usr/local", "/usr/local/bin")`).
Params("base", NonNull(StringType), "path", NonNull(StringType)).
Returns(NonNull(BooleanType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
contained := pathContains(args.GetString("base"), args.GetString("path"))
return ToValue(contained)
})

// Path.dir(path: String!) -> String!
StaticMethod(PathModule, "dir").
Doc("returns all but the last element of path, typically after cleaning").
Example(`Path.dir("/usr/local/bin")`).
Params("path", NonNull(StringType)).
Returns(NonNull(StringType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
return ToValue(stdpath.Dir(args.GetString("path")))
})

// Path.ext(path: String!) -> String!
StaticMethod(PathModule, "ext").
Doc("returns the file name extension used by path").
Example(`Path.ext("archive.tar.gz")`).
Params("path", NonNull(StringType)).
Returns(NonNull(StringType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
return ToValue(stdpath.Ext(args.GetString("path")))
})

// Path.isAbs(path: String!) -> Boolean!
StaticMethod(PathModule, "isAbs").
Doc("reports whether path is absolute").
Example(`Path.isAbs("/usr/local/bin")`).
Params("path", NonNull(StringType)).
Returns(NonNull(BooleanType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
return ToValue(stdpath.IsAbs(args.GetString("path")))
})

// Path.join(elements: [String!]!) -> String!
StaticMethod(PathModule, "join").
Doc("joins any number of path elements into a single clean slash-separated path").
Example(`Path.join(["a", "b", "c.txt"])`).
Params("elements", NonNull(ListOf(NonNull(StringType)))).
Returns(NonNull(StringType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
elements := args.GetList("elements")
parts := make([]string, len(elements))
for i, elem := range elements {
str, ok := elem.(StringValue)
if !ok {
return nil, fmt.Errorf("path.join: element %d must be String!, got %T", i, elem)
}
parts[i] = str.Val
}
return ToValue(stdpath.Join(parts...))
})

// Path.match(pattern: String!, name: String!) -> Boolean!
StaticMethod(PathModule, "match").
Doc("reports whether name matches the shell-style path pattern").
Example(`Path.match("*.dang", "main.dang")`).
Params("pattern", NonNull(StringType), "name", NonNull(StringType)).
Returns(NonNull(BooleanType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
matched, err := stdpath.Match(args.GetString("pattern"), args.GetString("name"))
if err != nil {
return nil, fmt.Errorf("path.match: %w", err)
}
return ToValue(matched)
})

// Path.rel(base: String!, path: String!) -> String!
StaticMethod(PathModule, "rel").
Doc("returns a relative path lexically equivalent to path when joined to base, using slash-separated paths").
Example(`Path.rel("/usr/local", "/usr/local/bin")`).
Params("base", NonNull(StringType), "path", NonNull(StringType)).
Returns(NonNull(StringType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
rel, err := pathRel(args.GetString("base"), args.GetString("path"))
if err != nil {
return nil, fmt.Errorf("path.rel: %w", err)
}
return ToValue(rel)
})

// Path.split(path: String!) -> Path.Split!
StaticMethod(PathModule, "split").
Doc("splits path immediately after the final slash, separating it into dir and file fields").
Example(`Path.split("a/b/c.txt").file`).
Params("path", NonNull(StringType)).
Returns(NonNull(PathSplitType)).
Impl(func(ctx context.Context, args Args) (Value, error) {
dir, file := stdpath.Split(args.GetString("path"))
result := NewObject(PathSplitType)
result.Bind("dir", StringValue{Val: dir}, PublicVisibility)
result.Bind("file", StringValue{Val: file}, PublicVisibility)
return result, nil
})
}

// registerPathSplitMethods declares Split's members for the type checker and
// the stdlib reference. Path.split binds these as ordinary fields on its result;
// these registrations keep docs/examples coherent with that shape.
func registerPathSplitMethods() {
Method(PathSplitType, "dir").
Doc("the path prefix ending immediately after the final slash, or empty if there is no slash").
Example(`Path.split("a/b/c.txt").dir`).
Returns(NonNull(StringType)).
Impl(pathSplitMember("dir"))

Method(PathSplitType, "file").
Doc("the path suffix after the final slash").
Example(`Path.split("a/b/c.txt").file`).
Returns(NonNull(StringType)).
Impl(pathSplitMember("file"))
}

func pathSplitMember(name string) func(context.Context, Value, Args) (Value, error) {
return func(ctx context.Context, self Value, _ Args) (Value, error) {
v, _, err := self.(ValueScope).Lookup(ctx, name)
return v, err
}
}

func pathContains(base, target string) bool {
rel, err := pathRel(base, target)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, "../"))
}

// pathRel is filepath.Rel adapted to Go's slash-only path package semantics.
func pathRel(basepath, targpath string) (string, error) {
base := stdpath.Clean(basepath)
targ := stdpath.Clean(targpath)
if targ == base {
return ".", nil
}
if stdpath.IsAbs(base) != stdpath.IsAbs(targ) {
return "", fmt.Errorf("can't make %q relative to %q", targpath, basepath)
}
if base == "." {
base = ""
}

bl := len(base)
tl := len(targ)
var b0, bi, t0, ti int
for {
for bi < bl && base[bi] != '/' {
bi++
}
for ti < tl && targ[ti] != '/' {
ti++
}
if targ[t0:ti] != base[b0:bi] {
break
}
if bi < bl {
bi++
}
if ti < tl {
ti++
}
b0 = bi
t0 = ti
}
if base[b0:bi] == ".." {
return "", fmt.Errorf("can't make %q relative to %q", targpath, basepath)
}
if b0 != bl {
seps := strings.Count(base[b0:bl], "/")
size := 2 + seps*3
if tl != t0 {
size += 1 + tl - t0
}
buf := make([]byte, size)
n := copy(buf, "..")
for range seps {
buf[n] = '/'
copy(buf[n+1:], "..")
n += 3
}
if t0 != tl {
buf[n] = '/'
copy(buf[n+1:], targ[t0:])
}
return string(buf), nil
}
return targ[t0:], nil
}
Loading