cybertrain 0.1.0 → 0.2.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/cybertrain/cli.rb CHANGED
@@ -1,19 +1,25 @@
1
1
  # The `cybertrain` command: exe/cybertrain under CRuby (the gem), or
2
2
  # bin/cybertrain.rb built by spin (`spin install` from a checkout).
3
3
  #
4
- # cybertrain new NAME [--path DIR | --version V] [--skip-spin]
4
+ # cybertrain new NAME [--path DIR | --version V | --git URL [--ref R]] [--skip-spin]
5
5
  # cybertrain generate scaffold NAME field:type ... [parent:references]
6
- # cybertrain version | help
6
+ # cybertrain db migrate | db COMMAND... | server [PORT] | build | spin ARGS...
7
+ # cybertrain setup [--force] | doctor | version | help
8
+ #
9
+ # Every command that runs spin first puts a Spinel of the pinned release on
10
+ # PATH (Toolchain.ensure!), installing it under ~/.cybertrain the first time.
7
11
  require "cybertrain/version"
8
12
  require "cybertrain/cli/templates"
9
13
  require "cybertrain/cli/new_app"
10
14
  require "cybertrain/cli/scaffold"
15
+ require "cybertrain/cli/build"
16
+ require "cybertrain/cli/toolchain"
11
17
 
12
18
  module Cybertrain
13
19
  module CLI
14
20
  USAGE = <<~TEXT
15
21
  Usage:
16
- cybertrain new NAME [--path DIR | --version V] [--skip-spin]
22
+ cybertrain new NAME [--path DIR | --version V | --git URL [--ref R]] [--skip-spin]
17
23
  Create an application in NAME, then run `spin lock` and
18
24
  `spin run gen` in it (skipped with --skip-spin). Its spin.toml
19
25
  depends on this cybertrain's release by default:
@@ -21,9 +27,33 @@ module Cybertrain
21
27
  --path DIR: the framework checkout at DIR (relative to the
22
28
  current directory; written as an absolute path).
23
29
  --version V: the index version constraint V.
30
+ --git URL [--ref R]: the framework at URL, at branch or tag R.
24
31
  cybertrain generate scaffold NAME field:type ... [parent:references]
25
32
  Add a resource to the application in the current directory.
26
33
  Types: #{Field::TYPES.join(", ")} (default string).
34
+ cybertrain db migrate
35
+ Generate, apply pending migrations, generate again
36
+ (spin run gen; spin run db -- migrate; spin run gen).
37
+ cybertrain db COMMAND...
38
+ Any other database command, after `spin run gen`: status,
39
+ rollback [N] (generates again afterwards), schema:dump, create.
40
+ `cybertrain migration` is the same as `cybertrain db migrate`.
41
+ cybertrain server [PORT]
42
+ Generate, then start the development server
43
+ (spin run gen; spin run NAME [-- PORT]; default port 3000).
44
+ cybertrain build
45
+ Build NAME with app/views embedded and assemble dist/
46
+ (the binary, public/, storage/, tmp/).
47
+ cybertrain spin ARGS...
48
+ Run spin with the pinned Spinel, from any directory
49
+ (cybertrain spin test; cybertrain spin run gen -- --check).
50
+ cybertrain setup [--force]
51
+ Install Spinel #{Cybertrain::SPINEL_TAG} into ~/.cybertrain (CYBERTRAIN_HOME)
52
+ unless a spinel of that release is already on PATH, and print
53
+ the PATH line for ~/.cybertrain/bin. The commands above do the
54
+ install by themselves the first time they need spin.
55
+ cybertrain doctor
56
+ Check the C toolchain, the SQLite headers and the Spinel install.
27
57
  cybertrain version
28
58
  cybertrain help
29
59
  TEXT
@@ -34,6 +64,13 @@ module Cybertrain
34
64
  case command
35
65
  when "new" then run_new(argv)
36
66
  when "generate", "g" then run_generate(argv)
