foxtail-tools 0.5.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +66 -0
  5. data/exe/foxtail +12 -0
  6. data/lib/foxtail/cli/commands/check.rb +60 -0
  7. data/lib/foxtail/cli/commands/dump.rb +43 -0
  8. data/lib/foxtail/cli/commands/ids.rb +73 -0
  9. data/lib/foxtail/cli/commands/tidy.rb +107 -0
  10. data/lib/foxtail/cli.rb +59 -0
  11. data/lib/foxtail/syntax/error.rb +8 -0
  12. data/lib/foxtail/syntax/parser/ast/annotation.rb +23 -0
  13. data/lib/foxtail/syntax/parser/ast/attribute.rb +23 -0
  14. data/lib/foxtail/syntax/parser/ast/base_comment.rb +19 -0
  15. data/lib/foxtail/syntax/parser/ast/base_literal.rb +24 -0
  16. data/lib/foxtail/syntax/parser/ast/base_node.rb +89 -0
  17. data/lib/foxtail/syntax/parser/ast/call_arguments.rb +23 -0
  18. data/lib/foxtail/syntax/parser/ast/comment.rb +13 -0
  19. data/lib/foxtail/syntax/parser/ast/function_reference.rb +23 -0
  20. data/lib/foxtail/syntax/parser/ast/group_comment.rb +13 -0
  21. data/lib/foxtail/syntax/parser/ast/identifier.rb +19 -0
  22. data/lib/foxtail/syntax/parser/ast/junk.rb +23 -0
  23. data/lib/foxtail/syntax/parser/ast/message.rb +28 -0
  24. data/lib/foxtail/syntax/parser/ast/message_reference.rb +23 -0
  25. data/lib/foxtail/syntax/parser/ast/named_argument.rb +23 -0
  26. data/lib/foxtail/syntax/parser/ast/number_literal.rb +24 -0
  27. data/lib/foxtail/syntax/parser/ast/pattern.rb +22 -0
  28. data/lib/foxtail/syntax/parser/ast/placeable.rb +21 -0
  29. data/lib/foxtail/syntax/parser/ast/resource.rb +55 -0
  30. data/lib/foxtail/syntax/parser/ast/resource_comment.rb +13 -0
  31. data/lib/foxtail/syntax/parser/ast/select_expression.rb +23 -0
  32. data/lib/foxtail/syntax/parser/ast/span.rb +22 -0
  33. data/lib/foxtail/syntax/parser/ast/string_literal.rb +45 -0
  34. data/lib/foxtail/syntax/parser/ast/syntax_node.rb +22 -0
  35. data/lib/foxtail/syntax/parser/ast/term.rb +28 -0
  36. data/lib/foxtail/syntax/parser/ast/term_reference.rb +25 -0
  37. data/lib/foxtail/syntax/parser/ast/text_element.rb +19 -0
  38. data/lib/foxtail/syntax/parser/ast/variable_reference.rb +21 -0
  39. data/lib/foxtail/syntax/parser/ast/variant.rb +25 -0
  40. data/lib/foxtail/syntax/parser/ast.rb +12 -0
  41. data/lib/foxtail/syntax/parser/parse_error.rb +94 -0
  42. data/lib/foxtail/syntax/parser/stream.rb +338 -0
  43. data/lib/foxtail/syntax/parser.rb +797 -0
  44. data/lib/foxtail/syntax/serializer.rb +242 -0
  45. data/lib/foxtail/syntax/visitor.rb +61 -0
  46. data/lib/foxtail/syntax.rb +12 -0
  47. data/lib/foxtail/tools/error.rb +8 -0
  48. data/lib/foxtail/tools/version.rb +9 -0
  49. data/lib/foxtail-tools.rb +22 -0
  50. metadata +141 -0
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Syntax
5
+ # Serializes AST nodes back to FTL format
6
+ class Serializer
7
+ # Create a new Serializer instance
8
+ # @param with_junk [Boolean] Whether to include junk entries in output (default: false)
9
+ def initialize(with_junk: false)
10
+ @with_junk = with_junk
11
+ end
12
+
13
+ # @return [Boolean] Whether to include junk entries in output
14
+ def with_junk? = @with_junk
15
+
16
+ # Serialize a Resource AST to FTL string
17
+ # @param resource [Parser::AST::Resource] Resource to serialize
18
+ # @return [String] FTL formatted source text
19
+ def serialize(resource)
20
+ has_entries = false
21
+ parts = []
22
+
23
+ resource.body.each do |entry|
24
+ next if entry.is_a?(Parser::AST::Junk) && !with_junk?
25
+
26
+ parts << serialize_entry(entry, has_entries:)
27
+ has_entries = true
28
+ end
29
+
30
+ parts.join
31
+ end
32
+
33
+ # Serialize a single entry (Message, Term, Comment, or Junk)
34
+ # @param entry [Parser::AST::Message, Parser::AST::Term, Parser::AST::BaseComment, Parser::AST::Junk] Entry to serialize
35
+ # @param has_entries [Boolean] Whether previous entries exist (for spacing)
36
+ # @return [String] FTL formatted entry text
37
+ def serialize_entry(entry, has_entries: false)
38
+ case entry
39
+ when Parser::AST::Message
40
+ serialize_message(entry)
41
+ when Parser::AST::Term
42
+ serialize_term(entry)
43
+ when Parser::AST::Comment
44
+ serialize_standalone_comment(entry, "#", has_entries)
45
+ when Parser::AST::GroupComment
46
+ serialize_standalone_comment(entry, "##", has_entries)
47
+ when Parser::AST::ResourceComment
48
+ serialize_standalone_comment(entry, "###", has_entries)
49
+ when Parser::AST::Junk
50
+ serialize_junk(entry)
51
+ else
52
+ raise ArgumentError, "Unknown entry type: #{entry.class}"
53
+ end
54
+ end
55
+
56
+ private def serialize_standalone_comment(comment, prefix, has_entries)
57
+ result = serialize_comment(comment, prefix)
58
+ if has_entries
59
+ "\n#{result}\n"
60
+ else
61
+ "#{result}\n"
62
+ end
63
+ end
64
+
65
+ private def serialize_comment(comment, prefix="#")
66
+ prefixed = comment.content.split("\n").map {|line|
67
+ line.empty? ? prefix : "#{prefix} #{line}"
68
+ }.join("\n")
69
+ "#{prefixed}\n"
70
+ end
71
+
72
+ private def serialize_junk(junk) = junk.content
73
+
74
+ private def serialize_message(message)
75
+ parts = []
76
+ parts << serialize_comment(message.comment) if message.comment
77
+ parts << "#{message.id.name} ="
78
+ parts << serialize_pattern(message.value) if message.value
79
+ message.attributes.each {|attr| parts << serialize_attribute(attr) }
80
+ parts << "\n"
81
+ parts.join
82
+ end
83
+
84
+ private def serialize_term(term)
85
+ parts = []
86
+ parts << serialize_comment(term.comment) if term.comment
87
+ parts << "-#{term.id.name} ="
88
+ parts << serialize_pattern(term.value)
89
+ term.attributes.each {|attr| parts << serialize_attribute(attr) }
90
+ parts << "\n"
91
+ parts.join
92
+ end
93
+
94
+ private def serialize_attribute(attribute)
95
+ value = indent_except_first_line(serialize_pattern(attribute.value))
96
+ "\n .#{attribute.id.name} =#{value}"
97
+ end
98
+
99
+ private def serialize_pattern(pattern)
100
+ content = pattern.elements.map {|elem| serialize_element(elem) }.join
101
+
102
+ if should_start_on_new_line?(pattern)
103
+ "\n #{indent_except_first_line(content)}"
104
+ else
105
+ " #{indent_except_first_line(content)}"
106
+ end
107
+ end
108
+
109
+ private def serialize_element(element)
110
+ case element
111
+ when Parser::AST::TextElement
112
+ element.value
113
+ when Parser::AST::Placeable
114
+ serialize_placeable(element)
115
+ else
116
+ raise ArgumentError, "Unknown element type: #{element.class}"
117
+ end
118
+ end
119
+
120
+ private def serialize_placeable(placeable)
121
+ expr = placeable.expression
122
+ case expr
123
+ when Parser::AST::Placeable
124
+ "{#{serialize_placeable(expr)}}"
125
+ when Parser::AST::SelectExpression
126
+ "{ #{serialize_expression(expr)}}"
127
+ else
128
+ "{ #{serialize_expression(expr)} }"
129
+ end
130
+ end
131
+
132
+ private def serialize_expression(expr)
133
+ case expr
134
+ when Parser::AST::StringLiteral
135
+ "\"#{expr.value}\""
136
+ when Parser::AST::NumberLiteral
137
+ expr.value
138
+ when Parser::AST::VariableReference
139
+ "$#{expr.id.name}"
140
+ when Parser::AST::TermReference
141
+ serialize_term_reference(expr)
142
+ when Parser::AST::MessageReference
143
+ serialize_message_reference(expr)
144
+ when Parser::AST::FunctionReference
145
+ "#{expr.id.name}#{serialize_call_arguments(expr.arguments)}"
146
+ when Parser::AST::SelectExpression
147
+ serialize_select_expression(expr)
148
+ when Parser::AST::Placeable
149
+ serialize_placeable(expr)
150
+ else
151
+ raise ArgumentError, "Unknown expression type: #{expr.class}"
152
+ end
153
+ end
154
+
155
+ private def serialize_term_reference(ref)
156
+ out = "-#{ref.id.name}"
157
+ out += ".#{ref.attribute.name}" if ref.attribute
158
+ out += serialize_call_arguments(ref.arguments) if ref.arguments
159
+ out
160
+ end
161
+
162
+ private def serialize_message_reference(ref)
163
+ out = ref.id.name
164
+ out += ".#{ref.attribute.name}" if ref.attribute
165
+ out
166
+ end
167
+
168
+ private def serialize_select_expression(expr)
169
+ out = "#{serialize_expression(expr.selector)} ->"
170
+ expr.variants.each {|variant| out += serialize_variant(variant) }
171
+ "#{out}\n"
172
+ end
173
+
174
+ private def serialize_variant(variant)
175
+ key = serialize_variant_key(variant.key)
176
+ value = indent_except_first_line(serialize_pattern(variant.value))
177
+
178
+ if variant.default
179
+ "\n *[#{key}]#{value}"
180
+ else
181
+ "\n [#{key}]#{value}"
182
+ end
183
+ end
184
+
185
+ private def serialize_variant_key(key)
186
+ case key
187
+ when Parser::AST::Identifier
188
+ key.name
189
+ when Parser::AST::NumberLiteral
190
+ key.value
191
+ else
192
+ raise ArgumentError, "Unknown variant key type: #{key.class}"
193
+ end
194
+ end
195
+
196
+ private def serialize_call_arguments(args)
197
+ return "()" if args.nil?
198
+
199
+ positional = args.positional.map {|arg| serialize_expression(arg) }.join(", ")
200
+ named = args.named.map {|arg| serialize_named_argument(arg) }.join(", ")
201
+
202
+ if !positional.empty? && !named.empty?
203
+ "(#{positional}, #{named})"
204
+ else
205
+ "(#{positional}#{named})"
206
+ end
207
+ end
208
+
209
+ private def serialize_named_argument(arg)
210
+ value = serialize_expression(arg.value)
211
+ "#{arg.name.name}: #{value}"
212
+ end
213
+
214
+ private def indent_except_first_line(content)
215
+ content.split("\n").join("\n ")
216
+ end
217
+
218
+ private def should_start_on_new_line?(pattern)
219
+ is_multiline = pattern.elements.any? {|elem|
220
+ select_expr?(elem) || includes_newline?(elem)
221
+ }
222
+
223
+ return false unless is_multiline
224
+
225
+ first_element = pattern.elements.first
226
+ return true unless first_element.is_a?(Parser::AST::TextElement)
227
+
228
+ first_char = first_element.value[0]
229
+ # These characters may not appear as the first character on a new line
230
+ !["[", ".", "*"].include?(first_char)
231
+ end
232
+
233
+ private def select_expr?(elem)
234
+ elem.is_a?(Parser::AST::Placeable) && elem.expression.is_a?(Parser::AST::SelectExpression)
235
+ end
236
+
237
+ private def includes_newline?(elem)
238
+ elem.is_a?(Parser::AST::TextElement) && elem.value.include?("\n")
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Syntax
5
+ # Visitor module for traversing AST nodes
6
+ #
7
+ # Include this module and override the visit_* methods you need.
8
+ # By default, all visit_* methods traverse children automatically.
9
+ # Override a method and omit `super` to stop traversal at that node.
10
+ #
11
+ # @example Collecting message IDs
12
+ # class MessageIdCollector
13
+ # include Foxtail::Syntax::Visitor
14
+ #
15
+ # def initialize
16
+ # @ids = []
17
+ # end
18
+ #
19
+ # attr_reader :ids
20
+ #
21
+ # def visit_message(node)
22
+ # @ids << node.id.name
23
+ # super # continue traversal
24
+ # end
25
+ # end
26
+ #
27
+ # collector = MessageIdCollector.new
28
+ # resource.accept(collector)
29
+ # collector.ids # => ["hello", "goodbye", ...]
30
+ module Visitor
31
+ def visit_resource(node) = visit_children(node)
32
+ def visit_message(node) = visit_children(node)
33
+ def visit_term(node) = visit_children(node)
34
+ def visit_attribute(node) = visit_children(node)
35
+ def visit_pattern(node) = visit_children(node)
36
+ def visit_placeable(node) = visit_children(node)
37
+ def visit_select_expression(node) = visit_children(node)
38
+ def visit_variant(node) = visit_children(node)
39
+ def visit_call_arguments(node) = visit_children(node)
40
+ def visit_named_argument(node) = visit_children(node)
41
+ def visit_message_reference(node) = visit_children(node)
42
+ def visit_term_reference(node) = visit_children(node)
43
+ def visit_function_reference(node) = visit_children(node)
44
+ def visit_variable_reference(node) = visit_children(node)
45
+ def visit_string_literal(node) = visit_children(node)
46
+ def visit_number_literal(node) = visit_children(node)
47
+ def visit_text_element(node) = visit_children(node)
48
+ def visit_identifier(node) = visit_children(node)
49
+ def visit_comment(node) = visit_children(node)
50
+ def visit_group_comment(node) = visit_children(node)
51
+ def visit_resource_comment(node) = visit_children(node)
52
+ def visit_junk(node) = visit_children(node)
53
+ def visit_annotation(node) = visit_children(node)
54
+ def visit_span(node) = visit_children(node)
55
+
56
+ def visit_children(node)
57
+ node.children.each {|child| child&.accept(self) }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ # Syntax module provides full FTL parsing with complete AST representation.
5
+ # This is the fluent-syntax equivalent, suitable for tools like linters,
6
+ # formatters, and editors that need position information and comments.
7
+ #
8
+ # For runtime message formatting, use {Foxtail::Bundle} instead which uses
9
+ # a lightweight parser optimized for execution.
10
+ module Syntax
11
+ end
12
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Tools
5
+ # Base error class for all Foxtail Tools-specific exceptions
6
+ class Error < StandardError; end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Tools
5
+ # Current version of the Foxtail tools gem
6
+ VERSION = "0.5.0"
7
+ public_constant :VERSION
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require_relative "foxtail/tools/version"
5
+
6
+ # Tooling entrypoint for Foxtail (syntax + CLI)
7
+ module Foxtail
8
+ loader = Zeitwerk::Loader.new
9
+ loader.push_dir(__dir__ + "/foxtail", namespace: Foxtail)
10
+
11
+ # Ignore version.rb since it's required by gemspec before Zeitwerk loads
12
+ loader.ignore(__dir__ + "/foxtail/tools/version.rb")
13
+ # Ignore gem entrypoint file (no constant defined)
14
+ loader.ignore(__dir__ + "/foxtail-tools.rb")
15
+
16
+ loader.inflector.inflect(
17
+ "ast" => "AST",
18
+ "cli" => "CLI"
19
+ )
20
+
21
+ loader.setup
22
+ end
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: foxtail-tools
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - OZAWA Sakuro
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-05-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-cli
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-inflector
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: zeitwerk
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.7'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.7'
55
+ description: 'Tooling components for Foxtail: fluent syntax parser/serializer and
56
+ CLI utilities.
57
+
58
+ '
59
+ email:
60
+ - 10973+sakuro@users.noreply.github.com
61
+ executables:
62
+ - foxtail
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - CHANGELOG.md
67
+ - LICENSE.txt
68
+ - README.md
69
+ - exe/foxtail
70
+ - lib/foxtail-tools.rb
71
+ - lib/foxtail/cli.rb
72
+ - lib/foxtail/cli/commands/check.rb
73
+ - lib/foxtail/cli/commands/dump.rb
74
+ - lib/foxtail/cli/commands/ids.rb
75
+ - lib/foxtail/cli/commands/tidy.rb
76
+ - lib/foxtail/syntax.rb
77
+ - lib/foxtail/syntax/error.rb
78
+ - lib/foxtail/syntax/parser.rb
79
+ - lib/foxtail/syntax/parser/ast.rb
80
+ - lib/foxtail/syntax/parser/ast/annotation.rb
81
+ - lib/foxtail/syntax/parser/ast/attribute.rb
82
+ - lib/foxtail/syntax/parser/ast/base_comment.rb
83
+ - lib/foxtail/syntax/parser/ast/base_literal.rb
84
+ - lib/foxtail/syntax/parser/ast/base_node.rb
85
+ - lib/foxtail/syntax/parser/ast/call_arguments.rb
86
+ - lib/foxtail/syntax/parser/ast/comment.rb
87
+ - lib/foxtail/syntax/parser/ast/function_reference.rb
88
+ - lib/foxtail/syntax/parser/ast/group_comment.rb
89
+ - lib/foxtail/syntax/parser/ast/identifier.rb
90
+ - lib/foxtail/syntax/parser/ast/junk.rb
91
+ - lib/foxtail/syntax/parser/ast/message.rb
92
+ - lib/foxtail/syntax/parser/ast/message_reference.rb
93
+ - lib/foxtail/syntax/parser/ast/named_argument.rb
94
+ - lib/foxtail/syntax/parser/ast/number_literal.rb
95
+ - lib/foxtail/syntax/parser/ast/pattern.rb
96
+ - lib/foxtail/syntax/parser/ast/placeable.rb
97
+ - lib/foxtail/syntax/parser/ast/resource.rb
98
+ - lib/foxtail/syntax/parser/ast/resource_comment.rb
99
+ - lib/foxtail/syntax/parser/ast/select_expression.rb
100
+ - lib/foxtail/syntax/parser/ast/span.rb
101
+ - lib/foxtail/syntax/parser/ast/string_literal.rb
102
+ - lib/foxtail/syntax/parser/ast/syntax_node.rb
103
+ - lib/foxtail/syntax/parser/ast/term.rb
104
+ - lib/foxtail/syntax/parser/ast/term_reference.rb
105
+ - lib/foxtail/syntax/parser/ast/text_element.rb
106
+ - lib/foxtail/syntax/parser/ast/variable_reference.rb
107
+ - lib/foxtail/syntax/parser/ast/variant.rb
108
+ - lib/foxtail/syntax/parser/parse_error.rb
109
+ - lib/foxtail/syntax/parser/stream.rb
110
+ - lib/foxtail/syntax/serializer.rb
111
+ - lib/foxtail/syntax/visitor.rb
112
+ - lib/foxtail/tools/error.rb
113
+ - lib/foxtail/tools/version.rb
114
+ homepage: https://github.com/sakuro/foxtail
115
+ licenses:
116
+ - MIT
117
+ metadata:
118
+ homepage_uri: https://github.com/sakuro/foxtail
119
+ source_code_uri: https://github.com/sakuro/foxtail.git
120
+ changelog_uri: https://github.com/sakuro/foxtail/blob/main/foxtail-tools/CHANGELOG.md
121
+ rubygems_mfa_required: 'true'
122
+ post_install_message:
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '3.3'
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 3.5.22
138
+ signing_key:
139
+ specification_version: 4
140
+ summary: Foxtail CLI and syntax tooling for Project Fluent
141
+ test_files: []