diff --git a/.agents/skills/dang-language/SKILL.md b/.agents/skills/dang-language/SKILL.md index 4cec41fb..09e03776 100644 --- a/.agents/skills/dang-language/SKILL.md +++ b/.agents/skills/dang-language/SKILL.md @@ -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 | diff --git a/.agents/skills/dang-language/reference/stdlib.md b/.agents/skills/dang-language/reference/stdlib.md index d5a42b9f..03a1f909 100644 --- a/.agents/skills/dang-language/reference/stdlib.md +++ b/.agents/skills/dang-language/reference/stdlib.md @@ -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 @@ -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 → `: invalid enum value "X" for ` +## `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)` diff --git a/docs/go/stdlib.go b/docs/go/stdlib.go index 6788eb4e..819e40cc 100644 --- a/docs/go/stdlib.go +++ b/docs/go/stdlib.go @@ -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 diff --git a/docs/lit/reference/stdlib.md b/docs/lit/reference/stdlib.md index 167c5c88..3f4d416a 100644 --- a/docs/lit/reference/stdlib.md +++ b/docs/lit/reference/stdlib.md @@ -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 @@ -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} diff --git a/pkg/dang/builtins_test.go b/pkg/dang/builtins_test.go index edf19a72..ced5fcf6 100644 --- a/pkg/dang/builtins_test.go +++ b/pkg/dang/builtins_test.go @@ -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 { diff --git a/pkg/dang/env.go b/pkg/dang/env.go index 756b56b2..e4a38b6b 100644 --- a/pkg/dang/env.go +++ b/pkg/dang/env.go @@ -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) diff --git a/pkg/dang/stdlib.go b/pkg/dang/stdlib.go index e8ffa6d8..ca92e8c7 100644 --- a/pkg/dang/stdlib.go +++ b/pkg/dang/stdlib.go @@ -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() diff --git a/pkg/dang/stdlib_path.go b/pkg/dang/stdlib_path.go new file mode 100644 index 00000000..1143be8c --- /dev/null +++ b/pkg/dang/stdlib_path.go @@ -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 +} diff --git a/tests/test_path.dang b/tests/test_path.dang new file mode 100644 index 00000000..fb20a9d8 --- /dev/null +++ b/tests/test_path.dang @@ -0,0 +1,73 @@ +# Test Path module functions + +# Path.base mirrors Go path.Base. +assert { Path.base("/usr/local/bin") == "bin" } +assert { Path.base("") == "." } +assert { Path.base("/") == "/" } + +# Path.clean mirrors Go path.Clean. +assert { Path.clean("/a/b/../c//") == "/a/c" } +assert { Path.clean("") == "." } +assert { Path.clean("a/./b") == "a/b" } + +# Path.contains is inclusive and cleans both paths first. +assert { Path.contains("/foo/bar", "/foo/bar") == true } +assert { Path.contains("/foo/bar", "/foo/bar/baz") == true } +assert { Path.contains("/foo/bar", "/foo/baz") == false } +assert { Path.contains("/foo/bar/.", "/foo/bar/baz/..") == true } +assert { Path.contains("foo/bar", "foo/bar/baz") == true } +assert { Path.contains("foo/bar", "foo/baz") == false } +assert { Path.contains("/foo/bar", "foo/bar") == false } +assert { Path.contains(".", "foo") == true } +assert { Path.contains(".", "../foo") == false } + +# Path.dir mirrors Go path.Dir. +assert { Path.dir("/usr/local/bin") == "/usr/local" } +assert { Path.dir("file.txt") == "." } +assert { Path.dir("/") == "/" } + +# Path.ext mirrors Go path.Ext. +assert { Path.ext("archive.tar.gz") == ".gz" } +assert { Path.ext("Makefile") == "" } +assert { Path.ext("/a.b/c") == "" } + +# Path.isAbs mirrors Go path.IsAbs. +assert { Path.isAbs("/usr/local") == true } +assert { Path.isAbs("usr/local") == false } + +# Path.join mirrors Go path.Join, with a list for Go's variadic arguments. +assert { Path.join(["a", "b", "c.txt"]) == "a/b/c.txt" } +assert { Path.join(["", "a", "b/../c"]) == "a/c" } +let empty: [String!]! = [] +assert { Path.join(empty) == "" } + +# Path.match mirrors Go path.Match. +assert { Path.match("*.dang", "main.dang") == true } +assert { Path.match("*.dang", "main.go") == false } +assert { Path.match("a/*", "a/b/c") == false } + +# Path.rel mirrors filepath.Rel's lexical behavior with slash-separated paths. +assert { Path.rel("/a/b", "/a/b/c/d") == "c/d" } +assert { Path.rel("/a/b", "/a/c") == "../c" } +assert { Path.rel("/a/b", "/a/b") == "." } +assert { Path.rel("/", "/a/b") == "a/b" } +assert { Path.rel("a/b", "a/b/c") == "c" } +assert { Path.rel("/a/b/./c", "/a/b/d/../c/e") == "e" } + +let relErr = try { + Path.rel("/a", "a") +} catch { + err => err.message +} +assert { relErr.contains("can't make") } + +# Path.split mirrors Go path.Split, returning an object with dir and file fields. +let split = Path.split("a/b/c.txt") +assert { split.dir == "a/b/" } +assert { split.file == "c.txt" } + +let simple = Path.split("file.txt") +assert { simple.dir == "" } +assert { simple.file == "file.txt" } + +print("Path module tests passed!")