67
+ when "migration" then run_in_app { |_name| run_spin(Build.db_commands(["migrate"])) }
68
+ when "db" then run_in_app { |_name| run_db(argv) }
69
+ when "server" then run_in_app { |name| run_server(name, argv) }
70
+ when "build" then run_in_app { |name| Toolchain.ensure! ? Build.run(".", name) : 1 }
71
+ when "spin" then run_spin_passthrough(argv)
72
+ when "setup" then Toolchain.setup(argv[1, argv.size - 1])
73
+ when "doctor" then run_doctor(argv)
37
74
  when "version", "--version", "-v"
38
75
  puts "cybertrain #{Cybertrain::VERSION}"
39
76
  0
@@ -51,7 +88,7 @@ module Cybertrain
51
88
  end
52
89
 
53
90
  def self.run_new(argv)
54
- raise InvalidArgument, "usage: cybertrain new NAME [--path DIR | --version V] [--skip-spin]" if argv.size < 2
91
+ raise InvalidArgument, "usage: cybertrain new NAME [--path DIR | --version V | --git URL [--ref R]] [--skip-spin]" if argv.size < 2
55
92
 
56
93
  dir = argv[1]
57
94
  raise InvalidArgument, "'#{File.basename(dir)}' is not a valid app name (lowercase letters, digits and _)" unless Templates.identifier?(File.basename(dir))
@@ -59,8 +96,15 @@ module Cybertrain
59
96
 
60
97
  NewApp.create(dir, framework_dep(argv))
61
98
  return 0 if argv.include?("--skip-spin")
99
+ return 1 unless NewApp.bootstrap(dir)
62
100
 
63
- NewApp.bootstrap(dir) ? 0 : 1
101
+ puts ""
102
+ puts "next:"
103
+ puts " cd #{Build.quote_arg(dir)}"
104
+ puts " cybertrain generate scaffold article title:string body:text"
105
+ puts " cybertrain db migrate"
106
+ puts " cybertrain server"
107
+ 0
64
108
  end
65
109
 
66
110
  # The spin.toml value of the `cybertrain =` dependency. By default the
@@ -72,20 +116,116 @@ module Cybertrain
72
116
  def self.framework_dep(argv)
73
117
  path = option(argv, "--path")
74
118
  version = option(argv, "--version")
75
- raise InvalidArgument, "pass either --path or --version, not both" unless path == "" || version == ""
76
- return "\"#{version}\"" unless version == ""
77
- return "{ path = \"#{File.expand_path(path, Dir.pwd)}\" }" unless path == ""
119
+ git = option(argv, "--git")
120
+ ref = option(argv, "--ref")
121
+ given = 0
122
+ given += 1 unless path == ""
123
+ given += 1 unless version == ""
124
+ given += 1 unless git == ""
125
+ raise InvalidArgument, "pass only one of --path, --version and --git" if given > 1
126
+ raise InvalidArgument, "--ref needs --git" if ref != "" && git == ""
127
+ return toml_string(version) unless version == ""
128
+ return "{ path = #{toml_string(File.expand_path(path, Dir.pwd))} }" unless path == ""
129
+ return "{ git = #{toml_string(git)} }" if git != "" && ref == ""
130
+ return "{ git = #{toml_string(git)}, ref = #{toml_string(ref)} }" unless git == ""
78
131
 
79
132
  "{ git = \"#{Cybertrain::REPOSITORY}\", ref = \"v#{Cybertrain::VERSION}\" }"
80
133
  end
81
134
 
82
- # The value after `--flag`, or "" when the flag is absent.
135
+ # `value` as a TOML basic string: `\` and `"` escaped. Control
136
+ # characters are refused rather than encoded: no path, URL or git ref
137
+ # needs one, and spin.toml stays readable.
138
+ def self.toml_string(value)
139
+ raise InvalidArgument, "control characters are not allowed in --path, --version, --git or --ref" unless value.bytes.all? { |b| b >= 32 && b != 127 }
140
+
141
+ "\"#{value.gsub("\\", "\\\\\\\\").gsub("\"", "\\\"")}\""
142
+ end
143
+
144
+ # The value after `--flag`, or "" when the flag is absent. An empty
145
+ # value, or another option in the value's place, means it is missing.
83
146
  def self.option(argv, flag)
