Class: Canopus::Project

Inherits:
Object
  • Object
show all
Defined in:
lib/canopus/project.rb,
sig/services.rbs

Defined Under Namespace

Modules: _NativeWatcher Classes: Tree, Watcher

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root) ⇒ Project

Returns a new instance of Project.

Parameters:

  • root (String)

Raises:

  • (ArgumentError)


16
17
18
19
# File 'lib/canopus/project.rb', line 16

def initialize(root)
  @root = File.realpath(root)
  raise ArgumentError, "project root must be a directory" unless File.directory?(@root)
end

Instance Attribute Details

#root ⇒ String (readonly)

Returns the value of attribute root.

Returns:

  • (String)


14
15
16
# File 'lib/canopus/project.rb', line 14

def root
  @root
end

Instance Method Details

#files(include_hidden:, follow_symlinks:, include_symlinks:, include_directories:, extensions:, max_size:) ⇒ Enumerator[String, self] #files(include_hidden:, follow_symlinks:, include_symlinks:, include_directories:, extensions:, max_size:) ⇒ self

Paths are relative to root, sorted, and never include .git directories. Directory symlinks are opt-in and visited at most once per inode.

Overloads:

  • #files(include_hidden:, follow_symlinks:, include_symlinks:, include_directories:, extensions:, max_size:) ⇒ Enumerator[String, self]

    Parameters:

    • include_hidden: (Boolean)
    • follow_symlinks: (Boolean)
    • include_symlinks: (Boolean)
    • include_directories: (Boolean)
    • extensions: (Array[String], nil)
    • max_size: (Integer, nil)

    Returns:

    • (Enumerator[String, self])
  • #files(include_hidden:, follow_symlinks:, include_symlinks:, include_directories:, extensions:, max_size:) ⇒ self

    Parameters:

    • include_hidden: (Boolean)
    • follow_symlinks: (Boolean)
    • include_symlinks: (Boolean)
    • include_directories: (Boolean)
    • extensions: (Array[String], nil)
    • max_size: (Integer, nil)

    Returns:

    • (self)

Yields:

Yield Parameters:

  • arg0 (String)

Yield Returns:

  • (void)


29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/canopus/project.rb', line 29

def files(include_hidden: true, follow_symlinks: false, include_symlinks: false, include_directories: false, extensions: nil, max_size: nil)
  return enum_for(__method__, include_hidden: include_hidden, follow_symlinks: follow_symlinks,
    include_symlinks: include_symlinks, include_directories: include_directories, extensions: extensions, max_size: max_size) unless block_given?
  extensions = extensions&.map { |extension| extension.start_with?(".") ? extension : ".#{extension}" }&.to_set
  visited = Set.new
  base_rules = Thuban::IgnoreMatcher.new
  exclude = File.join(root, ".git", "info", "exclude")
  base_rules = base_rules.add(File.read(exclude), base: "") if File.file?(exclude)
  walk = lambda do |directory, rules|
    stat = File.stat(path(directory))
    return unless visited.add?([stat.dev, stat.ino])
    %w[.gitignore .ignore].each do |name|
      source = path(directory.empty? ? name : File.join(directory, name))
      rules = rules.add(File.read(source, encoding: "UTF-8"), base: directory) if File.file?(source)
    end
    Dir.children(path(directory)).sort.each do |name|
      next if name == ".git" || (!include_hidden && name.start_with?("."))
      relative = directory.empty? ? name : File.join(directory, name)
      next if relative == ".canopus/trash"
      absolute = path(relative)
      begin
        entry = File.lstat(absolute)
        symbolic = entry.symlink?
        if symbolic && !follow_symlinks
          next unless include_symlinks
          next if rules.ignored?(relative)
          next if extensions && !extensions.include?(File.extname(name))
          yield relative
          next
        end
        entry = File.stat(absolute) if symbolic
        next if rules.ignored?(relative, directory: entry.directory?)
        if entry.directory?
          yield relative + "/" if include_directories
          walk.call(relative, rules)
        elsif entry.file?
          next if max_size && entry.size > max_size
          next if extensions && !extensions.include?(File.extname(name))
          yield relative
        end
      rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
        next # An editor walk tolerates concurrent deletion and inaccessible files.
      end
    end
  end
  walk.call("", base_rules)
  self
