countless 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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