Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ jobs:
- name: clippy
run: cargo clippy --all-targets -- -D warnings

# tests/hook.rs evaluates the emitted snippet in each shell `vanadis hook` claims, and
# refuses to skip a shell when `CI` is set.
- name: shells
run: sudo apt-get update && sudo apt-get install -y zsh fish

- name: test
run: cargo test --all-targets

Expand Down
8 changes: 5 additions & 3 deletions docs/examples/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,12 @@ name = "starship"
template = "templates/starship/starship.toml.in"
output = "~/.config/starship.toml"

# fzf reads these colours from the environment, so the shell has to be replaced for a change
# to show. `exec zsh` replaces the user's shell and vanadis runs as a child process, so it
# cannot be a reload command.
# fzf reads these colours from the environment, so a write is not enough on its own: the
# shell has to source the file again. No `reload` can do that, because the shell that needs
# fixing is vanadis's parent. `shell` marks the target instead, and the prompt hook that
# `eval "$(vanadis hook zsh)"` registers sources the file again whenever it changes.
[[targets]]
name = "zsh"
template = "templates/zsh/palette.zsh.in"
output = "~/.config/zsh/palette.zsh"
shell = "zsh"
14 changes: 14 additions & 0 deletions docs/hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ Prints a snippet to stdout, to be evaluated by the shell it names:
eval "$(vanadis hook zsh)"
```

fish reads it the way fish reads every tool in this shape, because `eval` there would need the
output collected into one argument first:

```fish
vanadis hook fish | source
```

The snippet sources the outputs of the targets that name that shell, once at evaluation, and
registers a prompt hook that sources each of them again when its file has changed. This is the
shape `direnv hook zsh`, `mise activate zsh`, `starship init zsh` and `zoxide init zsh` already
Expand Down Expand Up @@ -211,6 +218,13 @@ alone does not; and the exit status of the command before the prompt survives.
This is also what fixes the versions claimed. `path mtime` is not in every fish that is
installed anywhere, and the fish CI runs on is the fish this document claims.

**fish needs a terminal before any of that can be asked.** It emits `fish_prompt` only while
drawing a prompt, and it draws none when its input is a pipe, `-i` or not, so a piped fish
answers every prompt question with the state the evaluation itself left. zsh and bash run
`precmd` and `PROMPT_COMMAND` either way. The fish run is therefore given one, and what comes
back carries the prompt and the echo of what was typed around what the script printed, which is
why the assertion reads its marker out of the text rather than off the start of a line.

## Rejected alternatives

The state file as the signal, a top-level table naming shell targets, and a config read at
Expand Down
5 changes: 4 additions & 1 deletion skills/vanadis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ not travel with it.
| `vanadis get --json` | the whole resolved theme, flat, keyed by token path |
| `vanadis render <template> --theme <id>` | one template against one named theme, to stdout. Reads no target and writes no file — this is how a theme is written out as a base16 scheme or any other format |
| `vanadis render --target <name>` | what an apply would write for that target, to stdout, writing no file. The theme resolves the way `check` resolves it, so `render --target x > <its output>` leaves `check --only x` clean |
| `vanadis hook <zsh\|fish\|bash>` | the snippet a shell evaluates to follow an apply, for a target marked `shell` — `eval "$(vanadis hook zsh)"`, or `vanadis hook fish \| source` |
| `vanadis init <file>` | interactive. Hand it to the user — see below. |

`apply` overwrites files the user wrote. Show `vanadis apply <theme> --diff` and get their
Expand Down Expand Up @@ -196,7 +197,8 @@ what lets `check` say a colour value is well-formed at all.
6. **Add the values to the theme** under the names you chose, as lowercase `#rrggbb` literals
or as `{{colors.x}}` references to colours it already carries.
7. **Append the `[[targets]]` entry.** `name`, `template`, `output`. Leave `reload` out unless
you know the command — see [references/reload.md](references/reload.md).
you know the command, and add `shell` when the output is a file a shell sources rather than
a config a tool reads — see [references/reload.md](references/reload.md).
8. **Verify.** This is the step that makes the work checkable:

