diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2dac50c..5cdcac6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,14 +48,16 @@ jobs: - name: Test run: ctest --test-dir build --build-config Release --output-on-failure - - name: Upload the interpreter + - name: Upload the interpreter and package manager if: ${{ matrix.artifact }} uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} path: | build/ascend + build/apkg build/Release/ascend.exe + build/Release/apkg.exe if-no-files-found: error sanitizers: diff --git a/.gitignore b/.gitignore index 09e255a..6bf84c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -# Build output (build/deleteme.txt keeps the folder in git) -build/* -!build/deleteme.txt +# Build output +build/ + +# Packages installed by apkg +ascend_packages/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d248a..fa41ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.4.0 + +### New + +- **`apkg`, the Ascend package manager**, built and installed next to + `ascend`. `apkg install`, `uninstall`, `list`, `show`, `search` and `init` + work like pip and npm. Packages can come from the registry (`colors`, + `colors@1.0.0`), GitHub (`github:user/repo@v1.0`), any git repository + (`git+https://...`) or a folder (`./my-lib`), and their dependencies are + installed with them. See [docs/packages.md](docs/packages.md). +- Projects: `apkg init` writes an `ascend.json`; inside a project packages go + into `ascend_packages/` and are recorded in `ascend.json`, and `apkg install` + with no names installs everything listed there. +- `import("name")` finds installed packages when there's no `name.asc` next to + the importing file, and `import("name/file")` imports a file inside one. +- A package registry (`registry/index.json`) with three official packages: + `colors` (terminal colors), `strings` (case conversion, word wrapping, + edit distance and more) and `testing` (unit tests). + +### Changed + +- Importing a file that doesn't exist suggests `apkg install` when the name + could be a package. + ## 0.3.0 ### New diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f802d2..ee66444 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.10) -project(ascend VERSION 0.3.0 LANGUAGES CXX) +project(ascend VERSION 0.4.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -33,9 +33,17 @@ add_library(ascend_core STATIC src/json.cpp src/runner.cpp src/repl.cpp + src/process.cpp + src/packages.cpp + src/package_manager.cpp ) target_include_directories(ascend_core PUBLIC src/include) +# GCC 8 keeps std::filesystem in a separate library. +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 9) + target_link_libraries(ascend_core PUBLIC stdc++fs) +endif() + if(MSVC) target_compile_options(ascend_core PRIVATE /W4 /permissive-) else() @@ -54,7 +62,12 @@ endfunction() add_executable(ascend src/main.cpp) target_link_libraries(ascend PRIVATE ascend_core) ascend_set_stack_size(ascend) -install(TARGETS ascend RUNTIME DESTINATION bin) + +# The package manager. +add_executable(apkg src/apkg_main.cpp) +target_link_libraries(apkg PRIVATE ascend_core) + +install(TARGETS ascend apkg RUNTIME DESTINATION bin) option(ASCEND_BUILD_TESTS "Build the Ascend test suite" ON) if(ASCEND_BUILD_TESTS) @@ -65,8 +78,11 @@ if(ASCEND_BUILD_TESTS) tests/lexer_tests.cpp tests/parser_tests.cpp tests/interpreter_tests.cpp + tests/package_tests.cpp ) target_link_libraries(ascend_tests PRIVATE ascend_core) + # The package tests install the packages in this repository's registry. + target_compile_definitions(ascend_tests PRIVATE ASCEND_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") ascend_set_stack_size(ascend_tests) add_test(NAME unit_tests COMMAND ascend_tests) diff --git a/README.md b/README.md index 0253042..f7a2bc6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ for name, score in scores { - f-strings with format specs: `f"{price:>8.2f}"` - Hex, binary and octal literals, type suffixes (`255u8`) and bitwise operators - Unicode strings and a `char` type -- Modules with `import("file")` +- Modules with `import("file")`, and **`apkg`, a package manager** like pip: + `apkg install colors`, then `import("colors")` - `try` / `catch` / `throw` error handling - 100+ builtins for strings, lists, maps, math, files, JSON and the system - Clear error messages that point at the problem, many caught before running: @@ -65,10 +66,18 @@ cmake --build build ./build/ascend # interactive REPL ``` +Install a package and use it: + +```sh +./build/apkg install colors +./build/ascend -e 'print(import("colors").green("Hello!"))' +``` + ## Documentation - [Language guide](docs/syntax.md) - [Standard library](docs/standard_library.md) +- [Packages and apkg](docs/packages.md) - [Building, installing, testing and editor support](docs/installation.md) - [Example programs](examples/) @@ -83,6 +92,7 @@ cmake --build build | Values, numeric types and checked arithmetic | `src/value.cpp`, `src/numeric.cpp`, `src/types.cpp` | | Builtin functions, formatting and JSON | `src/stdlib.cpp`, `src/format.cpp`, `src/json.cpp` | | Command line and REPL | `src/main.cpp`, `src/repl.cpp`, `src/runner.cpp` | +| Package manager (`apkg`) and finding packages for `import` | `src/apkg_main.cpp`, `src/package_manager.cpp`, `src/packages.cpp` | ## License diff --git a/docs/installation.md b/docs/installation.md index 32f804f..1ab97bc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,7 +5,9 @@ - A C++17 compiler: GCC 8+, Clang 7+ or Visual Studio 2019+ - CMake 3.10 or newer -Ascend has no other dependencies. +Ascend has no other dependencies. The package manager, `apkg`, uses +[git](https://git-scm.com) to download packages, so install git too if you +want to use packages from the internet. ## Build @@ -14,8 +16,9 @@ cmake -S . -B build cmake --build build ``` -This produces the interpreter, `build/ascend` (`build\Release\ascend.exe` -with Visual Studio). Builds default to the optimized `Release` configuration. +This produces the interpreter, `build/ascend`, and the package manager, +`build/apkg` (`build\Release\ascend.exe` and `build\Release\apkg.exe` with +Visual Studio). Builds default to the optimized `Release` configuration. ## Run @@ -26,9 +29,14 @@ with Visual Studio). Builds default to the optimized `Release` configuration. ./build/ascend --check my_program.asc # find errors without running ``` -To run `ascend` from anywhere, copy `build/ascend` into a directory on your -`PATH`, or run `cmake --install build` (add `--prefix ~/.local` to install -without administrator rights). +To run `ascend` and `apkg` from anywhere, copy them from `build/` into a +directory on your `PATH`, or run `cmake --install build` (add +`--prefix ~/.local` to install without administrator rights). + +```sh +apkg install colors # see docs/packages.md +ascend -e 'print(import("colors").bold("ready"))' +``` ## Test @@ -37,9 +45,10 @@ cmake --build build cd build && ctest --output-on-failure ``` -This runs the unit tests in `tests/` and checks that every program in -`examples/` runs without errors. You can also run the unit tests directly and -filter them by name: +This runs the unit tests in `tests/` (including tests that install the +packages in `packages/` with `apkg`, and from temporary git repositories when +git is available) and checks that every program in `examples/` runs without +errors. You can also run the unit tests directly and filter them by name: ```sh ./build/ascend_tests # all tests @@ -53,9 +62,9 @@ To skip building the tests, configure with `-DASCEND_BUILD_TESTS=OFF`. Every push to `main` and every pull request is built and tested by GitHub Actions (`.github/workflows/build.yml`) on Linux (GCC and Clang), macOS and Windows (MSVC), plus a run with AddressSanitizer and UndefinedBehaviorSanitizer. -Each run also uploads ready-to-use interpreters for Linux, macOS and Windows: -open the run on the repository's **Actions** tab and download them under -**Artifacts**. +Each run also uploads ready-to-use builds of `ascend` and `apkg` for Linux, +macOS and Windows: open the run on the repository's **Actions** tab and +download them under **Artifacts**. ## Editor support diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..3a2987c --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,173 @@ +# Packages and apkg + +`apkg` is Ascend's package manager. It's built and installed next to the +`ascend` interpreter, and works much like `pip` or `npm`: + +```sh +apkg install colors # install a package +apkg list # see what's installed +apkg uninstall colors # remove it again +``` + +Programs use installed packages with `import`: + +``` +colors := import("colors") +print(colors.green("it works!")) +``` + +`apkg` downloads packages with [git](https://git-scm.com), so git must be +installed and on your `PATH` (folders on your computer can be installed +without it). + +## Commands + +| Command | What it does | +|---------|--------------| +| `apkg install ...` | Installs packages and everything they depend on. Aliases: `add`, `i`. | +| `apkg install` | In a project, installs everything its `ascend.json` lists. | +| `apkg uninstall ...` | Removes packages. Aliases: `remove`, `rm`. | +| `apkg list` | Lists the installed packages. Alias: `ls`. | +| `apkg show ` | Shows an installed package's details: version, source, what it needs and what needs it. Alias: `info`. | +| `apkg search [text]` | Searches the registry by name and description. Without text, lists every package. | +| `apkg init [name]` | Makes the current folder a project by writing an `ascend.json` (and a `main.asc`, if there isn't one). | + +Options (they can go anywhere on the command line): + +| Option | | +|--------|--| +| `-g`, `--global` | Use your own packages folder even inside a project. | +| `-U`, `--upgrade` | Get the newest version of packages that are already installed. Without it, installing a package that's already there does nothing. | +| `-h`, `--help` | Show help. | +| `-v`, `--version` | Show the version. | + +## Where packages come from + +```sh +apkg install colors # the registry, newest version +apkg install colors@1.0.0 # the registry, a particular version +apkg install github:someone/their-package # a GitHub repository +apkg install github:someone/their-package@v2.1 # ...at a tag or branch +apkg install github:someone/monorepo/packages/thing # ...a folder inside it +apkg install git+https://gitlab.com/someone/pkg.git # any git repository +apkg install git+https://example.com/repo.git@v1#sub # ...at a tag, in a folder +apkg install git@github.com:someone/private-pkg.git # git over SSH +apkg install ./my-library # a folder on this computer +``` + +The **registry** is a list of known packages: [`registry/index.json`](../registry/index.json) +in the Ascend repository. To use a different one, set `ASCEND_REGISTRY` to +another git repository or to a folder with the same layout. + +## Projects and your own packages + +**Inside a project** (a folder with an `ascend.json`, or any folder below +one), `apkg install` puts packages into the project's `ascend_packages` folder +and records them in `ascend.json`: + +```sh +mkdir weather && cd weather +apkg init # writes ascend.json and main.asc +apkg install colors # installs into ./ascend_packages, adds "colors" to ascend.json +``` + +```json +{ + "name": "weather", + "version": "0.1.0", + "description": "", + "main": "main.asc", + "dependencies": { + "colors": "*" + } +} +``` + +Commit `ascend.json` but not `ascend_packages` (`apkg init` adds it to +`.gitignore`). Anyone who checks out the project runs `apkg install` with no +package names to install everything it lists. + +**Anywhere else**, packages are installed just for you, in +`~/.ascend/packages` (`%USERPROFILE%\.ascend\packages` on Windows). Use `-g` +to install there from inside a project. Set `ASCEND_HOME` to keep them +somewhere else. + +## How import finds packages + +`import("name")` first looks for a file `name.asc` next to the importing file, +as always. If there isn't one, it looks for an installed package called +`name`: + +1. in `ascend_packages/name` next to the importing file, then in the + `ascend_packages` folder of each folder above it; +2. in your own packages (`~/.ascend/packages/name`). + +It runs the package's main file (`main` in its `ascend.json`). To import a +different file from a package, add its path: `import("strings/strings")`. + +A package that depends on another one imports it the same way, so packages +can be used together without knowing where they're installed. + +## Making a package + +A package is a folder with an `ascend.json`: + +```json +{ + "name": "shapes", + "version": "1.0.0", + "description": "Areas and perimeters of common shapes", + "main": "shapes.asc", + "dependencies": { + "strings": "*", + "units": "1.2.0", + "geometry": "github:someone/geometry@v3.0" + } +} +``` + +| Field | | +|-------|--| +| `name` | Required. Lowercase letters, digits, `-` and `_`. This is the name used to `import` it. | +| `version` | Required. Use `major.minor.patch` numbers. | +| `description` | A one-line summary, shown by `apkg show` and `apkg search`. | +| `main` | The file `import("name")` runs. Defaults to `main.asc`. | +| `dependencies` | Other packages it needs: `"*"` for any version from the registry, a version number like `"1.2.0"`, or any source `apkg install` accepts, like `"github:user/repo@v1.0"` or `"file:../other-folder"`. | + +Everything at the top level of the main file that doesn't start with `_` +becomes part of the package's map when it's imported. + +To share a package, put the folder in a git repository and others can install +it with `apkg install github:you/your-package`. Tag each release `v` + its +version (`v1.0.0`), so `name@1.0.0` can find it once it's in the registry. + +### Adding a package to the registry + +Open a pull request that adds an entry to [`registry/index.json`](../registry/index.json): + +```json +"shapes": { + "description": "Areas and perimeters of common shapes", + "source": "git+https://github.com/you/shapes.git" +} +``` + +`source` can be any git source (as above, but without a version: `apkg` +adds `@v` when someone asks for a particular one), or +`registry:packages/` for packages kept in the Ascend repository's +[`packages`](../packages) folder. + +## Official packages + +| Package | | +|---------|--| +| [`colors`](../packages/colors) | Color and style text in the terminal. | +| [`strings`](../packages/strings) | Case conversion, word wrapping, padding, edit distance and other text tools. | +| [`testing`](../packages/testing) | Write and run unit tests for Ascend code. | + +## Environment variables + +| Variable | | +|----------|--| +| `ASCEND_HOME` | Where your own packages (`packages/`) and downloaded data (`cache/`) are kept. Default: `~/.ascend`. | +| `ASCEND_REGISTRY` | The registry: a git repository address or a folder containing `registry/index.json`. Default: `https://github.com/csubbdev/Ascend.git`. | diff --git a/docs/standard_library.md b/docs/standard_library.md index 4991201..80cfab7 100644 --- a/docs/standard_library.md +++ b/docs/standard_library.md @@ -145,7 +145,7 @@ Lengths and positions count characters, not bytes. | Function | Description | |----------|-------------| -| `import(path)` | Runs another Ascend file once and returns its top-level names as a map. Relative paths start from the importing file; `.asc` is optional. | +| `import(path)` | Runs another Ascend file once and returns its top-level names as a map. Relative paths start from the importing file; `.asc` is optional. If there's no such file, imports the installed package with that name (see [packages.md](packages.md)). | | `assert(condition, message?)` | Throws an error if `condition` is falsy. | | `exit(code?)` | Ends the program with exit code `code` (default 0). | | `args` | List of the extra command line arguments given after the script name. | diff --git a/docs/syntax.md b/docs/syntax.md index d591394..17e7735 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -366,6 +366,10 @@ geometry := import("geometry") print(geometry.circle_area(2)) ``` +If there's no such file, `import` looks for an installed package with that +name, so after `apkg install colors` you can write `import("colors")`. See +[packages.md](packages.md). + ## Errors Errors stop the program with a message that points at the problem: diff --git a/packages/colors/README.md b/packages/colors/README.md new file mode 100644 index 0000000..0a7b5c2 --- /dev/null +++ b/packages/colors/README.md @@ -0,0 +1,29 @@ +# colors + +Color and style text in the terminal. + +``` +apkg install colors +``` + +``` +colors := import("colors") + +print(colors.green("passed"), colors.red("failed")) +print(colors.style("Warning:", "bold yellow on_black"), "disk almost full") +print(colors.hex("orange", "#ff8800"), colors.rgb("teal", 0, 128, 128)) +``` + +| Function | What it does | +|----------|--------------| +| `red(text)`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray`, `black` | Colors the text. | +| `bold(text)`, `dim`, `italic`, `underline`, `inverse`, `strike` | Styles the text. | +| `style(text, styles)` | Applies several styles named in one string, like `"bold red on_white"`. `on_` sets the background, and `bright_` picks the bright version. | +| `rgb(text, r, g, b)`, `on_rgb(text, r, g, b)` | Any foreground / background color (each part 0-255). | +| `hex(text, color)` | A color written as `"#ff8800"` or `"f80"`. | +| `strip(text)` | Removes color codes, e.g. to measure how wide colored text is. | +| `names()` | The style names `style()` accepts. | +| `enable(on)`, `enabled()` | Turns colors on or off. | + +Colors are off when the `NO_COLOR` environment variable is set +([no-color.org](https://no-color.org)). diff --git a/packages/colors/ascend.json b/packages/colors/ascend.json new file mode 100644 index 0000000..d283cca --- /dev/null +++ b/packages/colors/ascend.json @@ -0,0 +1,7 @@ +{ + "name": "colors", + "version": "1.0.0", + "description": "Color and style text in the terminal", + "main": "colors.asc", + "dependencies": {} +} diff --git a/packages/colors/colors.asc b/packages/colors/colors.asc new file mode 100644 index 0000000..be6e17b --- /dev/null +++ b/packages/colors/colors.asc @@ -0,0 +1,118 @@ +// colors: color and style text in the terminal. +// +// colors := import("colors") +// print(colors.green("ok"), colors.style("failed", "bold red")) +// +// Colors are turned off when the NO_COLOR environment variable is set +// (see https://no-color.org), or by calling colors.enable(false). + +const _ESC = "\x1b[" + +_enabled := env("NO_COLOR") == null || env("NO_COLOR") == "" + +// Style names and their ANSI codes. "on_" names set the background. +const _CODES = { + reset: 0, bold: 1, dim: 2, italic: 3, underline: 4, blink: 5, inverse: 7, hidden: 8, strike: 9, + black: 30, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37, gray: 90, + bright_red: 91, bright_green: 92, bright_yellow: 93, bright_blue: 94, bright_magenta: 95, + bright_cyan: 96, bright_white: 97, + on_black: 40, on_red: 41, on_green: 42, on_yellow: 43, on_blue: 44, on_magenta: 45, on_cyan: 46, + on_white: 47, on_gray: 100 +} + +// Turns colors on or off for everything that uses this package. +void enable(bool on = true) { + _enabled = on +} + +bool enabled() { + return _enabled +} + +// All the style names style() accepts. +list names() { + return keys(_CODES) +} + +// Applies styles given as names separated by spaces: style("hi", "bold red on_white"). +string style(text, string styles) { + codes := [] + for name in split(styles) { + if not (name in _CODES) { + throw f"colors: there's no style named '{name}' (see colors.names())" + } + codes.push(_CODES[name]) + } + if not _enabled or len(codes) == 0 { + return str(text) + } + return _ESC + join(codes, ";") + "m" + str(text) + _ESC + "0m" +} + +// Any color, given as red, green and blue amounts from 0 to 255. +string rgb(text, u8 r, u8 g, u8 b) { + if not _enabled { + return str(text) + } + return f"{_ESC}38;2;{r};{g};{b}m{text}{_ESC}0m" +} + +// The same for the background. +string on_rgb(text, u8 r, u8 g, u8 b) { + if not _enabled { + return str(text) + } + return f"{_ESC}48;2;{r};{g};{b}m{text}{_ESC}0m" +} + +// A color given in hex, like "#ff8800" or "f80". +string hex(text, string color) { + digits := color + if starts_with(digits, "#") { + digits = digits[1:] + } + if len(digits) == 3 { + digits = digits[0] * 2 + digits[1] * 2 + digits[2] * 2 + } + if len(digits) != 6 { + throw f"colors: '{color}' isn't a hex color like \"#ff8800\"" + } + return rgb(text, u8("0x" + digits[0:2]), u8("0x" + digits[2:4]), u8("0x" + digits[4:6])) +} + +// Removes color codes, e.g. to measure how wide colored text is. +string strip(string text) { + out := [] + cs := chars(text) + i := 0 + while i < len(cs) { + if cs[i] == '\x1b' and i + 1 < len(cs) and cs[i + 1] == '[' { + i += 2 + while i < len(cs) and not is_alpha(cs[i]) { + i += 1 + } + i += 1 + continue + } + out.push(cs[i]) + i += 1 + } + return join(out) +} + +string bold(text) { return style(text, "bold") } +string dim(text) { return style(text, "dim") } +string italic(text) { return style(text, "italic") } +string underline(text) { return style(text, "underline") } +string inverse(text) { return style(text, "inverse") } +string strike(text) { return style(text, "strike") } + +string black(text) { return style(text, "black") } +string red(text) { return style(text, "red") } +string green(text) { return style(text, "green") } +string yellow(text) { return style(text, "yellow") } +string blue(text) { return style(text, "blue") } +string magenta(text) { return style(text, "magenta") } +string cyan(text) { return style(text, "cyan") } +string white(text) { return style(text, "white") } +string gray(text) { return style(text, "gray") } diff --git a/packages/strings/README.md b/packages/strings/README.md new file mode 100644 index 0000000..0e08245 --- /dev/null +++ b/packages/strings/README.md @@ -0,0 +1,31 @@ +# strings + +Text tools that go beyond Ascend's builtins. + +``` +apkg install strings +``` + +``` +strings := import("strings") + +print(strings.snake_case("parseHTTPResponse")) // parse_http_response +print(strings.title("the quick brown fox")) // The Quick Brown Fox +print(strings.fill(long_text, 60)) // wrapped to 60 columns +print(strings.closest("prnt", ["print", "input"])) // print +``` + +| Function | What it does | +|----------|--------------| +| `capitalize(s)`, `title(s)` | Upper-cases the first letter / the first letter of every word. | +| `words(s)` | The words in a name or sentence, in lower case (`"userID"` → `["user", "id"]`). | +| `snake_case`, `kebab_case`, `camel_case`, `pascal_case`, `constant_case` | Converts between naming styles. | +| `slugify(s)` | `"Hello, World!"` → `"hello-world"`. | +| `center(s, width, fill?)` | Centers `s` in `width` characters. | +| `truncate(s, width, ending?)` | Shortens `s` to `width` characters, ending with `"..."` if it was cut. | +| `wrap(text, width)`, `fill(text, width)` | Word-wraps text into a list of lines / one string. | +| `indent(text, prefix?)`, `dedent(text)` | Adds / removes indentation on every line. | +| `remove_prefix(s, prefix)`, `remove_suffix(s, suffix)` | Removes text from the start / end if it's there. | +| `is_blank(s)`, `count_words(s)`, `is_palindrome(s)` | Small checks. | +| `distance(a, b)` | Levenshtein edit distance. | +| `closest(word, candidates, max_distance?)` | The most similar candidate, or `null` — for "did you mean ...?" messages. | diff --git a/packages/strings/ascend.json b/packages/strings/ascend.json new file mode 100644 index 0000000..bacbffe --- /dev/null +++ b/packages/strings/ascend.json @@ -0,0 +1,7 @@ +{ + "name": "strings", + "version": "1.0.0", + "description": "Case conversion, word wrapping, padding, edit distance and other text tools", + "main": "strings.asc", + "dependencies": {} +} diff --git a/packages/strings/strings.asc b/packages/strings/strings.asc new file mode 100644 index 0000000..69f3de7 --- /dev/null +++ b/packages/strings/strings.asc @@ -0,0 +1,216 @@ +// strings: extra tools for working with text. +// +// strings := import("strings") +// print(strings.snake_case("userName")) // user_name +// print(strings.fill(long_text, 60)) // wrapped to 60 columns + +// "hello world" -> "Hello world" +string capitalize(string s) { + if s == "" { + return s + } + return upper(s[0]) + s[1:] +} + +// "hello big world" -> "Hello Big World" +string title(string s) { + out := [] + start := true + for c in s { + out.push(start ? upper(c) : lower(c)) + start = is_space(c) or c == '-' + } + return join(out) +} + +// The words in a name or sentence, in lower case. Splits on spaces, '_', '-' +// and other punctuation, and where camelCase changes case: +// "parseHTTPResponse_v2" -> ["parse", "http", "response", "v2"] +list words(string s) { + result := [] + current := [] + cs := chars(s) + for i, c in cs { + if not is_alnum(c) { + if len(current) > 0 { + result.push(lower(join(current))) + current = [] + } + continue + } + if len(current) > 0 and is_upper(c) { + previous := cs[i - 1] + next_is_lower := i + 1 < len(cs) and is_lower(cs[i + 1]) + if is_lower(previous) or is_digit(previous) or (is_upper(previous) and next_is_lower) { + result.push(lower(join(current))) + current = [] + } + } + current.push(c) + } + if len(current) > 0 { + result.push(lower(join(current))) + } + return result +} + +string snake_case(string s) { return join(words(s), "_") } +string kebab_case(string s) { return join(words(s), "-") } +string constant_case(string s) { return upper(join(words(s), "_")) } + +string camel_case(string s) { + parts := words(s) + for i in range(1, len(parts)) { + parts[i] = capitalize(parts[i]) + } + return join(parts) +} + +string pascal_case(string s) { + return join(map(words(s), capitalize)) +} + +// Text for use in a URL or file name: "Hello, World!" -> "hello-world" +string slugify(string s) { + return join(words(s), "-") +} + +// Centers s in a field of `width` characters. +string center(s, i64 width, char fill = ' ') { + text := str(s) + space := width - len(text) + if space <= 0 { + return text + } + left := space / 2 + return fill * left + text + fill * (space - left) +} + +// Shortens s to at most `width` characters, ending with `ending` if it was cut. +string truncate(s, i64 width, string ending = "...") { + text := str(s) + if len(text) <= width { + return text + } + if width <= len(ending) { + return text[:width] + } + return text[:width - len(ending)] + ending +} + +// Splits text into lines of at most `width` characters, breaking between words. +// Words longer than `width` get a line of their own. +list wrap(string text, i64 width) { + if width < 1 { + throw "strings.wrap: width must be at least 1" + } + result := [] + for paragraph in lines(text) { + line := "" + for word in split(paragraph) { + if line == "" { + line = word + } else if len(line) + 1 + len(word) <= width { + line += " " + word + } else { + result.push(line) + line = word + } + } + result.push(line) + } + return result +} + +// wrap(), joined into one string. +string fill(string text, i64 width) { + return join(wrap(text, width), "\n") +} + +// Adds `prefix` to the start of every line that isn't empty. +string indent(string text, string prefix = " ") { + out := [] + for line in split(text, "\n") { + out.push(trim(line) == "" ? line : prefix + line) + } + return join(out, "\n") +} + +// Removes the indentation that every non-empty line shares. +string dedent(string text) { + all_lines := split(text, "\n") + common := -1 + for line in all_lines { + if trim(line) == "" { + continue + } + n := 0 + while n < len(line) and (line[n] == ' ' or line[n] == '\t') { + n += 1 + } + if common == -1 or n < common { + common = n + } + } + if common <= 0 { + return text + } + out := [] + for line in all_lines { + out.push(trim(line) == "" ? "" : line[common:]) + } + return join(out, "\n") +} + +string remove_prefix(string s, string prefix) { + return starts_with(s, prefix) ? s[len(prefix):] : s +} + +string remove_suffix(string s, string suffix) { + return suffix != "" and ends_with(s, suffix) ? s[:len(s) - len(suffix)] : s +} + +bool is_blank(string s) { + return trim(s) == "" +} + +i64 count_words(string s) { + return len(split(s)) +} + +// Reads the same backwards, ignoring case, spaces and punctuation. +bool is_palindrome(string s) { + letters := lower(join(filter(chars(s), is_alnum))) + return letters == reverse(letters) +} + +// How many single-character edits turn a into b (Levenshtein distance). +i64 distance(string a, string b) { + x := chars(a) + y := chars(b) + previous := range(len(y) + 1) + for i in range(len(x)) { + current := [i + 1] + for j in range(len(y)) { + cost := x[i] == y[j] ? 0 : 1 + current.push(min(previous[j + 1] + 1, current[j] + 1, previous[j] + cost)) + } + previous = current + } + return previous[len(y)] +} + +// The candidate closest to `word`, or null if none is within `max_distance` edits. +// Handy for "did you mean ...?" messages. +string? closest(string word, list candidates, i64 max_distance = 3) { + best := null + best_distance := max_distance + 1 + for candidate in candidates { + d := distance(lower(word), lower(str(candidate))) + if d < best_distance { + best = str(candidate) + best_distance = d + } + } + return best +} diff --git a/packages/testing/README.md b/packages/testing/README.md new file mode 100644 index 0000000..f7a4c56 --- /dev/null +++ b/packages/testing/README.md @@ -0,0 +1,47 @@ +# testing + +Write and run unit tests for Ascend code. + +``` +apkg install testing +``` + +``` +t := import("testing") +geometry := import("geometry") + +t.test("area of a square", func() { + t.equal(geometry.area(3, 3), 9) +}) + +t.test("negative sizes are rejected", func() { + t.throws(func() { return geometry.area(-1, 2) }, "negative") +}) + +t.run() +``` + +``` +$ ascend geometry_test.asc + pass area of a square + pass negative sizes are rejected + +2 passed (0.00s) +``` + +`run()` ends the program with exit code 1 if any test failed, so test files +work in CI scripts. + +| Function | What it does | +|----------|--------------| +| `test(name, body)` | Adds a test; `body` is a function with no parameters. | +| `skip(name, body)` | Adds a test that's listed but not run. | +| `run()` | Runs the tests and prints a report. | +| `equal(actual, expected, message?)` | Fails unless the values are equal (`==`). | +| `not_equal(actual, unexpected, message?)` | Fails if the values are equal. | +| `ok(condition, message?)` | Fails unless the condition is truthy. | +| `close(actual, expected, tolerance?)` | Fails unless two numbers are within `tolerance` (default `1e-9`). | +| `throws(body, contains?)` | Fails unless calling `body` throws an error (whose message contains `contains`, if given). Returns the error. | +| `fail(message?)` | Fails the current test. | + +Depends on [`colors`](../colors) for its output. diff --git a/packages/testing/ascend.json b/packages/testing/ascend.json new file mode 100644 index 0000000..66a74f1 --- /dev/null +++ b/packages/testing/ascend.json @@ -0,0 +1,9 @@ +{ + "name": "testing", + "version": "1.0.0", + "description": "Write and run unit tests for Ascend code", + "main": "testing.asc", + "dependencies": { + "colors": "*" + } +} diff --git a/packages/testing/testing.asc b/packages/testing/testing.asc new file mode 100644 index 0000000..c653c5b --- /dev/null +++ b/packages/testing/testing.asc @@ -0,0 +1,120 @@ +// testing: write and run unit tests. +// +// t := import("testing") +// +// t.test("adding numbers", func() { +// t.equal(1 + 1, 2) +// }) +// t.test("dividing by zero is an error", func() { +// t.throws(func() { return 1 / 0 }, "division by zero") +// }) +// +// t.run() // prints the results; exits with code 1 if a test failed + +colors := import("colors") + +_tests := [] + +// Adds a test. `body` is a function with no parameters that uses the checks below. +void test(string name, func body) { + _tests.push({name: name, body: body, skip: false}) +} + +// Adds a test that's reported but not run, e.g. while it's being fixed. +void skip(string name, func body) { + _tests.push({name: name, body: body, skip: true}) +} + +// Stops the current test and reports it as failed. +void fail(string message = "failed") { + throw {_testing_failure: true, message: message} +} + +void ok(condition, string message = "expected a true value") { + if not condition { + fail(f"{message} (got {repr(condition)})") + } +} + +void equal(actual, expected, string message = "") { + if actual != expected { + fail((message == "" ? "" : message + ": ") + f"expected {repr(expected)}, got {repr(actual)}") + } +} + +void not_equal(actual, unexpected, string message = "") { + if actual == unexpected { + fail((message == "" ? "" : message + ": ") + f"expected anything but {repr(unexpected)}") + } +} + +// Checks that two numbers are within `tolerance` of each other. +void close(f64 actual, f64 expected, f64 tolerance = 1e-9) { + if abs(actual - expected) > tolerance { + fail(f"expected {expected} (within {tolerance}), got {actual}") + } +} + +// Checks that calling `body` throws an error, and that the error message +// contains `contains` when it's given. Returns the error. +func throws(func body, string? contains = null) { + try { + body() + } catch e { + if type(e) == "map" and get(e, "_testing_failure", false) { + throw e + } + if contains != null and not (contains in str(e)) { + fail(f"expected an error containing {repr(contains)}, got {repr(str(e))}") + } + return e + } + fail("expected an error, but nothing was thrown") +} + +// Runs every test added so far and prints a report. Ends the program with +// exit code 1 if any test failed; otherwise returns the number of tests run. +i64 run() { + passed := 0 + failed := [] + skipped := 0 + started := clock() + for entry in _tests { + if entry.skip { + skipped += 1 + print(colors.yellow(" skip"), entry.name) + continue + } + try { + entry.body() + passed += 1 + print(colors.green(" pass"), entry.name) + } catch e { + message := type(e) == "map" and get(e, "_testing_failure", false) ? e.message : "error: " + str(e) + failed.push({name: entry.name, message: message}) + print(colors.red(" FAIL"), entry.name) + print(colors.gray(" " + replace(message, "\n", "\n "))) + } + } + _tests = [] + + seconds := clock() - started + summary := f"{passed} passed" + if len(failed) > 0 { + summary += ", " + colors.red(f"{len(failed)} failed") + } + if skipped > 0 { + summary += ", " + colors.yellow(f"{skipped} skipped") + } + print() + print(summary + colors.gray(f" ({seconds:.2f}s)")) + if len(failed) > 0 { + print() + print("Failed tests:") + for failure in failed { + print(f" {failure.name}: {failure.message}") + } + exit(1) + } + return passed +} diff --git a/registry/index.json b/registry/index.json new file mode 100644 index 0000000..6a3a7bd --- /dev/null +++ b/registry/index.json @@ -0,0 +1,16 @@ +{ + "packages": { + "colors": { + "description": "Color and style text in the terminal", + "source": "registry:packages/colors" + }, + "strings": { + "description": "Case conversion, word wrapping, padding, edit distance and other text tools", + "source": "registry:packages/strings" + }, + "testing": { + "description": "Write and run unit tests for Ascend code", + "source": "registry:packages/testing" + } + } +} diff --git a/src/apkg_main.cpp b/src/apkg_main.cpp new file mode 100644 index 0000000..54c617b --- /dev/null +++ b/src/apkg_main.cpp @@ -0,0 +1,114 @@ +// apkg: the Ascend package manager. + +#include "package_manager.hpp" +#include "runner.hpp" + +#include +#include +#include + +namespace { + +void printUsage(std::ostream& out) { + out << "apkg - the Ascend package manager\n" + "\n" + "Usage:\n" + " apkg install ... install packages (and what they depend on)\n" + " apkg install install everything the project's ascend.json lists\n" + " apkg uninstall ... remove packages\n" + " apkg list show the installed packages\n" + " apkg show show details about an installed package\n" + " apkg search [text] find packages in the registry\n" + " apkg init [name] make the current folder a project (writes ascend.json)\n" + "\n" + "Options:\n" + " -g, --global use your own packages folder, even inside a project\n" + " -U, --upgrade get the newest version of packages that are already installed\n" + " -h, --help show this help\n" + " -v, --version show the version\n" + "\n" + "Packages can come from:\n" + " colors the registry (the newest version)\n" + " colors@1.0.0 the registry (a particular version)\n" + " github:user/repo a GitHub repository (add @tag, or /folder for a folder in it)\n" + " git+https://host/repo.git any git repository (add @tag, or #folder for a folder in it)\n" + " ./path/to/folder a folder on this computer\n" + "\n" + "Inside a project (a folder with an ascend.json) packages are installed into its\n" + "ascend_packages folder and recorded in ascend.json. Anywhere else they're\n" + "installed for you, in ~/.ascend/packages. Programs use them with import(\"colors\").\n" + "\n" + "Environment:\n" + " ASCEND_HOME where your packages and downloads are kept (default ~/.ascend)\n" + " ASCEND_REGISTRY the registry to use: a git address or a folder\n" + " (default " << ascend::kDefaultRegistry << ")\n"; +} + +} // namespace + +int main(int argc, char* argv[]) { + ascend::PackageOptions options; + try { + options = ascend::defaultPackageOptions(); + } catch (const std::exception& error) { + std::cerr << "error: " << error.what() << "\n"; + return 1; + } + + std::string command; + std::vector operands; + bool flagsDone = false; + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (!flagsDone && arg.size() > 1 && arg[0] == '-') { + if (arg == "--") { + flagsDone = true; + } else if (arg == "-g" || arg == "--global") { + options.global = true; + } else if (arg == "-U" || arg == "--upgrade") { + options.upgrade = true; + } else if (arg == "-h" || arg == "--help") { + printUsage(std::cout); + return 0; + } else if (arg == "-v" || arg == "--version") { + std::cout << "apkg " << ascend::kVersion << " (Ascend " << ascend::kVersion << ")\n"; + return 0; + } else { + std::cerr << "error: unknown option '" << arg << "' (run `apkg --help` to see the options)\n"; + return 1; + } + } else if (command.empty()) { + command = arg; + } else { + operands.push_back(arg); + } + } + + if (command.empty() || command == "help") { + printUsage(command.empty() ? std::cerr : std::cout); + return command.empty() ? 1 : 0; + } + + auto expectAtMost = [&](size_t count) { + if (operands.size() <= count) return true; + std::cerr << "error: too many arguments for `apkg " << command << "`\n"; + return false; + }; + + ascend::PackageManager manager(options, std::cout, std::cerr); + if (command == "install" || command == "add" || command == "i") return manager.install(operands); + if (command == "uninstall" || command == "remove" || command == "rm") return manager.uninstall(operands); + if (command == "list" || command == "ls") return expectAtMost(0) ? manager.list() : 1; + if (command == "search") return expectAtMost(1) ? manager.search(operands.empty() ? "" : operands[0]) : 1; + if (command == "init") return expectAtMost(1) ? manager.init(operands.empty() ? "" : operands[0]) : 1; + if (command == "show" || command == "info") { + if (operands.size() != 1) { + std::cerr << "error: say which package to show, like: apkg show colors\n"; + return 1; + } + return manager.show(operands[0]); + } + + std::cerr << "error: unknown command '" << command << "' (run `apkg --help` to see the commands)\n"; + return 1; +} diff --git a/src/include/package_manager.hpp b/src/include/package_manager.hpp new file mode 100644 index 0000000..f4a31a3 --- /dev/null +++ b/src/include/package_manager.hpp @@ -0,0 +1,122 @@ +#ifndef ASCEND_PACKAGE_MANAGER_HPP +#define ASCEND_PACKAGE_MANAGER_HPP + +#include "interpreter.hpp" +#include "packages.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ascend { + +// The registry used when ASCEND_REGISTRY isn't set: registry/index.json in +// the Ascend repository. +constexpr const char* kDefaultRegistry = "https://github.com/csubbdev/Ascend.git"; + +// Where a package comes from, parsed from what the user typed: +// colors a package from the registry (latest version) +// colors@1.2.0 a specific version from the registry +// github:user/repo[/dir][@tag] a GitHub repository (optionally a folder in it) +// git+[@tag][#dir] any git repository +// ./path/to/folder a folder on this computer (also file:path) +struct PackageSource { + enum class Kind { Registry, Local, Git, RegistryPath }; + + Kind kind = Kind::Registry; + std::string spec; // as written + std::string name; // Registry: the package name + std::string version; // Registry: the version asked for ("" = latest) + std::string location; // Local: folder; Git: URL; RegistryPath: folder inside the registry + std::string ref; // Git: tag or branch ("" = the default branch) + std::string subdir; // Git: folder inside the repository +}; + +// Throws std::runtime_error with a readable message for invalid specs. +PackageSource parsePackageSpec(const std::string& spec, const std::filesystem::path& baseDirectory); + +struct PackageOptions { + std::filesystem::path home; // user packages and caches (ASCEND_HOME) + std::string registry; // registry: a git URL or a local folder + std::filesystem::path workingDirectory; // where the command was run + bool global = false; // use the user's packages even inside a project + bool upgrade = false; // reinstall packages that are already installed +}; + +// Options from the environment: ASCEND_HOME, ASCEND_REGISTRY and the current folder. +PackageOptions defaultPackageOptions(); + +// Installs, removes and lists packages. Each public command prints what it +// does to `out`, problems to `err`, and returns a process exit code. +// +// Inside a project (a folder with ascend.json, or below one) packages go into +// the project's ascend_packages folder and are recorded in its ascend.json. +// Elsewhere, or with `global`, they go into the user's packages folder. +class PackageManager { +public: + PackageManager(PackageOptions options, std::ostream& out, std::ostream& err); + + int init(const std::string& name); + int install(const std::vector& specs); + int uninstall(const std::vector& names); + int list(); + int show(const std::string& name); + int search(const std::string& term); + + const std::filesystem::path& packagesDir() const { return packagesDir_; } + const std::filesystem::path& projectRoot() const { return projectRoot_; } + bool inProject() const { return !projectRoot_.empty(); } + +private: + struct RegistryEntry { + std::string description; + std::string source; + }; + + std::string installSpec(const std::string& spec, const std::filesystem::path& base, const std::string& wantedName, + const std::string& requiredBy); + std::string installFrom(const PackageSource& source, const std::string& spec, const std::string& expectedName, + const std::string& expectedVersion, const std::string& requiredBy); + void installDependencies(const Manifest& manifest, const std::filesystem::path& base); + // --upgrade reinstalls the packages that were asked for; their + // dependencies are only installed or changed when needed. + bool upgrading(const std::string& requiredBy) const { return options_.upgrade && requiredBy.empty(); } + + const std::filesystem::path& registryRoot(); + const std::map& registryIndex(); + int git(const std::vector& args, bool quiet = false); + + bool installedManifest(const std::string& name, Manifest& out); + std::map installInfo(const std::string& name); + std::vector installedNames(); + void recordDependencies(const std::vector>& added, + const std::vector& removed); + + PackageOptions options_; + std::ostream& out_; + std::ostream& err_; + std::ostringstream discard_; + std::istringstream noInput_; + Interpreter json_; // only used to read and write JSON + + std::filesystem::path projectRoot_; + std::filesystem::path packagesDir_; + std::filesystem::path registryRoot_; + std::map registryIndex_; + bool registryLoaded_ = false; + std::set handled_; // packages installed (or checked) by this command + int tempCounter_ = 0; +}; + +// Deletes a folder and everything in it, including read-only files (which +// git creates on Windows). Errors are ignored. +void removeTree(const std::filesystem::path& path); + +} // namespace ascend + +#endif // ASCEND_PACKAGE_MANAGER_HPP diff --git a/src/include/packages.hpp b/src/include/packages.hpp new file mode 100644 index 0000000..5a5d451 --- /dev/null +++ b/src/include/packages.hpp @@ -0,0 +1,50 @@ +#ifndef ASCEND_PACKAGES_HPP +#define ASCEND_PACKAGES_HPP + +#include +#include +#include +#include + +namespace ascend { + +class Interpreter; + +// A package's ascend.json. +struct Manifest { + std::string name; + std::string version; + std::string description; + std::string main = "main.asc"; // the file import("name") runs + // name -> what to install: "*" (any version from the registry), a version + // like "1.2.0", or a source such as "github:user/repo@v1.0". + std::vector> dependencies; +}; + +constexpr const char* kManifestFile = "ascend.json"; +constexpr const char* kProjectPackagesDir = "ascend_packages"; + +// Package names: lowercase letters, digits, '-' and '_', starting with a +// letter or digit. +bool isValidPackageName(const std::string& name); + +// Where per-user packages and caches live: $ASCEND_HOME, or ~/.ascend. +std::filesystem::path ascendHome(); + +// Packages installed for the user (outside any project): /packages. +std::filesystem::path globalPackagesDir(); + +// Reads and checks a package manifest. On failure returns false and +// describes the problem in `error`. +bool readManifest(Interpreter& interpreter, const std::filesystem::path& file, Manifest& out, std::string& error); + +// Finds the file that import("name") or import("name/path/in/package") +// refers to. Looks in the ascend_packages folder of `fromDirectory` and each +// folder above it, then in the user's packages. Returns false if there is no +// such package (error stays empty) or the package is broken (error says why). +bool findPackageModule(Interpreter& interpreter, const std::string& request, const std::filesystem::path& fromDirectory, + std::filesystem::path& out, std::string& error); + +} // namespace ascend + +#endif // ASCEND_PACKAGES_HPP diff --git a/src/include/process.hpp b/src/include/process.hpp new file mode 100644 index 0000000..7dbf2ef --- /dev/null +++ b/src/include/process.hpp @@ -0,0 +1,22 @@ +#ifndef ASCEND_PROCESS_HPP +#define ASCEND_PROCESS_HPP + +#include +#include + +namespace ascend { + +// Runs a program (found on the PATH) with the given arguments and waits for +// it. No shell is involved, so arguments are passed exactly as given. +// Returns the program's exit code, or -1 if it couldn't be started. +// With `quiet`, the program's output is discarded. +int runProcess(const std::vector& args, bool quiet = false); + +// Reads an environment variable ("" if it isn't set). +std::string environmentVariable(const std::string& name); + +void setEnvironmentVariable(const std::string& name, const std::string& value); + +} // namespace ascend + +#endif // ASCEND_PROCESS_HPP diff --git a/src/include/runner.hpp b/src/include/runner.hpp index 44ee5ad..5b53a47 100644 --- a/src/include/runner.hpp +++ b/src/include/runner.hpp @@ -8,7 +8,7 @@ namespace ascend { -constexpr const char* kVersion = "0.3.0"; +constexpr const char* kVersion = "0.4.0"; // Process exit codes (following the BSD sysexits convention). constexpr int kExitSyntaxError = 65; diff --git a/src/interpreter.cpp b/src/interpreter.cpp index d62db53..870976c 100644 --- a/src/interpreter.cpp +++ b/src/interpreter.cpp @@ -2,6 +2,7 @@ #include "lexer.hpp" #include "numeric.hpp" +#include "packages.hpp" #include "parser.hpp" #include "runner.hpp" #include "semantic_analyzer.hpp" @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -177,6 +179,13 @@ bool hasExtension(const std::string& path) { return dot != std::string::npos && (slash == std::string::npos || dot > slash); } +// import("colors") or import("colors/extra") may name an installed package; +// import("./helpers") and import("/abs/path") are always files. +bool mayBePackage(const std::string& path) { + if (path.empty() || path[0] == '.' || isAbsolutePath(path)) return false; + return isValidPackageName(path.substr(0, path.find('/'))); +} + } // namespace // Switches the current function and source file for the duration of a call, @@ -428,6 +437,24 @@ Value Interpreter::importModule(const std::string& path) { std::string resolved = isAbsolutePath(path) ? path : importDirectory() + path; if (!hasExtension(resolved)) resolved += ".asc"; + // Not a file next to the importing one: look for an installed package. + std::error_code ignored; + if (!std::filesystem::is_regular_file(resolved, ignored) && mayBePackage(path)) { + std::filesystem::path found; + std::string problem; + std::string directory = importDirectory(); + std::filesystem::path from = directory.empty() ? std::filesystem::current_path() : std::filesystem::path(directory); + if (findPackageModule(*this, path, from, found, problem)) { + resolved = found.string(); + } else { + std::string name = path.substr(0, path.find('/')); + if (!problem.empty()) throw RuntimeError("can't import \"" + path + "\": " + problem); + throw RuntimeError("can't import \"" + path + "\": there's no file '" + resolved + + "' and no installed package named '" + name + "' (to install it, run: apkg install " + + name + ")"); + } + } + auto cached = modules_.find(resolved); if (cached != modules_.end()) return cached->second; if (loadingModules_.count(resolved) != 0) { diff --git a/src/package_manager.cpp b/src/package_manager.cpp new file mode 100644 index 0000000..c1a533b --- /dev/null +++ b/src/package_manager.cpp @@ -0,0 +1,878 @@ +#include "package_manager.hpp" + +#include "process.hpp" +#include "stdlib.hpp" +#include "value.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace ascend { + +namespace { + +constexpr const char* kInstallInfoFile = ".apkg.json"; // how a package was installed + +class PackageError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +bool startsWith(const std::string& text, const std::string& prefix) { return text.compare(0, prefix.size(), prefix) == 0; } + +bool endsWith(const std::string& text, const std::string& suffix) { + return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +std::string trim(const std::string& text) { + size_t first = text.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return ""; + size_t last = text.find_last_not_of(" \t\r\n"); + return text.substr(first, last - first + 1); +} + +std::string lowercase(std::string text) { + for (char& c : text) c = static_cast(std::tolower(static_cast(c))); + return text; +} + +std::vector splitOn(const std::string& text, char separator) { + std::vector parts; + size_t start = 0; + while (true) { + size_t end = text.find(separator, start); + parts.push_back(text.substr(start, end - start)); + if (end == std::string::npos) return parts; + start = end + 1; + } +} + +// A relative path that stays inside its folder. +bool isSafeRelative(const std::string& text) { + if (text.empty()) return false; + fs::path path(text); + if (path.is_absolute() || path.has_root_name() || path.has_root_directory()) return false; + for (const auto& part : path) { + if (part == "..") return false; + } + return true; +} + +bool isValidRepoPart(const std::string& part) { + if (part.empty() || part == "." || part == "..") return false; + for (char c : part) { + if (!std::isalnum(static_cast(c)) && c != '-' && c != '_' && c != '.') return false; + } + return true; +} + +// Splits "address@ref" at an '@' that comes after the last '/', so the user +// part of "git@host:user/repo.git" isn't mistaken for a ref. +void splitRef(std::string& text, std::string& ref) { + size_t at = text.rfind('@'); + size_t slash = text.rfind('/'); + if (at != std::string::npos && (slash == std::string::npos || at > slash)) { + ref = text.substr(at + 1); + text = text.substr(0, at); + } +} + +bool looksLikePath(const std::string& spec) { + return spec[0] == '.' || spec[0] == '/' || spec[0] == '\\' || spec[0] == '~' || + (spec.size() > 1 && spec[1] == ':') || spec.find('/') != std::string::npos || + spec.find('\\') != std::string::npos; +} + +// What to install for a dependency written in ascend.json as name -> value. +std::string dependencySpec(const std::string& name, const std::string& value) { + if (value.empty() || value == "*" || value == "latest") return name; + if (std::isdigit(static_cast(value[0]))) return name + "@" + value; + return value; +} + +std::string timestamp() { + std::time_t now = std::time(nullptr); + std::tm local{}; +#if defined(_WIN32) + if (localtime_s(&local, &now) != 0) return ""; +#else + if (localtime_r(&now, &local) == nullptr) return ""; +#endif + char buffer[32] = ""; + std::strftime(buffer, sizeof buffer, "%Y-%m-%d %H:%M", &local); + return buffer; +} + +std::string randomSuffix() { + std::random_device device; + const char* digits = "0123456789abcdef"; + std::string out; + for (int i = 0; i < 8; ++i) out += digits[device() % 16]; + return out; +} + +fs::path findProjectRoot(const fs::path& start) { + std::error_code ignored; + fs::path dir = fs::absolute(start, ignored).lexically_normal(); + if (!dir.has_filename() && dir.has_relative_path()) dir = dir.parent_path(); // "a/b/" -> "a/b" + while (!dir.empty()) { + if (fs::is_regular_file(dir / kManifestFile, ignored)) return dir; + fs::path parent = dir.parent_path(); + if (parent == dir) break; + dir = parent; + } + return {}; +} + +// Copies a package's files, leaving out version control data and installed packages. +void copyPackageFiles(const fs::path& from, const fs::path& to) { + fs::create_directories(to); + for (const auto& entry : fs::directory_iterator(from)) { + std::string name = entry.path().filename().string(); + if (name == ".git" || name == kProjectPackagesDir || name == kInstallInfoFile) continue; + fs::path target = to / entry.path().filename(); + if (entry.is_directory()) { + copyPackageFiles(entry.path(), target); + } else if (entry.is_regular_file()) { + fs::copy_file(entry.path(), target, fs::copy_options::overwrite_existing); + } + } +} + +// The commit a fresh clone has checked out, read straight from .git. +std::string readGitCommit(const fs::path& repo) { + std::string head; + if (!readTextFile((repo / ".git" / "HEAD").string(), head)) return ""; + head = trim(head); + if (!startsWith(head, "ref: ")) return head; + std::string ref = head.substr(5); + std::string commit; + if (readTextFile((repo / ".git" / ref).string(), commit)) return trim(commit); + std::string packed; + if (readTextFile((repo / ".git" / "packed-refs").string(), packed)) { + for (const auto& line : splitOn(packed, '\n')) { + if (endsWith(trim(line), " " + ref)) return line.substr(0, line.find(' ')); + } + } + return ""; +} + +void writeTextFile(const fs::path& path, const std::string& text) { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) throw PackageError("can't write " + path.string()); + file << text; + if (!file) throw PackageError("can't write " + path.string()); +} + +std::string padded(const std::string& text, size_t width) { + return text.size() >= width ? text : text + std::string(width - text.size(), ' '); +} + +// A folder name turned into a package name: "My Project!" -> "my-project". +std::string packageNameFrom(const std::string& folder) { + std::string name; + for (char c : lowercase(folder)) { + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + name += ok ? c : '-'; + } + while (!name.empty() && (name[0] == '-' || name[0] == '_')) name.erase(0, 1); + while (!name.empty() && name.back() == '-') name.pop_back(); + if (name.size() > 64) name.resize(64); + return name.empty() ? "my-package" : name; +} + +} // namespace + +void removeTree(const fs::path& path) { + std::error_code ec; + if (!fs::exists(path, ec)) return; + for (fs::recursive_directory_iterator it(path, ec), end; !ec && it != end; it.increment(ec)) { + std::error_code ignored; + fs::permissions(it->path(), fs::perms::owner_write, fs::perm_options::add, ignored); + } + fs::remove_all(path, ec); +} + +PackageSource parsePackageSpec(const std::string& spec, const fs::path& baseDirectory) { + std::string text = trim(spec); + if (text.empty()) throw PackageError("an empty package name was given"); + PackageSource source; + source.spec = text; + + auto checkRefAndFolder = [&] { + if (!source.ref.empty() && (source.ref[0] == '-' || source.ref.find("..") != std::string::npos)) { + throw PackageError("'" + source.ref + "' isn't a valid tag or branch name"); + } + if (!source.subdir.empty() && !isSafeRelative(source.subdir)) { + throw PackageError("'" + source.subdir + "' isn't a folder inside the repository"); + } + }; + + if (startsWith(text, "registry:")) { + source.kind = PackageSource::Kind::RegistryPath; + source.location = text.substr(9); + if (!isSafeRelative(source.location)) throw PackageError("'" + text + "' isn't a folder inside the registry"); + return source; + } + + if (startsWith(text, "github:")) { + std::string rest = text.substr(7); + splitRef(rest, source.ref); + std::vector parts = splitOn(rest, '/'); + if (parts.size() < 2 || !isValidRepoPart(parts[0]) || !isValidRepoPart(parts[1])) { + throw PackageError("'" + text + "' should look like github:user/repo, github:user/repo@v1.0 or " + "github:user/repo/folder"); + } + std::string repo = parts[1]; + if (endsWith(repo, ".git")) repo.resize(repo.size() - 4); + source.kind = PackageSource::Kind::Git; + source.location = "https://github.com/" + parts[0] + "/" + repo + ".git"; + for (size_t i = 2; i < parts.size(); ++i) source.subdir += (source.subdir.empty() ? "" : "/") + parts[i]; + checkRefAndFolder(); + return source; + } + + bool explicitGit = startsWith(text, "git+"); + if (explicitGit || text.find("://") != std::string::npos || startsWith(text, "git@")) { + std::string rest = explicitGit ? text.substr(4) : text; + size_t hash = rest.find('#'); + if (hash != std::string::npos) { + source.subdir = rest.substr(hash + 1); + rest = rest.substr(0, hash); + } + splitRef(rest, source.ref); + if (rest.empty() || rest[0] == '-') throw PackageError("'" + text + "' isn't a valid git address"); + source.kind = PackageSource::Kind::Git; + source.location = rest; + checkRefAndFolder(); + return source; + } + + if (startsWith(text, "file:") || looksLikePath(text)) { + std::string path = startsWith(text, "file:") ? text.substr(5) : text; + if (startsWith(path, "~")) { +#if defined(_WIN32) + path = environmentVariable("USERPROFILE") + path.substr(1); +#else + path = environmentVariable("HOME") + path.substr(1); +#endif + } + fs::path folder(path); + if (folder.is_relative()) folder = baseDirectory / folder; + source.kind = PackageSource::Kind::Local; + source.location = fs::absolute(folder).lexically_normal().string(); + return source; + } + + std::string name = text; + size_t at = text.find('@'); + if (at != std::string::npos) { + name = text.substr(0, at); + source.version = text.substr(at + 1); + if (source.version.empty()) throw PackageError("'" + text + "' is missing a version after '@'"); + } + if (!isValidPackageName(name)) { + throw PackageError("'" + name + "' isn't a valid package name (names use lowercase letters, digits, '-' and '_')"); + } + source.kind = PackageSource::Kind::Registry; + source.name = name; + return source; +} + +PackageOptions defaultPackageOptions() { + PackageOptions options; + options.home = ascendHome(); + options.registry = environmentVariable("ASCEND_REGISTRY"); + if (options.registry.empty()) options.registry = kDefaultRegistry; + options.workingDirectory = fs::current_path(); + return options; +} + +PackageManager::PackageManager(PackageOptions options, std::ostream& out, std::ostream& err) + : options_(std::move(options)), out_(out), err_(err), json_(discard_, noInput_) { + if (!options_.global) projectRoot_ = findProjectRoot(options_.workingDirectory); + packagesDir_ = inProject() ? projectRoot_ / kProjectPackagesDir : options_.home / "packages"; + // Never let git stop to ask for a password (it does for repositories that don't exist). + setEnvironmentVariable("GIT_TERMINAL_PROMPT", "0"); +} + +int PackageManager::git(const std::vector& args, bool quiet) { + // No "detached HEAD" lecture when cloning a tag. + std::vector command{"git", "-c", "advice.detachedHead=false"}; + command.insert(command.end(), args.begin(), args.end()); + out_.flush(); + err_.flush(); + int code = runProcess(command, quiet); + if (code == -1) { + throw PackageError("couldn't run git. apkg uses git to download packages: install it and make sure it's on " + "your PATH"); + } + return code; +} + +// --------------------------------------------------------------------------- +// The registry + +const fs::path& PackageManager::registryRoot() { + if (!registryRoot_.empty()) return registryRoot_; + std::error_code ignored; + if (fs::is_directory(options_.registry, ignored)) { + registryRoot_ = fs::absolute(options_.registry); + return registryRoot_; + } + + // A git repository, kept in a cache folder named after its address. + std::string key; + for (char c : options_.registry) key += std::isalnum(static_cast(c)) ? c : '_'; + if (key.size() > 80) key = key.substr(key.size() - 80); + fs::path cache = options_.home / "cache" / ("registry-" + key); + + if (fs::is_directory(cache / ".git", ignored)) { + bool updated = git({"-C", cache.string(), "fetch", "--depth", "1", "--quiet", "origin"}, true) == 0 && + git({"-C", cache.string(), "reset", "--hard", "--quiet", "FETCH_HEAD"}, true) == 0; + if (!updated) err_ << "warning: couldn't update the package registry, so using the copy downloaded earlier\n"; + } else { + out_ << "Downloading the package registry...\n"; + removeTree(cache); + fs::create_directories(cache.parent_path()); + if (git({"clone", "--depth", "1", "--quiet", "--", options_.registry, cache.string()}) != 0) { + removeTree(cache); + throw PackageError("couldn't download the package registry from " + options_.registry + " (are you online?)"); + } + } + registryRoot_ = cache; + return registryRoot_; +} + +const std::map& PackageManager::registryIndex() { + if (registryLoaded_) return registryIndex_; + fs::path file = registryRoot() / "registry" / "index.json"; + std::string text; + if (!readTextFile(file.string(), text)) throw PackageError("the package registry has no registry/index.json"); + + Value root; + try { + root = jsonParse(json_, text); + } catch (const RuntimeError& problem) { + runtimeErrorPending() = false; + throw PackageError(std::string("the package registry's index.json is invalid: ") + problem.what()); + } + const Value* packages = root.isMap() ? root.asMap().find(Value::fromString("packages")) : nullptr; + if (packages == nullptr || !packages->isMap()) { + throw PackageError("the package registry's index.json has no \"packages\" object"); + } + for (const auto& entry : packages->asMap().entries) { + if (!entry.first.isString() || !entry.second.isMap()) continue; + const MapObj& details = entry.second.asMap(); + const Value* source = details.find(Value::fromString("source")); + if (source == nullptr || !source->isString()) continue; + RegistryEntry registryEntry; + registryEntry.source = source->asString(); + const Value* description = details.find(Value::fromString("description")); + if (description != nullptr && description->isString()) registryEntry.description = description->asString(); + registryIndex_[entry.first.asString()] = registryEntry; + } + registryLoaded_ = true; + return registryIndex_; +} + +// --------------------------------------------------------------------------- +// Installed packages + +bool PackageManager::installedManifest(const std::string& name, Manifest& out) { + std::string ignored; + return readManifest(json_, packagesDir_ / name / kManifestFile, out, ignored); +} + +std::map PackageManager::installInfo(const std::string& name) { + std::map info; + std::string text; + if (!readTextFile((packagesDir_ / name / kInstallInfoFile).string(), text)) return info; + try { + Value root = jsonParse(json_, text); + if (!root.isMap()) return info; + for (const auto& entry : root.asMap().entries) { + if (entry.first.isString() && entry.second.isString()) info[entry.first.asString()] = entry.second.asString(); + } + } catch (const RuntimeError&) { + runtimeErrorPending() = false; + } + return info; +} + +std::vector PackageManager::installedNames() { + std::vector names; + std::error_code ec; + if (!fs::is_directory(packagesDir_, ec)) return names; + for (const auto& entry : fs::directory_iterator(packagesDir_, ec)) { + std::string name = entry.path().filename().string(); + if (name.empty() || name[0] == '.' || !entry.is_directory()) continue; + if (fs::is_regular_file(entry.path() / kManifestFile, ec)) names.push_back(name); + } + std::sort(names.begin(), names.end()); + return names; +} + +// Adds or removes entries in the project's ascend.json, keeping everything else. +void PackageManager::recordDependencies(const std::vector>& added, + const std::vector& removed) { + fs::path file = projectRoot_ / kManifestFile; + std::string text; + if (!readTextFile(file.string(), text)) return; + Value root; + try { + root = jsonParse(json_, text); + } catch (const RuntimeError&) { + runtimeErrorPending() = false; + return; + } + if (!root.isMap()) return; + MapObj& manifest = root.asMap(); + const Value* ownName = manifest.find(Value::fromString("name")); + + Value* dependencies = manifest.find(Value::fromString("dependencies")); + if (dependencies == nullptr || !dependencies->isMap()) { + manifest.set(Value::fromString("dependencies"), Value::fromMap(json_.newMap())); + dependencies = manifest.find(Value::fromString("dependencies")); + } + MapObj& map = dependencies->asMap(); + bool changed = false; + for (const auto& entry : added) { + if (ownName != nullptr && ownName->isString() && ownName->asString() == entry.first) continue; + const Value* existing = map.find(Value::fromString(entry.first)); + if (existing != nullptr && existing->isString() && existing->asString() == entry.second) continue; + map.set(Value::fromString(entry.first), Value::fromString(entry.second)); + out_ << "Added " << entry.first << " to " << kManifestFile << "\n"; + changed = true; + } + for (const auto& name : removed) { + if (map.remove(Value::fromString(name))) { + out_ << "Removed " << name << " from " << kManifestFile << "\n"; + changed = true; + } + } + if (changed) writeTextFile(file, jsonStringify(root, 2) + "\n"); +} + +// --------------------------------------------------------------------------- +// Installing + +std::string PackageManager::installSpec(const std::string& spec, const fs::path& base, const std::string& wantedName, + const std::string& requiredBy) { + PackageSource source = parsePackageSpec(spec, base); + if (source.kind != PackageSource::Kind::Registry) return installFrom(source, spec, wantedName, "", requiredBy); + + if (handled_.count(source.name) != 0) return source.name; + Manifest installed; + if (!upgrading(requiredBy) && installedManifest(source.name, installed) && + (source.version.empty() || installed.version == source.version)) { + handled_.insert(source.name); + if (requiredBy.empty()) { + out_ << source.name << " " << installed.version << " is already installed (use --upgrade to reinstall it)\n"; + } + installDependencies(installed, packagesDir_ / source.name); + return source.name; + } + + const auto& index = registryIndex(); + auto entry = index.find(source.name); + if (entry == index.end()) { + throw PackageError("there's no package named '" + source.name + "' in the registry" + + (requiredBy.empty() ? "" : " (" + requiredBy + " needs it)") + + "; to look for packages, run: apkg search " + source.name); + } + PackageSource resolved = parsePackageSpec(entry->second.source, registryRoot()); + if (resolved.kind == PackageSource::Kind::Registry) { + throw PackageError("the registry entry for '" + source.name + "' should point to a folder or repository"); + } + // Packages hosted in their own repository tag each version as v. + if (resolved.kind == PackageSource::Kind::Git && !source.version.empty() && resolved.ref.empty()) { + resolved.ref = "v" + source.version; + } + return installFrom(resolved, spec, source.name, source.version, requiredBy); +} + +std::string PackageManager::installFrom(const PackageSource& source, const std::string& spec, + const std::string& expectedName, const std::string& expectedVersion, + const std::string& requiredBy) { + std::error_code ignored; + fs::create_directories(packagesDir_); + fs::path temp = packagesDir_ / (".apkg-tmp-" + std::to_string(++tempCounter_) + "-" + randomSuffix()); + struct Cleanup { + fs::path path; + ~Cleanup() { removeTree(path); } + } cleanup{temp}; + fs::create_directories(temp); + + fs::path files; + std::string commit; + switch (source.kind) { + case PackageSource::Kind::Local: + files = source.location; + if (!fs::is_directory(files, ignored)) throw PackageError("the folder '" + source.location + "' doesn't exist"); + break; + case PackageSource::Kind::RegistryPath: + files = registryRoot() / source.location; + if (!fs::is_directory(files, ignored)) { + throw PackageError("the registry has no folder '" + source.location + "'"); + } + break; + case PackageSource::Kind::Git: { + fs::path clone = temp / "repo"; + std::vector args{"clone", "--depth", "1", "--quiet"}; + if (!source.ref.empty()) { + args.push_back("--branch"); + args.push_back(source.ref); + } + args.push_back("--"); + args.push_back(source.location); + args.push_back(clone.string()); + out_ << "Downloading " << source.location << (source.ref.empty() ? "" : " (" + source.ref + ")") << "...\n"; + if (git(args) != 0) { + throw PackageError("couldn't download " + source.location + + (source.ref.empty() ? "" : " at '" + source.ref + "'") + + (source.ref.empty() ? " (check the address)" + : " (check the address and that the tag or branch exists)")); + } + commit = readGitCommit(clone); + files = source.subdir.empty() ? clone : clone / source.subdir; + if (!fs::is_directory(files, ignored)) { + throw PackageError("the repository has no folder '" + source.subdir + "'"); + } + break; + } + case PackageSource::Kind::Registry: + throw PackageError("internal error: a registry package wasn't resolved"); + } + + Manifest manifest; + std::string error; + if (!readManifest(json_, files / kManifestFile, manifest, error)) throw PackageError(spec + ": " + error); + if (!expectedName.empty() && manifest.name != expectedName) { + throw PackageError(spec + " contains the package '" + manifest.name + "', not '" + expectedName + "'"); + } + if (!expectedVersion.empty() && manifest.version != expectedVersion) { + throw PackageError(expectedName + " " + expectedVersion + " isn't available (the registry has version " + + manifest.version + ")"); + } + if (!fs::is_regular_file(files / manifest.main, ignored)) { + throw PackageError(manifest.name + " is broken: its main file '" + manifest.main + "' is missing"); + } + if (handled_.count(manifest.name) != 0) return manifest.name; + + Manifest current; + bool wasInstalled = installedManifest(manifest.name, current); + bool sameVersion = wasInstalled && current.version == manifest.version; + // Folders on this computer are always reinstalled, since they change while being worked on. + if (sameVersion && !upgrading(requiredBy) && source.kind != PackageSource::Kind::Local) { + handled_.insert(manifest.name); + if (requiredBy.empty()) { + out_ << manifest.name << " " << manifest.version << " is already installed (use --upgrade to reinstall it)\n"; + } + installDependencies(manifest, files); + return manifest.name; + } + + // Copy the files next to the destination first, so a failure can't leave half a package. + fs::path staged = temp / "package"; + copyPackageFiles(files, staged); + auto info = json_.newMap(); + info->set(Value::fromString("source"), Value::fromString(spec)); + info->set(Value::fromString("version"), Value::fromString(manifest.version)); + if (!commit.empty()) info->set(Value::fromString("commit"), Value::fromString(commit)); + info->set(Value::fromString("installed"), Value::fromString(timestamp())); + writeTextFile(staged / kInstallInfoFile, jsonStringify(Value::fromMap(info), 2) + "\n"); + + fs::path target = packagesDir_ / manifest.name; + removeTree(target); + fs::rename(staged, target); + handled_.insert(manifest.name); + + out_ << (!wasInstalled ? "Installed " : sameVersion ? "Reinstalled " : "Upgraded ") << manifest.name << " " + << manifest.version; + if (wasInstalled && !sameVersion) out_ << " (was " << current.version << ")"; + if (!requiredBy.empty()) out_ << " (needed by " << requiredBy << ")"; + out_ << "\n"; + + installDependencies(manifest, files); + return manifest.name; +} + +// Installs whatever the package depends on that isn't installed yet. A local +// dependency path is relative to the folder the package came from. +void PackageManager::installDependencies(const Manifest& manifest, const fs::path& base) { + for (const auto& dependency : manifest.dependencies) { + const std::string& name = dependency.first; + if (handled_.count(name) != 0) continue; + std::string spec = dependencySpec(name, dependency.second); + Manifest existing; + if (installedManifest(name, existing)) { + PackageSource source = parsePackageSpec(spec, base); + bool satisfied = source.kind != PackageSource::Kind::Registry || source.version.empty() || + source.version == existing.version; + if (satisfied) { + handled_.insert(name); + installDependencies(existing, packagesDir_ / name); + continue; + } + out_ << manifest.name << " needs " << name << " " << source.version << " (" << existing.version + << " is installed)\n"; + } + installSpec(spec, base, name, manifest.name); + } +} + +// --------------------------------------------------------------------------- +// Commands + +int PackageManager::install(const std::vector& specs) { + try { + handled_.clear(); + if (specs.empty()) { + if (!inProject()) { + err_ << "error: say which packages to install, like: apkg install colors\n" + << "(in a project folder with an ascend.json, `apkg install` installs its dependencies)\n"; + return 1; + } + Manifest project; + std::string error; + if (!readManifest(json_, projectRoot_ / kManifestFile, project, error)) throw PackageError(error); + if (project.dependencies.empty()) { + out_ << "Nothing to install: " << project.name << " has no dependencies\n"; + return 0; + } + for (const auto& dependency : project.dependencies) { + installSpec(dependencySpec(dependency.first, dependency.second), projectRoot_, dependency.first, ""); + } + out_ << "All dependencies of " << project.name << " are installed in " << packagesDir_.string() << "\n"; + return 0; + } + + std::vector> added; + for (const auto& spec : specs) { + PackageSource source = parsePackageSpec(spec, options_.workingDirectory); + std::string name = installSpec(spec, options_.workingDirectory, "", ""); + if (!inProject()) continue; + // Remember the dependency in the project's ascend.json. + std::string value = source.spec; + if (source.kind == PackageSource::Kind::Registry) { + value = source.version.empty() ? "*" : source.version; + } else if (source.kind == PackageSource::Kind::Local) { + fs::path relative = fs::path(source.location).lexically_relative(projectRoot_); + value = "file:" + (relative.empty() ? source.location : relative.generic_string()); + } + added.emplace_back(name, value); + } + if (!added.empty()) recordDependencies(added, {}); + return 0; + } catch (const std::exception& error) { + err_ << "error: " << error.what() << "\n"; + return 1; + } +} + +int PackageManager::uninstall(const std::vector& names) { + if (names.empty()) { + err_ << "error: say which packages to uninstall, like: apkg uninstall colors\n"; + return 1; + } + try { + int status = 0; + std::vector removed; + for (const auto& name : names) { + std::error_code ignored; + Manifest manifest; + bool installed = isValidPackageName(name) && + (installedManifest(name, manifest) || fs::is_directory(packagesDir_ / name, ignored)); + if (!installed) { + err_ << "error: " << name << " isn't installed in " << packagesDir_.string() << "\n"; + status = 1; + continue; + } + removeTree(packagesDir_ / name); + out_ << "Uninstalled " << name << (manifest.version.empty() ? "" : " " + manifest.version) << "\n"; + removed.push_back(name); + } + if (inProject() && !removed.empty()) recordDependencies({}, removed); + + for (const auto& other : installedNames()) { + Manifest manifest; + if (!installedManifest(other, manifest)) continue; + for (const auto& dependency : manifest.dependencies) { + if (std::find(removed.begin(), removed.end(), dependency.first) != removed.end()) { + err_ << "note: " << other << " needs " << dependency.first << ", which is no longer installed\n"; + } + } + } + return status; + } catch (const std::exception& error) { + err_ << "error: " << error.what() << "\n"; + return 1; + } +} + +int PackageManager::list() { + try { + std::vector names = installedNames(); + if (names.empty()) { + out_ << "No packages are installed in " << packagesDir_.string() << "\n"; + } else { + std::vector> rows; + for (const auto& name : names) { + Manifest manifest; + installedManifest(name, manifest); + auto info = installInfo(name); + rows.push_back({name, manifest.version, info.count("source") != 0 ? info["source"] : ""}); + } + size_t nameWidth = 7; + size_t versionWidth = 7; + for (const auto& row : rows) { + nameWidth = std::max(nameWidth, row[0].size()); + versionWidth = std::max(versionWidth, row[1].size()); + } + out_ << "Packages in " << packagesDir_.string() << ":\n\n"; + out_ << padded("Package", nameWidth) << " " << padded("Version", versionWidth) << " Source\n"; + out_ << std::string(nameWidth, '-') << " " << std::string(versionWidth, '-') << " ------\n"; + for (const auto& row : rows) { + out_ << padded(row[0], nameWidth) << " " << padded(row[1], versionWidth) << " " << row[2] << "\n"; + } + } + + if (inProject()) { + Manifest project; + std::string error; + if (readManifest(json_, projectRoot_ / kManifestFile, project, error)) { + std::vector missing; + for (const auto& dependency : project.dependencies) { + Manifest ignored; + if (!installedManifest(dependency.first, ignored)) missing.push_back(dependency.first); + } + if (!missing.empty()) { + out_ << "\nNot installed yet (run `apkg install`):"; + for (const auto& name : missing) out_ << " " << name; + out_ << "\n"; + } + } + } + return 0; + } catch (const std::exception& error) { + err_ << "error: " << error.what() << "\n"; + return 1; + } +} + +int PackageManager::show(const std::string& name) { + Manifest manifest; + if (!isValidPackageName(name) || !installedManifest(name, manifest)) { + err_ << "error: " << name << " isn't installed in " << packagesDir_.string() << "\n"; + return 1; + } + auto info = installInfo(name); + out_ << "Name: " << manifest.name << "\n"; + out_ << "Version: " << manifest.version << "\n"; + if (!manifest.description.empty()) out_ << "Description: " << manifest.description << "\n"; + out_ << "Location: " << (packagesDir_ / name).string() << "\n"; + out_ << "Main file: " << manifest.main << "\n"; + if (info.count("source") != 0) out_ << "Source: " << info["source"] << "\n"; + if (info.count("commit") != 0) out_ << "Commit: " << info["commit"] << "\n"; + if (info.count("installed") != 0) out_ << "Installed: " << info["installed"] << "\n"; + + std::string needs; + for (const auto& dependency : manifest.dependencies) needs += (needs.empty() ? "" : ", ") + dependency.first; + out_ << "Requires: " << (needs.empty() ? "nothing" : needs) << "\n"; + + std::string requiredBy; + for (const auto& other : installedNames()) { + Manifest otherManifest; + if (!installedManifest(other, otherManifest)) continue; + for (const auto& dependency : otherManifest.dependencies) { + if (dependency.first == name) requiredBy += (requiredBy.empty() ? "" : ", ") + other; + } + } + out_ << "Required by: " << (requiredBy.empty() ? "nothing" : requiredBy) << "\n"; + return 0; +} + +int PackageManager::search(const std::string& term) { + try { + const auto& index = registryIndex(); + std::string wanted = lowercase(term); + std::vector> matches; + for (const auto& entry : index) { + if (wanted.empty() || lowercase(entry.first).find(wanted) != std::string::npos || + lowercase(entry.second.description).find(wanted) != std::string::npos) { + matches.emplace_back(entry.first, entry.second.description); + } + } + if (matches.empty()) { + out_ << "No packages match '" << term << "'\n"; + return 0; + } + size_t width = 0; + for (const auto& match : matches) width = std::max(width, match.first.size()); + for (const auto& match : matches) { + Manifest installed; + bool isInstalled = installedManifest(match.first, installed); + out_ << padded(match.first, width) << " " << match.second + << (isInstalled ? " [installed " + installed.version + "]" : "") << "\n"; + } + return 0; + } catch (const std::exception& error) { + err_ << "error: " << error.what() << "\n"; + return 1; + } +} + +int PackageManager::init(const std::string& name) { + try { + const fs::path& folder = options_.workingDirectory; + std::error_code ignored; + if (fs::exists(folder / kManifestFile, ignored)) { + err_ << "error: " << (folder / kManifestFile).string() << " already exists\n"; + return 1; + } + std::string packageName = name.empty() ? packageNameFrom(fs::absolute(folder).filename().string()) : name; + if (!isValidPackageName(packageName)) { + err_ << "error: '" << packageName + << "' isn't a valid package name (use lowercase letters, digits, '-' and '_')\n"; + return 1; + } + + auto manifest = json_.newMap(); + manifest->set(Value::fromString("name"), Value::fromString(packageName)); + manifest->set(Value::fromString("version"), Value::fromString("0.1.0")); + manifest->set(Value::fromString("description"), Value::fromString("")); + manifest->set(Value::fromString("main"), Value::fromString("main.asc")); + manifest->set(Value::fromString("dependencies"), Value::fromMap(json_.newMap())); + writeTextFile(folder / kManifestFile, jsonStringify(Value::fromMap(manifest), 2) + "\n"); + + if (!fs::exists(folder / "main.asc", ignored)) { + writeTextFile(folder / "main.asc", "// " + packageName + "\n\nprint(\"Hello from " + packageName + "!\")\n"); + } + + // Installed packages don't belong in version control. + fs::path gitignore = folder / ".gitignore"; + std::string existing; + readTextFile(gitignore.string(), existing); + if (existing.find(kProjectPackagesDir) == std::string::npos) { + if (!existing.empty() && existing.back() != '\n') existing += '\n'; + writeTextFile(gitignore, existing + kProjectPackagesDir + "/\n"); + } + + out_ << "Created " << kManifestFile << " for " << packageName << "\n\n" + << " Add packages: apkg install colors\n" + << " Run it: ascend main.asc\n"; + return 0; + } catch (const std::exception& error) { + err_ << "error: " << error.what() << "\n"; + return 1; + } +} + +} // namespace ascend diff --git a/src/packages.cpp b/src/packages.cpp new file mode 100644 index 0000000..44c90b9 --- /dev/null +++ b/src/packages.cpp @@ -0,0 +1,175 @@ +#include "packages.hpp" + +#include "interpreter.hpp" +#include "process.hpp" +#include "stdlib.hpp" + +namespace fs = std::filesystem; + +namespace ascend { + +namespace { + +// A relative path that stays inside its folder (no "..", not absolute). +bool isSafeRelativePath(const std::string& text) { + if (text.empty()) return false; + fs::path path(text); + if (path.is_absolute() || path.has_root_name() || path.has_root_directory()) return false; + for (const auto& part : path) { + if (part == "..") return false; + } + return true; +} + +const Value* field(const MapObj& map, const char* name) { return map.find(Value::fromString(name)); } + +} // namespace + +bool isValidPackageName(const std::string& name) { + if (name.empty() || name.size() > 64) return false; + if (!((name[0] >= 'a' && name[0] <= 'z') || (name[0] >= '0' && name[0] <= '9'))) return false; + for (char c : name) { + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; + if (!ok) return false; + } + return true; +} + +fs::path ascendHome() { + std::string home = environmentVariable("ASCEND_HOME"); + if (!home.empty()) return fs::path(home); +#if defined(_WIN32) + std::string user = environmentVariable("USERPROFILE"); +#else + std::string user = environmentVariable("HOME"); +#endif + return (user.empty() ? fs::current_path() : fs::path(user)) / ".ascend"; +} + +fs::path globalPackagesDir() { return ascendHome() / "packages"; } + +bool readManifest(Interpreter& interpreter, const fs::path& file, Manifest& out, std::string& error) { + std::string text; + if (!readTextFile(file.string(), text)) { + error = "there is no " + std::string(kManifestFile) + " in " + file.parent_path().string() + + ", so it isn't an Ascend package"; + return false; + } + + Value root; + try { + root = jsonParse(interpreter, text); + } catch (const RuntimeError& problem) { + runtimeErrorPending() = false; // handled here + error = file.string() + " isn't valid JSON: " + problem.what(); + return false; + } + if (!root.isMap()) { + error = file.string() + " should contain a JSON object"; + return false; + } + const MapObj& map = root.asMap(); + + auto text_field = [&](const char* name, std::string& target, bool required) { + const Value* value = field(map, name); + if (value == nullptr || value->isNull()) { + if (required) error = file.string() + " needs a \"" + name + "\""; + return !required; + } + if (!value->isString()) { + error = "\"" + std::string(name) + "\" in " + file.string() + " should be a string"; + return false; + } + target = value->asString(); + return true; + }; + + Manifest manifest; + if (!text_field("name", manifest.name, true) || !text_field("version", manifest.version, true) || + !text_field("description", manifest.description, false) || !text_field("main", manifest.main, false)) { + return false; + } + if (!isValidPackageName(manifest.name)) { + error = "\"" + manifest.name + "\" in " + file.string() + + " isn't a valid package name (use lowercase letters, digits, '-' and '_')"; + return false; + } + if (manifest.version.empty()) { + error = file.string() + " needs a non-empty \"version\""; + return false; + } + if (!isSafeRelativePath(manifest.main)) { + error = "\"main\" in " + file.string() + " should be a file inside the package"; + return false; + } + + if (const Value* dependencies = field(map, "dependencies")) { + if (!dependencies->isNull() && !dependencies->isMap()) { + error = "\"dependencies\" in " + file.string() + " should be an object"; + return false; + } + if (dependencies->isMap()) { + for (const auto& entry : dependencies->asMap().entries) { + if (!entry.first.isString() || !isValidPackageName(entry.first.asString()) || !entry.second.isString()) { + error = "each dependency in " + file.string() + + " should be a package name and a version or source, like \"colors\": \"*\""; + return false; + } + manifest.dependencies.emplace_back(entry.first.asString(), entry.second.asString()); + } + } + } + out = std::move(manifest); + return true; +} + +bool findPackageModule(Interpreter& interpreter, const std::string& request, const fs::path& fromDirectory, + fs::path& out, std::string& error) { + size_t slash = request.find('/'); + std::string name = request.substr(0, slash); + std::string rest = slash == std::string::npos ? "" : request.substr(slash + 1); + if (!isValidPackageName(name)) return false; + + // Project packages first, from the nearest folder up; then the user's packages. + fs::path packageDir; + std::error_code ignored; + fs::path dir = fs::absolute(fromDirectory, ignored).lexically_normal(); + if (!dir.has_filename() && dir.has_relative_path()) dir = dir.parent_path(); // "a/b/" -> "a/b" + while (!dir.empty()) { + fs::path candidate = dir / kProjectPackagesDir / name; + if (fs::is_directory(candidate, ignored)) { + packageDir = candidate; + break; + } + fs::path parent = dir.parent_path(); + if (parent == dir) break; + dir = parent; + } + if (packageDir.empty()) { + fs::path candidate = globalPackagesDir() / name; + if (fs::is_directory(candidate, ignored)) packageDir = candidate; + } + if (packageDir.empty()) return false; + + fs::path file; + if (rest.empty()) { + Manifest manifest; + if (!readManifest(interpreter, packageDir / kManifestFile, manifest, error)) return false; + file = packageDir / manifest.main; + } else { + if (!isSafeRelativePath(rest)) { + error = "'" + rest + "' isn't a file inside the package"; + return false; + } + file = packageDir / rest; + if (!file.has_extension()) file += ".asc"; + } + if (!fs::is_regular_file(file, ignored)) { + error = "the package '" + name + "' has no file '" + file.filename().string() + "'"; + return false; + } + out = file.lexically_normal(); + return true; +} + +} // namespace ascend diff --git a/src/process.cpp b/src/process.cpp new file mode 100644 index 0000000..932147b --- /dev/null +++ b/src/process.cpp @@ -0,0 +1,140 @@ +#include "process.hpp" + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#include +#else +#include +#include +#include +#include +extern char** environ; +#endif + +namespace ascend { + +namespace { + +#if defined(_WIN32) +// Quotes one argument so the C runtime of the started program splits the +// command line back into the same arguments. +std::string quoteArgument(const std::string& arg) { + if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg; + std::string out = "\""; + for (size_t i = 0;; ++i) { + size_t backslashes = 0; + while (i < arg.size() && arg[i] == '\\') { + ++i; + ++backslashes; + } + if (i == arg.size()) { + out.append(backslashes * 2, '\\'); + break; + } + if (arg[i] == '"') { + out.append(backslashes * 2 + 1, '\\'); + } else { + out.append(backslashes, '\\'); + } + out += arg[i]; + } + out += '"'; + return out; +} +#endif + +} // namespace + +int runProcess(const std::vector& args, bool quiet) { + if (args.empty()) return -1; + std::cout.flush(); + std::cerr.flush(); + +#if defined(_WIN32) + std::vector quoted; + for (const auto& arg : args) quoted.push_back(quoteArgument(arg)); + std::vector argv; + for (const auto& arg : quoted) argv.push_back(arg.c_str()); + argv.push_back(nullptr); + + // The child inherits our standard handles, so point them at NUL while it runs. + int savedOut = -1; + int savedErr = -1; + int nul = -1; + if (quiet) { + std::fflush(stdout); + std::fflush(stderr); + nul = _open("NUL", _O_WRONLY); + savedOut = _dup(1); + savedErr = _dup(2); + if (nul != -1) { + _dup2(nul, 1); + _dup2(nul, 2); + } + } + intptr_t result = _spawnvp(_P_WAIT, args[0].c_str(), argv.data()); + if (quiet) { + if (savedOut != -1) { + _dup2(savedOut, 1); + _close(savedOut); + } + if (savedErr != -1) { + _dup2(savedErr, 2); + _close(savedErr); + } + if (nul != -1) _close(nul); + } + return result == -1 ? -1 : static_cast(result); +#else + std::vector argv; + for (const auto& arg : args) argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + if (quiet) { + posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0); + posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0); + } + pid_t pid; + int spawned = posix_spawnp(&pid, argv[0], &actions, nullptr, argv.data(), environ); + posix_spawn_file_actions_destroy(&actions); + if (spawned != 0) return -1; + + int status = 0; + while (waitpid(pid, &status, 0) == -1) { + if (errno != EINTR) return -1; + } + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +#endif +} + +std::string environmentVariable(const std::string& name) { +#if defined(_WIN32) + char* value = nullptr; + size_t length = 0; + if (_dupenv_s(&value, &length, name.c_str()) != 0 || value == nullptr) return ""; + std::string result = value; + std::free(value); + return result; +#else + const char* value = std::getenv(name.c_str()); + return value == nullptr ? "" : value; +#endif +} + +void setEnvironmentVariable(const std::string& name, const std::string& value) { +#if defined(_WIN32) + _putenv_s(name.c_str(), value.c_str()); +#else + setenv(name.c_str(), value.c_str(), 1); +#endif +} + +} // namespace ascend diff --git a/tests/interpreter_tests.cpp b/tests/interpreter_tests.cpp index 39b62bd..d91311a 100644 --- a/tests/interpreter_tests.cpp +++ b/tests/interpreter_tests.cpp @@ -727,7 +727,9 @@ TEST(interp_imports) { // Errors inside a module point at the module's own file. CHECK_ERROR("m := import(\"ascend_test_module\")\nm.fail()", "ascend_test_module.asc:4:24: runtime error: division by zero"); CHECK_ERROR("import(\"ascend_test_broken\")", "can't import \"ascend_test_broken\" because it has errors"); - CHECK_ERROR("import(\"ascend_no_such_module\")", "file 'ascend_no_such_module.asc' not found"); + CHECK_ERROR("import(\"ascend_no_such_module\")", + "no file 'ascend_no_such_module.asc' and no installed package named 'ascend_no_such_module'"); + CHECK_ERROR("import(\"./ascend_no_such_module\")", "file './ascend_no_such_module.asc' not found"); } TEST(interp_check_only_mode) { diff --git a/tests/package_tests.cpp b/tests/package_tests.cpp new file mode 100644 index 0000000..8cb5437 --- /dev/null +++ b/tests/package_tests.cpp @@ -0,0 +1,445 @@ +#include "interpreter.hpp" +#include "package_manager.hpp" +#include "process.hpp" +#include "runner.hpp" +#include "test_framework.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace ascend; +namespace fs = std::filesystem; + +#ifndef ASCEND_SOURCE_DIR +#error "ASCEND_SOURCE_DIR must be defined (the repository root, which holds the package registry)" +#endif + +namespace { + +bool contains(const std::string& text, const std::string& fragment) { return text.find(fragment) != std::string::npos; } + +std::string readFile(const fs::path& path) { + std::ifstream file(path, std::ios::binary); + return std::string(std::istreambuf_iterator(file), std::istreambuf_iterator()); +} + +void writeFile(const fs::path& path, const std::string& text) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; +} + +// A fresh folder for one test, with a project folder and an ASCEND_HOME in it. +// Deleted (and ASCEND_HOME restored) when the test ends. +struct Sandbox { + Sandbox() { + std::random_device device; + root = fs::temp_directory_path() / ("ascend-package-test-" + std::to_string(device())); + project = root / "project"; + home = root / "home"; + fs::create_directories(project); + fs::create_directories(home); + savedHome = environmentVariable("ASCEND_HOME"); + setEnvironmentVariable("ASCEND_HOME", home.string()); + setEnvironmentVariable("NO_COLOR", "1"); + } + ~Sandbox() { + setEnvironmentVariable("ASCEND_HOME", savedHome); + removeTree(root); + } + + PackageOptions options(const fs::path& directory, bool global = false) const { + PackageOptions result; + result.home = home; + result.registry = ASCEND_SOURCE_DIR; + result.workingDirectory = directory; + result.global = global; + return result; + } + + fs::path root; + fs::path project; + fs::path home; + std::string savedHome; +}; + +struct Outcome { + int code; + std::string out; + std::string err; +}; + +// Runs one apkg command, like the command line does. +template +Outcome apkg(const PackageOptions& options, Command command) { + std::ostringstream out; + std::ostringstream err; + PackageManager manager(options, out, err); + int code = command(manager); + return {code, out.str(), err.str()}; +} + +// Runs Ascend code as if it were the file `file`, so imports start from its folder. +std::string runAt(const fs::path& file, const std::string& code) { + std::ostringstream out; + std::ostringstream err; + std::istringstream in; + RunResult result; + { + Interpreter interpreter(out, in); + RunOptions options; + options.filename = file.string(); + result = runSource(interpreter, code, options, err); + } + return result.status == RunStatus::Ok ? out.str() : "ERROR: " + err.str(); +} + +bool gitAvailable() { return runProcess({"git", "--version"}, true) == 0; } + +// git, with enough settings to commit on a machine with no git configuration. +int gitIn(const fs::path& repo, std::vector args) { + std::vector command{"git", "-C", repo.string(), "-c", "user.name=Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}; + command.insert(command.end(), args.begin(), args.end()); + return runProcess(command, true); +} + +std::string fileUrl(const fs::path& path) { + std::string text = fs::absolute(path).generic_string(); + return "file://" + std::string(text[0] == '/' ? "" : "/") + text; +} + +void makePackage(const fs::path& folder, const std::string& name, const std::string& version, const std::string& code, + const std::string& dependencies = "{}") { + writeFile(folder / "ascend.json", "{\"name\": \"" + name + "\", \"version\": \"" + version + + "\", \"main\": \"main.asc\", \"dependencies\": " + dependencies + "}"); + writeFile(folder / "main.asc", code); +} + +} // namespace + +TEST(pkg_parses_package_specs) { + fs::path base = fs::absolute("somewhere"); + + PackageSource registry = parsePackageSpec("colors", base); + CHECK(registry.kind == PackageSource::Kind::Registry); + CHECK_EQ(registry.name, "colors"); + CHECK_EQ(registry.version, ""); + + PackageSource pinned = parsePackageSpec("my-lib@1.2.0", base); + CHECK(pinned.kind == PackageSource::Kind::Registry); + CHECK_EQ(pinned.name, "my-lib"); + CHECK_EQ(pinned.version, "1.2.0"); + + PackageSource github = parsePackageSpec("github:someone/tools/packages/fmt@v2.0", base); + CHECK(github.kind == PackageSource::Kind::Git); + CHECK_EQ(github.location, "https://github.com/someone/tools.git"); + CHECK_EQ(github.subdir, "packages/fmt"); + CHECK_EQ(github.ref, "v2.0"); + + PackageSource git = parsePackageSpec("git+https://example.com/a/b.git@main#lib", base); + CHECK(git.kind == PackageSource::Kind::Git); + CHECK_EQ(git.location, "https://example.com/a/b.git"); + CHECK_EQ(git.ref, "main"); + CHECK_EQ(git.subdir, "lib"); + + PackageSource ssh = parsePackageSpec("git@github.com:someone/repo.git", base); + CHECK(ssh.kind == PackageSource::Kind::Git); + CHECK_EQ(ssh.location, "git@github.com:someone/repo.git"); + CHECK_EQ(ssh.ref, ""); + + PackageSource local = parsePackageSpec("./libs/thing", base); + CHECK(local.kind == PackageSource::Kind::Local); + CHECK_EQ(fs::path(local.location), (base / "libs" / "thing").lexically_normal()); + CHECK(parsePackageSpec("file:../x", base).kind == PackageSource::Kind::Local); + + PackageSource inRegistry = parsePackageSpec("registry:packages/colors", base); + CHECK(inRegistry.kind == PackageSource::Kind::RegistryPath); + CHECK_EQ(inRegistry.location, "packages/colors"); + + auto rejects = [&](const std::string& spec, const std::string& fragment) { + try { + parsePackageSpec(spec, base); + } catch (const std::exception& error) { + return contains(error.what(), fragment); + } + return false; + }; + CHECK(rejects("Colors", "isn't a valid package name")); + CHECK(rejects("colors@", "missing a version")); + CHECK(rejects("github:just-a-user", "should look like github:user/repo")); + CHECK(rejects("git+https://example.com/repo.git@--upload-pack=evil", "isn't a valid tag or branch name")); + CHECK(rejects("git+https://example.com/repo.git#../../etc", "isn't a folder inside the repository")); + CHECK(rejects("registry:../outside", "isn't a folder inside the registry")); + CHECK(rejects(" ", "empty package name")); +} + +TEST(pkg_init_creates_a_project) { + Sandbox sandbox; + Outcome result = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init("demo"); }); + CHECK_EQ(result.code, 0); + CHECK(contains(readFile(sandbox.project / "ascend.json"), "\"name\": \"demo\"")); + CHECK(contains(readFile(sandbox.project / ".gitignore"), "ascend_packages/")); + CHECK_EQ(runAt(sandbox.project / "main.asc", readFile(sandbox.project / "main.asc")), "Hello from demo!\n"); + + Outcome again = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init(""); }); + CHECK_EQ(again.code, 1); + CHECK(contains(again.err, "already exists")); + + fs::path other = sandbox.root / "My Cool App"; + fs::create_directories(other); + CHECK_EQ(apkg(sandbox.options(other), [](PackageManager& m) { return m.init(""); }).code, 0); + CHECK(contains(readFile(other / "ascend.json"), "\"name\": \"my-cool-app\"")); +} + +TEST(pkg_installs_from_the_registry_into_a_project) { + Sandbox sandbox; + apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init("app"); }); + + // testing depends on colors, which gets installed with it. + Outcome result = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.install({"testing", "strings"}); }); + CHECK_EQ(result.err, ""); + CHECK_EQ(result.code, 0); + CHECK(contains(result.out, "Installed testing 1.0.0\n")); + CHECK(contains(result.out, "Installed colors 1.0.0 (needed by testing)\n")); + CHECK(contains(result.out, "Installed strings 1.0.0\n")); + CHECK(fs::is_regular_file(sandbox.project / "ascend_packages" / "colors" / "colors.asc")); + CHECK(fs::is_regular_file(sandbox.project / "ascend_packages" / "testing" / ".apkg.json")); + + // Only the packages asked for are recorded. + std::string manifest = readFile(sandbox.project / "ascend.json"); + CHECK(contains(manifest, "\"testing\": \"*\"")); + CHECK(contains(manifest, "\"strings\": \"*\"")); + CHECK(!contains(manifest, "\"colors\"")); + + // Programs anywhere in the project can import them, and packages can import their dependencies. + fs::path program = sandbox.project / "src" / "deep" / "main.asc"; + CHECK_EQ(runAt(program, "s := import(\"strings\")\nprint(s.snake_case(\"helloWorld\"))"), "hello_world\n"); + std::string tested = runAt(program, + "t := import(\"testing\")\n" + "t.test(\"adds\", func() { t.equal(1 + 1, 2) })\n" + "print(t.run())"); + CHECK(contains(tested, " pass adds\n\n1 passed (")); + CHECK(contains(tested, "s)\n1\n")); + CHECK_EQ(runAt(program, "c := import(\"colors/colors\")\nprint(c.red(\"plain\"))"), "plain\n"); + + Outcome again = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.install({"colors"}); }); + CHECK_EQ(again.code, 0); + CHECK(contains(again.out, "colors 1.0.0 is already installed")); +} + +TEST(pkg_list_show_uninstall_and_reinstall) { + Sandbox sandbox; + apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init("app"); }); + apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.install({"testing"}); }); + + Outcome list = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.list(); }); + CHECK_EQ(list.code, 0); + CHECK(contains(list.out, "colors 1.0.0 colors\n")); + CHECK(contains(list.out, "testing 1.0.0 testing\n")); + + Outcome show = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.show("colors"); }); + CHECK_EQ(show.code, 0); + CHECK(contains(show.out, "Name: colors\n")); + CHECK(contains(show.out, "Required by: testing\n")); + + Outcome search = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.search("unit test"); }); + CHECK_EQ(search.code, 0); + CHECK(contains(search.out, "testing Write and run unit tests for Ascend code [installed 1.0.0]")); + CHECK(!contains(search.out, "strings")); + + Outcome removed = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.uninstall({"testing"}); }); + CHECK_EQ(removed.code, 0); + CHECK(contains(removed.out, "Uninstalled testing 1.0.0\n")); + CHECK(contains(removed.out, "Removed testing from ascend.json\n")); + CHECK(!fs::exists(sandbox.project / "ascend_packages" / "testing")); + CHECK(!contains(readFile(sandbox.project / "ascend.json"), "testing")); + + Outcome notInstalled = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.uninstall({"testing"}); }); + CHECK_EQ(notInstalled.code, 1); + CHECK(contains(notInstalled.err, "testing isn't installed")); + + // `apkg install` with no names installs what ascend.json lists. + removeTree(sandbox.project / "ascend_packages"); + writeFile(sandbox.project / "ascend.json", + "{\"name\": \"app\", \"version\": \"1.0.0\", \"dependencies\": {\"strings\": \"1.0.0\", \"testing\": \"*\"}}"); + Outcome missing = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.list(); }); + CHECK(contains(missing.out, "Not installed yet (run `apkg install`): strings testing")); + Outcome restored = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.install({}); }); + CHECK_EQ(restored.code, 0); + CHECK(contains(restored.out, "Installed strings 1.0.0\n")); + CHECK(contains(restored.out, "Installed colors 1.0.0 (needed by testing)\n")); + CHECK(fs::is_directory(sandbox.project / "ascend_packages" / "testing")); +} + +TEST(pkg_global_packages_work_outside_projects) { + Sandbox sandbox; + fs::path elsewhere = sandbox.root / "scripts"; + fs::create_directories(elsewhere); + + // Outside a project, packages go to ASCEND_HOME/packages. + Outcome result = apkg(sandbox.options(elsewhere), [](PackageManager& m) { return m.install({"strings"}); }); + CHECK_EQ(result.code, 0); + CHECK(fs::is_directory(sandbox.home / "packages" / "strings")); + CHECK_EQ(runAt(elsewhere / "tool.asc", "print(import(\"strings\").title(\"global package\"))"), "Global Package\n"); + + // --global skips the project even inside one. + apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init("app"); }); + Outcome global = apkg(sandbox.options(sandbox.project, true), [](PackageManager& m) { return m.install({"colors"}); }); + CHECK_EQ(global.code, 0); + CHECK(fs::is_directory(sandbox.home / "packages" / "colors")); + CHECK(!fs::exists(sandbox.project / "ascend_packages")); + CHECK(!contains(readFile(sandbox.project / "ascend.json"), "colors")); + + // A project's own copy wins over the user's. + fs::path local = sandbox.root / "my-strings"; + makePackage(local, "strings", "9.0.0", "string title(string s) { return \"project copy\" }\n"); + apkg(sandbox.options(sandbox.project), [&](PackageManager& m) { return m.install({local.string()}); }); + CHECK_EQ(runAt(sandbox.project / "main.asc", "print(import(\"strings\").title(\"x\"))"), "project copy\n"); + CHECK_EQ(runAt(elsewhere / "tool.asc", "print(import(\"strings\").title(\"x\"))"), "X\n"); +} + +TEST(pkg_installs_local_folders_with_dependencies) { + Sandbox sandbox; + apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init("app"); }); + + // shapes depends on a folder next to it and on a registry package. + makePackage(sandbox.root / "libs" / "units", "units", "0.1.0", "f64 cm(f64 inches) { return inches * 2.5 }\n"); + makePackage(sandbox.root / "libs" / "shapes", "shapes", "0.3.0", + "units := import(\"units\")\nstrings := import(\"strings\")\n" + "string describe(f64 side) { return strings.capitalize(f\"square of {units.cm(side)} cm\") }\n", + "{\"units\": \"file:../units\", \"strings\": \"*\"}"); + + Outcome result = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.install({"../libs/shapes"}); }); + CHECK_EQ(result.err, ""); + CHECK_EQ(result.code, 0); + CHECK(contains(result.out, "Installed shapes 0.3.0\n")); + CHECK(contains(result.out, "Installed units 0.1.0 (needed by shapes)\n")); + CHECK(contains(result.out, "Installed strings 1.0.0 (needed by shapes)\n")); + CHECK(contains(readFile(sandbox.project / "ascend.json"), "\"shapes\": \"file:../libs/shapes\"")); + CHECK_EQ(runAt(sandbox.project / "main.asc", "print(import(\"shapes\").describe(10))"), "Square of 25.0 cm\n"); + + // Local folders are reinstalled every time, to pick up changes. + makePackage(sandbox.root / "libs" / "units", "units", "0.1.0", "f64 cm(f64 inches) { return 0.0 }\n"); + Outcome again = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.install({"../libs/units"}); }); + CHECK(contains(again.out, "Reinstalled units 0.1.0\n")); + CHECK_EQ(runAt(sandbox.project / "main.asc", "print(import(\"shapes\").describe(10))"), "Square of 0.0 cm\n"); +} + +TEST(pkg_reports_problems_clearly) { + Sandbox sandbox; + apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init("app"); }); + auto install = [&](const std::string& spec) { + return apkg(sandbox.options(sandbox.project), [&](PackageManager& m) { return m.install({spec}); }); + }; + + Outcome unknown = install("no-such-package"); + CHECK_EQ(unknown.code, 1); + CHECK(contains(unknown.err, "there's no package named 'no-such-package' in the registry")); + + Outcome version = install("colors@99.0.0"); + CHECK_EQ(version.code, 1); + CHECK(contains(version.err, "colors 99.0.0 isn't available (the registry has version 1.0.0)")); + + fs::path folder = sandbox.root / "not-a-package"; + fs::create_directories(folder); + Outcome noManifest = install(folder.string()); + CHECK_EQ(noManifest.code, 1); + CHECK(contains(noManifest.err, "there is no ascend.json in")); + + writeFile(folder / "ascend.json", "{\"name\": \"x\", "); + CHECK(contains(install(folder.string()).err, "isn't valid JSON")); + + writeFile(folder / "ascend.json", "{\"name\": \"Bad Name\", \"version\": \"1.0\"}"); + CHECK(contains(install(folder.string()).err, "isn't a valid package name")); + + writeFile(folder / "ascend.json", "{\"name\": \"x\", \"version\": \"1.0\", \"main\": \"../../evil.asc\"}"); + CHECK(contains(install(folder.string()).err, "should be a file inside the package")); + + writeFile(folder / "ascend.json", "{\"name\": \"x\", \"version\": \"1.0\", \"main\": \"missing.asc\"}"); + CHECK(contains(install(folder.string()).err, "its main file 'missing.asc' is missing")); + + Outcome missingFolder = install("./nowhere"); + CHECK_EQ(missingFolder.code, 1); + CHECK(contains(missingFolder.err, "doesn't exist")); + CHECK(!fs::exists(sandbox.project / "ascend_packages" / "x")); + + // Nothing half-installed is left behind. + std::error_code ignored; + for (const auto& entry : fs::directory_iterator(sandbox.project / "ascend_packages", ignored)) { + CHECK(entry.path().filename().string().rfind(".apkg-tmp", 0) != 0); + } + + // Importing a package that isn't installed, or is broken, says so. + CHECK(contains(runAt(sandbox.project / "main.asc", "import(\"colors\")"), + "no installed package named 'colors' (to install it, run: apkg install colors)")); + writeFile(sandbox.project / "ascend_packages" / "broken" / "ascend.json", "{\"name\": \"broken\", \"version\": \"1\"}"); + CHECK(contains(runAt(sandbox.project / "main.asc", "import(\"broken\")"), "the package 'broken' has no file 'main.asc'")); + CHECK(contains(runAt(sandbox.project / "main.asc", "import(\"broken/../../x\")"), "isn't a file inside the package")); +} + +TEST(pkg_installs_from_git_repositories) { + if (!gitAvailable()) return; // nothing to test without git + Sandbox sandbox; + apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.init("app"); }); + + // A repository with two tagged versions and a second package in a folder. + fs::path repo = sandbox.root / "repo"; + makePackage(repo, "greet", "1.0.0", "string hello(string name) { return \"Hello, \" + name }\n"); + makePackage(repo / "extra", "extra", "0.1.0", "ANSWER := 42\n"); + CHECK_EQ(gitIn(repo, {"init", "-q"}), 0); + CHECK_EQ(gitIn(repo, {"add", "-A"}), 0); + CHECK_EQ(gitIn(repo, {"commit", "-q", "-m", "first"}), 0); + CHECK_EQ(gitIn(repo, {"tag", "v1.0.0"}), 0); + makePackage(repo, "greet", "2.0.0", "string hello(string name) { return \"Hi, \" + name }\n"); + CHECK_EQ(gitIn(repo, {"commit", "-q", "-a", "-m", "second"}), 0); + CHECK_EQ(gitIn(repo, {"tag", "v2.0.0"}), 0); + std::string url = fileUrl(repo); + + Outcome tagged = apkg(sandbox.options(sandbox.project), [&](PackageManager& m) { return m.install({"git+" + url + "@v1.0.0"}); }); + CHECK_EQ(tagged.code, 0); + CHECK(contains(tagged.out, "Installed greet 1.0.0\n")); + CHECK(!fs::exists(sandbox.project / "ascend_packages" / "greet" / ".git")); + CHECK_EQ(runAt(sandbox.project / "main.asc", "print(import(\"greet\").hello(\"Ada\"))"), "Hello, Ada\n"); + CHECK(contains(readFile(sandbox.project / "ascend.json"), "\"greet\": \"git+" + url + "@v1.0.0\"")); + Outcome show = apkg(sandbox.options(sandbox.project), [](PackageManager& m) { return m.show("greet"); }); + CHECK(contains(show.out, "Commit: ")); + + Outcome latest = apkg(sandbox.options(sandbox.project), [&](PackageManager& m) { return m.install({"git+" + url}); }); + CHECK(contains(latest.out, "Upgraded greet 2.0.0 (was 1.0.0)\n")); + CHECK_EQ(runAt(sandbox.project / "main.asc", "print(import(\"greet\").hello(\"Ada\"))"), "Hi, Ada\n"); + + Outcome folder = apkg(sandbox.options(sandbox.project), [&](PackageManager& m) { return m.install({"git+" + url + "#extra"}); }); + CHECK_EQ(folder.code, 0); + CHECK_EQ(runAt(sandbox.project / "main.asc", "print(import(\"extra\").ANSWER)"), "42\n"); + + Outcome badTag = apkg(sandbox.options(sandbox.project), [&](PackageManager& m) { return m.install({"git+" + url + "@v9"}); }); + CHECK_EQ(badTag.code, 1); + CHECK(contains(badTag.err, "check the address and that the tag or branch exists")); + + // A registry kept in git: packages in their own repositories are found by version tag. + fs::path registry = sandbox.root / "registry"; + writeFile(registry / "registry" / "index.json", + "{\"packages\": {\"greet\": {\"description\": \"Says hello\", \"source\": \"git+" + url + "\"}}}"); + CHECK_EQ(gitIn(registry, {"init", "-q"}), 0); + CHECK_EQ(gitIn(registry, {"add", "-A"}), 0); + CHECK_EQ(gitIn(registry, {"commit", "-q", "-m", "registry"}), 0); + PackageOptions options = sandbox.options(sandbox.root / "elsewhere"); + fs::create_directories(options.workingDirectory); + options.registry = fileUrl(registry); + + Outcome pinned = apkg(options, [](PackageManager& m) { return m.install({"greet@1.0.0"}); }); + CHECK_EQ(pinned.err, ""); + CHECK(contains(pinned.out, "Downloading the package registry...\n")); + CHECK(contains(pinned.out, "Installed greet 1.0.0\n")); + Outcome search = apkg(options, [](PackageManager& m) { return m.search("hello"); }); + CHECK(contains(search.out, "greet Says hello [installed 1.0.0]")); + CHECK(!contains(search.out, "Downloading")); // the cached copy is updated, not downloaded again + options.upgrade = true; + Outcome upgraded = apkg(options, [](PackageManager& m) { return m.install({"greet"}); }); + CHECK(contains(upgraded.out, "Upgraded greet 2.0.0 (was 1.0.0)\n")); +}