84
147
  i = argv.index(flag)
85
148
  return "" if i.nil?
86
- raise InvalidArgument, "#{flag} needs a value" if i + 1 >= argv.size
87
149
 
88
- argv[i + 1]
150
+ value = i + 1 < argv.size ? argv[i + 1] : ""
151
+ raise InvalidArgument, "#{flag} needs a value" if value == "" || value.start_with?("--")
152
+
153
+ value
154
+ end
155
+
156
+ # Commands that need the app: its name comes from ./spin.toml.
157
+ def self.run_in_app
158
+ name = Build.app_name(".")
159
+ if name == ""
160
+ puts "error: no spin.toml with a [package] name here; run this inside a cybertrain application"
161
+ return 1
162
+ end
163
+ yield name
164
+ end
165
+
166
+ # Runs spin commands in turn once a Spinel of the pinned release is on
167
+ # PATH; stops at the first failure.
168
+ def self.run_spin(commands)
169
+ return 1 unless Toolchain.ensure!
170
+
171
+ run_all(commands)
172
+ end
173
+
174
+ def self.run_all(commands)
175
+ status = 0
176
+ commands.each do |command|
177
+ puts "run #{command}"
178
+ unless system(command)
179
+ status = 1
180
+ break
181
+ end
182
+ end
183
+ status
184
+ end
185
+
186
+ # `cybertrain spin ARGS...`: spin from the pinned toolchain, wherever
187
+ # the command runs (an application directory or not).
188
+ def self.run_spin_passthrough(argv)
189
+ args = argv[1, argv.size - 1]
190
+ if args.empty?
191
+ puts "usage: cybertrain spin ARGS... (for example: cybertrain spin test)"
192
+ return 1
193
+ end
194
+ return 1 unless Toolchain.ensure!
195
+
196
+ system((["spin"] + args.map { |arg| Build.quote_arg(arg) }).join(" ")) ? 0 : 1
197
+ end
198
+
199
+ def self.run_doctor(argv)
200
+ if argv.size > 1
201
+ puts "usage: cybertrain doctor"
202
+ return 1
203
+ end
204
+ Toolchain.doctor
205
+ end
206
+
207
+ # `cybertrain db COMMAND...`.
208
+ def self.run_db(argv)
209
+ args = argv[1, argv.size - 1]
210
+ if args.empty?
211
+ puts "usage: cybertrain db (migrate|rollback [N]|status|schema:dump|create)"
212
+ return 1
213
+ end
214
+ run_spin(Build.db_commands(args))
215
+ end
216
+
217
+ # `cybertrain server [PORT]`.
218
+ def self.run_server(name, argv)
219
+ port = argv.size > 1 ? argv[1] : ""
220
+ unless port == "" || Build.port?(port)
221
+ puts "error: PORT must be a number"
222
+ return 1
223
+ end
224
+ if argv.size > 2
225
+ puts "error: unexpected argument '#{argv[2]}'"
226
+ return 1
227
+ end
228
+ run_spin(Build.server_commands(name, port))
89
229
  end
90
230
 
91
231
  def self.run_generate(argv)
@@ -1,29 +1,45 @@
1
1
  module Cybertrain
