Class: Textus::Manifest

Inherits:
Object
  • Object
show all
Defined in:
lib/textus/manifest.rb,
lib/textus/manifest/entry.rb

Defined Under Namespace

Classes: Entry

Constant Summary collapse

EXT_TO_FORMAT =
{
  ".md" => "markdown",
  ".json" => "json",
  ".yaml" => "yaml",
  ".yml" => "yaml",
  ".txt" => "text",
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root, raw) ⇒ Manifest

Returns a new instance of Manifest.

Raises:



40
41
42
43
44
45
46
47
# File 'lib/textus/manifest.rb', line 40

def initialize(root, raw)
  @root = root
  @raw = raw
  raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?

  @entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
  validate_declared_keys!
end

Instance Attribute Details

#entriesObject (readonly)

Returns the value of attribute entries.



13
14
15
# File 'lib/textus/manifest.rb', line 13

def entries
  @entries
end

#rawObject (readonly)

Returns the value of attribute raw.



13
14
15
# File 'lib/textus/manifest.rb', line 13

def raw
  @raw
end

#rootObject (readonly)

Returns the value of attribute root.



13
14
15
# File 'lib/textus/manifest.rb', line 13

def root
  @root
end

Class Method Details

.load(root) ⇒ Object

Raises:



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/textus/manifest.rb', line 23

def self.load(root)
  manifest_path = File.join(root, "manifest.yaml")
  raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)

  raw = YAML.safe_load_file(manifest_path, aliases: false)
  unless raw["version"] == PROTOCOL
    msg = if raw["version"] == "textus/1"
            "manifest is textus/1; edit manifest.yaml: change 'version: textus/1' to 'version: #{PROTOCOL}'"
          else
            "unsupported manifest version #{raw["version"].inspect}"
          end
    raise BadFrontmatter.new(manifest_path, msg)
  end

  new(root, raw)
end

Instance Method Details

#enumerate(prefix: nil) ⇒ Object

Enumerate all entry files reachable through the manifest. Returns

{ key:, path:, manifest_entry: }, …

rubocop:disable Metrics/AbcSize



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/textus/manifest.rb', line 89

def enumerate(prefix: nil)
  out = []
  @entries.each do |entry|
    if entry.nested
      base = File.join(@root, "zones", entry.path)
      next unless File.directory?(base)

      glob_pattern = nested_glob(entry.format)
      Dir.glob(File.join(base, glob_pattern)).each do |fp|
        rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
        stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
        segs = stripped.split("/").reject(&:empty?)
        next if segs.empty?

        illegal = segs.find { |s| !valid_segment?(s) }
        if illegal
          warn("textus: skipping illegal key segment '#{illegal}' at #{fp} — run 'textus key migrate --dry-run'")
          next
        end

        full_key = (entry.key.split(".") + segs).join(".")
        out << { key: full_key, path: fp, manifest_entry: entry }
      end
    else
      fp = resolve_leaf_path(entry)
      out << { key: entry.key, path: fp, manifest_entry: entry } if File.exist?(fp)
    end
  end
  out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
  out.sort_by { |row| row[:key] }
end

#resolve(key) ⇒ Object

Returns [Manifest::Entry, resolved_path, remaining_segments]

Raises:



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/textus/manifest.rb', line 50

def resolve(key)
  validate_key!(key)
  segments = key.split(".")
  # longest-prefix match
  candidates = @entries
               .map { |e| [e, e.key.split(".")] }
               .select { |(_, esegs)| esegs == segments[0, esegs.length] }
               .sort_by { |(_, esegs)| -esegs.length }
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?

  entry, esegs = candidates.first
  remaining = segments[esegs.length..]
  if remaining.empty?
    path = resolve_leaf_path(entry)
    [entry, path, []]
  else
    raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested

    primary_ext = Textus::Entry.for_format(entry.format).extensions.first
    path = File.join(@root, "zones", entry.path, *remaining) + primary_ext
    [entry, path, remaining]
  end
end

#suggestions_for(key) ⇒ Object

Returns up to 5 dotted keys from the manifest that look similar to the requested key, ranked by shared-prefix length then Levenshtein distance.



76
77
78
79
80
81
82
83
84
# File 'lib/textus/manifest.rb', line 76

def suggestions_for(key)
  candidates = enumerate.map { |r| r[:key] }
  # Include declared (non-nested) entry keys even if file is missing.
  candidates.concat(@entries.reject(&:nested).map(&:key))
  candidates.uniq!
  Key::Distance.suggest(key, candidates, limit: 5)
rescue StandardError
  []
end

#validate_key!(key) ⇒ Object

Raises:



133
134
135
136
137
# File 'lib/textus/manifest.rb', line 133

def validate_key!(key)
  raise UsageError.new("empty key") if key.nil? || key.empty?

  Key::Grammar.validate!(key)
end

#validate_keys!Object

Validates all declared entry keys; raises UsageError listing all offenders.

Raises:



123
124
125
126
127
128
129
130
131
# File 'lib/textus/manifest.rb', line 123

def validate_keys!
  offenders = []
  @entries.each do |entry|
    validate_key!(entry.key)
  rescue UsageError => e
    offenders << e.message
  end
  raise UsageError.new("invalid manifest keys: #{offenders.join("; ")}") unless offenders.empty?
end

#zone_writers(zone_name) ⇒ Object



19
20
21
# File 'lib/textus/manifest.rb', line 19

def zone_writers(zone_name)
  zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
end

#zonesObject



15
16
17
# File 'lib/textus/manifest.rb', line 15

def zones
  @zones ||= Array(@raw["zones"]).to_h { |z| [z["name"], Array(z["writable_by"])] }
end