todoloo 0.0.0 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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