2
- # The handful of Rails inflections the generators need. Singular/plural
3
- # forms come from a small irregulars table plus English suffix rules;
4
- # a word that is in neither simply gets the rules ("octopus" -> "octopuses").
2
+ # ActiveSupport::Inflector's camelize, underscore, pluralize and
3
+ # singularize with Rails' default English inflections
4
+ # (active_support/inflections.rb), checked rule for rule against
5
+ # ActiveSupport 8.1.4.
6
+ #
7
+ # Rails keeps its rules in tables that inflect.plural/singular/irregular/
8
+ # uncountable prepend to and tries them newest first, the first match
9
+ # winning. Spinel cannot keep Regexp objects in a collection, so each table
10
+ # is written out below as literal regexes in that application order: the
11
+ # expansion of every inflect.irregular (newest first), then the plain rules
12
+ # (newest first). An uncountable word -- or a compound ending in one after a
13
+ # word boundary, so "my money" but not "blog_fish" -- and "" come back
14
+ # unchanged.
15
+ #
16
+ # Application-defined inflections (config/initializers/inflections.rb:
17
+ # inflect.irregular, inflect.uncountable, inflect.acronym, ...) and locales
18
+ # other than English are not supported; camelize and underscore behave as
19
+ # Rails' do with no acronyms defined.
5
20
  module Inflector
6
- IRREGULARS = {
7
- "person" => "people", "man" => "men", "woman" => "women",
8
- "child" => "children", "mouse" => "mice", "ox" => "oxen"
9
- }
10
- UNCOUNTABLE = %w[equipment information rice money species series fish sheep news jeans]
11
- # Singulars ending in "s" whose plural adds "es" (Rails' alias/status/bus rules).
12
- ES_WORDS = %w[status alias bus]
13
-
14
21
  # "posts_controller" -> "PostsController"; "admin/posts" -> "Admin::Posts".
15
- def self.camelize(str)
22
+ # As Rails: the leading lowercase/digit run is capitalized, and each "_"
23
+ # or "/" is dropped and the alphanumeric run after it capitalized (the
24
+ # rest of that run downcased: "foo_BAR" -> "FooBar"); "/" becomes "::".
25
+ def self.camelize(term)
16
26
  out = +""
17
- upper = true
18
- str.each_char do |ch|
19
- if ch == "_"
20
- upper = true
21
- elsif ch == "/"
22
- out << "::"
23
- upper = true
24
- elsif upper
25
- out << ch.upcase
26
- upper = false
27
+ n = term.size
28
+ i = 0
29
+ while i < n && lower_or_digit?(term[i])
30
+ out << (i == 0 ? term[i].upcase : term[i])
31
+ i += 1
32
+ end
33
+ while i < n
34
+ ch = term[i]
35
+ i += 1
36
+ if ch == "_" || ch == "/"
37
+ out << "::" if ch == "/"
38
+ start = i
39
+ while i < n && alnum?(term[i])
40
+ out << (i == start ? term[i].upcase : term[i].downcase)
41
+ i += 1
42
+ end
27
43
  else
28
44
  out << ch
29
45
  end
@@ -31,96 +47,145 @@ module Cybertrain
31
47
  out
32
48
  end
33
49
 
34
- # "PostsController" -> "posts_controller"; "HTMLParser" -> "html_parser".
35
- def self.underscore(str)
50
+ # "PostsController" -> "posts_controller"; "HTMLTidyGenerator" ->
51
+ # "html_tidy_generator"; "Admin::Posts" -> "admin/posts". As Rails: "_"
52
+ # goes before an uppercase letter that follows a lowercase letter or digit,
53
+ # or that follows an uppercase letter and precedes a lowercase one; "-"
54
+ # becomes "_" and the result is downcased.
55
+ def self.underscore(camel_cased_word)
56
+ return camel_cased_word.dup unless camel_cased_word.match?(/[A-Z-]|::/)
57
+
36
58
  # Two steps: in a threaded program a collection inside `chars` freed the
37
59
  # unnamed gsub result it was splitting ("Post" came back as "").
38
- path = str.gsub("::", "/")
60
+ path = camel_cased_word.gsub("::", "/")
39
61
  chars = path.chars
40
62
  out = +""
41
63
  chars.each_with_index do |ch, i|