```
Expand Down Expand Up @@ -230,6 +232,7 @@ first apply of a target you just wrote.
| `template` | yes | path to the template |
| `output` | yes | path to write |
| `reload` | no | argv to run after writing, as an array — no shell |
| `shell` | no | `zsh`, `fish` or `bash`: the shell whose `vanadis hook` sources this output — see [references/reload.md](references/reload.md) |
| `themes` | no | `{ light = "...", dark = "..." }`, this target's own themes |

`name` identifies the target, not the tool. One program with three config files is three
Expand Down
45 changes: 44 additions & 1 deletion skills/vanadis/references/reload.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ There is no `pre` counterpart and no hook system.
| btop | system monitor | restart | not a command |
| hunk | diff viewer | restart | not a command |
| lazygit | git UI | restart | not a command |
| zsh with fzf | shell and fuzzy finder | `exec zsh` — fzf reads its colours from the environment | **no**: it replaces the user's shell, and vanadis is a child process |
| zsh with fzf | shell and fuzzy finder | the shell sources the output again — fzf reads its colours from the environment | **no**: mark the target `shell = "zsh"` and use [the prompt hook](#a-target-a-shell-sources) |
| rio | terminal emulator | rereads its config; note it runs on the machine the terminal is on, which over SSH is not the machine vanadis runs on | nothing to run |

Two of these nine have a command vanadis can usefully run. That ratio is the normal case, not a
Expand All @@ -39,6 +39,49 @@ gap in the format.
showing the old theme with no error anywhere — the file on disk is correct and `vanadis check`
is clean.

## A target a shell sources

`reload` cannot reach a tool configured through the environment. The shell holding the stale
colours is vanadis's parent, and no process replaces its parent's image, so `exec zsh` is
unreachable however it is written. `reload = ["exec", "zsh"]` fails before that: `exec` is a
shell builtin and `reload` takes no shell.

Mark the target with the shell that sources its output:

```toml
[[targets]]
name = "zsh"
template = "templates/zsh/palette.zsh.in"
output = "~/.config/zsh/palette.zsh"
shell = "zsh"
```

`zsh`, `fish` and `bash`. The value names the shell whose syntax the output is written in, so a
zsh target and a fish target sit side by side, each rendered from its own template.

Then the user adds one line to their rc file:

```zsh
eval "$(vanadis hook zsh)"
```

```fish
vanadis hook fish | source
```

That registers a prompt hook which sources the output again whenever the file changes, so an
`apply` or a `cycle` run in another terminal reaches this shell at its next prompt. The paths
are written into the snippet, so the line has to be re-run after an `output` moves or a target
is added.

`shell` does not replace `reload`. Both may be present: `reload` runs a command after the
write, and `shell` reaches a shell vanadis cannot run a command in.

**What belongs in such a template.** Exports and variable assignments. The file is sourced in
every new shell and again on every change, so a template that starts a program or writes a file
does that each time. A template that renders invalid shell syntax breaks every shell started
after the next apply.

## Working out a tool that is not in the table

Ask in this order, and stop at the first yes.
Expand Down
112 changes: 111 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,52 @@ impl fmt::Display for TargetName {
}
}

/// A shell that sources a target's output.
///
/// `vanadis hook <shell>` prints the snippet that sources every target carrying this shell,
/// and sources it again when its output changes. See `docs/hook.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Shell {
/// zsh, which reads an mtime with `zstat` and hooks `precmd`.
Zsh,
/// fish, which reads an mtime with `path mtime` and hooks the `fish_prompt` event.
Fish,
/// bash, which forks `stat` and appends to `PROMPT_COMMAND`.
Bash,
}

impl Shell {
/// Every shell `hook` emits for, in the order `--help` lists them.
pub const ALL: [Self; 3] = [Self::Zsh, Self::Fish, Self::Bash];

/// Parses `text` as a shell name, returning `None` when it names none of them.
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
match text {
"zsh" => Some(Self::Zsh),
"fish" => Some(Self::Fish),
"bash" => Some(Self::Bash),
_ => None,
}
}

