todoloo 0.0.0 → 0.0.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee1e4b7b084c80e186f67664ee9b885e6d6c4f87030b4afd9b6be1e526166c7c
4
- data.tar.gz: 0277403f2d2a3d9950134dc6d95b5342d34c6fd0f080d20e54080ba0ffc1ffbb
3
+ metadata.gz: 3e10a05abef8c425b5e2e9347d1bccc9e592f5e7a03a5608f84aa5495cfdf45e
4
+ data.tar.gz: 3e0febaa61ee4a872d045d8df00f9b49e17716ae3384b52a1aad8e0efbd4ec68
5
5
  SHA512:
6
- metadata.gz: e672a59673b228e6cb71c2bf82fdff2c1de91ff6fbc070ae82c02b9e607e28e2a5d8ba0d053ed83d579c8bb2ae83f6cb86d3c1495ca5f822a9659d43081d969d
7
- data.tar.gz: eb1c5f51d59b62e9a90956f6bf0cea66545937da04edb9b2d79a544b391d0de4d376978e581f7076ee1310b023640e0f3539a450a682c5f7799a4134c7e98a0a
6
+ metadata.gz: aefc739a86f6120a9383f3edec5b84433498cdcfe747fa40fd72654fc61ab4925137be92402c2303e37db9ead9b632837171a58d9c1142fdc80fa664deb43387
7
+ data.tar.gz: b0862355aa8fdb6bbfc4c405f8b0f83fd84a489f930c9aca3728bbae1b9888481bde8f34c1d3ed9632934068c685d84302e3f9df995dfea209b3573ddcef3033
data/CHANGELOG ADDED
@@ -0,0 +1,3 @@
1
+ 0.0.2 - 19 July 2024
2
+ - Add regression for `Todoloo::TaskList` on writing tasks with blank topics
3
+
data/exe/todoloo ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require "todoloo"
3
+
4
+ Todoloo::CLI.start
@@ -0,0 +1,27 @@
1
+ require "thor"
2
+
3
+ class Todoloo::CLI < Thor
4
+ package_name "Todoloo"
5
+
6
+ desc "scan", "Scans all files that match the given globs and outputs a tasks.yml"
7
+ method_option :exclude, type: :array, aliases: "-e", desc: "List of path globs to exclude"
8
+ def scan
9
+ Todoloo::FileScanner
10
+ .new("**/*.rb", excludes: options[:exclude] || [], trace: true)
11
+ .scan
12
+ .write("tasks.yml")
13
+ end
14
+
15
+ desc "io", "Reads input from stdio and writes to stdout"
16
+ def io
17
+ Todoloo::TaskList.new.add(
18
+ Todoloo::Parser
19
+ .new
20
+ .parse_and_transform($stdin.read)
21
+ ).write($stdout)
22
+ end
23
+
24
+ def self.exit_on_failure?
25
+ true
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ module Todoloo
2
+ class ErrorHandler
3
+ def initialize(kind)
4
+ @handler = new_handler_proc(kind)
5
+ end
6
+
7
+ def call(error, original_exception: nil)
8
+ # TODO(errors): Handle `original_exception`
9
+ @handler.call(error)
10
+ end
11
+
12
+ private
13
+
14
+ def new_handler_proc(kind)
15
+ case kind
16
+ when :raise
17
+ proc { |e| raise e }
18
+ when :stderr, :log, :trace
19
+ new_handler($stderr)
20
+ when String
21
+ File.open(kind, "w")
22
+ else
23
+ if kind.respond_to?(:write)
24
+ proc do |e|
25
+ if e.is_a?(Error)
26
+ kind.puts(e.message)
27
+ kind.puts(e.backtrace) if e.backtrace && !e.backtrace.empty?
28
+ else
29
+ kind.puts(e)
30
+ end
31
+ end
32
+ elsif kind.respond_to?(:call)
33
+ kind
34
+ else
35
+ raise ArgumentError, "Unsupported type for ErrorHandler: #{kind}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ require "concurrent-ruby"
2
+ require "parallel"
3
+
4
+ # File scanner for finding todos
5
+ class Todoloo::FileScanner
6
+ def initialize(pattern, excludes: [], trace: nil)
7
+ @parsers = Concurrent::Hash.new
8
+
9
+ @pattern = pattern
10
+
11
+ @excludes = excludes
12
+
13
+ @trace_output = case trace
14
+ when nil, false
15
+ nil
16
+ when true
17
+ $stderr
18
+ when String
19
+ File.open(trace, "w")
20
+ else
21
+ raise ArgumentError, "Invalid value #{trace} for argument trace"
22
+ end
23
+ end
24
+
25
+ # @return [Todoloo::TaskList]
26
+ def scan
27
+ Todoloo::TaskList.new.add(scan_files)
28
+ end
29
+
30
+ private
31
+
32
+ def parser
33
+ (@parsers[Parallel.worker_number] ||= Todoloo::Parser.new)
34
+ end
35
+
36
+ # @return [Array<Array<Task>>]
37
+ def scan_files
38
+ Parallel.map(Todoloo::Helpers.glob_paths(@pattern, excludes: @excludes)) do |path|
39
+ trace { "* Scan #{path}" }
40
+
41
+ parser.parse_and_transform(File.read(path), path: path)
42
+ end
43
+ end
44
+
45
+ def trace(&block)
46
+ return unless @trace_output
47
+
48
+ @trace_output.puts(block.call)
49
+ end
50
+ end
@@ -0,0 +1,24 @@
1
+ module Todoloo
2
+ module Helpers
3
+ extend self
4
+ # Returns an `Enumerator` over the paths that match `pattern`
5
+ #
6
+ # TODO We need to optimize how we traverse the file-system so that we can short-circuit the excludes instead
7
+ # of only filtering after finding them.
8
+ #
9
+ # @pattern [String]
10
+ # The file paths to glob
11
+ #
12
+ # @excludes [Array<String>]
13
+ # Optional list of file globs to exclude from the enumeration
14
+ #
15
+ # @return [Enumerator<String>]
16
+ def glob_paths(pattern, excludes: [])
17
+ Enumerator.new do |y|
18
+ Dir[pattern].lazy.each do |path|
19
+ y << path unless excludes.any? { |e| File.fnmatch(e, path) }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,159 @@
1
+ module Todoloo
2
+ # Read https://kschiess.github.io/parslet/get-started.html
3
+ class Parser < Parslet::Parser
4
+ TASK_TYPES = %w[TODO NOTE FIXME HBD HACK XXX]
5
+ root :file
6
+
7
+ # Returns a list of tasks
8
+ def parse_and_transform(text, path: "")
9
+ tree = parse(text)
10
+
11
+ pp tree if Todoloo.debug?
12
+
13
+ Transformer.new.apply(tree).map do |hash|
14
+ line, column = hash.fetch(:type).line_and_column
15
+
16
+ Task.from_hash(hash.merge(path: path, line: line, column: column))
17
+ end
18
+ end
19
+
20
+ def safe_parse(text)
21
+ parse(text)
22
+ rescue Parslet::ParseFailed => e
23
+ puts e.parse_failure_cause.ascii_tree
24
+ nil
25
+ end
26
+
27
+ # Extension to automatically define `?` predicate rules
28
+ def self.rule(name, *args, **kwargs, &block)
29
+ if name.to_s.end_with?("?")
30
+ super
31
+ else
32
+ super # Define the original rule
33
+ super("#{name}?") { public_send(name).maybe }
34
+ end
35
+ end
36
+
37
+ # Single char rules
38
+ rule(:lparen) { str("(") }
39
+ rule(:rparen) { str(")") }
40
+
41
+ rule(:eof) { any.absent? }
42
+
43
+ rule(:eol) { str("\n") }
44
+
45
+ rule(:space) { match(/[ \t]/) }
46
+
47
+ rule(:code_text) { match(/[^#\n]/) }
48
+
49
+ # FIXME: Cannot handle JS comments yet
50
+ rule(:comment_char) { str("#") } # || str("//") }
51
+
52
+ # Can read all the way without honoring anything special,
53
+ # since its use will only be when the comment has already started
54
+ rule(:comment_text) { match(/[^\n]/) }
55
+
56
+ # Parts
57
+ rule(:spacing) { space.repeat(1) }
58
+
59
+ rule(:code) { code_text.repeat(1).as(:code) }
60
+
61
+ rule(:line) { (comment | code >> comment | code).as(:line) }
62
+
63
+ rule(:file) { (line >> eol | eol | line >> eof).repeat.as(:file) }
64
+
65
+ rule(:comment) { (comment_start >> (task.as(:task) | line).maybe).as(:comment) }
66
+
67
+ rule(:comment_start) do
68
+ comment_char.capture(:comment_start) >> spacing?
69
+ end
70
+
71
+ # examples
72
+ # TODO(topic): My task
73
+ # TODO: My task
74
+ # TODO: My task
75
+ rule(:task) { task_type.as(:type).capture(:type) >> topics.repeat(0, 1).as(:topics) >> task_separator? >> spacing? >> description.repeat(0, 1).as(:description) }
76
+
77
+ rule(:description) { multiline_description | single_line_description }
78
+
79
+ rule(:description_text) { comment_text.repeat(1) }
80
+
81
+ rule(:single_line_description) { description_text.as(:text) }
82
+
83
+ rule(:multiline_description) do
84
+ (description_text >> comment_continuation.repeat(1)).as(:text)
85
+ end
86
+
87
+ MIN_INDENTATION_BEYOND_TYPE_START = 1
88
+ def comment_continuation
89
+ dynamic do |_source, context|
90
+ parser = match('[\n]')
91
+
92
+ comment_start = column_offset_of_capture(:comment_start, context)
93
+
94
+ # Match any non comment content before the comment starts
95
+ parser = ignore_code_text(parser, comment_start)
96
+
97
+ # Consume the characther that the comment opened with, like a `#` or `//`
98
+ parser >>= str(context.captures[:comment_start]).ignore
99
+
100
+ parser >>= (spacing? >> task_type).absent?
101
+
102
+ required_indentation = column_offset_of_capture(:type, context) - comment_start - context.captures[:comment_start].length + MIN_INDENTATION_BEYOND_TYPE_START
103
+
104
+ parser = indentation(parser, required_indentation)
105
+
106
+ # parser >>= task_type.absent?
107
+
108
+ parser >>= description_text
109
+ parser
110
+ end
111
+ end
112
+
113
+ # Calculates zero-based column offset for the given capture.
114
+ # Use only within dynamic blocks
115
+ def column_offset_of_capture(name, context)
116
+ case name
117
+ when :type
118
+ # FIXME: Not sure yet why this is even necessary...
119
+ _, col_index = context.captures[:type][:type].line_and_column
120
+ else
121
+ _, col_index = context.captures[name].line_and_column
122
+ end
123
+
124
+ col_index - 1
125
+ end
126
+
127
+ def ignore_code_text(parser, count)
128
+ if count.positive?
129
+ parser >> code_text.repeat(count, count).ignore
130
+ else
131
+ parser
132
+ end
133
+ end
134
+
135
+ def indentation(parser, count)
136
+ return parser unless count.positive?
137
+
138
+ parser >> space.repeat(count, count)
139
+ end
140
+
141
+ rule(:task_type) do
142
+ TASK_TYPES
143
+ .map { |t| str(t) }
144
+ .reduce do |result, partial_matcher|
145
+ result | partial_matcher
146
+ end
147
+ end
148
+
149
+ rule(:topic) { match(/[^),]/).repeat(1).as(:topic) }
150
+
151
+ rule(:topic_rest) { (str(",") >> spacing.maybe >> topic).repeat(0, 1) }
152
+
153
+ # (topic1, topic2)
154
+ rule(:topics) { lparen >> topic >> topic_rest >> rparen }
155
+
156
+ rule(:task_separator) { str(":") }
157
+ rule(:task_separator?) { task_separator.maybe }
158
+ end
159
+ end
@@ -0,0 +1,14 @@
1
+ module Todoloo
2
+ class Task < Struct.new(:type, :topics, :description, :path, :line, :column)
3
+ def self.from_hash(hash)
4
+ new(
5
+ hash.fetch(:type),
6
+ hash.fetch(:topics),
7
+ hash.fetch(:description),
8
+ hash.fetch(:path),
9
+ hash.fetch(:line),
10
+ hash.fetch(:column)
11
+ )
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ require "psych"
2
+ # A string that is written to YAML as a folded string by default
3
+ class Todoloo::TaskList::FoldedString
4
+ def initialize(string)
5
+ @string = string
6
+ end
7
+
8
+ def encode_with(coder)
9
+ coder.style = Psych::Nodes::Scalar::LITERAL
10
+ coder.scalar = @string
11
+ coder.tag = nil
12
+ end
13
+ end
@@ -0,0 +1,74 @@
1
+ require "yaml"
2
+
3
+ module Todoloo
4
+ class TaskList
5
+ def initialize
6
+ @tasks = []
7
+ end
8
+
9
+ def add(task)
10
+ if task.is_a?(Array)
11
+ task.each { |t| add(t) }
12
+ else
13
+ raise ArgumentError, "Task type must be Todoloo::Task: #{task.inspect}" unless task.is_a?(Todoloo::Task)
14
+ @tasks << task
15
+ end
16
+
17
+ self
18
+ end
19
+
20
+ # Structure:
21
+ # TOPIC:
22
+ # TYPE:
23
+ # - description:
24
+ # path:
25
+ def write(path, error: :raise)
26
+ output = YAML.dump(
27
+ converted_tasks(
28
+ error_handler: ErrorHandler.new(error)
29
+ )
30
+ )
31
+
32
+ if path.is_a?(String)
33
+ File.write(path, output)
34
+ else
35
+ path.write(output)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def topics_for_task(task)
42
+ if task.topics.empty?
43
+ [""]
44
+ else
45
+ task.topics
46
+ end
47
+ end
48
+
49
+ def converted_tasks(error_handler)
50
+ output = {}
51
+
52
+ @tasks.each do |task|
53
+ output_task = {
54
+ "description" => FoldedString.new(task.description.to_s),
55
+ "path" => "#{task.path}:#{task.line}:#{task.column}"
56
+ }
57
+
58
+ topics_for_task(task).each do |topic|
59
+ by_type = output[topic.to_s] ||= {}
60
+ tasks = by_type[task.type.to_s.downcase] ||= []
61
+ tasks << output_task.dup
62
+ end
63
+ rescue Error => e
64
+ if task.respond_to?(:line) && task.respond_to?(:column)
65
+ error_handler.call("#{path}:#{task.line}:#{task.column}: Error: #{e}", original_exception: e)
66
+ else
67
+ error_handler.call("#{path}:??:??:Error: #{e}", original_exception: e)
68
+ end
69
+ end
70
+
71
+ output
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,28 @@
1
+ module Todoloo
2
+ class Transformer < Parslet::Transform
3
+ rule(file: subtree(:x)) { x.compact }
4
+
5
+ rule(lines: subtree(:x)) { x }
6
+
7
+ rule(line: {code: subtree(:x)}) { nil }
8
+
9
+ rule(line: {code: subtree(:x), comment: subtree(:y)}) { y }
10
+
11
+ # Discard lines with only code and comments without tasks
12
+ rule(line: {code: subtree(:x), comment: simple(:y)}) { nil }
13
+
14
+ rule(line: {comment: subtree(:x)}) { x }
15
+
16
+ rule(line: {comment: simple(:x)}) { nil }
17
+
18
+ rule(type: simple(:type), topics: sequence(:topics), description: sequence(:description)) do
19
+ {type: type, topics: topics, description: description.empty? ? "" : (raise "must be empty")}
20
+ end
21
+
22
+ rule(topic: simple(:x)) { x }
23
+
24
+ rule(task: subtree(:task)) { task }
25
+
26
+ rule([{text: simple(:x)}]) { x }
27
+ end
28
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Todoloo
4
- VERSION = "0.0.0"
4
+ VERSION = "0.0.2"
5
5
  end
data/lib/todoloo.rb CHANGED
@@ -1,7 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "todoloo/version"
4
+
5
+ require "parslet"
6
+
7
+ require "zeitwerk"
8
+
9
+ loader = Zeitwerk::Loader.for_gem
10
+
11
+ loader.inflector.inflect "cli" => "CLI"
12
+
13
+ loader.setup # ready!
14
+
4
15
  module Todoloo
5
16
  class Error < StandardError; end
6
17
  # Your code goes here...
18
+
19
+ class << self
20
+ attr_accessor :debug
21
+
22
+ def debug?
23
+ @debug
24
+ end
25
+ end
7
26
  end
27
+
28
+ Todoloo.debug = false
metadata CHANGED
@@ -1,31 +1,124 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: todoloo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Coetzee
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-18 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2024-07-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.3.3
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.3.3
27
+ - !ruby/object:Gem::Dependency
28
+ name: parallel
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.25.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.25.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: parslet
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.0.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.0.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: psych
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 5.1.2
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 5.1.2
69
+ - !ruby/object:Gem::Dependency
70
+ name: thor
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.3.1
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.3.1
83
+ - !ruby/object:Gem::Dependency
84
+ name: zeitwerk
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
13
97
  description: A tool for managing and saying bye to todos
14
98
  email:
15
99
  - chriscz93@gmail.com
16
- executables: []
100
+ executables:
101
+ - todoloo
17
102
  extensions: []
18
103
  extra_rdoc_files: []
19
104
  files:
20
- - ".ruby-version"
105
+ - CHANGELOG
21
106
  - CODE_OF_CONDUCT.md
22
107
  - LICENSE.txt
23
- - Makefile
24
108
  - README.md
25
109
  - Rakefile
110
+ - exe/todoloo
26
111
  - lib/todoloo.rb
112
+ - lib/todoloo/cli.rb
113
+ - lib/todoloo/error_handler.rb
114
+ - lib/todoloo/file_scanner.rb
115
+ - lib/todoloo/helpers.rb
116
+ - lib/todoloo/parser.rb
117
+ - lib/todoloo/task.rb
118
+ - lib/todoloo/task_list.rb
119
+ - lib/todoloo/task_list/folded_string.rb
120
+ - lib/todoloo/transformer.rb
27
121
  - lib/todoloo/version.rb
28
- - sig/todoloo.rbs
29
122
  homepage: https://github.com/chriscz/todoloo
30
123
  licenses:
31
124
  - MIT
data/.ruby-version DELETED
@@ -1 +0,0 @@
1
- 3.2.2
data/Makefile DELETED
@@ -1,8 +0,0 @@
1
- test:
2
- bundle exec ruby test.rb
3
-
4
- format:
5
- rubocop -a *.rb
6
-
7
- scan:
8
- bundle exec ruby main.rb scan
data/sig/todoloo.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Todoloo
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end