42
- if upper?(ch)
43
- prev = i > 0 ? chars[i - 1] : ""
64
+ if i > 0 && upper?(ch)
65
+ prev = chars[i - 1]
44
66
  nxt = i + 1 < chars.size ? chars[i + 1] : ""
45
- if prev != "" && prev != "/" && (lower_or_digit?(prev) || (upper?(prev) && lower_or_digit?(nxt)))
46
- out << "_"
47
- end
48
- out << ch.downcase
49
- else
50
- out << ch
67
+ out << "_" if lower_or_digit?(prev) || (upper?(prev) && lower?(nxt))
51
68
  end
69
+ out << (ch == "-" ? "_" : ch)
52
70
  end
53
- out
54
- end
55
-
56
- # "posts" -> "post"; only the last "_" word is inflected ("blog_posts").
57
- def self.singularize(str)
58
- parts = split_last_word(str)
59
- parts[0] + singular_word(parts[1])
71
+ out.downcase
60
72
  end
61
73
 
62
- # "post" -> "posts"; only the last "_" word is inflected.
63
- def self.pluralize(str)
64
- parts = split_last_word(str)
65
- parts[0] + plural_word(parts[1])
66
- end
67
-
68
- def self.singular_word(word)
69
- return word if UNCOUNTABLE.include?(word)
74
+ # "post" -> "posts", "person" -> "people", "sales_person" ->
75
+ # "sales_people", "Category" -> "Categories", "sheep" -> "sheep".
76
+ def self.pluralize(word)
77
+ return word.dup if word.empty? || uncountable?(word)
70
78
 
71
- IRREGULARS.each { |singular, plural| return singular if word == plural }
72
- return word if IRREGULARS.key?(word)
79
+ # inflect.irregular, newest first: zombie, move, sex, child, man, person.
80
+ return word.sub(/(z)ombies$/i, '\1ombies') if word.match?(/(z)ombies$/i)
81
+ return word.sub(/(z)ombie$/i, '\1ombies') if word.match?(/(z)ombie$/i)
82
+ return word.sub(/(m)oves$/i, '\1oves') if word.match?(/(m)oves$/i)
83
+ return word.sub(/(m)ove$/i, '\1oves') if word.match?(/(m)ove$/i)
84
+ return word.sub(/(s)exes$/i, '\1exes') if word.match?(/(s)exes$/i)
85
+ return word.sub(/(s)ex$/i, '\1exes') if word.match?(/(s)ex$/i)
86
+ return word.sub(/(c)hildren$/i, '\1hildren') if word.match?(/(c)hildren$/i)
87
+ return word.sub(/(c)hild$/i, '\1hildren') if word.match?(/(c)hild$/i)
88
+ return word.sub(/(m)en$/i, '\1en') if word.match?(/(m)en$/i)
89
+ return word.sub(/(m)an$/i, '\1en') if word.match?(/(m)an$/i)
90
+ return word.sub(/(p)eople$/i, '\1eople') if word.match?(/(p)eople$/i)
91
+ return word.sub(/(p)erson$/i, '\1eople') if word.match?(/(p)erson$/i)
92
+ # inflect.plural, newest first.
93
+ return word.sub(/(quiz)$/i, '\1zes') if word.match?(/(quiz)$/i)
94
+ return word.sub(/^(oxen)$/i, '\1') if word.match?(/^(oxen)$/i)
95
+ return word.sub(/^(ox)$/i, '\1en') if word.match?(/^(ox)$/i)
96
+ return word.sub(/^(m|l)ice$/i, '\1ice') if word.match?(/^(m|l)ice$/i)
97
+ return word.sub(/^(m|l)ouse$/i, '\1ice') if word.match?(/^(m|l)ouse$/i)
98
+ return word.sub(/(matr|vert|ind)(?:ix|ex)$/i, '\1ices') if word.match?(/(matr|vert|ind)(?:ix|ex)$/i)
99
+ return word.sub(/(x|ch|ss|sh)$/i, '\1es') if word.match?(/(x|ch|ss|sh)$/i)
100
+ return word.sub(/([^aeiouy]|qu)y$/i, '\1ies') if word.match?(/([^aeiouy]|qu)y$/i)
101
+ return word.sub(/(hive)$/i, '\1s') if word.match?(/(hive)$/i)
102
+ return word.sub(/(?:([^f])fe|([lr])f)$/i, '\1\2ves') if word.match?(/(?:([^f])fe|([lr])f)$/i)
103
+ return word.sub(/sis$/i, "ses") if word.match?(/sis$/i)
104
+ return word.sub(/([ti])a$/i, '\1a') if word.match?(/([ti])a$/i)
105
+ return word.sub(/([ti])um$/i, '\1a') if word.match?(/([ti])um$/i)
106
+ return word.sub(/(buffal|tomat)o$/i, '\1oes') if word.match?(/(buffal|tomat)o$/i)
107
+ return word.sub(/(bu)s$/i, '\1ses') if word.match?(/(bu)s$/i)
108
+ return word.sub(/(alias|status)$/i, '\1es') if word.match?(/(alias|status)$/i)
109
+ return word.sub(/(octop|vir)i$/i, '\1i') if word.match?(/(octop|vir)i$/i)
110
+ return word.sub(/(octop|vir)us$/i, '\1i') if word.match?(/(octop|vir)us$/i)
111
+ return word.sub(/^(ax|test)is$/i, '\1es') if word.match?(/^(ax|test)is$/i)
112
+ return word.sub(/s$/i, "s") if word.match?(/s$/i)
73
113
 
