Skip to content

Latest commit

 

History

History
175 lines (121 loc) · 11.1 KB

nos-cli.md

File metadata and controls

175 lines (121 loc) · 11.1 KB

The nos CLI

nos is for MAGIC what clj is for JVM Clojure and cljr is for ClojureCLR: the command that resolves your dependencies and runs your code. MAGIC needs one because it is a compiler and nothing more: it turns Clojure forms into MSIL and has no idea what a dependency or a test suite is.

nos loads the committed compiler, resolves the deps file onto the load path, and hands its command line to clojure.main. It has no build DSL and no lifecycle: a task is a plain Clojure defn, and nos build is a defn that happens to ship with the host. Installing nos is in its README.

Running a command

nos hands its command line to clojure.main, as clj and cljr do. A task name goes through clojure.main -m nostrand.exec, the way clojure -X goes through clojure.main -m clojure.run.exec.

flowchart TD
    arg["nos &lt;arg&gt; [args...]"] --> opt{"an option (-m, -e, -i, -r, -), an @path,<br/>or a .clj, .cljc or .cljr file?"}
    opt -->|"yes: -m my.app, script.clj"| main["clojure.main runs it"]
    opt -->|"no: a task name"| slash{"does the name<br/>contain a slash?"}
    slash -->|"yes: dotnet/build"| load["require the namespace from<br/>the load path (dotnet.clj)"]
    load --> v1["call that namespace's var"]
    slash -->|"no: build"| bi{"a var in nostrand.tasks,<br/>then nostrand.cli?"}
    bi -->|yes| v2["call the built-in"]
    bi -->|no| fail["error, exit 1"]
Loading

nos dotnet/build requires dotnet from the load path, which includes the directory you ran nos from, and calls build in it. The file name carries no meaning: nos release/publish loads release.clj just as happily. dotnet.clj is a convention inherited from the projects that came first, not a name the host knows.

A file runs as clojure.main runs a script: its top-level forms are evaluated, *command-line-args* holds the arguments after it, and nothing calls its -main. To call a namespace's -main, use nos -m my.app, which passes the arguments as strings.

Arguments to a task are read as EDN, the whole command line at once, so a form may span several shell words. When that fails to read, nos retries argument by argument and passes any that still will not read through as a symbol, which is what lets an absolute path work.

nos tasks/build true            # (tasks/build true)
nos tasks/build {:utils? true}  # (tasks/build {:utils? true})

Everything runs in the user namespace.

Errors

An uncaught error prints a summary, writes the full report to a temporary file and exits 1. A task that cannot be found is one too: a namespace missing from the load path fails with the loader's message, a var missing from its namespace with "Namespace … loaded but function not found", and an unknown unqualified name with "No task named …".

--report stderr or --report none redirects the report for -m, -e and scripts only: in nos --report stderr build, build is taken for a script path. For a task, set the clojure.main.report environment variable through env, since a shell assignment rejects the dots:

env clojure.main.report=stderr nos dotnet/build

nos build and nos test

Two of the tasks that ship with the host do the work a project needs, which is why most projects need no task file at all.

nos build    # compile the project's namespaces into ./build, with the C# assemblies its deps ship
nos test     # run its clojure.test suites, exit 1 on any failure or error

Both derive the set of namespaces they work on rather than being told. They resolve the deps file, collect the source paths it contributes (the base :paths, plus the :extra-paths of each activated alias), and scan those directories recursively for .clj, .cljc and .cljr files, taking the namespace each one declares.

Neither task takes an alias flag. What they work on comes from a file.

magic.edn

magic.edn is a convenience file to configure the build and test tasks as pure EDN at the project root.

It is a map with a :build and a :test sub-map. Every key has a default, so state only what differs, and omit the file entirely when nothing does.

;; magic.edn
{:test {:aliases [:clr :test]}}

nos test turns that map into the call a task file would write by hand, (tasks/run-clojure-tests :aliases [:clr :test]), and nos build does the same with :build and tasks/compile-project. The file is pure configuration over nostrand.tasks, the functions Task files below compose directly.

Key :build :test Meaning Default
:aliases yes yes deps aliases to activate none, and [:test] for :test
:namespaces yes yes explicit set, replacing the derivation derived from the source paths
:exclude yes yes namespaces to drop from the set none
:flags yes yes compiler flag overrides the sets below
:out yes compile output directory "build"
:csharp-out yes where the C# assemblies a library ships are copied :out
:clean? yes wipe :out first true
:re yes regex string scoping the run the derived namespaces
:exclude-vars yes deftest symbols to skip none

The file is spec-checked on read, so a malformed value fails with an explanation naming the key path.

