Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ node_modules/
.build/
tests/tool-build/new/myapp/
tests/tool-build/new-lib/mylib/
tests/tool-build/template-single/myapp/
tests/tool-build/template-single/myapp-pinned/
tests/tool-build/template-multi/myapp/
tests/tool-build/*/repo/
tests/tool-build/*/jo.lock
tests/tool-build/*/*/jo.lock
Expand Down
61 changes: 57 additions & 4 deletions docs/usage/commands/new.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,71 @@
# jo new

Create a new project with a standard scaffold.
Create a new project with a standard scaffold, or from a third-party
template repo.

## Usage

```
jo new [--lib] <name>
jo new --template <ref> <name>
jo new --template <ref> --list
```

## Options

| Option | Description |
|---------|-----------------------------------------|
| `--lib` | Create a library project. Default: app. |
| Option | Description |
|---------------|----------------------------------------------------------|
| `--lib` | Create a library project. Default: app. Not combinable with `--template`. |
| `--template` | Scaffold from a template ref instead of the built-in app/lib scaffold. |
| `--list` | List the templates declared by `--template`'s repo, instead of scaffolding. Requires `--template`, no `<name>`. |

## Template Scaffold

`jo new --template <ref> <name>` scaffolds from a third-party GitHub repo
instead of the built-in scaffolds. The repo must have a `jo-templates.jsonl`
manifest at its root declaring one or more named templates — this is what
makes a repo a valid Jo template repo.

Ref grammar: `[gh:]owner/repo[#gitref][:name]`. `gh:` is optional and
implied when omitted. `gitref` (branch, tag, or commit) defaults to the
repo's default branch. `name` selects a template when the repo declares
more than one.

```
jo new --template acme/jo-templates my-app # single-template repo
jo new --template acme/jo-templates:web-app my-app # multi-template repo, pick one
jo new --template acme/jo-templates#v2:web-app my-app # pin a ref
jo new --template acme/jo-templates --list # list available templates
```

There is no variable substitution, prompts, or setup hooks — files are
copied as-is.

### `jo-templates.jsonl`

One JSON object per line, at the repo root:

```jsonl
{"name": "web-app", "path": "templates/web-app", "description": "Minimal web app with a REST endpoint"}
{"name": "cli", "path": "templates/cli", "description": "CLI scaffold with arg parsing"}
```

| Field | Required | Notes |
|---------------|----------|-----------------------------------------------------------------------|
| `name` | yes | Matches `[a-zA-Z0-9][a-zA-Z0-9_-]*`. Must be unique within the file. |
| `path` | yes | Relative POSIX path from the repo root. No leading `/`, no `..` segments. Everything under it is copied verbatim into the new project. |
| `description` | no | Free text, shown by `--list`. Not used for resolution. |

A single-template repo still needs this file, just one line, typically:

```jsonl
{"name": "default", "path": ".", "description": "Minimal starter"}
```

With `path: "."`, the entire repo root — including `jo-templates.jsonl`
itself — gets copied into the scaffolded project, so keep any repo-only
files (CI config, contributor docs) in a subdirectory instead if you don't
want them showing up in generated projects.

## App Scaffold

