Packages
fnord
0.9.28
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/external_configs/cursor_rule.ex
defmodule ExternalConfigs.CursorRule do
@moduledoc """
A parsed Cursor rule (`.mdc` file or legacy `.cursorrules`).
Cursor rules have four application modes determined by which frontmatter
fields are set:
* `:always` - `alwaysApply: true`. Injected unconditionally.
* `:auto_attached` - `globs` non-empty. Injected when a file already
in the chat context matches one of the globs (fnord triggers on file
read/write).
* `:agent_requested` - `description` set, no globs, not always. The
model sees the description and chooses whether to fetch.
* `:manual` - none of the above. Only fetched on explicit
request.
Legacy `.cursorrules` files at the project root are treated as a single
`:always` rule.
"""
defstruct [
:name,
:description,
:globs,
:always_apply,
:body,
:path,
:source,
:mode
]
@type source :: :global | :project | :legacy
@type mode :: :always | :auto_attached | :agent_requested | :manual
@type t :: %__MODULE__{
name: String.t(),
description: String.t() | nil,
globs: [String.t()],
always_apply: boolean(),
body: String.t(),
path: String.t(),
source: source(),
mode: mode()
}
@doc """
Load a `.mdc` rule file. The rule name is derived from the file basename
(without the `.mdc` extension).
"""
@spec from_file(String.t(), source()) :: {:ok, t} | {:error, term()}
def from_file(path, source) when is_binary(path) do
with {:ok, content} <- File.read(path),
{:ok, %{frontmatter: fm, body: body}} <-
ExternalConfigs.Frontmatter.parse(content) do
name = derive_name(path)
description = fetch_string(fm, "description")
globs = fetch_globs(fm, "globs")
always_apply = fetch_bool(fm, "alwaysApply", false)
{:ok,
%__MODULE__{
name: name,
description: description,
globs: globs,
always_apply: always_apply,
body: String.trim(body),
path: path,
source: source,
mode: classify(always_apply, globs, description)
}}
end
end
@doc """
Load a legacy `.cursorrules` file as a single `:always` rule. The body is
the full file contents (there is no frontmatter in the legacy format).
"""
@spec from_legacy_file(String.t()) :: {:ok, t} | {:error, term()}
def from_legacy_file(path) when is_binary(path) do
with {:ok, content} <- File.read(path) do
{:ok,
%__MODULE__{
name: ".cursorrules",
description: nil,
globs: [],
always_apply: true,
body: String.trim(content),
path: path,
source: :legacy,
mode: :always
}}
end
end
@doc """
Returns true when any of the rule's globs match the given path (relative
to the project source root).
"""
@spec matches_path?(t, String.t()) :: boolean()
def matches_path?(%__MODULE__{globs: []}, _path), do: false
def matches_path?(%__MODULE__{globs: globs}, path) when is_binary(path) do
Enum.any?(globs, &glob_match?(&1, path))
end
defp classify(true, _globs, _desc), do: :always
defp classify(false, globs, _desc) when globs != [], do: :auto_attached
defp classify(false, [], desc) when is_binary(desc) and desc != "", do: :agent_requested
defp classify(_, _, _), do: :manual
defp derive_name(path) do
path
|> Path.basename()
|> String.replace_suffix(".mdc", "")
end
defp fetch_string(fm, key) do
case Map.get(fm, key) do
v when is_binary(v) ->
case String.trim(v) do
"" -> nil
trimmed -> trimmed
end
_ ->
nil
end
end
defp fetch_bool(fm, key, default) do
case Map.get(fm, key) do
true -> true
false -> false
"true" -> true
"false" -> false
_ -> default
end
end
# Cursor stores `globs` as a comma-separated string by convention, but the
# YAML flow also allows a list. Accept either.
defp fetch_globs(fm, key) do
case Map.get(fm, key) do
nil ->
[]
list when is_list(list) ->
list
|> Enum.filter(&is_binary/1)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
str when is_binary(str) ->
str
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
_ ->
[]
end
end
# Path.wildcard compares against the filesystem; we need a pure pattern
# match against a string. Convert glob -> regex and test.
#
# Supported glob syntax:
# * matches any run of non-slash characters (does NOT cross /)
# ** matches anything including slashes (tail-position)
# **/ matches zero or more path segments (including empty)
# ? matches a single non-slash character
#
# Not supported (fall through as literal regex-escaped chars):
# {a,b,c} brace alternation
# [abc] character classes
# extglob +(...), @(...), !(...), ?(...)
#
# Cursor's own .mdc globs use the same subset in practice, so any rule
# that works in Cursor will match here.
defp glob_match?(glob, path) do
regex = glob_to_regex(glob)
Regex.match?(regex, path)
end
@spec glob_to_regex(String.t()) :: Regex.t()
defp glob_to_regex(glob) do
# Strip any leading "./" since paths we test are already project-relative.
glob =
glob
|> String.trim_leading("./")
|> String.trim_leading("/")
pattern = translate(glob, "")
Regex.compile!("^" <> pattern <> "$")
end
defp translate("", acc), do: acc
defp translate("**/" <> rest, acc), do: translate(rest, acc <> "(?:.*/)?")
defp translate("**", acc), do: acc <> ".*"
defp translate("*" <> rest, acc), do: translate(rest, acc <> "[^/]*")
defp translate("?" <> rest, acc), do: translate(rest, acc <> "[^/]")
defp translate(<<c::utf8, rest::binary>>, acc) do
translate(rest, acc <> escape_char(<<c::utf8>>))
end
defp escape_char(c) when c in [".", "+", "(", ")", "|", "^", "$", "{", "}", "[", "]", "\\"],
do: "\\" <> c
defp escape_char(c), do: c
end