74
- if word.end_with?("ies") && word.size > 3 && !vowel?(word[word.size - 4])
75
- word[0, word.size - 3] + "y"
76
- elsif ES_WORDS.include?(word[0, word.size - 2]) || word.end_with?("sses") ||
77
- word.end_with?("xes") || word.end_with?("ches") || word.end_with?("shes")
78
- word[0, word.size - 2]
79
- elsif word.end_with?("ss") || ES_WORDS.include?(word)
80
- word
81
- elsif word.end_with?("s")
82
- word[0, word.size - 1]
83
- else
84
- word
85
- end
114
+ word.sub(/$/, "s")
86
115
  end
87
116
 
88
- def self.plural_word(word)
89
- return word if UNCOUNTABLE.include?(word)
117
+ # "posts" -> "post", "people" -> "person", "sales_people" ->
118
+ # "sales_person", "Categories" -> "Category", "news" -> "news".
119
+ def self.singularize(word)
120
+ return word.dup if word.empty? || uncountable?(word)
90
121
 
91
- plural = IRREGULARS[word]
92
- return plural unless plural.nil?
93
-
94
- if word.end_with?("y") && word.size > 1 && !vowel?(word[word.size - 2])
95
- word[0, word.size - 1] + "ies"
96
- elsif ES_WORDS.include?(word) || word.end_with?("ss") || word.end_with?("x") ||
97
- word.end_with?("ch") || word.end_with?("sh")
98
- word + "es"
99
- elsif word.end_with?("s")
100
- word
101
- else
102
- word + "s"
122
+ # inflect.irregular, newest first: zombie, move, sex, child, man, person.
123
+ return word.sub(/(z)ombies$/i, '\1ombie') if word.match?(/(z)ombies$/i)
124
+ return word.sub(/(z)ombie$/i, '\1ombie') if word.match?(/(z)ombie$/i)
125
+ return word.sub(/(m)oves$/i, '\1ove') if word.match?(/(m)oves$/i)
126
+ return word.sub(/(m)ove$/i, '\1ove') if word.match?(/(m)ove$/i)
127
+ return word.sub(/(s)exes$/i, '\1ex') if word.match?(/(s)exes$/i)
128
+ return word.sub(/(s)ex$/i, '\1ex') if word.match?(/(s)ex$/i)
129
+ return word.sub(/(c)hildren$/i, '\1hild') if word.match?(/(c)hildren$/i)
130
+ return word.sub(/(c)hild$/i, '\1hild') if word.match?(/(c)hild$/i)
131
+ return word.sub(/(m)en$/i, '\1an') if word.match?(/(m)en$/i)
132
+ return word.sub(/(m)an$/i, '\1an') if word.match?(/(m)an$/i)
133
+ return word.sub(/(p)eople$/i, '\1erson') if word.match?(/(p)eople$/i)
134
+ return word.sub(/(p)erson$/i, '\1erson') if word.match?(/(p)erson$/i)
135
+ # inflect.singular, newest first.
136
+ return word.sub(/(database)s$/i, '\1') if word.match?(/(database)s$/i)
137
+ return word.sub(/(quiz)zes$/i, '\1') if word.match?(/(quiz)zes$/i)
138
+ return word.sub(/(matr)ices$/i, '\1ix') if word.match?(/(matr)ices$/i)
139
+ return word.sub(/(vert|ind)ices$/i, '\1ex') if word.match?(/(vert|ind)ices$/i)
140
+ return word.sub(/^(ox)en/i, '\1') if word.match?(/^(ox)en/i)
141
+ return word.sub(/(alias|status)(es)?$/i, '\1') if word.match?(/(alias|status)(es)?$/i)
142
+ return word.sub(/(octop|vir)(us|i)$/i, '\1us') if word.match?(/(octop|vir)(us|i)$/i)
143
+ return word.sub(/^(a)x[ie]s$/i, '\1xis') if word.match?(/^(a)x[ie]s$/i)
144
+ return word.sub(/(cris|test)(is|es)$/i, '\1is') if word.match?(/(cris|test)(is|es)$/i)
145
+ return word.sub(/(shoe)s$/i, '\1') if word.match?(/(shoe)s$/i)
146
+ return word.sub(/(o)es$/i, '\1') if word.match?(/(o)es$/i)
147
+ return word.sub(/(bus)(es)?$/i, '\1') if word.match?(/(bus)(es)?$/i)
148
+ return word.sub(/^(m|l)ice$/i, '\1ouse') if word.match?(/^(m|l)ice$/i)
149
+ return word.sub(/(x|ch|ss|sh)es$/i, '\1') if word.match?(/(x|ch|ss|sh)es$/i)
150
+ return word.sub(/(m)ovies$/i, '\1ovie') if word.match?(/(m)ovies$/i)
151
+ return word.sub(/(s)eries$/i, '\1eries') if word.match?(/(s)eries$/i)
152
+ return word.sub(/([^aeiouy]|qu)ies$/i, '\1y') if word.match?(/([^aeiouy]|qu)ies$/i)
153
+ return word.sub(/([lr])ves$/i, '\1f') if word.match?(/([lr])ves$/i)
154
+ return word.sub(/(tive)s$/i, '\1') if word.match?(/(tive)s$/i)
155
+ return word.sub(/(hive)s$/i, '\1') if word.match?(/(hive)s$/i)
156
+ return word.sub(/([^f])ves$/i, '\1fe') if word.match?(/([^f])ves$/i)
157
+ return word.sub(/(^analy)(sis|ses)$/i, '\1sis') if word.match?(/(^analy)(sis|ses)$/i)
158
+ if word.match?(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i)
159
+ return word.sub(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, '\1sis')
103
160
  end