/// The name the config file and the command line both write.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Zsh => "zsh",
Self::Fish => "fish",
Self::Bash => "bash",
}
}
}

impl fmt::Display for Shell {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}

/// The themes a bare `vanadis apply --variant` resolves through.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Auto {
Expand Down Expand Up @@ -110,6 +156,7 @@ pub struct Target {
template: PathBuf,
output: PathBuf,
reload: Vec<String>,
shell: Option<Shell>,
themes: Option<Themes>,
}

Expand Down Expand Up @@ -138,6 +185,12 @@ impl Target {
&self.reload
}

/// The shell whose `hook` sources this output, or `None` when no shell does.
#[must_use]
pub fn shell(&self) -> Option<Shell> {
self.shell
}

/// The target's own themes, or `None` when it follows the theme being applied.
#[must_use]
pub fn themes(&self) -> Option<&Themes> {
Expand Down Expand Up @@ -222,6 +275,14 @@ pub enum Problem {
/// The value, as the file writes it.
value: String,
},
/// A `shell` naming a shell `hook` does not emit for.
#[error("line {line}: targets.shell is `{value}`, and the shells are zsh, fish and bash")]
Shell {
/// The value, as the file writes it.
value: String,
/// The line the key is written on.
line: usize,
},
/// A path starting with `~` when there is no home directory to expand it to.
#[error("line {line}: {key} starts with `~`, and there is no home directory")]
Home {
Expand All @@ -245,6 +306,7 @@ impl Problem {
| Self::Repeated { line, .. }
| Self::Short { line, .. }
| Self::Theme { line, .. }
| Self::Shell { line, .. }
| Self::Home { line, .. } => *line,
}
}
Expand Down Expand Up @@ -530,7 +592,7 @@ impl Parse<'_> {
for (key, item) in table {
let at = self.line_of(table.key(key));
match key {
"name" | "template" | "output" | "reload" => {}
"name" | "template" | "output" | "reload" | "shell" => {}
"themes" => {
if let Some(inner) = item.as_table_like() {
themes = Some(self.themes(inner));
Expand All @@ -549,12 +611,14 @@ impl Parse<'_> {
let template = self.path(table, "template", line);
let output = self.path(table, "output", line);
let reload = self.reload(table);
let shell = self.shell(table);

Some(Target {
name: name?,
template: template?,
output: output?,
reload,
shell,
themes,
})
}
Expand Down Expand Up @@ -598,6 +662,28 @@ impl Parse<'_> {
reload
}

/// Reads a target's `shell`, which is `None` when the target has none.
///
/// A value that names no shell is a problem rather than a `None`, so a typo is a config
/// error and not a target that silently never sources.
fn shell(&mut self, table: &Table) -> Option<Shell> {
let item = table.get("shell")?;
let at = self.line_of(table.key("shell"));
let Some(text) = item.as_str() else {
self.wrong_type("targets.shell", at, item);
return None;
};

let shell = Shell::parse(text);
if shell.is_none() {
self.problems.push(Problem::Shell {
value: text.to_owned(),
line: at,
});
}
shell
}

/// Reads a target's `themes`, which names a theme for one mode or both.
fn themes(&mut self, table: &dyn TableLike) -> Themes {
let mut themes = BTreeMap::new();
Expand Down Expand Up @@ -792,6 +878,30 @@ mod tests {
assert!(target(TARGET).reload().is_empty());
}

#[test]
fn reads_the_shell_whose_hook_sources_a_target() {
let source = format!("{TARGET}shell = \"fish\"\n");
assert_eq!(target(&source).shell(), Some(Shell::Fish));
}

#[test]
fn leaves_a_target_no_shell_sources_unmarked() {
assert_eq!(target(TARGET).shell(), None);
}

#[test]
fn reports_a_shell_hook_does_not_emit_for() {
// A typo is a config error rather than a target that silently never sources.
let source = format!("{TARGET}shell = \"nu\"\n");
assert_eq!(
problems(&source),
vec![Problem::Shell {
value: "nu".to_owned(),
line: 5,
}]
);
}

#[test]
fn reads_the_per_target_theme_override() {
let source =
Expand Down
Loading