]> git.wincent.com - wincent.git/commitdiff
feat(fig): support dynamic variables
authorGreg Hurrell <greg@hurrell.net>
Sun, 19 Apr 2020 23:16:05 +0000 (01:16 +0200)
committerGreg Hurrell <greg@hurrell.net>
Sun, 19 Apr 2020 23:16:05 +0000 (01:16 +0200)
At two levels: global, and per-aspect. I think that should have us
covered. We don't want JSON to be anything but static so the two escape
hatches are:

- variables.ts (global).
- per-aspect `variables()` DSL.

16 files changed:
aspects/dotfiles/aspect.json
aspects/dotfiles/index.ts
aspects/dotfiles/templates/.gitconfig.erb [moved from roles/dotfiles/templates/.gitconfig with 79% similarity]
group_vars/all.yml
package.json
roles/dotfiles/defaults/main.yml
src/Compiler.ts
src/Fig/Context.ts
src/Fig/VariableRegistry.ts [new file with mode: 0644]
src/Fig/index.ts
src/Fig/task.ts
src/Fig/variables.ts [new file with mode: 0644]
src/main.ts
src/template.ts
tsconfig.json
variables.ts [new file with mode: 0644]

index 2235ed1174a5de2f736793b1d6498f2d3759aca9..e0407a45c75f4b21c811719a7862a84e90ebc485 100644 (file)
@@ -27,6 +27,6 @@
             ".zshenv",
             ".zshrc"
         ],
-        "templates": [".corpusrc.erb"]
+        "templates": [".corpusrc.erb", ".gitconfig.erb"]
     }
 }
index cea800157dd6cb5b7c58a90648594435e1067f67..b874085c5a6bfbe214211fff64ab8b3435c3b244 100644 (file)
@@ -1,8 +1,21 @@
-import {command, file, template, task, variable} from '../../src/Fig/index.js';
+import {
+    command,
+    file,
+    template,
+    task,
+    variable,
+    variables,
+} from '../../src/Fig/index.js';
 import assert from '../../src/assert.js';
 import stat from '../../src/fs/stat.js';
 import path from '../../src/path.js';
 
