cybertrain 0.1.1 → 0.2.1

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,21 +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 migration | db COMMAND... | server [PORT] | build
7
- # 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.
8
11
  require "cybertrain/version"
9
12
  require "cybertrain/cli/templates"
10
13
  require "cybertrain/cli/new_app"
11
14
  require "cybertrain/cli/scaffold"
12
15
  require "cybertrain/cli/build"
16
+ require "cybertrain/cli/toolchain"
13
17
 
14
18
  module Cybertrain
15
19
  module CLI
16
20
  USAGE = <<~TEXT
17
21
  Usage:
18
- cybertrain new NAME [--path DIR | --version V] [--skip-spin]
22
+ cybertrain new NAME [--path DIR | --version V | --git URL [--ref R]] [--skip-spin]
19
23
  Create an application in NAME, then run `spin lock` and
20
24
  `spin run gen` in it (skipped with --skip-spin). Its spin.toml
21
25
  depends on this cybertrain's release by default:
@@ -23,20 +27,33 @@ module Cybertrain
23
27
  --path DIR: the framework checkout at DIR (relative to the
24
28
  current directory; written as an absolute path).
25
29
  --version V: the index version constraint V.
30
+ --git URL [--ref R]: the framework at URL, at branch or tag R.
26
31
  cybertrain generate scaffold NAME field:type ... [parent:references]
27
32
  Add a resource to the application in the current directory.
28
33
  Types: #{Field::TYPES.join(", ")} (default string).
29
- cybertrain migration
34
+ cybertrain db migrate
30
35
  Generate, apply pending migrations, generate again
31
36
  (spin run gen; spin run db -- migrate; spin run gen).
32
37
  cybertrain db COMMAND...