end

#ignore_matcher ⇒ Thuban::IgnoreMatcher

Returns:

  • (Thuban::IgnoreMatcher)


96
97
98
# File 'lib/canopus/project.rb', line 96

def ignore_matcher
  Thuban::IgnoreMatcher.load(root, global: false).add("/.canopus/trash/\n")
end

#ignored?(relative, directory: File.directory?(path(relative))) ⇒ Boolean

Parameters:

  • relative (String)
  • directory: (Boolean) (defaults to: File.directory?(path(relative)))

Returns:

  • (Boolean)


78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/canopus/project.rb', line 78

def ignored?(relative, directory: File.directory?(path(relative)))
  pieces = relative.sub(%r{\A\./}, "").split("/")
  rules = Thuban::IgnoreMatcher.new
  base = ""
  exclude = File.join(root, ".git", "info", "exclude")
  rules = rules.add(File.read(exclude), base: "") if File.file?(exclude)
  pieces.each_with_index do |piece, index|
    return true if piece == ".git"
    %w[.gitignore .ignore].each do |name|
      source = path(base.empty? ? name : File.join(base, name))
      rules = rules.add(File.read(source), base: base) if File.file?(source)
    end
    base = base.empty? ? piece : "#{base}/#{piece}"
    return true if rules.ignored?(base, directory: index < pieces.length - 1 || directory)
  end
  false
end

#path(relative) ⇒ String

Parameters:

  • relative (String)

Returns:

  • (String)

Raises:

  • (ArgumentError)


21
22
23
24
25
# File 'lib/canopus/project.rb', line 21

def path(relative)
  absolute = File.expand_path(relative, root)
  raise ArgumentError, "path outside project" unless absolute == root || absolute.start_with?(root + File::SEPARATOR)
  absolute
end

#replace(pattern, replacement, workers: 4, max_size: 10 * 1024 * 1024, extensions: nil, limit: nil, case_sensitive: true, cancelled: nil, paths: nil) ⇒ Hash[String, Integer]

Each file is replaced atomically; a concurrent write aborts that file.

Parameters:

  • pattern (String, Regexp)
  • replacement (String)
  • workers: (Integer) (defaults to: 4)
  • max_size: (Integer, nil) (defaults to: 10 * 1024 * 1024)
  • extensions: (Array[String], nil) (defaults to: nil)
  • limit: (Integer, nil) (defaults to: nil)
  • case_sensitive: (Boolean) (defaults to: true)
  • cancelled: (^() -> bool) (defaults to: nil)
  • paths: (Array[String], nil) (defaults to: nil)

Returns:

  • (Hash[String, Integer])


103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/canopus/project.rb', line 103

def replace(pattern, replacement, workers: 4, max_size: 10 * 1024 * 1024, extensions: nil, limit: nil,
  case_sensitive: true, cancelled: nil, paths: nil)
  expression = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern), case_sensitive ? 0 : Regexp::IGNORECASE)
  matches = Alkaid::Search.new(root, pattern: expression, ignore: ignore_matcher, workers: workers,
    max_file_size: max_size, max_matches: limit, extensions: extensions, paths: paths, hidden: true,
    cancelled: cancelled).run
  paths = matches.map(&:path).uniq
  paths.to_h do |relative|
    absolute = path(relative)
    before = File.stat(absolute)
    text = File.read(absolute, encoding: "UTF-8")
    count, updated = Canopus.with_regexp_timeout(expression) do
      [text.scan(expression).length, text.gsub(expression, replacement)]
    end
    Tempfile.create([".canopus-", ".tmp"], File.dirname(absolute)) do |temp|
      temp.binmode
      temp.chmod(before.mode & 0o777)
      temp.write(updated)
      temp.flush
      temp.fsync
      temp.close
      current = File.stat(absolute)
      unless [before.ino, before.size, before.mtime, before.ctime] == [current.ino, current.size, current.mtime, current.ctime]
        raise IOError, "file changed during replacement: #{relative}"
      end
      File.rename(temp.path, absolute)
    end
    [relative, count]
  end
end

#watcher(**options) ⇒ Watcher

Parameters:

Returns:



100
# File 'lib/canopus/project.rb', line 100

def watcher(**options) = Watcher.new(self, **options)