Expand Down
2 changes: 2 additions & 0 deletions stack-lang/cli/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ object Main:
println("""Usage:
| jo <source.jo> Run program (defaults to 'eval')
| jo new <name> Create a new project
| jo new --template <ref> <name> Create a new project from a template repo (e.g. 'gh:owner/repo')
| jo new --template <ref> --list List templates declared by a template repo
| jo clean [module] Remove build artifacts (default: all modules)
| jo build [module] Build a module (default module if omitted)
| jo check [module] Type-check and compile to sast, skip executable
Expand Down
101 changes: 1 addition & 100 deletions stack-lang/tool/HttpPackageProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import java.net.URI
import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.nio.file.{Files, Path}
import java.util.zip.ZipFile
import scala.collection.mutable

import tool.toml.TomlParser

Expand Down Expand Up @@ -187,7 +186,7 @@ case class HttpPackageProvider(

private object ReleaseJson:
def parse(line: String): Either[String, ReleaseRecord] =
JsonParser.parseObj(line.trim).flatMap: obj =>
Json.parseObj(line.trim).flatMap: obj =>
for
versionStr <- requireStr(obj, "version")
url <- requireStr(obj, "url")
Expand All @@ -213,101 +212,3 @@ private object ReleaseJson:
case Some(s: String) => Right(s)
case Some(_) => Left(s"'$key' must be a string")
case None => Left(s"missing required field '$key'")


// ---- Minimal recursive-descent JSON parser -----------------------------------

private object JsonParser:
def parseObj(input: String): Either[String, Map[String, Any]] =
parseObject(input, 0) match
case Right((obj, _)) => Right(obj)
case Left(msg) => Left(msg)

private def ws(s: String, i: Int): Int =
var j = i
while j < s.length && s(j).isWhitespace do j += 1
j

private def parseObject(s: String, i0: Int): Either[String, (Map[String, Any], Int)] =
var i = ws(s, i0)
if i >= s.length || s(i) != '{' then return Left(s"expected '{' at $i")
i = ws(s, i + 1)

val fields = collection.mutable.LinkedHashMap.empty[String, Any]
var first = true

while i < s.length && s(i) != '}' do
if !first then
if s(i) != ',' then return Left(s"expected ',' at $i")
i = ws(s, i + 1)
first = false

parseString(s, i) match
case Left(msg) => return Left(msg)
case Right((key, j)) =>
i = ws(s, j)
if i >= s.length || s(i) != ':' then return Left(s"expected ':' at $i")
i = ws(s, i + 1)
parseValue(s, i) match
case Left(msg) => return Left(msg)
case Right((v, j2)) =>
fields(key) = v
i = ws(s, j2)

if i >= s.length then Left("unterminated object")
else Right((fields.toMap, i + 1))

private def parseValue(s: String, i: Int): Either[String, (Any, Int)] =
if i >= s.length then Left("unexpected end of input")
else s(i) match
case '"' => parseString(s, i)
case '{' => parseObject(s, i)
case '[' => parseArray(s, i)
case 't' if s.startsWith("true", i) => Right((true, i + 4))
case 'f' if s.startsWith("false", i) => Right((false, i + 5))
case 'n' if s.startsWith("null", i) => Right((null, i + 4))
case c if c.isDigit || c == '-' => parseNumber(s, i)
case c => Left(s"unexpected char '$c' at $i")

private def parseString(s: String, i0: Int): Either[String, (String, Int)] =
if i0 >= s.length || s(i0) != '"' then return Left(s"expected '\"' at $i0")
val sb = new StringBuilder
var i = i0 + 1
while i < s.length && s(i) != '"' do
if s(i) == '\\' && i + 1 < s.length then
s(i + 1) match
case '"' => sb += '"'; i += 2
case '\\' => sb += '\\'; i += 2
case '/' => sb += '/'; i += 2
case 'n' => sb += '\n'; i += 2
case 'r' => sb += '\r'; i += 2
case 't' => sb += '\t'; i += 2
case c => sb += c; i += 2
else
sb += s(i)
i += 1
if i >= s.length then Left("unterminated string")
else Right((sb.toString, i + 1))

private def parseArray(s: String, i0: Int): Either[String, (List[Any], Int)] =
var i = ws(s, i0 + 1)
val items = new mutable.ArrayBuffer[Any]
var first = true
while i < s.length && s(i) != ']' do
if !first then
if s(i) != ',' then return Left(s"expected ',' at $i")
i = ws(s, i + 1)
first = false
parseValue(s, i) match
case Left(msg) => return Left(msg)
case Right((v, j)) =>
items += v
i = ws(s, j)
if i >= s.length then Left("unterminated array")
else Right((items.toList, i + 1))

private def parseNumber(s: String, i0: Int): Either[String, (String, Int)] =
var i = i0
if i < s.length && s(i) == '-' then i += 1
while i < s.length && s(i).isDigit do i += 1
Right((s.substring(i0, i), i))
155 changes: 155 additions & 0 deletions stack-lang/tool/Json.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package tool

import scala.collection.mutable

/** Minimal recursive-descent JSON parser for single-object lines (JSONL).
*
* Deliberately strict rather than permissive: input comes from a registry
* index or a third-party `jo-templates.jsonl` file, both untrusted, so
* parser leniency (accepting `\q`, silently truncating trailing garbage,
* merging duplicate keys) would make malformed or adversarial input harder
* to catch rather than easier. Numbers are represented as `Double` in the
* `Any` result, never as `String` — otherwise a caller checking `case s:
* String` for a required field would wrongly accept `{"name": 123}`.
*/
object Json:
def parseObj(input: String): Either[String, Map[String, Any]] =
parseObject(input, 0).flatMap: (obj, j) =>
val end = ws(input, j)
if end != input.length then Left(s"unexpected trailing data at $end")
else Right(obj)

private def ws(s: String, i: Int): Int =
var j = i
while j < s.length && s(j).isWhitespace do j += 1
j

private def parseObject(s: String, i0: Int): Either[String, (Map[String, Any], Int)] =
var i = ws(s, i0)
if i >= s.length || s(i) != '{' then return Left(s"expected '{' at $i")
i = ws(s, i + 1)

val fields = collection.mutable.LinkedHashMap.empty[String, Any]
var first = true

while i < s.length && s(i) != '}' do
if !first then
if s(i) != ',' then return Left(s"expected ',' at $i")
i = ws(s, i + 1)
first = false

parseString(s, i) match
case Left(msg) => return Left(msg)
case Right((key, j)) =>
if fields.contains(key) then return Left(s"duplicate key '$key' at $i")
i = ws(s, j)
if i >= s.length || s(i) != ':' then return Left(s"expected ':' at $i")
i = ws(s, i + 1)
parseValue(s, i) match
case Left(msg) => return Left(msg)
case Right((v, j2)) =>
fields(key) = v
i = ws(s, j2)

if i >= s.length then Left("unterminated object")
else Right((fields.toMap, i + 1))

private def parseValue(s: String, i: Int): Either[String, (Any, Int)] =
if i >= s.length then Left("unexpected end of input")
else s(i) match
case '"' => parseString(s, i)
case '{' => parseObject(s, i)
case '[' => parseArray(s, i)
case 't' if s.startsWith("true", i) => Right((true, i + 4))
case 'f' if s.startsWith("false", i) => Right((false, i + 5))
case 'n' if s.startsWith("null", i) => Right((null, i + 4))
case c if c.isDigit || c == '-' => parseNumber(s, i)
case c => Left(s"unexpected char '$c' at $i")

private def parseString(s: String, i0: Int): Either[String, (String, Int)] =
if i0 >= s.length || s(i0) != '"' then return Left(s"expected '\"' at $i0")
val sb = new StringBuilder
var i = i0 + 1

while i < s.length && s(i) != '"' do
val c = s(i)

if c == '\\' then
if i + 1 >= s.length then return Left(s"unterminated escape at $i")

s(i + 1) match
case '"' => sb += '"'; i += 2
case '\\' => sb += '\\'; i += 2
case '/' => sb += '/'; i += 2
case 'b' => sb += '\b'; i += 2
case 'f' => sb += '\f'; i += 2
case 'n' => sb += '\n'; i += 2
case 'r' => sb += '\r'; i += 2
case 't' => sb += '\t'; i += 2

case 'u' =>
if i + 6 > s.length then return Left(s"incomplete unicode escape at $i")
val hex = s.substring(i + 2, i + 6)
val code =
try Integer.parseInt(hex, 16)
catch case _: NumberFormatException => return Left(s"invalid unicode escape '\\u$hex' at $i")
sb += code.toChar
i += 6

case other => return Left(s"invalid escape '\\$other' at $i")

else if c < ' ' then
return Left(s"unescaped control character at $i")

else
sb += c
i += 1

if i >= s.length then Left("unterminated string")
else Right((sb.toString, i + 1))

private def parseArray(s: String, i0: Int): Either[String, (List[Any], Int)] =
var i = ws(s, i0 + 1)
val items = new mutable.ArrayBuffer[Any]
var first = true
while i < s.length && s(i) != ']' do
if !first then
if s(i) != ',' then return Left(s"expected ',' at $i")
i = ws(s, i + 1)
first = false
parseValue(s, i) match
case Left(msg) => return Left(msg)
case Right((v, j)) =>
items += v
i = ws(s, j)
if i >= s.length then Left("unterminated array")
else Right((items.toList, i + 1))

/** Full JSON number grammar: `-`? int frac? exp?, with `int` being either
* `0` or a non-zero digit followed by more digits (no leading zeros). A
* lone `-` or any other malformed shape is rejected here rather than
* silently accepted as a truncated "number".
*/
private def parseNumber(s: String, i0: Int): Either[String, (Double, Int)] =
var i = i0
if i < s.length && s(i) == '-' then i += 1

if i >= s.length || !s(i).isDigit then return Left(s"invalid number at $i0")

if s(i) == '0' then i += 1
else while i < s.length && s(i).isDigit do i += 1

if i < s.length && s(i) == '.' then
i += 1
if i >= s.length || !s(i).isDigit then return Left(s"invalid number at $i0")
while i < s.length && s(i).isDigit do i += 1

if i < s.length && (s(i) == 'e' || s(i) == 'E') then
i += 1
if i < s.length && (s(i) == '+' || s(i) == '-') then i += 1
if i >= s.length || !s(i).isDigit then return Left(s"invalid number at $i0")
while i < s.length && s(i).isDigit do i += 1

val text = s.substring(i0, i)
try Right((text.toDouble, i))
catch case _: NumberFormatException => Left(s"invalid number '$text' at $i0")
Loading
Loading