33
- Any database command: status, rollback [N], schema:dump, create.
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`.
34
41
  cybertrain server [PORT]
35
42
  Generate, then start the development server
36
43
  (spin run gen; spin run NAME [-- PORT]; default port 3000).
37
44
  cybertrain build
38
45
  Build NAME with app/views embedded and assemble dist/
39
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.
40
57
  cybertrain version
41
58
  cybertrain help
42
59
  TEXT
@@ -47,10 +64,13 @@ module Cybertrain
47
64
  case command
48
65
  when "new" then run_new(argv)
49
66
  when "generate", "g" then run_generate(argv)
50
- when "migration" then run_in_app { |_name| run_all(Build.migration_commands) }
51
- when "db" then run_in_app { |_name| run_all(["spin run db -- #{db_args(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) }
52
69
  when "server" then run_in_app { |name| run_server(name, argv) }
53
- when "build" then run_in_app { |name| Build.run(".", name) }
70
+ when "build" then run_in_app { |name| build_app(name) }
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)
54
74
  when "version", "--version", "-v"
55
75
  puts "cybertrain #{Cybertrain::VERSION}"
56
76
  0
@@ -68,7 +88,7 @@ module Cybertrain
68
88
  end
69
89
 
70
90
  def self.run_new(argv)
71
- 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
72
92
 
73
93
  dir = argv[1]
74
94
  raise InvalidArgument, "'#{File.basename(dir)}' is not a valid app name (lowercase letters, digits and _)" unless Templates.identifier?(File.basename(dir))
@@ -76,8 +96,15 @@ module Cybertrain
76
96
 
77
97
  NewApp.create(dir, framework_dep(argv))
78
98
  return 0 if argv.include?("--skip-spin")
79
-
80
- NewApp.bootstrap(dir) ? 0 : 1
99
+ return 1 unless NewApp.bootstrap(dir)
100
+
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
81
108
  end
82
109
 
83
110
  # The spin.toml value of the `cybertrain =` dependency. By default the
@@ -89,20 +116,41 @@ module Cybertrain
89
116
  def self.framework_dep(argv)
90
117
  path = option(argv, "--path")
91
118
  version = option(argv, "--version")
92
- raise InvalidArgument, "pass either --path or --version, not both" unless path == "" || version == ""
93
- return "\"#{version}\"" unless version == ""
94
- 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 == ""
95
131
 
96
132
  "{ git = \"#{Cybertrain::REPOSITORY}\", ref = \"v#{Cybertrain::VERSION}\" }"
97
133
  end
98
134
 
99
- # 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.
100
146
  def self.option(argv, flag)
101
147
  i = argv.index(flag)
102
148
  return "" if i.nil?
103
- raise InvalidArgument, "#{flag} needs a value" if i + 1 >= argv.size
104
149
 
105
- 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
106
154
  end
107
155
 
108
156
  # Commands that need the app: its name comes from ./spin.toml.
@@ -115,7 +163,22 @@ module Cybertrain
115
163
  yield name
116
164
  end
117
165
 
118
- # Runs each command in turn; stops at the first failure.
166
+ # `cybertrain build`: the one dist/ name check, made before the toolchain
167
+ # is fetched or any spin command runs; Build.run assumes a name that got
168
+ # past it.
169
+ def self.build_app(name)
170
+ Build.require_dist_name!(name)
171
+ Toolchain.ensure! ? Build.run(".", name) : 1
172
+ end
173
+
174
+ # Runs spin commands in turn once a Spinel of the pinned release is on
175
+ # PATH; stops at the first failure.
176
+ def self.run_spin(commands)
177
+ return 1 unless Toolchain.ensure!
178
+
179
+ run_all(commands)
180
+ end
181
+
119
182
  def self.run_all(commands)
120
183
  status = 0
121
184
  commands.each do |command|
@@ -128,6 +191,37 @@ module Cybertrain
128
191
  status
129
192
  end
130
193
 
194
+ # `cybertrain spin ARGS...`: spin from the pinned toolchain, wherever
195
+ # the command runs (an application directory or not).
196
+ def self.run_spin_passthrough(argv)
197
+ args = argv[1, argv.size - 1]
198
+ if args.empty?
199
+ puts "usage: cybertrain spin ARGS... (for example: cybertrain spin test)"
200
+ return 1
201
+ end
202
+ return 1 unless Toolchain.ensure!
203
+
204
+ system((["spin"] + args.map { |arg| Build.quote_arg(arg) }).join(" ")) ? 0 : 1
205
+ end
206
+
207
+ def self.run_doctor(argv)
208
+ if argv.size > 1
209
+ puts "usage: cybertrain doctor"
210
+ return 1
211
+ end
212
+ Toolchain.doctor
213
+ end
214
+
215
+ # `cybertrain db COMMAND...`.
216
+ def self.run_db(argv)
217
+ args = argv[1, argv.size - 1]
218
+ if args.empty?
219
+ puts "usage: cybertrain db (migrate|rollback [N]|status|schema:dump|create)"
220
+ return 1
221
+ end
222
+ run_spin(Build.db_commands(args))
223
+ end
224
+
131
225
  # `cybertrain server [PORT]`.
132
226
  def self.run_server(name, argv)
133
227
  port = argv.size > 1 ? argv[1] : ""
@@ -139,12 +233,7 @@ module Cybertrain
139
233
  puts "error: unexpected argument '#{argv[2]}'"
140
234
  return 1
141
235
  end
142
- run_all(Build.server_commands(name, port))
143
- end
144
-
145
- # The words after `db`, each shell-quoted.
146
- def self.db_args(argv)
147
- argv[1, argv.size - 1].map { |arg| Build.shell_quote(arg) }.join(" ")
236
+ run_spin(Build.server_commands(name, port))
148
237
  end
149
238
 
150
239
  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,146 @@ module Cybertrain
31
47
  out
32
48
  end
33
49
 
34
- # "PostsController" -> "posts_controller"; "HTMLParser" -> "html_parser".
35
- def self.underscore(str)
36
- # Two steps: in a threaded program a collection inside `chars` freed the
37
- # unnamed gsub result it was splitting ("Post" came back as "").
38
- path = str.gsub("::", "/")
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
+
58
+ # Two steps, the gsub result held in a local: in a threaded program a
59
+ # collection inside `chars` frees an unnamed gsub result it is
60
+ # splitting ("Post" comes back as "").
61
+ path = camel_cased_word.gsub("::", "/")
39
62
  chars = path.chars
40
63
  out = +""
41
64
  chars.each_with_index do |ch, i|
42
- if upper?(ch)
43
- prev = i > 0 ? chars[i - 1] : ""
65
+ if i > 0 && upper?(ch)
66
+ prev = chars[i - 1]
44
67
  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
68
+ out << "_" if lower_or_digit?(prev) || (upper?(prev) && lower?(nxt))
51
69
  end
70
+ out << (ch == "-" ? "_" : ch)
52
71
  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])
72
+ out.downcase
60
73
  end
61
74
 
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)
75
+ # "post" -> "posts", "person" -> "people", "sales_person" ->
76
+ # "sales_people", "Category" -> "Categories", "sheep" -> "sheep".
77
+ def self.pluralize(word)
78
+ return word.dup if word.empty? || uncountable?(word)
70
79
 
71
- IRREGULARS.each { |singular, plural| return singular if word == plural }
72
- return word if IRREGULARS.key?(word)
80
+ # inflect.irregular, newest first: zombie, move, sex, child, man, person.
81
+ return word.sub(/(z)ombies$/i, '\1ombies') if word.match?(/(z)ombies$/i)
82
+ return word.sub(/(z)ombie$/i, '\1ombies') if word.match?(/(z)ombie$/i)
83
+ return word.sub(/(m)oves$/i, '\1oves') if word.match?(/(m)oves$/i)
84
+ return word.sub(/(m)ove$/i, '\1oves') if word.match?(/(m)ove$/i)
85
+ return word.sub(/(s)exes$/i, '\1exes') if word.match?(/(s)exes$/i)
86
+ return word.sub(/(s)ex$/i, '\1exes') if word.match?(/(s)ex$/i)
87
+ return word.sub(/(c)hildren$/i, '\1hildren') if word.match?(/(c)hildren$/i)
88
+ return word.sub(/(c)hild$/i, '\1hildren') if word.match?(/(c)hild$/i)
89
+ return word.sub(/(m)en$/i, '\1en') if word.match?(/(m)en$/i)
90
+ return word.sub(/(m)an$/i, '\1en') if word.match?(/(m)an$/i)
91
+ return word.sub(/(p)eople$/i, '\1eople') if word.match?(/(p)eople$/i)
92
+ return word.sub(/(p)erson$/i, '\1eople') if word.match?(/(p)erson$/i)
93
+ # inflect.plural, newest first.
94
+ return word.sub(/(quiz)$/i, '\1zes') if word.match?(/(quiz)$/i)
95
+ return word.sub(/^(oxen)$/i, '\1') if word.match?(/^(oxen)$/i)
96
+ return word.sub(/^(ox)$/i, '\1en') if word.match?(/^(ox)$/i)
97
+ return word.sub(/^(m|l)ice$/i, '\1ice') if word.match?(/^(m|l)ice$/i)
98
+ return word.sub(/^(m|l)ouse$/i, '\1ice') if word.match?(/^(m|l)ouse$/i)
99
+ return word.sub(/(matr|vert|ind)(?:ix|ex)$/i, '\1ices') if word.match?(/(matr|vert|ind)(?:ix|ex)$/i)
100
+ return word.sub(/(x|ch|ss|sh)$/i, '\1es') if word.match?(/(x|ch|ss|sh)$/i)
101
+ return word.sub(/([^aeiouy]|qu)y$/i, '\1ies') if word.match?(/([^aeiouy]|qu)y$/i)
102
+ return word.sub(/(hive)$/i, '\1s') if word.match?(/(hive)$/i)
103
+ return word.sub(/(?:([^f])fe|([lr])f)$/i, '\1\2ves') if word.match?(/(?:([^f])fe|([lr])f)$/i)
104
+ return word.sub(/sis$/i, "ses") if word.match?(/sis$/i)
105
+ return word.sub(/([ti])a$/i, '\1a') if word.match?(/([ti])a$/i)
106
+ return word.sub(/([ti])um$/i, '\1a') if word.match?(/([ti])um$/i)
107
+ return word.sub(/(buffal|tomat)o$/i, '\1oes') if word.match?(/(buffal|tomat)o$/i)
108
+ return word.sub(/(bu)s$/i, '\1ses') if word.match?(/(bu)s$/i)
109
+ return word.sub(/(alias|status)$/i, '\1es') if word.match?(/(alias|status)$/i)
110
+ return word.sub(/(octop|vir)i$/i, '\1i') if word.match?(/(octop|vir)i$/i)
111
+ return word.sub(/(octop|vir)us$/i, '\1i') if word.match?(/(octop|vir)us$/i)
112
+ return word.sub(/^(ax|test)is$/i, '\1es') if word.match?(/^(ax|test)is$/i)
113
+ return word.sub(/s$/i, "s") if word.match?(/s$/i)
73
114
 
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
115
+ word.sub(/$/, "s")
86
116
  end
87
117
 
88
- def self.plural_word(word)
89
- return word if UNCOUNTABLE.include?(word)
118
+ # "posts" -> "post", "people" -> "person", "sales_people" ->
119
+ # "sales_person", "Categories" -> "Category", "news" -> "news".
120
+ def self.singularize(word)
121
+ return word.dup if word.empty? || uncountable?(word)
90
122
 
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"
123
+ # inflect.irregular, newest first: zombie, move, sex, child, man, person.
124
+ return word.sub(/(z)ombies$/i, '\1ombie') if word.match?(/(z)ombies$/i)
125
+ return word.sub(/(z)ombie$/i, '\1ombie') if word.match?(/(z)ombie$/i)
126
+ return word.sub(/(m)oves$/i, '\1ove') if word.match?(/(m)oves$/i)
127
+ return word.sub(/(m)ove$/i, '\1ove') if word.match?(/(m)ove$/i)
128
+ return word.sub(/(s)exes$/i, '\1ex') if word.match?(/(s)exes$/i)
129
+ return word.sub(/(s)ex$/i, '\1ex') if word.match?(/(s)ex$/i)
130
+ return word.sub(/(c)hildren$/i, '\1hild') if word.match?(/(c)hildren$/i)
131
+ return word.sub(/(c)hild$/i, '\1hild') if word.match?(/(c)hild$/i)
132
+ return word.sub(/(m)en$/i, '\1an') if word.match?(/(m)en$/i)
133
+ return word.sub(/(m)an$/i, '\1an') if word.match?(/(m)an$/i)
134
+ return word.sub(/(p)eople$/i, '\1erson') if word.match?(/(p)eople$/i)
135
+ return word.sub(/(p)erson$/i, '\1erson') if word.match?(/(p)erson$/i)
136
+ # inflect.singular, newest first.
137
+ return word.sub(/(database)s$/i, '\1') if word.match?(/(database)s$/i)
138
+ return word.sub(/(quiz)zes$/i, '\1') if word.match?(/(quiz)zes$/i)
139
+ return word.sub(/(matr)ices$/i, '\1ix') if word.match?(/(matr)ices$/i)
140
+ return word.sub(/(vert|ind)ices$/i, '\1ex') if word.match?(/(vert|ind)ices$/i)
141
+ return word.sub(/^(ox)en/i, '\1') if word.match?(/^(ox)en/i)
142
+ return word.sub(/(alias|status)(es)?$/i, '\1') if word.match?(/(alias|status)(es)?$/i)
143
+ return word.sub(/(octop|vir)(us|i)$/i, '\1us') if word.match?(/(octop|vir)(us|i)$/i)
144
+ return word.sub(/^(a)x[ie]s$/i, '\1xis') if word.match?(/^(a)x[ie]s$/i)
145
+ return word.sub(/(cris|test)(is|es)$/i, '\1is') if word.match?(/(cris|test)(is|es)$/i)
146
+ return word.sub(/(shoe)s$/i, '\1') if word.match?(/(shoe)s$/i)
147
+ return word.sub(/(o)es$/i, '\1') if word.match?(/(o)es$/i)
148
+ return word.sub(/(bus)(es)?$/i, '\1') if word.match?(/(bus)(es)?$/i)
149
+ return word.sub(/^(m|l)ice$/i, '\1ouse') if word.match?(/^(m|l)ice$/i)
150
+ return word.sub(/(x|ch|ss|sh)es$/i, '\1') if word.match?(/(x|ch|ss|sh)es$/i)
151
+ return word.sub(/(m)ovies$/i, '\1ovie') if word.match?(/(m)ovies$/i)
152
+ return word.sub(/(s)eries$/i, '\1eries') if word.match?(/(s)eries$/i)
153
+ return word.sub(/([^aeiouy]|qu)ies$/i, '\1y') if word.match?(/([^aeiouy]|qu)ies$/i)
154
+ return word.sub(/([lr])ves$/i, '\1f') if word.match?(/([lr])ves$/i)
155
+ return word.sub(/(tive)s$/i, '\1') if word.match?(/(tive)s$/i)
156
+ return word.sub(/(hive)s$/i, '\1') if word.match?(/(hive)s$/i)
157
+ return word.sub(/([^f])ves$/i, '\1fe') if word.match?(/([^f])ves$/i)
158
+ return word.sub(/(^analy)(sis|ses)$/i, '\1sis') if word.match?(/(^analy)(sis|ses)$/i)
159
+ if word.match?(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i)
160
+ return word.sub(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, '\1sis')
103
161
  end
104
- end
162
+ return word.sub(/([ti])a$/i, '\1um') if word.match?(/([ti])a$/i)
163
+ return word.sub(/(n)ews$/i, '\1ews') if word.match?(/(n)ews$/i)
164
+ return word.sub(/(ss)$/i, '\1') if word.match?(/(ss)$/i)
165
+ return word.sub(/s$/i, "") if word.match?(/s$/i)
105
166
 
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]]
167
+ word.dup
112
168
  end
113
169
 
114
- def self.vowel?(ch)
115
- ch == "a" || ch == "e" || ch == "i" || ch == "o" || ch == "u"
170
+ # inflect.uncountable: Rails matches /\b<word>\Z/i, so "my money" and
171
+ # "Sheep" are uncountable but "blog_fish" ("_" is a word character) is not.
172
+ def self.uncountable?(word)
173
+ word.match?(/\b(?:equipment|information|rice|money|species|series|fish|sheep|jeans|police)\Z/i)
116
174
  end
117
175
 
118
176
  def self.upper?(ch)
119
177
  ch >= "A" && ch <= "Z"
120
178
  end
121
179
 
180
+ def self.lower?(ch)
181
+ ch >= "a" && ch <= "z"
182
+ end
183
+
122
184
  def self.lower_or_digit?(ch)
123
- (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")
185
+ lower?(ch) || (ch >= "0" && ch <= "9")
186
+ end
187
+
188
+ def self.alnum?(ch)
189
+ upper?(ch) || lower_or_digit?(ch)
124
190
  end
125
191
  end
126
192
  end