Note the following:

  • :re is matched with re-matches, so it has to match a namespace name whole. Write it as a string, since EDN has no regex literal: "my\\.lib\\..*".
  • :exclude-vars takes fully-qualified deftest symbols. After require, the run clears the :test metadata on each, so clojure.test skips exactly those vars and the rest of their namespace still runs. It is for a handful of platform-specific failures scattered through otherwise-passing namespaces, where excluding the namespace would throw away good tests. A symbol that does not resolve warns rather than failing.
  • :csharp-out separates the copies from the compiled Clojure. nos build copies the C# assemblies a library ships (a library's C# assembly) into :out, and :clean? deletes :out before every compile, so Unity imports the plugin fresh and mints it a new GUID each time. A Unity project points this at a directory of its own to hold the GUID still (the two plugin folders). :clean? never touches that directory, so a copy whose library left the deps stays until you delete it; the build names it rather than deleting it.
  • :namespaces is the escape hatch for what the derivation cannot see: a single compile root, or a namespace bundled in the repo that no require reaches.

Compiler flags

nos build compiles under the flags a shipped MAGIC project runs on:

{#'*unchecked-math*                false
 #'*warn-on-reflection*            true
 #'magic.flags/*strongly-typed-invokes* true
 #'magic.flags/*direct-linking*         true
 #'magic.flags/*elide-meta*             false}
  • *unchecked-math*: Clojure's default. Arithmetic and narrowing casts keep their overflow checks. Set it per namespace where wrapping is wanted.
  • *warn-on-reflection*: interop the compiler cannot resolve statically warns at compile time.
  • *strongly-typed-invokes*: a call whose Magic.Function type is statically known lowers to a typed interface call, skipping argument boxing.
  • *direct-linking*: a call to a non-variadic, non-dynamic fn lowers to a direct invokeStatic, bypassing the Var.
  • *elide-meta*: metadata expressions are dropped during analysis. Pinned off.

These five are what the built-ins set. The full surface, spells included, is magic.flags, one docstring per var.

nos test uses the same set with *direct-linking* and *strongly-typed-invokes* turned off, and that is not a detail: both lower a call so it no longer goes through the Var, so with-redefs has nothing left to rebind. Tests that mock would silently keep calling the real function.

The consequence is worth stating plainly. A green nos test says nothing about whether your library compiles and runs under the flags nos build uses. Writing cross-platform Clojure covers the code patterns that break only under those flags.

:flags merges over whichever base applies, so name the vars you change as fully-qualified symbols.

{:build {:flags {magic.flags/*elide-meta* true}}}

Task files

A task file is a namespace with public functions. It exists for work the built-ins do not do.

;; publish.clj
(ns publish
  (:require [nostrand.tasks :as tasks]))

(defn nuget [] ...)

nostrand.tasks holds the pieces the built-ins are made of, so a custom task composes them instead of restating them. compile-project and run-clojure-tests take the same options as the magic.edn keys above, production-flags and test-flags are the two flag maps, and project-namespaces returns the derived set without touching the load path.

A task file can load an assembly before its ns form, so the ns can import from it:

;; publish.clj
(assembly-load-from "assemblies/SomeLib.dll")
(ns publish
  (:import [SomeLib SomeType]))

Older projects wrote a dotnet.clj defining build and run-tests by hand, before the built-ins existed. Those still work unchanged, and nos dotnet/build and nos build coexist in the same project.

(ns dotnet
  (:require [nostrand.tasks :as tasks]))

(defn build     [] (tasks/compile-project :clean? true))
(defn run-tests [] (tasks/run-clojure-tests :aliases [:test]))

One difference to know if you keep one: compile-project defaults :clean? to false, while nos build defaults it to true.

Diagnostics

Command What it tells you
nos version the versions of the host, both C# runtimes, Clojure, and the CLR underneath
nos where the directory the running host loaded Clojure.dll from
nos where Clojure.dll that one assembly's full path, as bare stdout for $(...) capture
nos print-basis [:alias ...] the resolved paths and libs, without compiling
nos tasks the docstring of every public var in each .clj file in the current directory: your task files, not the built-ins
nos repl a REPL on a warm runtime

nos where is what a build script uses to compile C# against the same runtime the host will load, instead of guessing at install paths (C# assemblies). nos print-basis is the one to reach for when a namespace is missing and you cannot tell whether the dependency resolved (declaring CLR dependencies).

What nos test cannot catch

nos test runs on Mono, which compiles at run time. Unity players use IL2CPP, which compiles to C++ ahead of time and has no JIT at all, so a whole class of failure exists only there: anything that emits code at run time, and anything reflected over that static analysis cannot see and stripping therefore removes.

No Mono run reaches those. A player build is the only thing that does (Unity integration).