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
6 changes: 3 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var rootCmd = &cobra.Command{
Use: "knot",
Short: "A lightweight, configurable dotfiles manager",
Long: `Knot manages your dotfiles via symlinks.
It reads a knot.yml config file and creates or removes symlinks
It reads a Knotfile and creates or removes symlinks
based on your package definitions.`,
}

Expand All @@ -30,11 +30,11 @@ func Execute() {
}

func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "path to knot.yml (default: auto-discover)")
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "path to Knotfile (default: auto-discover)")
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "print actions without executing them")
}

// loadConfig finds and parses the knot.yml config file.
// loadConfig finds and parses the Knotfile.
func loadConfig() (*config.Config, string, error) {
if cfgFile != "" {
cfg, err := config.Load(cfgFile)
Expand Down
2 changes: 1 addition & 1 deletion cmd/tie.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var tieCmd = &cobra.Command{
Use: "tie [package...]",
Short: "Create symlinks for one or more packages",
Long: `Tie creates symlinks for the specified packages.
Use --all to tie all packages defined in knot.yml.`,
Use --all to tie all packages defined in Knotfile.`,
RunE: func(cmd *cobra.Command, args []string) error {
if !tieAll && len(args) == 0 {
return fmt.Errorf("specify at least one package or use --all")
Expand Down
109 changes: 109 additions & 0 deletions cmd/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cmd

import (
"fmt"
"os"
"sort"

"github.com/oxgrad/knot/internal/resolver"
"github.com/spf13/cobra"
)

var validateCmd = &cobra.Command{
Use: "validate",
Short: "Validate the Knotfile for errors and warnings",
Long: `Validate checks the Knotfile for structural correctness:
- required fields (source, target) are present for every package
- source directories exist on disk
- condition.os values are known OS names
- ignore patterns are valid glob expressions

Exit codes:
0 valid, no issues
1 one or more errors
2 no errors, but warnings present`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, path, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading Knotfile: %v\n", err)
os.Exit(1)
}

fmt.Printf("Validating Knotfile: %s\n\n", path)

var errs, warns []string

knownOS := map[string]bool{
"darwin": true, "linux": true, "windows": true, "freebsd": true,
}

if len(cfg.Packages) == 0 {
warns = append(warns, "no packages defined")
}

// Sort package names for deterministic output.
names := make([]string, 0, len(cfg.Packages))
for n := range cfg.Packages {
names = append(names, n)
}
sort.Strings(names)

home, _ := os.UserHomeDir()

for _, name := range names {
pkg := cfg.Packages[name]

if pkg.Source == "" {
errs = append(errs, fmt.Sprintf(`[%s]: "source" is required`, name))
} else {
expanded := resolver.ExpandPath(pkg.Source, home)
info, statErr := os.Stat(expanded)
if statErr != nil {
errs = append(errs, fmt.Sprintf("[%s]: source directory %q does not exist", name, expanded))
} else if !info.IsDir() {
errs = append(errs, fmt.Sprintf("[%s]: source %q is not a directory", name, expanded))
}
}

if pkg.Target == "" {
errs = append(errs, fmt.Sprintf(`[%s]: "target" is required`, name))
}

if pkg.Condition != nil && pkg.Condition.OS != "" && !knownOS[pkg.Condition.OS] {
errs = append(errs, fmt.Sprintf(
"[%s]: unknown condition.os value %q (must be one of: darwin, linux, windows, freebsd)",
name, pkg.Condition.OS))
}

for _, pattern := range pkg.Ignore {
if _, matchErr := resolver.ShouldIgnore("test", []string{pattern}); matchErr != nil {
errs = append(errs, fmt.Sprintf("[%s]: invalid ignore pattern %q: %v", name, pattern, matchErr))
}
}
}

for _, e := range errs {
fmt.Printf(" ERROR %s\n", e)
}
for _, w := range warns {
fmt.Printf(" WARN %s\n", w)
}
fmt.Println()

switch {
case len(errs) > 0:
fmt.Printf("Validation failed: %d error(s), %d warning(s)\n", len(errs), len(warns))
os.Exit(1)
case len(warns) > 0:
fmt.Printf("Validation passed with %d warning(s).\n", len(warns))
os.Exit(2)
default:
fmt.Printf(" OK: %d package(s) valid\n\nValidation passed.\n", len(cfg.Packages))
}
return nil
},
}

func init() {
rootCmd.AddCommand(validateCmd)
}
127 changes: 127 additions & 0 deletions editors/neovim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# knot.nvim

Neovim plugin for the [knot](https://github.com/oxgrad/knot) dotfiles manager.

## Features

- Automatic filetype detection for files named exactly `Knotfile`
- 🪢 Devicon registration for [nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) (shown in statuslines, file trees, tablines)
- YAML-based syntax highlighting with Knotfile-specific keyword groups:
- `packages` — highlighted as a structure keyword
- `source`, `target`, `ignore`, `condition` — highlighted as identifiers
- `os` — highlighted as a special keyword
- `darwin`, `linux`, `windows`, `freebsd` — highlighted as constants
- Buffer-local settings (`tabstop=2`, `shiftwidth=2`, `commentstring=# %s`)
- Optional `yaml-language-server` schema auto-configuration for inline validation and completions
- Treesitter YAML parser override for enhanced syntax and text-objects (Neovim 0.9+)

## Requirements

- Neovim 0.8+
- **Optional:** [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) + [`yaml-language-server`](https://github.com/redhat-developer/yaml-language-server) for schema validation and completions
- **Optional:** [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) with `yaml` parser (Neovim 0.9+) for enhanced highlighting

## Installation

### lazy.nvim

```lua
{
-- Path to the neovim plugin directory inside the knot repo:
dir = vim.fn.expand("~/path/to/knot/editors/neovim"),
-- Once published as a standalone plugin, replace dir with:
-- "oxgrad/knot.nvim",
name = "knot.nvim", -- registers the plugin under this name in lazy's registry
main = "knot", -- tells lazy.nvim to call require("knot").setup(opts)
ft = "knotfile",
opts = {
auto_configure_yamlls = true,
},
},
```

> **Why `main = "knot"`?** Without it, lazy.nvim derives the module name from the
> directory name (`neovim`) and calls `require("neovim").setup(opts)`, which fails.

### packer.nvim

```lua
use {
-- Path to the neovim plugin directory inside the knot repo
-- (packer does not expand ~, so vim.fn.expand is required):
vim.fn.expand("~/path/to/knot/editors/neovim"),
-- Once published as a standalone plugin, replace with:
-- "oxgrad/knot.nvim",
ft = { "knotfile" },
config = function()
require("knot").setup({
auto_configure_yamlls = true,
})
end,
}
```

### Manual (no plugin manager)

Add the plugin directory to your runtime path in `init.lua`:

```lua
vim.opt.runtimepath:append(vim.fn.expand("~/path/to/knot/editors/neovim"))
require("knot").setup()
```

Or in `init.vim`:

```vim
set runtimepath+=~/path/to/knot/editors/neovim
lua require("knot").setup()
```

## Configuration

```lua
require("knot").setup({
-- Set to false if you manage yamlls schemas yourself.
auto_configure_yamlls = true,
})
```

| Option | Type | Default | Description |
|---|---|---|---|
| `auto_configure_yamlls` | `boolean` | `true` | Automatically notify the active `yaml-language-server` client to use the Knotfile JSON Schema for all `Knotfile` buffers. |

## Manual yaml-language-server schema association

If you prefer to configure `yamlls` yourself, add this to your `lspconfig` setup:

```lua
require("lspconfig").yamlls.setup({
settings = {
yaml = {
schemas = {
["https://raw.githubusercontent.com/oxgrad/knot/main/schema/knotfile.schema.json"] = "**/Knotfile",
},
},
},
})
```

Or add this modeline as the first line of any `Knotfile`:

```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/oxgrad/knot/main/schema/knotfile.schema.json
packages:
nvim:
source: ./nvim
target: ~/.config/nvim
```

## Schema

The official JSON Schema is published at:

```
https://raw.githubusercontent.com/oxgrad/knot/main/schema/knotfile.schema.json
```

See [`../../schema/knotfile.schema.json`](../../schema/knotfile.schema.json) for the full definition.
21 changes: 21 additions & 0 deletions editors/neovim/after/ftplugin/knotfile.vim
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
" Buffer-local settings for Knotfile buffers.
" Loaded after all standard ftplugin scripts.

" Indentation — YAML convention is 2 spaces
setlocal tabstop=2
setlocal shiftwidth=2
setlocal expandtab
setlocal softtabstop=2

" YAML uses '#' for comments
setlocal commentstring=#\ %s

" Treat long lines as normal (dotfiles configs are typically short)
setlocal textwidth=0

" Tell yaml-language-server which schema to use via an inline modeline.
" Uncomment the block below to insert the modeline automatically on first open:
"
" if !search('yaml-language-server: \$schema=', 'nw')
" call append(0, '# yaml-language-server: $schema=https://raw.githubusercontent.com/oxgrad/knot/main/schema/knotfile.schema.json')
" endif
5 changes: 5 additions & 0 deletions editors/neovim/ftdetect/knotfile.vim
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
" Detect files named exactly 'Knotfile' and assign the knotfile filetype.
augroup knotfile_ftdetect
autocmd!
autocmd BufNewFile,BufRead Knotfile set filetype=knotfile
augroup END
96 changes: 96 additions & 0 deletions editors/neovim/lua/knot/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
--- knot.nvim — Neovim support for the knot dotfiles manager.
---
--- Provides:
--- - Filetype detection for files named exactly "Knotfile"
--- - Treesitter YAML parser override for knotfile buffers
--- - Optional automatic yaml-language-server schema configuration
---
--- Minimum requirement: Neovim 0.8+

local M = {}

--- The published JSON Schema URL for Knotfile validation.
M.schema_url = "https://raw.githubusercontent.com/oxgrad/knot/main/schema/knotfile.schema.json"

local defaults = {
-- Automatically tell yaml-language-server to validate Knotfile buffers
-- using the official JSON Schema. Requires nvim-lspconfig + yamlls.
auto_configure_yamlls = true,
}

--- Register the 🪢 icon with nvim-web-devicons for the knotfile filetype.
--- Called automatically from setup(); safe to call if devicons is not installed.
function M._register_devicon()
local ok, devicons = pcall(require, "nvim-web-devicons")
if not ok then return end
devicons.set_icon({
knotfile = {
icon = "🪢",
color = "#a78bfa", -- soft violet — rope-like
cterm_color = "141",
name = "Knotfile",
},
})
end

--- Set up the plugin.
---@param opts table|nil Optional overrides for the default config table.
function M.setup(opts)
local cfg = vim.tbl_deep_extend("force", defaults, opts or {})

-- 1. Register "Knotfile" → "knotfile" filetype mapping.
-- Belt-and-suspenders alongside ftdetect/knotfile.vim.
vim.filetype.add({
filename = {
["Knotfile"] = "knotfile",
},
})

-- 2. Register the 🪢 devicon (no-op if nvim-web-devicons is not installed).
M._register_devicon()

-- 3. Override treesitter parser to "yaml" for knotfile buffers so that
-- tree-sitter-yaml highlighting and text-objects work out of the box.
vim.api.nvim_create_autocmd("FileType", {
pattern = "knotfile",
group = vim.api.nvim_create_augroup("KnotfileTreesitter", { clear = true }),
callback = function(ev)
-- vim.treesitter.start() requires Neovim 0.9+
if vim.fn.has("nvim-0.9") == 1 then
local ok, parsers = pcall(require, "nvim-treesitter.parsers")
if ok and parsers.has_parser("yaml") then
vim.treesitter.start(ev.buf, "yaml")
end
end
end,
})

-- 4. Optionally configure yamlls schema at runtime.
if cfg.auto_configure_yamlls then
M._configure_yamlls()
end
end

--- Notify the active yaml-language-server client to associate the Knotfile
--- schema with all files named "Knotfile" via workspace/didChangeConfiguration.
function M._configure_yamlls()
vim.api.nvim_create_autocmd("FileType", {
pattern = "knotfile",
group = vim.api.nvim_create_augroup("KnotfileYamlls", { clear = true }),
callback = function()
local clients = vim.lsp.get_clients({ name = "yamlls" })
for _, client in ipairs(clients) do
local settings = vim.tbl_deep_extend("force", client.config.settings or {}, {
yaml = {
schemas = {
[M.schema_url] = "**/Knotfile",
},
},
})
client.notify("workspace/didChangeConfiguration", { settings = settings })
end
end,
})
end

return M
Loading