104
- end
161
+ return word.sub(/([ti])a$/i, '\1um') if word.match?(/([ti])a$/i)
162
+ return word.sub(/(n)ews$/i, '\1ews') if word.match?(/(n)ews$/i)
163
+ return word.sub(/(ss)$/i, '\1') if word.match?(/(ss)$/i)
164
+ return word.sub(/s$/i, "") if word.match?(/s$/i)
105
165
 
106
- # "sales_people" -> ["sales_", "people"].
107
- def self.split_last_word(str)
108
- i = str.rindex("_")
109
- return ["", str] if i.nil?
110
-
111
- [str[0, i + 1], str[i + 1, str.size - i - 1]]
166
+ word.dup
112
167
  end
113
168
 
114
- def self.vowel?(ch)
115
- ch == "a" || ch == "e" || ch == "i" || ch == "o" || ch == "u"
169
+ # inflect.uncountable: Rails matches /\b<word>\Z/i, so "my money" and
170
+ # "Sheep" are uncountable but "blog_fish" ("_" is a word character) is not.
171
+ def self.uncountable?(word)
172
+ word.match?(/\b(?:equipment|information|rice|money|species|series|fish|sheep|jeans|police)\Z/i)
116
173
  end
117
174
 
118
175
  def self.upper?(ch)
