feat: add path and collection standard library helpers

This commit is contained in:
Hermes Agent
2026-07-09 21:04:25 -06:00
parent f907cfb63c
commit c66c122c30
10 changed files with 182 additions and 42 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## v1.18.26-dev standard-library reliability update
- Added a tested `lib/collections.claro` module with list and map helpers.
- Made the existing `lib/path.claro` import provide working native helpers for joining and inspecting paths.
- Added golden tests for both modules.
- Normalized CRLF/LF golden-test expectations so `claro test` reflects interpreter behavior across Windows and Unix checkouts.
- Replaced stale standard-library command claims with current, runnable documentation.
## v1.18.26-dev object field CHECK TYPE unknown-field diagnostic
- Added a focused negative `claro typecheck` fixture for direct object-field metadata checks against an undeclared field: `CHECK TYPE player.level IS NUMBER` after `NEW Player player` when the class only declares `HAS score NUMBER`.
+20
View File
@@ -239,6 +239,26 @@ packages/
Package names are checked so unsafe names such as `../bad` are rejected.
## Standard-library path and collection helpers
Claro includes tested, namespaced helpers for everyday programs:
```claro
IMPORT "lib/path.claro" AS path
IMPORT "lib/collections.claro" AS collections
CALL path.join WITH "notes", "today.txt"
SAY RESULT
SET names AS LIST OF TEXT TO LIST
ADD "Ada" TO names
ADD "Grace" TO names
CALL collections.join WITH names, " and "
SAY RESULT
```
The `path` module includes `join`, `basename`, `dirname`, `ext`, `stem`, and `absolute`. The `collections` module includes list length/search/join/reverse helpers and map key/value/default-access helpers. See `docs/STDLIB.md` for the complete, current API.
## Networking
v1.18.26 adds safer beginner networking commands with offline `claro://` test URLs.
+1 -1
View File
@@ -22,7 +22,7 @@ Ready now:
- choices with `IF` / `ELSE` / `END`
- loops
- lists and maps
- files, JSON, and text helpers
- files, JSON, text helpers, and tested path/collection library helpers
- friendly checking with `claro check`
Good starting docs:
+59 -39
View File
@@ -1,56 +1,76 @@
# Claro Standard Library (Commands)
# Claro Standard Library
Claro’s “stdlib” is exposed as readable commands.
Claro keeps common tasks readable. Most beginner programs can use commands such as `TEXT LENGTH`, `COUNT`, `GET`, `PUT`, `READ FILE`, and `WRITE FILE` directly. For reusable helper functions, import a standard-library module.
## TEXT
## Path helpers
### Split
```claro
TEXT SPLIT "a,b,c" BY "," AS parts
IMPORT "lib/path.claro" AS path
CALL path.join WITH "notes", "today.txt"
SAY RESULT # notes/today.txt
CALL path.basename WITH "notes/today.txt"
SAY RESULT # today.txt
CALL path.dirname WITH "notes/today.txt"
SAY RESULT # notes
CALL path.ext WITH "notes/today.txt"
SAY RESULT # txt
CALL path.stem WITH "notes/today.txt"
SAY RESULT # today
CALL path.absolute WITH "/home/learner"
SAY RESULT # YES
```
### Join
`path.join` returns portable `/`-separated paths. `path.absolute` recognizes `/`, `\\`, and Windows drive-letter paths.
## Collection helpers
```claro
TEXT JOIN parts BY "-" AS out
IMPORT "lib/collections.claro" AS collections
SET names AS LIST OF TEXT TO LIST
ADD "Ada" TO names
ADD "Grace" TO names
CALL collections.length WITH names
SAY RESULT # 2
CALL collections.join WITH names, " and "
SAY RESULT # Ada and Grace
CALL collections.contains WITH names, "Grace"
SAY RESULT # YES
CALL collections.index WITH names, "Grace"
SAY RESULT # 2
```
### Replace all
```claro
TEXT REPLACE "one two two" FROM "two" TO "three" AS out
```
Available list helpers:
## PATH
- `collections.length WITH items`
- `collections.contains WITH items, value`
- `collections.index WITH items, value` — returns a **1-based** position, or `0` when absent
- `collections.join WITH items, separator`
- `collections.reversed WITH items` — returns a new list; the source list is unchanged
### Dirname
```claro
PATH DIRNAME "C:/games/save/file.txt" AS dir
```
Available map helpers:
### Normalize
Unifies slashes and removes `.` / `..` where possible:
```claro
PATH NORMALIZE "../data/./levels/../sprites" AS clean
```
- `collections.has_key WITH values, key`
- `collections.get WITH values, key, fallback`
- `collections.keys WITH values`
- `collections.values WITH values`
### Is absolute
```claro
PATH ISABS "/home/me/file.txt" AS isAbs
```
Use the returned value through `RESULT`, exactly as with other Claro function calls.
### Exists
Checks if a file exists at the path:
```claro
PATH EXISTS "config.json" AS ok
```
## Existing modules
## TIME
- `lib/text.claro`: text case checks and simple splitting
- `lib/math.claro`: numeric helpers
- `lib/random.claro`: deterministic seed and integer helpers
- `lib/csv.claro`: CSV read/write helpers
### Unix seconds
```claro
TIME NOW AS t
```
## Commands rather than promises
### Unix milliseconds (coarse)
```claro
TIME NOWMS AS ms
```
The `PATH ...` and `TIME ...` command forms in older planning material are not part of the stable Claro command set. Use the documented `path` module above and the core file commands instead. This page describes the APIs currently shipped and validated in the stable executable.
+6
View File
@@ -0,0 +1,6 @@
# Claro Standard Library: collections
# Use: IMPORT "lib/collections.claro" AS collections
#
# This module provides beginner-friendly helpers for LIST and MAP values.
# All list positions are 1-based so they match GET ... AT and FIND.
EXPORT ALL
+25 -2
View File
@@ -296,6 +296,27 @@ static Value builtin_call(Runtime *rt,const char *name,Value *args,int argc,int
if(strcmp(kind,"random")==0){ if(strcmp(fn,"seed")==0){ srand((unsigned)v_number(args[0])); return v_none(); } if(strcmp(fn,"int")==0){ int a=(int)v_number(args[0]), b=(int)v_number(args[1]); int r=a + (rand() % (b-a+1)); return v_num(r); } }
if(strcmp(kind,"csv")==0){ if(strcmp(fn,"read")==0){ char *txt=argc>0?v_to_string(args[0]):xstrdup(""); char *del=argc>1?v_to_string(args[1]):xstrdup(","); char d=del[0]?del[0]:','; Value rows=v_list(); const char *p=txt; while(*p){ Value row=v_list(); Str field; str_init(&field); int inq=0; while(*p&&*p!='\n'&&*p!='\r'){ if(*p=='"'){ if(inq&&p[1]=='"'){ str_ch(&field,'"'); p+=2; continue;} inq=!inq; p++; continue;} if(!inq&&*p==d){ list_add(row.list,v_str(field.s?field.s:"")); field.len=0; if(field.s) field.s[0]=0; p++; continue;} str_ch(&field,*p++); } list_add(row.list,v_str(field.s?field.s:"")); free(field.s); list_add(rows.list,row); while(*p=='\r'||*p=='\n') p++; } free(txt); free(del); return rows; }
if(strcmp(fn,"write")==0){ char *del=argc>1?v_to_string(args[1]):xstrdup(","); char d=del[0]?del[0]:','; Str out; str_init(&out); int i,j; if(argc>0&&args[0].type==V_LIST){ for(i=0;i<args[0].list->count;i++){ Value row=args[0].list->items[i]; if(row.type==V_LIST){ for(j=0;j<row.list->count;j++){ char *f=v_to_string(row.list->items[j]); int quote=strchr(f,d)||strchr(f,'\n')||strchr(f,'"'); if(j) str_ch(&out,d); if(quote){ const char *q=f; str_ch(&out,'"'); while(*q){ if(*q=='"') str_add(&out,"\"\""); else str_ch(&out,*q); q++; } str_ch(&out,'"'); } else str_add(&out,f); free(f); } } str_ch(&out,'\n'); } } free(del); return v_str(out.s?out.s:""); } }
if(strcmp(kind,"path")==0){
char *a=argc>0?v_to_string(args[0]):xstrdup("");
if(strcmp(fn,"join")==0){ char *b=argc>1?v_to_string(args[1]):xstrdup(""); Str out; size_t n=strlen(a); str_init(&out); str_add(&out,a); if(a[0]&&b[0]&&a[n-1]!='/'&&a[n-1]!='\\') str_ch(&out,'/'); while(*b=='/'||*b=='\\') b++; str_add(&out,b); free(a); return v_str(out.s?out.s:""); }
if(strcmp(fn,"basename")==0){ char *slash=strrchr(a,'/'), *back=strrchr(a,'\\'), *cut=slash; if(back&&(!cut||back>cut)) cut=back; Value r=v_str(cut?cut+1:a); free(a); return r; }
if(strcmp(fn,"dirname")==0){ char *slash=strrchr(a,'/'), *back=strrchr(a,'\\'), *cut=slash; if(back&&(!cut||back>cut)) cut=back; if(!cut){ free(a); return v_str(""); } if(cut==a){ free(a); return v_str("/"); } *cut=0; { Value r=v_str(a); free(a); return r; } }
if(strcmp(fn,"ext")==0||strcmp(fn,"stem")==0){ char *slash=strrchr(a,'/'), *back=strrchr(a,'\\'), *base=slash; char *dot; if(back&&(!base||back>base)) base=back; base=base?base+1:a; dot=strrchr(base,'.'); if(!dot||dot==base){ Value r=v_str(strcmp(fn,"stem")==0?base:""); free(a); return r; } if(strcmp(fn,"ext")==0){ Value r=v_str(dot+1); free(a); return r; } *dot=0; { Value r=v_str(base); free(a); return r; } }
if(strcmp(fn,"absolute")==0){ int yes=(a[0]=='/'||a[0]=='\\'||((isalpha((unsigned char)a[0]))&&a[1]==':')); free(a); return v_bool(yes); }
free(a);
}
if(strcmp(kind,"collections")==0){
Value source=argc>0?args[0]:v_none();
if(strcmp(fn,"length")==0){ if(source.type==V_LIST&&source.list) return v_num(source.list->count); if(source.type==V_MAP&&source.map) return v_num(source.map->count); return v_num(0); }
if(strcmp(fn,"contains")==0&&source.type==V_LIST&&source.list){ int i; Value needle=argc>1?args[1]:v_none(); for(i=0;i<source.list->count;i++) if(value_compare(source.list->items[i],needle)==0) return v_bool(1); return v_bool(0); }
if(strcmp(fn,"index")==0&&source.type==V_LIST&&source.list){ int i; Value needle=argc>1?args[1]:v_none(); for(i=0;i<source.list->count;i++) if(value_compare(source.list->items[i],needle)==0) return v_num(i+1); return v_num(0); }
if(strcmp(fn,"join")==0&&source.type==V_LIST&&source.list){ char *separator=argc>1?v_to_string(args[1]):xstrdup(""); Str out; int i; str_init(&out); for(i=0;i<source.list->count;i++){ char *part=v_to_string(source.list->items[i]); if(i) str_add(&out,separator); str_add(&out,part); free(part); } free(separator); return v_str(out.s?out.s:""); }
if(strcmp(fn,"reversed")==0&&source.type==V_LIST&&source.list){ Value out=v_list(); int i; for(i=source.list->count-1;i>=0;i--) list_add(out.list,source.list->items[i]); return out; }
if(strcmp(fn,"has_key")==0&&source.type==V_MAP&&source.map){ char *key=argc>1?v_to_string(args[1]):xstrdup(""); int found=map_index(source.map,key)>=0; free(key); return v_bool(found); }
if(strcmp(fn,"get")==0&&source.type==V_MAP&&source.map){ char *key=argc>1?v_to_string(args[1]):xstrdup(""); int index=map_index(source.map,key); Value fallback=argc>2?args[2]:v_none(); Value r=index>=0?v_copy(source.map->vals[index]):v_copy(fallback); free(key); return r; }
if(strcmp(fn,"keys")==0&&source.type==V_MAP&&source.map){ Value out=v_list(); int i; for(i=0;i<source.map->count;i++) list_add(out.list,v_str(source.map->keys[i])); return out; }
if(strcmp(fn,"values")==0&&source.type==V_MAP&&source.map){ Value out=v_list(); int i; for(i=0;i<source.map->count;i++) list_add(out.list,source.map->vals[i]); return out; }
}
return v_none(); }
/* ---------- execution ---------- */
@@ -313,7 +334,7 @@ static int call_object_method(Runtime *rt,const char *name,Value *args,int argc)
static void call_function(Runtime *rt,const char *name,Value *args,int argc){ int handled=0,i; Value br=builtin_call(rt,name,args,argc,&handled); if(handled){ rt_set(rt,"RESULT",br); return; } if(call_object_method(rt,name,args,argc)) return; Function *f=find_function(rt,name); if(!f){ rt_error(rt,"",0,"Unknown function: %s",name); return;} Var *old=rt->locals; rt->locals=NULL; for(i=0;i<f->pcnt;i++) rt_set(rt,f->params[i], i<argc?args[i]:v_none()); rt->returning=0; exec_range(rt,f->prog,f->start,f->end); if(rt->returning){ rt_set(rt,"RESULT",rt->ret); rt->returning=0; } rt->locals=old; }
static int stack_has(Runtime *rt,const char *path){ int i; for(i=0;i<rt->import_depth;i++) if(strcmp(rt->import_stack[i],path)==0) return 1; return 0; }
static int export_allows(char **exports,int ec,const char *name){ int i; if(ec==0) return 1; for(i=0;i<ec;i++) if(strcmp(exports[i],name)==0) return 1; return 0; }
static void do_import(Runtime *rt,const char *path,const char *ns,const char *curfile,int line){ Program *p; char *prefix=NULL; char **exports=NULL; int ec=0,i; if(stack_has(rt,path)){ rt_error(rt,curfile,line,"Import cycle detected at: %s",path); return;} if(strstr(path,"lib/text.claro")&&ns){ add_module(rt,ns,"text"); return;} if(strstr(path,"lib/math.claro")&&ns){ add_module(rt,ns,"math"); return;} if(strstr(path,"lib/random.claro")&&ns){ add_module(rt,ns,"random"); return;} if(strstr(path,"lib/csv.claro")&&ns){ add_module(rt,ns,"csv"); return;} if(strstr(path,"lib/path.claro")&&ns){ add_module(rt,ns,"path"); return;} if(strstr(path,"lib/time.claro")&&ns){ add_module(rt,ns,"time"); return;}
static void do_import(Runtime *rt,const char *path,const char *ns,const char *curfile,int line){ Program *p; char *prefix=NULL; char **exports=NULL; int ec=0,i; if(stack_has(rt,path)){ rt_error(rt,curfile,line,"Import cycle detected at: %s",path); return;} if(strstr(path,"lib/text.claro")&&ns){ add_module(rt,ns,"text"); return;} if(strstr(path,"lib/math.claro")&&ns){ add_module(rt,ns,"math"); return;} if(strstr(path,"lib/random.claro")&&ns){ add_module(rt,ns,"random"); return;} if(strstr(path,"lib/csv.claro")&&ns){ add_module(rt,ns,"csv"); return;} if(strstr(path,"lib/path.claro")&&ns){ add_module(rt,ns,"path"); return;} if(strstr(path,"lib/collections.claro")&&ns){ add_module(rt,ns,"collections"); return;} if(strstr(path,"lib/time.claro")&&ns){ add_module(rt,ns,"time"); return;}
p=load_program(rt,path); if(!p){ rt_error(rt,curfile,line,"Could not import file: %s",path); return;} rt->import_stack=(char**)xrealloc(rt->import_stack,sizeof(char*)*(rt->import_depth+1)); rt->import_stack[rt->import_depth++]=xstrdup(path);
for(i=0;i<p->count;i++){ char tmp[1024],up[64],w[64]; char *t; strncpy(tmp,p->lines[i],sizeof(tmp)-1); tmp[sizeof(tmp)-1]=0; t=trim_inplace(tmp); first_word(t,w,sizeof(w)); upper_copy(up,w,sizeof(up)); if(strcmp(up,"EXPORT")==0){ t+=6; t=trim_inplace(t); if(ci_eq(t,"ALL")){ ec=0; break;} ec=parse_params(t,&exports); break; } }
if(ns){ Str pr; str_init(&pr); str_add(&pr,ns); str_ch(&pr,'.'); prefix=str_take(&pr); scan_functions(rt,p,prefix); exec_range(rt,p,0,p->count); /* copy exported vars */ for(Var *v=rt->globals; v; v=v->next){ if(strncmp(v->name,prefix,strlen(prefix))!=0 && export_allows(exports,ec,v->name)){ char nn[256]; snprintf(nn,sizeof(nn),"%s%s",prefix,v->name); if(!rt_has(rt,nn)) rt_set_global(rt,nn,v->val); } } } else { scan_functions(rt,p,""); exec_range(rt,p,0,p->count); }
@@ -533,7 +554,9 @@ static int typecheck_file(const char *path){ FILE *f=fopen(path,"rb"); char line
}
fclose(f); if(errs==0) printf("Type check OK\n"); return errs?1:0; }
static char *read_file_text(const char *path){ FILE *f=fopen(path,"rb"); Str b; int c; if(!f) return NULL; str_init(&b); while((c=fgetc(f))!=EOF) str_ch(&b,(char)c); fclose(f); return str_take(&b); }
static int run_one_test(const char *path){ char outpath[512]; char *dot; char *expect,*got; int rc,ok; Runtime rt; snprintf(outpath,sizeof(outpath),"%s",path); dot=strrchr(outpath,'.'); if(dot) strcpy(dot,".out"); expect=read_file_text(outpath); if(!expect) return 0; rt_init(&rt); rt.capture=1; rc=run_file(&rt,path); got=rt.captured.s?xstrdup(rt.captured.s):xstrdup(""); ok=(rc==0 && strcmp(got,expect)==0); printf("%s %s\n",ok?"PASS":"FAIL",path); if(!ok) printf("Expected:\n%s\nGot:\n%s\n",expect,got); free(expect); free(got); return ok?0:1; }
/* Golden output files may be checked out as CRLF on Windows while Claro captures output as LF. Normalize only test expectations so test results represent behavior, not Git line-ending settings. */
static char *read_test_expectation(const char *path){ char *raw=read_file_text(path); Str normalized; size_t i; if(!raw) return NULL; str_init(&normalized); for(i=0;raw[i];i++){ if(raw[i]=='\015'){ if(raw[i+1]=='\012') continue; str_ch(&normalized,'\012'); } else str_ch(&normalized,raw[i]); } free(raw); return str_take(&normalized); }
static int run_one_test(const char *path){ char outpath[512]; char *dot; char *expect,*got; int rc,ok; Runtime rt; snprintf(outpath,sizeof(outpath),"%s",path); dot=strrchr(outpath,'.'); if(dot) strcpy(dot,".out"); expect=read_test_expectation(outpath); if(!expect) return 0; rt_init(&rt); rt.capture=1; rc=run_file(&rt,path); got=rt.captured.s?xstrdup(rt.captured.s):xstrdup(""); ok=(rc==0 && strcmp(got,expect)==0); printf("%s %s\n",ok?"PASS":"FAIL",path); if(!ok) printf("Expected:\n%s\nGot:\n%s\n",expect,got); free(expect); free(got); return ok?0:1; }
static int run_tests(void){ int fails=0;
#ifndef _WIN32
DIR *d=opendir("tests"); struct dirent *de; if(!d){ fprintf(stderr,"No tests directory\n"); return 1;} while((de=readdir(d))){ if(strstr(de->d_name,".claro")){ char path[512]; snprintf(path,sizeof(path),"tests/%s",de->d_name); fails+=run_one_test(path); } } closedir(d);
+16
View File
@@ -0,0 +1,16 @@
IMPORT "lib/path.claro" AS path
CALL path.join WITH "notes", "today.txt"
SAY RESULT
CALL path.basename WITH "notes/today.txt"
SAY RESULT
CALL path.dirname WITH "notes/today.txt"
SAY RESULT
CALL path.ext WITH "notes/today.txt"
SAY RESULT
CALL path.stem WITH "notes/today.txt"
SAY RESULT
CALL path.absolute WITH "/home/learner"
SAY RESULT
CALL path.absolute WITH "notes/today.txt"
SAY RESULT
+7
View File
@@ -0,0 +1,7 @@
notes/today.txt
today.txt
notes
txt
today
YES
NO
+31
View File
@@ -0,0 +1,31 @@
IMPORT "lib/collections.claro" AS collections
SET words AS LIST OF TEXT TO LIST
ADD "Ada" TO words
ADD "Grace" TO words
ADD "Lin" TO words
CALL collections.length WITH words
SAY RESULT
CALL collections.contains WITH words, "Grace"
SAY RESULT
CALL collections.index WITH words, "Lin"
SAY RESULT
CALL collections.join WITH words, " | "
SAY RESULT
CALL collections.reversed WITH words
SET backwards TO RESULT
GET backwards AT 1 AS first
SAY first
SET scores AS MAP OF NUMBER TO MAP
PUT scores KEY "math" VALUE 95
PUT scores KEY "science" VALUE 88
CALL collections.has_key WITH scores, "math"
SAY RESULT
CALL collections.get WITH scores, "science", 0
SAY RESULT
CALL collections.keys WITH scores
SET subjects TO RESULT
COUNT subjects AS subjectCount
SAY subjectCount
+8
View File
@@ -0,0 +1,8 @@
3
YES
3
Ada | Grace | Lin
Lin
YES
88
2