countless 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'countless'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/countless.gemspec ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'countless/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'countless'
9
+ spec.version = Countless::VERSION
10
+ spec.authors = ['Hermann Mayer']
11
+ spec.email = ['hermann.mayer@hausgold.de']
12
+
13
+ spec.summary = 'Code statistics/annotations helpers'
14
+ spec.description = 'This gem includes reusable code statistics / ' \
15
+ 'annotations helpers / Rake tasks.'
16
+
17
+ spec.homepage = 'https://github.com/hausgold/countless'
18
+ spec.license = 'MIT'
19
+
20
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
21
+ f.match(%r{^(test|spec|features)/})
22
+ end + ['bin/cloc']
23
+ spec.bindir = 'exe'
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.required_ruby_version = '>= 2.5'
28
+
29
+ spec.add_runtime_dependency 'activesupport', '>= 5.2.0'
30
+ spec.add_runtime_dependency 'zeitwerk', '~> 2.4'
31
+
32
+ spec.add_development_dependency 'appraisal'
33
+ spec.add_development_dependency 'benchmark-ips', '~> 2.10'
34
+ spec.add_development_dependency 'bundler', '>= 1.16', '< 3'
35
+ spec.add_development_dependency 'guard-rspec', '~> 4.7'
36
+ spec.add_development_dependency 'irb', '~> 1.2'
37
+ spec.add_development_dependency 'rspec', '~> 3.9'
38
+ spec.add_development_dependency 'rubocop', '~> 1.25'
39
+ spec.add_development_dependency 'rubocop-rails', '~> 2.14'
40
+ spec.add_development_dependency 'rubocop-rspec', '~> 2.10'
41
+ spec.add_development_dependency 'simplecov', '< 0.18'
42
+ spec.add_development_dependency 'yard', '~> 0.9.18'
43
+ spec.add_development_dependency 'yard-activesupport-concern', '~> 0.0.1'
44
+ end
@@ -0,0 +1,68 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <svg
3
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
4
+ xmlns:cc="http://creativecommons.org/ns#"
5
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
6
+ xmlns:svg="http://www.w3.org/2000/svg"
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ version="1.1"
9
+ id="Ebene_1"
10
+ x="0px"
11
+ y="0px"
12
+ viewBox="0 0 800 200"
13
+ xml:space="preserve"
14
+ width="800"
15
+ height="200"><metadata
16
+ id="metadata33"><rdf:RDF><cc:Work
17
+ rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
18
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
19
+ id="defs31" />
20
+ <style
21
+ type="text/css"
22
+ id="style2">
23
+ .st0{fill-rule:evenodd;clip-rule:evenodd;fill:#E73E11;}
24
+ .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#0371B9;}
25
+ .st2{fill:#132E48;}
26
+ .st3{font-family:'OpenSans-Bold';}
27
+ .st4{font-size:29.5168px;}
28
+ .st5{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
29
+ .st6{opacity:0.5;fill:#132E48;}
30
+ .st7{font-family:'OpenSans';}
31
+ .st8{font-size:12px;}
32
+ </style>
33
+ <g
34
+ transform="translate(0,1.53584)"
35
+ id="g828"><g
36
+ transform="translate(35.93985,35.66416)"
37
+ id="g8">
38
+ <path
39
+ style="clip-rule:evenodd;fill:#e73e11;fill-rule:evenodd"
40
+ id="path4"
41
+ d="m -0.1,124.4 c 0,0 33.7,-123.2 66.7,-123.2 12.8,0 26.9,21.9 38.8,47.2 -23.6,27.9 -66.6,59.7 -94,76 -7.1,0 -11.5,0 -11.5,0 z"
42
+ class="st0" />
43
+ <path
44
+ style="clip-rule:evenodd;fill:#0371b9;fill-rule:evenodd"
45
+ id="path6"
46
+ d="m 88.1,101.8 c 13.5,-10.4 18.4,-16.2 27.1,-25.4 10,25.7 16.7,48 16.7,48 0,0 -41.4,0 -78,0 14.6,-7.9 18.7,-10.7 34.2,-22.6 z"
47
+ class="st1" />
48
+ </g><text
49
+ y="106.40316"
50
+ x="192.43155"
51
+ style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:29.51733398px;font-family:'Open Sans', sans-serif;-inkscape-font-specification:'OpenSans-Bold, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#132e48"
52
+ id="text10"
53
+ class="st2 st3 st4">Countless</text>
54
+ <rect
55
+ style="clip-rule:evenodd;fill:none;fill-rule:evenodd"
56
+ id="rect12"
57
+ height="24"
58
+ width="314.5"
59
+ class="st5"
60
+ y="118.06416"
61
+ x="194.23985" /><text
62
+ y="127.22146"
63
+ x="194.21715"
64
+ style="font-size:12px;font-family:'Open Sans', sans-serif;opacity:0.5;fill:#132e48;-inkscape-font-specification:'Open Sans, sans-serif, Normal';font-weight:normal;font-style:normal;font-stretch:normal;font-variant:normal;text-anchor:start;text-align:start;writing-mode:lr;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;"
65
+ id="text14"
66
+ class="st6 st7 st8">Code statistics/annotations helpers</text>
67
+ </g>
68
+ </svg>
@@ -0,0 +1,8 @@
1
+ version: "3"
2
+ services:
3
+ test:
4
+ image: ruby:2.7
5
+ network_mode: bridge
6
+ working_dir: /app
7
+ volumes:
8
+ - .:/app
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "5.2.0"
6
+ gem "activerecord", "5.2.0"
7
+ gem "activesupport", "5.2.0"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "6.0.0"
6
+ gem "activerecord", "6.0.0"
7
+ gem "activesupport", "6.0.0"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "6.1.0"
6
+ gem "activerecord", "6.1.0"
7
+ gem "activesupport", "6.1.0"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "7.0.0"
6
+ gem "activerecord", "7.0.0"
7
+ gem "activesupport", "7.0.0"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Countless
4
+ # Annotation objects are triplets +:line+, +:tag+, +:text+ that represent the
5
+ # line where the annotation lives, its tag, and its text. Note the filename
6
+ # is not stored.
7
+ #
8
+ # Annotations are looked for in comments and modulus whitespace they have to
9
+ # start with the tag optionally followed by a colon. Everything up to the end
10
+ # of the line (or closing ERB comment tag) is considered to be their text.
11
+ #
12
+ # Heavily stolen from: https://bit.ly/3nBS0aj
13
+ #
14
+ # rubocop:disable Metrics/ClassLength because of the nested Annotation class
15
+ class Annotations
16
+ attr_reader :tag, :options, :dirs, :files, :annotations
17
+
18
+ # Setup a new instance of the source annotation extractor.
19
+ #
20
+ # If +tag+ is +nil+, annotations with either default or registered tags are
21
+ # printed. Specific directories can be explicitly set using the +:dirs+
22
+ # key in +options+.
23
+ #
24
+ # Countless::SourceAnnotationExtractor.enumerate(
25
+ # 'TODO|FIXME', dirs: %w(app lib), tag: true
26
+ # )
27
+ #
28
+ # If +options+ has a +:tag+ flag, it will be passed to each annotation's
29
+ # +to_s+. See +#find_in+ for a list of file extensions that will be taken
30
+ # into account.
31
+ #
32
+ # @param tag [String, nil] the annotation tags to use
33
+ # @param options [Hash{Symbol => Mixed}] additional options
34
+ # @return [Countless::SourceAnnotationExtractor] the new instance
35
+ def initialize(tag = nil, options = {})
36
+ @tag = tag || Annotation.tags.join('|')
37
+ @dirs = options.delete(:dirs) || Annotation.directories
38
+ @files = options.delete(:files) || Annotation.files
39
+ @options = options
40
+ @annotations = find(dirs: dirs, files: files)
41
+ end
42
+
43
+ # Returns a hash that maps filenames under +dirs+ (recursively) to arrays
44
+ # with their annotations.
45
+ #
46
+ # @param files [Array<String>] the files to use
47
+ # @param dirs [Array<String>] the directories to use
48
+ # @return [Hash{String => Array<Annotation>}] the found annotations per file
49
+ def find(files: [], dirs: [])
50
+ results = {}
51
+ files.inject(results) { |memo, file| memo.update(annotations_in(file)) }
52
+ dirs.inject(results) { |memo, dir| memo.update(find_in(dir)) }
53
+ results
54
+ end
55
+
56
+ # Returns a hash that maps filenames under +dir+ (recursively) to arrays
57
+ # with their annotations. Files with extensions registered in
58
+ # +Countless::SourceAnnotationExtractor::Annotation.extensions+ are
59
+ # taken into account. Only files with annotations are included.
60
+ #
61
+ # @param dir [String] the directory to use
62
+ # @return [Hash{String => Array<Annotation>}] the found annotations per file
63
+ def find_in(dir)
64
+ results = {}
65
+
66
+ Dir.glob("#{dir}/*") do |item|
67
+ next if File.basename(item)[0] == '.'
68
+
69
+ if File.directory?(item)
70
+ results.update(find_in(item))
71
+ else
72
+ results.update(annotations_in(item))
73
+ end
74
+ end
75
+
76
+ results
77
+ end
78
+
79
+ # Returns a hash that maps filenames under +file+ (de-glob-bed) to arrays
80
+ # with their annotations. Files with extensions registered in
81
+ # +Countless::SourceAnnotationExtractor::Annotation.extensions+ are
82
+ # taken into account. Only files with annotations are included.
83
+ #
84
+ # @param file [String] the file to use
85
+ # @return [Hash{String => Array<Annotation>}] the found annotations per file
86
+ def annotations_in(file)
87
+ results = {}
88
+
89
+ Dir.glob(file) do |item|
90
+ extension = \
91
+ Annotation.extensions.detect { |regexp, _block| regexp.match(item) }
92
+
93
+ if extension
94
+ pattern = extension.last.call(tag)
95
+ results.update(extract_annotations_from(item, pattern)) if pattern
96
+ end
97
+ end
98
+
99
+ results
100
+ end
101
+
102
+ # If +file+ is the filename of a file that contains annotations this method
103
+ # returns a hash with a single entry that maps +file+ to an array of its
104
+ # annotations. Otherwise it returns an empty hash.
105
+ #
106
+ # @param file [String] the file path to extract annotations from
107
+ # @param pattern [RegExp] the matching pattern to use
108
+ # @return [Hash{String => Annotation}] the found annotation of the file
109
+ def extract_annotations_from(file, pattern)
110
+ lineno = 0
111
+ result = File.readlines(
112
+ file, encoding: Encoding::BINARY
113
+ ).inject([]) do |list, line|
114
+ lineno += 1
115
+ next list unless line =~ pattern
116
+
117
+ list << Annotation.new(lineno, Regexp.last_match(1),
118
+ Regexp.last_match(2))
119
+ end
120
+ result.empty? ? {} : { file => result }
121
+ end
122
+
123
+ # Formats the found annotations.
124
+ #
125
+ # @return [String] the formatted annotations
126
+ #
127
+ # rubocop:disable Metrics/AbcSize because of the indentation logic
128
+ def to_s
129
+ buf = []
130
+ options[:indent] = annotations.flat_map do |_f, a|
131
+ a.map(&:line)
132
+ end.max.to_s.size
133
+ annotations.keys.sort.each do |file|
134
+ buf << "#{file}:"
135
+ annotations[file].each { |note| buf << " * #{note.to_s(options)}" }
136
+ buf << ''
137
+ end
138
+ buf.join("\n")
139
+ end
140
+ # rubocop:enable Metrics/AbcSize
141
+
142
+ # A single annotation representation.
143
+ Annotation = Struct.new(:line, :tag, :text) do
144
+ # Returns the currently configured files.
145
+ #
146
+ # @return [Array<String>] the configured files
147
+ def self.files
148
+ @files ||= \
149
+ Countless.configuration.annotations_files.deep_dup
150
+ end
151
+
152
+ # Registers additional files to be included.
153
+ #
154
+ # @param dirs [Array<String>] the additional files to include
155
+ def self.register_files(*dirs)
156
+ files.push(*dirs)
157
+ end
158
+
159
+ # Returns the currently configured directories.
160
+ #
161
+ # @return [Array<String>] the configured directories
162
+ def self.directories
163
+ @directories ||= \
164
+ Countless.configuration.annotations_directories.deep_dup
165
+ end
166
+
167
+ # Registers additional directories to be included.
168
+ #
169
+ # @param dirs [Array<String>] the additional directories to include
170
+ def self.register_directories(*dirs)
171
+ directories.push(*dirs)
172
+ end
173
+
174
+ # Returns the currently configured tags.
175
+ #
176
+ # @return [Array<String>] the configured tags
177
+ def self.tags
178
+ @tags ||= Countless.configuration.annotation_tags.deep_dup.map do |tag|
179
+ "@?#{tag}"
180
+ end
181
+ end
182
+
183
+ # Registers additional tags.
184
+ #
185
+ # @param additional_tags [Array<String>] the additional tags to include
186
+ def self.register_tags(*additional_tags)
187
+ tags.push(*additional_tags)
188
+ end
189
+
190
+ # Returns the currently configured file extension handlers.
191
+ #
192
+ # @return [Hash<RegExp => Proc>] the configured file extension handlers
193
+ def self.extensions
194
+ @extensions ||= begin
195
+ patterns = Countless.configuration.annotation_patterns.values
196
+ patterns.map do |conf|
197
+ [
198
+ extensions_regexp(conf[:extensions], conf[:files] || []),
199
+ conf[:regex]
200
+ ]
201
+ end.to_h
202
+ end
203
+ end
204
+
205
+ # Registers new annotations file extension handlers.
206
+ #
207
+ # @param exts [Array<String>] the file extensions to match
208
+ # @param block [Proc] the line/comment/annotation matching block
209
+ def self.register_extensions(*exts, &block)
210
+ extensions[extensions_regexp(exts)] = block
211
+ end
212
+
213
+ # Build a new extension regexp of the given extensions.
214
+ #
215
+ # @param exts [Array<String>] the file extensions to join
216
+ # @param files [Array<String>] a list of dedicated files
217
+ # @return [RegExp] the extensions matching regexp
218
+ def self.extensions_regexp(exts, files = [])
219
+ exts = /\.(#{exts.join('|')})$/
220
+ return exts if files.empty?
221
+
222
+ Regexp.union(/^#{files.join('|')}$/, exts)
223
+ end
224
+
225
+ # Returns a representation of the annotation that looks like this:
226
+ #
227
+ # [126] [TODO] This algorithm is nice and simple, make it faster.
228
+ #
229
+ # If +options+ has a flag +:tag+ the tag is shown as in the example
230
+ # above. Otherwise the string contains just line and text. When
231
+ # +options+ has a value for +:indent+ the line number block will be
232
+ # right-justified.
233
+ #
234
+ # @param options [Hash{Symbol => Mixed}] the additional options
235
+ def to_s(options = {})
236
+ s = +"[#{line.to_s.rjust(options[:indent])}] "
237
+ s << "[#{tag}] " if options[:tag]
238
+ s << text
239
+ end
240
+ end
241
+ end
242
+ # rubocop:enable Metrics/ClassLength
243
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Countless
4
+ # A simple wrapper around the CLOC utility.
5
+ class Cloc
6
+ class << self
7
+ # Extract code statistics from the given files with CLOC. Each key of the
8
+ # resulting hash is the file path which was inspected. Each value of the
9
+ # resulting hash contains the raw statistic numbers (blank, comment,
10
+ # code, total).
11
+ #
12
+ # Example:
13
+ #
14
+ # {
15
+ # "/app/lib/countless/configuration.rb" => {
16
+ # :blank=>24, :comment=>43, :code=>141, :total=>208
17
+ # }
18
+ # }
19
+ #
20
+ # @return [Hash{String => Hash{Symbol => Integer}}] the
21
+ # re-structured CLOC statistics
22
+ def stats(*paths)
23
+ raw_stats(*paths).except('SUM', 'header').transform_values do |obj|
24
+ obj.symbolize_keys.except(:language).tap do |stats|
25
+ stats[:total] = stats.values.sum
26
+ end
27
+ end
28
+ end
29
+
30
+ # Fetch the raw statistics via CLOC for the given paths.
31
+ #
32
+ # @param paths [Array<String, Pathname>] the paths (files or
33
+ # directories) to fetch the statistics for
34
+ # @return [Hash{String => Hash{String => Mixed}] the raw CLOC
35
+ # YAML output
36
+ #
37
+ # rubocop:disable Metrics/MethodLength because of the system
38
+ # command preparation
39
+ def raw_stats(*paths)
40
+ cmd = [
41
+ Countless.cloc_path,
42
+ '--quiet',
43
+ '--by-file',
44
+ '--yaml',
45
+ '--list-file -',
46
+ '2>/dev/null'
47
+ ].join(' ')
48
+
49
+ # We pipe in the file list via stdin to cloc, this allows us to
50
+ # pass large file lists down (ARGV is size limited)
51
+ stdout = IO.popen(cmd, File::RDWR) do |io|
52
+ paths.each { |path| io.puts(path) }
53
+ io.close_write
54
+ io.read
55
+ end
56
+
57
+ # When the system command was not successful,
58
+ # we return an fallback result
59
+ return {} unless $CHILD_STATUS.success?
60
+
61
+ # Otherwise we use the CLOC produced YAML and parse it
62
+ YAML.safe_load(stdout) || {}
63
+ end
64
+ # rubocop:enable Metrics/MethodLength
65
+ end
66
+ end
67
+ end