119
176
  ch >= "A" && ch <= "Z"
120
177
  end
121
178
 
179
+ def self.lower?(ch)
180
+ ch >= "a" && ch <= "z"
181
+ end
182
+
122
183
  def self.lower_or_digit?(ch)
123
- (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")
184
+ lower?(ch) || (ch >= "0" && ch <= "9")
185
+ end
186
+
187
+ def self.alnum?(ch)
188
+ upper?(ch) || lower_or_digit?(ch)
124
189
  end
125
190
  end
126
191
  end
@@ -1,8 +1,12 @@
1
1
  module Cybertrain
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
 
4
4
  # Where `cybertrain new` points an app's spin.toml by default: the git tag
5
5
  # "v#{VERSION}" of this repository. Kept here because the gem (CRuby) and
6
6
  # the spin-built CLI read the same file.
7
7
  REPOSITORY = "https://github.com/saeki-mototsune/cybertrain"
8
+
9
+ # The Spinel release cybertrain is written and tested against: the tag
10
+ # `cybertrain setup` builds and CI's SPINEL_TAG.
11
+ SPINEL_TAG = "2026.09.12"
8
12
  end
data/exe/cybertrain CHANGED
@@ -4,4 +4,12 @@
4
4
  # same entry point, compiled by spin).
5
5
  require "cybertrain/cli"
6
6
 
7
- exit(Cybertrain::CLI.run(ARGV))
7
+ # Ctrl-C reaches the whole process group: the child (`spin run NAME`, a
8
+ # build) stops itself, and CRuby raises Interrupt out of `system` here.
9
+ # Build.run's ensure has restored gen/views.rb by the time it arrives.
10
+ begin
11
+ status = Cybertrain::CLI.run(ARGV)
12
+ rescue Interrupt
13
+ status = 130
14
+ end
15
+ exit(status)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cybertrain
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Saeki Mototsune
@@ -12,8 +12,10 @@ dependencies: []
12
12
  description: |
13
13
  cybertrain is a Rails-shaped web framework written for Spinel, which
14
14
  compiles an application into a native binary. This gem installs the
15
- `cybertrain` command (`cybertrain new`, `cybertrain generate scaffold`);
16
- building and running an application needs Spinel's `spin` on PATH.
15
+ `cybertrain` command (`cybertrain new`, `generate scaffold`,
16
+ `db migrate`, `server`, `build`). The first command that needs Spinel
17
+ builds the pinned release into ~/.cybertrain (a C compiler, make, git,
18
+ curl and the SQLite headers are required); `cybertrain doctor` checks.
17
19
  executables:
18
20
  - cybertrain
19
21
  extensions: []
@@ -21,9 +23,11 @@ extra_rdoc_files: []
21
23
  files:
22
24
  - README.md
23
25
  - cybertrain/cli.rb
26
+ - cybertrain/cli/build.rb
24
27
  - cybertrain/cli/new_app.rb
25
28
  - cybertrain/cli/scaffold.rb
26
29
  - cybertrain/cli/templates.rb
30
+ - cybertrain/cli/toolchain.rb
27
31
  - cybertrain/generator/inflector.rb
28
32
  - cybertrain/version.rb
29
33
  - exe/cybertrain