+variables(({identity}) => ({
+    gitUserEmail: identity === 'wincent' ? 'greg@hurrell.net' : '',
+    gitUserName: identity === 'wincent' ? 'Greg Hurrell' : '',
+    gitHubUsername: identity === 'wincent' ? 'wincent' : '',
+}));
+
 task('make directories', async () => {
     await file({path: '~/.backups', state: 'directory'});
     await file({path: '~/.config', state: 'directory'});
similarity index 79%
rename from roles/dotfiles/templates/.gitconfig
rename to aspects/dotfiles/templates/.gitconfig.erb
index 9f01c4c5ca4737622755a9bf1cd076aee2a62c2e..1b51973c78b13d7c30641edfa666d1f876c9ab37 100644 (file)
@@ -1,4 +1,4 @@
-# {{ ansible_managed }}
+# <%= variables.figManaged %>
 
 [alias]
        abbrev = !sh -c 'git rev-parse --short ${1-`echo HEAD`}' -
        get = "!f() { git fresh && git ff \"$@\"; }; f"
 
        # equivalent to: graph --all
-       gr = log --graph --all --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%aN>%Creset'
+       gr = log --graph --all --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%= '<\%' %>aN>%Creset'
 
        # requires Git 1.6.3 or later; %C(auto) requires Git 1.8.3 or later
-       graph = log --graph --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%aN>%Creset'
+       graph = log --graph --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%= '<\%' %>aN>%Creset'
 
        # Show just the HEAD commit message (no indent) and nothing else
        message = log -1 --pretty=format:%B
@@ -36,8 +36,8 @@
        no-edit = commit --amend --no-edit
 
        # %C(auto) requires Git 1.8.3 or later
-       one = log --pretty=format:'%C(auto)%h%Creset %s%C(auto)%d%Creset %C(magenta bold)(%cr)%Creset %C(cyan)<%aN>%Creset'
-       oneline = log --pretty=format:'%C(auto)%h%Creset %s%C(auto)%d%Creset %C(magenta bold)(%cr)%Creset %C(cyan)<%aN>%Creset'
+       one = log --pretty=format:'%C(auto)%h%Creset %s%C(auto)%d%Creset %C(magenta bold)(%cr)%Creset %C(cyan)<%= '<\%' %>aN>%Creset'
+       oneline = log --pretty=format:'%C(auto)%h%Creset %s%C(auto)%d%Creset %C(magenta bold)(%cr)%Creset %C(cyan)<%= '<\%' %>aN>%Creset'
 
        # requires Git 1.5.4 or later
        p = add -p
 
        # "smartlog", although it's not that smart.
        # Equivalent to `git graph --all --simplify-by-decoration.
-       sl = log --graph --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%aN>%Creset' --all --simplify-by-decoration
+       sl = log --graph --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%= '<\%' %>aN>%Creset' --all --simplify-by-decoration
 
        st = status
        staged = diff --cached --ignore-submodules=dirty
 
        # %C(auto) requires Git 1.8.3 or later
-       ten = log -10 --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%aN>%Creset'
+       ten = log -10 --pretty=format:'%C(auto)%h%Creset%C(auto)%d%Creset %s %C(magenta bold)(%cr)%Creset %C(cyan)<%= '<\%' %>aN>%Creset'
 
        # compensate for brain damage caused by using Mercurial
        up = checkout
 
 [difftool]
        prompt = false
-{% if github_username != '' %}
+<%- if (variables.gitHubUsername) { -%>
 
 [github]
-       username = {{ github_username }}
-{% endif %}
+       username = <%= variables.gitHubUsername %>
+<%- } -%>
 
 [grep]
        lineNumber = true
 
-       # requires Git built with PCRE support; ie:
-       #   brew install git --with-pcre (on OS X)
+       # Requires PCRE support; ie: `brew install git --with-pcre` (on macOS).
        patternType = perl
 
 [help]
        smtpEncryption = tls
        smtpServer = smtp.gmail.com
        smtpServerPort = 587
-{% if git_user_email != '' %}
-       smtpUser = {{ git_user_email }}
-{% endif %}
+<%- if (variables.gitUserEmail) { -%>
+       smtpUser = <%= variables.gitUserEmail %>
+<%- } -%>
 
 [status]
        submodulesummary = true
        fetchJobs = 4
 
 [user]
-{% if git_user_email != '' %}
-       email = {{ git_user_email }}
-{% endif %}
-{% if git_user_name != '' %}
-       name = {{ git_user_name }}
-{% endif %}
+<%- if (variables.gitUserEmail) { -%>
+       email = <%= variables.gitUserEmail %>
+<%- } -%>
+<%- if (variables.gitUserName) { -%>
+       name = <%= variables.gitUserName %>
+<%- } -%>
 
 # ignored by Git older than 1.7.10
 [include]
index b447778f34a5e3ed14c58f7cc3c5f30bd523914e..211aba52264be04b547eb8358386c9bf34392441 100644 (file)
@@ -1,7 +1,10 @@
 ---
 git_cipher_path: vendor/git-cipher/bin/git-cipher
 http_proxy: ""
+
+# DONE
 identity: '{{ "wincent" if lookup("env", "USER") == "glh" or lookup("env", "USER") == "greghurrell" else "unknown" }}'
+
 iterm_dynamic_profiles:
   external:
     - dest: Mutt.json
index 393b4f51b76529520cc478ae57bec196fe862df9..83bfcd930cd8566536650ab568a80fdfeb76a6c2 100644 (file)
@@ -9,8 +9,8 @@
     "license": "Unlicense",
     "scripts": {
         "ci": "yarn format:check",
-        "format:check": "npx prettier --check \"**/*.{js,json,ts}\" \"*.md\" aspects/dotfiles/files/.zsh/liferay/bin/portool",
-        "format": "npx prettier --write \"**/*.{js,json,ts}\" \"*.md\" aspects/dotfiles/files/.zsh/liferay/bin/portool"
+        "format:check": "npx prettier --check \"**/*.{js,json,ts}\" \"*.{md,ts}\" aspects/dotfiles/files/.zsh/liferay/bin/portool",
+        "format": "npx prettier --write \"**/*.{js,json,ts}\" \"*.{md,ts}\" aspects/dotfiles/files/.zsh/liferay/bin/portool"
     },
     "type": "module",
     "dependencies": {
index 1993b288e7edf4bf84e23bbc4c0fafa2776eb6ec..6592631ceee53b2d4539e5e14c1cfa058a1d09e3 100644 (file)
@@ -9,7 +9,6 @@ dotfile_files:
 dotfile_templates:
   - .config/karabiner/karabiner.json
   - .gemrc
-  - .gitconfig
   - .hammerspoon/iterm.lua
   - .imapfilter/config.lua
   - .mbsyncrc
@@ -18,6 +17,7 @@ dotfile_templates:
   - .mutt/hooks/presync.sh
   - .notmuch-config
 
+# DONE
 git_user_email: '{{"greg@hurrell.net" if identity == "wincent" else ""}}'
 git_user_name: '{{"Greg Hurrell" if identity == "wincent" else ""}}'
 github_username: '{{"wincent" if identity == "wincent" else ""}}'
index 3af9baccc69f7fa0ef9060f98682198f74abc878..c0afba45e901808689d5efd361a0999d575a9843 100644 (file)
@@ -1,3 +1,4 @@
+// import {log} from './console/index.js';
 import {promises as fs} from './fs.js';
 import {compile, fill} from './template.js';
 
@@ -24,6 +25,9 @@ export default class Compiler {
 
             const compiled = compile(source);
 
+            // BUG: too verbose?
+            // log.debug(`Compiled template source:\n\n${compiled}\n`);
+
             map.set(path, {
                 fill(scope) {
                     return fill(compiled, scope);
index d4b9dd83291d8d69ffed1f18ad7b1174f44df73c..21f514fa1921c821a5ce4aebd322ce6669bc9332 100644 (file)
@@ -6,6 +6,7 @@ import prompt from '../prompt.js';
 import * as status from './status.js';
 import Compiler from '../Compiler.js';
 import TaskRegistry from './TaskRegistry.js';
+import VariableRegistry from './VariableRegistry.js';
 
 import type {Metadata} from '../ErrorWithMetadata.js';
 import type {Aspect} from '../types/Project.js';
@@ -32,6 +33,11 @@ class Context {
     #sudoPassphrase?: Promise<string>;
     #tasks: TaskRegistry;
 
+    // TODO: rename stuff to avoid confusion about `variables`
+    // (VariableRegistry) vs `currentVariables` (merged variables set
+    // from main.ts).
+    #variables: VariableRegistry;
+
     constructor() {
         this.#attributes = new Attributes();
         this.#compiler = new Compiler();
@@ -44,6 +50,7 @@ class Context {
         };
 
         this.#tasks = new TaskRegistry();
+        this.#variables = new VariableRegistry();
     }
 
     compile(path: string) {
@@ -156,6 +163,10 @@ class Context {
     get tasks(): TaskRegistry {
         return this.#tasks;
     }
+
+    get variables(): VariableRegistry {
+        return this.#variables;
+    }
 }
 
 export default new Context();
diff --git a/src/Fig/VariableRegistry.ts b/src/Fig/VariableRegistry.ts
new file mode 100644 (file)
index 0000000..f8aebab
--- /dev/null
@@ -0,0 +1,25 @@
+import type {Aspect} from '../types/Project.js';
+
+type Callback = (variables: Variables) => Variables;
+
+export default class VariableRegistry {
+    #callbacks: Map<Aspect, Callback>;
+
+    constructor() {
+        this.#callbacks = new Map();
+    }
+
+    register(aspect: Aspect, callback: Callback) {
+        if (this.#callbacks.has(aspect)) {
+            throw new Error(
+                `Variables have already been registered for aspect ${aspect}`
+            );
+        }
+
+        this.#callbacks.set(aspect, callback);
+    }
+
+    get(aspect: Aspect): Callback {
+        return this.#callbacks.get(aspect) || (() => ({}));
+    }
+}
index 3b4424f38010e6d12289412a56b7c88bfb4d52f3..c5dcdc9ce6a8221061ce7e99f991badb79c5f3b3 100644 (file)
@@ -5,6 +5,7 @@ import {default as root} from './root.js';
 import {default as task} from './task.js';
 import {default as template} from './operations/template.js';
 import {default as variable} from './variable.js';
+import {default as variables} from './variables.js';
 
 export {command};
 export {file};
@@ -13,6 +14,7 @@ export {root};
 export {task};
 export {template};
 export {variable};
+export {variables};
 
 export interface Fig {
     command: typeof command;
@@ -22,4 +24,5 @@ export interface Fig {
     task: typeof task;
     template: typeof template;
     variable: typeof variable;
+    variables: typeof variables;
 }
index 11cdc2ddc6438bb4ccd67019238efa8f57d33ba7..72571e398b76ba16cb7606dd91e371271f217f99 100644 (file)
@@ -22,8 +22,5 @@ export default function task(name: string, callback: () => Promise<void>) {
 
     assertAspect(aspect);
 
-    // TODO: we use `caller` to make namespaced task name.
-    // (will be useful for --start-at)
-    // also, we can make an interactive mode that lets us choose where to start
     Context.tasks.register(aspect, callback, `${aspect} | ${name}`);
 }
diff --git a/src/Fig/variables.ts b/src/Fig/variables.ts
new file mode 100644 (file)
index 0000000..ed6afdf
--- /dev/null
@@ -0,0 +1,34 @@
+import {relative, sep} from 'path';
+import * as url from 'url';
+
+import {assertAspect} from '../types/Project.js';
+import getCaller from '../getCaller.js';
+import Context from './Context.js';
+import {default as root} from './root.js';
+
+/**
+ * Register a callback to dynamically contribute variables when an aspect is
+ * running (useful for values that cannot be determined statically ahead of time
+ * and stored in JSON).
+ */
+export default function variables(callback: (v: Variables) => Variables) {
+    const caller = getCaller();
+
+    const path = url.fileURLToPath(caller);
+
+    const ancestors = relative(root, path).split(sep);
+
+    const aspect =
+        ancestors[0] === 'lib' && ancestors[1] === 'aspects' && ancestors[2];
+
+    if (!aspect) {
+        throw new Error(`Unable to determine aspect for ${caller}`);
+    }
+
+    assertAspect(aspect);
+
+    Context.variables.register(aspect, callback);
+}
+
+// TODO: dedupe this, which is almost identical to task.ts
+// TODO: rename this folder from "Fig" to "dsl"
index 0c515bcfa39ccff435863c432c86df200e963ff0..605ed67ddc6568499aa534e7d335d8a7586268bd 100644 (file)
@@ -1,6 +1,7 @@
 import * as os from 'os';
 import {join} from 'path';
 
+import variables from '../variables.js';
 import ErrorWithMetadata from './ErrorWithMetadata.js';
 import Context from './Fig/Context.js';
 import {root} from './Fig/index.js';
@@ -155,7 +156,8 @@ async function main() {
     const baseVariables = merge(
         defaultVariables,
         profileVariables,
-        platformVariables
+        platformVariables,
+        variables
     );
 
     // Execute tasks.
@@ -171,7 +173,12 @@ async function main() {
                     continue;
                 }
 
-                const variables = merge(aspectVariables, baseVariables);
+                const mergedVariables = merge(baseVariables, aspectVariables);
+
+                const variables = merge(
+                    mergedVariables,
+                    Context.variables.get(aspect)(mergedVariables)
+                );
 
                 log.debug(`Variables:\n\n${stringify(variables)}\n`);
 
index b99a54e87123e33a4ba953039683a91ff328729f..a3ed10dd7b615e228907a9808173192065c687c3 100644 (file)
@@ -185,13 +185,17 @@ export function* tokenize(input: string): Generator<Token> {
                         }
                     }
                 } else {
+                    // TODO: may want to tolerate this so that we can write
+                    // things like: <%= '<%' %>
+                    // would be useful in .gitconfig.erb
                     throw new Error(
-                        `Unexpected start delimiter "${text}" at index ${match.index}`
+                        `Unexpected start delimiter "${text}" at index ${match.index}:\n\n` +
+                            excerpt(input, match.index)
                     );
                 }
             } else {
                 if (text === '<%-') {
-                    // Eat whitspace between previous newline and delimiter.
+                    // Eat whitespace between previous newline and delimiter.
                     yield {
                         kind: 'TemplateText',
                         text: input
@@ -217,7 +221,8 @@ export function* tokenize(input: string): Generator<Token> {
                     };
                 } else if (text === '%>') {
                     throw new Error(
-                        `Unexpected end delimiter "%>" at index ${match.index}`
+                        `Unexpected end delimiter "%>" at index ${match.index}:\n\n` +
+                            excerpt(input, match.index)
                     );
                 }
             }
@@ -233,3 +238,10 @@ export function* tokenize(input: string): Generator<Token> {
         }
     }
 }
+
+/**
+ * Produce an except of `input` around position `index` for error-reporting.
+ */
+function excerpt(input: string, index: number): string {
+    return JSON.stringify(input.slice(Math.max(0, index - 10), index + 10));
+}
index 5a4846cfee39f7f903697aec20bb24d8a99c7237..0571f9942948663075857caec8ca225c43a34ee4 100644 (file)
@@ -10,5 +10,5 @@
         "strict": true,
         "target": "ES2019"
     },
-    "include": ["aspects/**/*.ts", "src/**/*.ts"]
+    "include": ["aspects/**/*.ts", "src/**/*.ts", "variables.ts"]
 }
diff --git a/variables.ts b/variables.ts
new file mode 100644 (file)
index 0000000..3130e51
--- /dev/null
@@ -0,0 +1,34 @@
+import Context from './src/Fig/Context.js';
+
+/**
+ * @file
+ *
+ * Dynamic variables.
+ *
+ * Priority (from lowest to highest):
+ *
+ * 1. Defaults from "variables" property in project.json.
+ * 2. Profile-specific overrides from "variables" properties in "profiles" in
+ *    project.json.
+ * 3. Platform-specific overrides from "variables" properties in "platforms" in
+ *    project.json.
+ * 4. Dynamic variables exported from variables.ts (ie. this file).
+ * 5. Aspect-specific overrides from "variables" property in aspect.json files.
+ * 6. Dynamic aspect-specific overrides registered using the `variables` DSL
+ *    function inside an aspect's index.ts file.
+ *
+ */
+const variables = {
+    get identity() {
+        if (
+            Context.attributes.username === 'glh' ||
+            Context.attributes.username === 'greghurrell'
+        ) {
+            return 'wincent';
+        } else {
+            return 'unknown';
+        }
+    },
+};
+
+export default variables;