@quenty/metricutils 2.4.3 → 2.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 (64) hide show
  1. package/CHANGELOG.md +16 -54
  2. package/README.md +80 -2
  3. package/deploy.nevermore.json +10 -0
  4. package/package.json +22 -4
  5. package/src/Shared/Definitions/MetricUnitDefinitions.lua +1082 -0
  6. package/src/Shared/Definitions/MetricUnitDefinitions.spec.lua +55 -0
  7. package/src/Shared/Definitions/MetricUnitDimensionUtils.lua +164 -0
  8. package/src/Shared/Definitions/MetricUnitDimensionUtils.spec.lua +49 -0
  9. package/src/Shared/Definitions/MetricUnitKinds.lua +237 -0
  10. package/src/Shared/Definitions/MetricUnitKinds.spec.lua +51 -0
  11. package/src/Shared/Formatting/MetricUnitDisplayUnitUtils.lua +129 -0
  12. package/src/Shared/Formatting/MetricUnitFormatUtils.lua +924 -0
  13. package/src/Shared/Formatting/MetricUnitFormatUtils.spec.lua +280 -0
  14. package/src/Shared/Formatting/RxMetricUnitFormatUtils.lua +62 -0
  15. package/src/Shared/Localization/MetricUnitFallbackStrings.lua +289 -0
  16. package/src/Shared/Localization/MetricUnitFallbackStrings.spec.lua +82 -0
  17. package/src/Shared/Localization/MetricUnitTranslator/ar.json.txt +182 -0
  18. package/src/Shared/Localization/MetricUnitTranslator/de.json.txt +182 -0
  19. package/src/Shared/Localization/MetricUnitTranslator/en.json.txt +182 -0
  20. package/src/Shared/Localization/MetricUnitTranslator/es.json.txt +182 -0
  21. package/src/Shared/Localization/MetricUnitTranslator/fr.json.txt +182 -0
  22. package/src/Shared/Localization/MetricUnitTranslator/id.json.txt +182 -0
  23. package/src/Shared/Localization/MetricUnitTranslator/init.lua +92 -0
  24. package/src/Shared/Localization/MetricUnitTranslator/it.json.txt +182 -0
  25. package/src/Shared/Localization/MetricUnitTranslator/ja.json.txt +182 -0
  26. package/src/Shared/Localization/MetricUnitTranslator/ko.json.txt +182 -0
  27. package/src/Shared/Localization/MetricUnitTranslator/pl.json.txt +182 -0
  28. package/src/Shared/Localization/MetricUnitTranslator/pt.json.txt +182 -0
  29. package/src/Shared/Localization/MetricUnitTranslator/ru.json.txt +182 -0
  30. package/src/Shared/Localization/MetricUnitTranslator/th.json.txt +182 -0
  31. package/src/Shared/Localization/MetricUnitTranslator/tr.json.txt +182 -0
  32. package/src/Shared/Localization/MetricUnitTranslator/vi.json.txt +182 -0
  33. package/src/Shared/Localization/MetricUnitTranslator/zh-cn.json.txt +182 -0
  34. package/src/Shared/Localization/MetricUnitTranslator/zh-tw.json.txt +182 -0
  35. package/src/Shared/Localization/MetricUnitTranslator.spec.lua +309 -0
  36. package/src/Shared/Localization/PluralCategoryUtils.lua +103 -0
  37. package/src/Shared/Localization/PluralCategoryUtils.spec.lua +77 -0
  38. package/src/Shared/Logarithmic/DecibelUtils.lua +72 -0
  39. package/src/Shared/Logarithmic/DecibelUtils.spec.lua +35 -0
  40. package/src/Shared/MetricUnit.Angles.spec.lua +80 -0
  41. package/src/Shared/MetricUnit.Customary.spec.lua +104 -0
  42. package/src/Shared/MetricUnit.Derived.spec.lua +173 -0
  43. package/src/Shared/MetricUnit.Display.spec.lua +153 -0
  44. package/src/Shared/MetricUnit.Electrical.spec.lua +114 -0
  45. package/src/Shared/MetricUnit.Math.spec.lua +112 -0
  46. package/src/Shared/MetricUnit.Roblox.spec.lua +34 -0
  47. package/src/Shared/MetricUnit.SI.spec.lua +127 -0
  48. package/src/Shared/MetricUnit.Spring.spec.lua +94 -0
  49. package/src/Shared/MetricUnit.Temperature.spec.lua +212 -0
  50. package/src/Shared/MetricUnit.Time.spec.lua +202 -0
  51. package/src/Shared/MetricUnit.Units.spec.lua +210 -0
  52. package/src/Shared/MetricUnit.lua +1730 -0
  53. package/src/Shared/MetricUnit.spec.lua +362 -0
  54. package/src/Shared/MetricUtils.lua +47 -16
  55. package/src/Shared/MetricUtils.spec.lua +76 -0
  56. package/src/Shared/Parsing/MetricUnitParseUtils.lua +799 -0
  57. package/src/Shared/Parsing/MetricUnitParseUtils.spec.lua +189 -0
  58. package/src/Shared/Systems/MetricUnitSystem.lua +322 -0
  59. package/src/Shared/Systems/MetricUnitSystem.spec.lua +181 -0
  60. package/src/Shared/Systems/MetricUnitSystems.lua +225 -0
  61. package/src/jest.config.lua +3 -0
  62. package/src/node_modules.project.json +7 -0
  63. package/test/default.project.json +25 -0
  64. package/test/scripts/Server/ServerMain.server.lua +14 -0
@@ -0,0 +1,799 @@
1
+ --!strict
2
+ --[=[
3
+ Reads measurements from text, for chat commands, admin tools, configs and text boxes. Returns plain
4
+ [MetricUnitData]; [MetricUnit.parse] wraps it in a [MetricUnit].
5
+
6
+ Accepts:
7
+
8
+ - A number and a unit, with or without a space: `"5 km"`, `"5km"`, `"2.5 kilometers"`, `"45%"`, `"20 °C"`,
9
+ `"20C"`, `"-3 °F"`, `"60 mph"`, `"1.5 MiB"`. A unit is any unit id, constructor name, symbol, English name
10
+ (American or Commonwealth spelling), a common alias such as `"kph"` or `"Mbps"`, or one of `@quenty/time`'s
11
+ names for a time unit.
12
+ - Several of one dimension, added up: `"1h30m"`, `"1 hour, 30 minutes"`, `"5 ft 3 in"`.
13
+ - A composed unit: `"5 m/s"`, `"1.6 MB/s"`, `"6 m·kg/s"`, `"9.81 m/s^2"`, `"5/d"`.
14
+ - Clock text: `"1:30"` is one minute thirty (`m:ss`) and `"1:30:00"` one hour thirty (`h:mm:ss`), as
15
+ [MetricUnitFormatUtils] writes them with `durationStyle = "clock"`.
16
+ - An ISO 8601 duration without years or months, such as `"PT1H30M"`.
17
+
18
+ Text such as `"m"` can name more than one unit: the metre, and `@quenty/time`'s minute. The rule is:
19
+
20
+ 1. With an expected `kind`, the unit of that kind wins: `parse("5 m", "time")` is five minutes.
21
+ 2. Otherwise, in text with several parts, the dimension every part can share wins: `"1m30s"` is minutes.
22
+ 3. Otherwise the SI symbol wins over `@quenty/time`'s alias: `"5 m"` is five metres.
23
+
24
+ Symbols match case exactly (`"MB"` is a megabyte, `"Mb"` is not); names match without case (`"Kilometers"`).
25
+ Numbers use the locale's decimal mark when `options.locale` is given (`"1,5 km"` in `de-de`), else `.`.
26
+ No-break spaces read as spaces, so formatted text reads back.
27
+
28
+ @class MetricUnitParseUtils
29
+ ]=]
30
+
31
+ local require = require(script.Parent.loader).load(script)
32
+
33
+ local MetricUnitDefinitions = require("MetricUnitDefinitions")
34
+ local MetricUnitDimensionUtils = require("MetricUnitDimensionUtils")
35
+ local MetricUnitDisplayUnitUtils = require("MetricUnitDisplayUnitUtils")
36
+ local MetricUnitFallbackStrings = require("MetricUnitFallbackStrings")
37
+ local MetricUnitFormatUtils = require("MetricUnitFormatUtils")
38
+ local MetricUnitKinds = require("MetricUnitKinds")
39
+ local MetricUnitSystem = require("MetricUnitSystem")
40
+ local TimeDurationUtils = require("TimeDurationUtils")
41
+
42
+ local MetricUnitParseUtils = {}
43
+
44
+ --[=[
45
+ Options for [MetricUnitParseUtils.parse].
46
+
47
+ @interface ParseOptions
48
+ .locale string? -- Reads numbers with this locale's decimal mark and grouping, defaults to `.` and `,`
49
+ .defaultUnit UnitLike? -- The unit of a bare number, such as `"meter"` for a field of metres
50
+ @within MetricUnitParseUtils
51
+ ]=]
52
+ export type ParseOptions = {
53
+ locale: string?,
54
+ defaultUnit: MetricUnitDefinitions.UnitLike?,
55
+ _system: MetricUnitSystem.MetricUnitSystem?,
56
+ }
57
+
58
+ type Dimensions = MetricUnitDimensionUtils.Dimensions
59
+ type UnitDefinition = MetricUnitDefinitions.UnitDefinition
60
+ type MetricUnitData = MetricUnitFormatUtils.MetricUnitData
61
+
62
+ type Expectation = { dimensions: Dimensions?, absolute: boolean? }
63
+ type MeasuredUnit = { factor: number, dimensions: Dimensions, zero: number? }
64
+ type Term = { number: number, unitText: string, units: { MeasuredUnit } }
65
+
66
+ local NO_BREAK_SPACES = { "\u{00A0}", "\u{202F}" }
67
+ local MINUS_SIGN = "\u{2212}"
68
+ local LOOKALIKE_SYMBOLS = table.freeze({
69
+ { "\u{03BC}", "\u{00B5}" }, -- Greek mu, as CLDR writes micro, to the micro sign
70
+ { "\u{2126}", "\u{03A9}" }, -- ohm sign to Greek omega
71
+ })
72
+ local CLOCK_WITH_HOURS = "^([-+]?)(%d+):(%d%d):(%d%d%.?%d*)$"
73
+ local CLOCK_WITHOUT_HOURS = "^([-+]?)(%d+):(%d%d%.?%d*)$"
74
+ local ISO_DURATION_START = "^[-+]?P[%dT]"
75
+ local ISO_DATE_PART = "^[-+]?P([^T]*)"
76
+ local COMPOSITION_MARKS = table.freeze({ "/", "·", "*", "^", "²", "³" })
77
+ local SUPERSCRIPT_POWERS: { [string]: number } = { ["²"] = 2, ["³"] = 3 }
78
+ local UNSPACED_SYMBOLS: { [string]: true } = { ["°"] = true, ["%"] = true }
79
+
80
+ local AliasPriority = table.freeze({
81
+ ID_SYMBOL_OR_NAME = 1,
82
+ UNIT_ALIAS = 2,
83
+ SYMBOL_ANOTHER_UNIT_HAD_FIRST = 3,
84
+ TIME_ALIAS = 4,
85
+ })
86
+
87
+ type UnitMatch = { unit: UnitDefinition, priority: number }
88
+
89
+ type UnitIndex = {
90
+ exactText: { [string]: { UnitMatch } },
91
+ lowercaseNames: { [string]: { UnitMatch } },
92
+ fallback: UnitIndex?,
93
+ }
94
+
95
+ local function addMatch(matches: { [string]: { UnitMatch } }, text: string, unit: UnitDefinition, priority: number)
96
+ local found = matches[text]
97
+ if not found then
98
+ found = {}
99
+ matches[text] = found
100
+ end
101
+ for _, match in found do
102
+ if match.unit.id == unit.id then
103
+ match.priority = math.min(match.priority, priority)
104
+ return
105
+ end
106
+ end
107
+ table.insert(found, { unit = unit, priority = priority })
108
+ end
109
+
110
+ local function addSymbol(index: UnitIndex, symbol: string, unit: UnitDefinition, priority: number)
111
+ addMatch(index.exactText, symbol, unit, priority)
112
+ end
113
+
114
+ local function addName(index: UnitIndex, name: string, unit: UnitDefinition, priority: number)
115
+ addMatch(index.exactText, name, unit, priority)
116
+ addMatch(index.lowercaseNames, string.lower(name), unit, priority)
117
+ end
118
+
119
+ local function buildIndex(units: { UnitDefinition }, fallback: UnitIndex?): UnitIndex
120
+ local index: UnitIndex = { exactText = {}, lowercaseNames = {}, fallback = fallback }
121
+ for _, unit in units do
122
+ local isBareRate = unit.per ~= nil
123
+
124
+ addName(index, unit.id, unit, AliasPriority.ID_SYMBOL_OR_NAME)
125
+ if not isBareRate then
126
+ local symbolPriority = if index.exactText[unit.symbol]
127
+ then AliasPriority.SYMBOL_ANOTHER_UNIT_HAD_FIRST
128
+ else AliasPriority.ID_SYMBOL_OR_NAME
129
+ addSymbol(index, unit.symbol, unit, symbolPriority)
130
+ end
131
+ if unit.names then
132
+ for _, name in { unit.names.one, unit.names.other } do
133
+ addName(index, name, unit, AliasPriority.ID_SYMBOL_OR_NAME)
134
+ addName(
135
+ index,
136
+ MetricUnitFallbackStrings.toCommonwealthSpelling(name),
137
+ unit,
138
+ AliasPriority.ID_SYMBOL_OR_NAME
139
+ )
140
+ end
141
+ end
142
+ local aliases: { string } = unit.aliases or {}
143
+ for _, alias in aliases do
144
+ addName(index, alias, unit, AliasPriority.UNIT_ALIAS)
145
+ end
146
+ end
147
+ return index
148
+ end
149
+
150
+ type PrefixSpelling = { prefix: MetricUnitDefinitions.Prefix, text: string, isName: boolean }
151
+
152
+ local wellKnownUnits: UnitIndex? = nil
153
+ local prefixSpellingsLongestFirst: { PrefixSpelling }? = nil
154
+
155
+ local INDEX_BY_SYSTEM: { [MetricUnitSystem.MetricUnitSystem]: UnitIndex } = setmetatable({} :: any, { __mode = "k" })
156
+
157
+ local function trim(text: string): string
158
+ return string.match(text, "^%s*(.-)%s*$") :: string
159
+ end
160
+
161
+ --[=[
162
+ Reads `text` as a measurement. Returns the measurement as plain data, or nil and a message suitable to show a
163
+ player. `kind` is what the caller expects, such as `"length"`; it settles ambiguous units and turns a mismatch or
164
+ a bare number into a helpful message.
165
+
166
+ ```lua
167
+ MetricUnitParseUtils.parse("5 km") -- { amount = 5000, dimensions = { length = 1 } }
168
+ MetricUnitParseUtils.parse("5", "length") -- nil, 'Add a unit, such as "5 m"'
169
+ MetricUnitParseUtils.parse("5", "length", { defaultUnit = "meter" }) -- { amount = 5, dimensions = { length = 1 } }
170
+ MetricUnitParseUtils.parse("20 C") -- an absolute temperature
171
+ MetricUnitParseUtils.parse("20 C", "temperatureDifference") -- a difference of 20 K
172
+ ```
173
+
174
+ @param text string
175
+ @param kind (KindName | Dimensions)?
176
+ @param options ParseOptions?
177
+ @return MetricUnitData?
178
+ @return string?
179
+ ]=]
180
+ function MetricUnitParseUtils.parse(
181
+ text: string,
182
+ kind: (MetricUnitKinds.KindName | Dimensions)?,
183
+ options: ParseOptions?
184
+ ): (MetricUnitData?, string?)
185
+ if type(text) ~= "string" then
186
+ return nil, "Enter a value"
187
+ end
188
+
189
+ local expected: Expectation = {}
190
+ if kind ~= nil then
191
+ local dimensions, absolute = MetricUnitKinds.fromName(kind)
192
+ expected = { dimensions = dimensions, absolute = absolute }
193
+ end
194
+
195
+ local normalized = MetricUnitParseUtils._normalizeText(text)
196
+ if normalized == "" then
197
+ return nil, "Enter a value"
198
+ end
199
+
200
+ local data, err = MetricUnitParseUtils._readText(normalized, expected, options or {})
201
+ if not data then
202
+ return nil, err
203
+ end
204
+ return MetricUnitParseUtils._checkKind(data, expected, normalized)
205
+ end
206
+
207
+ function MetricUnitParseUtils._parseInSystem(
208
+ text: string,
209
+ kind: (MetricUnitKinds.KindName | Dimensions)?,
210
+ options: ParseOptions?,
211
+ system: MetricUnitSystem.MetricUnitSystem?
212
+ ): (MetricUnitData?, string?)
213
+ local resolved: ParseOptions = if options then table.clone(options) else {}
214
+ resolved._system = system
215
+ return MetricUnitParseUtils.parse(text, kind, resolved)
216
+ end
217
+
218
+ function MetricUnitParseUtils._normalizeText(text: string): string
219
+ local normalized = text
220
+ for _, space in NO_BREAK_SPACES do
221
+ normalized = string.gsub(normalized, space, " ")
222
+ end
223
+ for _, pair in LOOKALIKE_SYMBOLS do
224
+ normalized = string.gsub(normalized, pair[1], pair[2])
225
+ end
226
+ normalized = trim(normalized)
227
+ return (string.gsub(normalized, MINUS_SIGN, "-"))
228
+ end
229
+
230
+ function MetricUnitParseUtils._readText(
231
+ text: string,
232
+ expected: Expectation,
233
+ options: ParseOptions
234
+ ): (MetricUnitData?, string?)
235
+ local clockSeconds = MetricUnitParseUtils._readClockSeconds(text)
236
+ if clockSeconds then
237
+ return MetricUnitParseUtils._durationData(clockSeconds), nil
238
+ elseif string.match(text, ISO_DURATION_START) then
239
+ return MetricUnitParseUtils._readIsoDuration(text)
240
+ else
241
+ return MetricUnitParseUtils._readMeasurement(text, expected, options)
242
+ end
243
+ end
244
+
245
+ function MetricUnitParseUtils._readClockSeconds(text: string): number?
246
+ local sign, hours, minutes, seconds = string.match(text, CLOCK_WITH_HOURS)
247
+ if not hours then
248
+ sign, minutes, seconds = string.match(text, CLOCK_WITHOUT_HOURS)
249
+ end
250
+ if not minutes then
251
+ return nil
252
+ end
253
+
254
+ local total = (tonumber(hours) or 0) * 3600 + (tonumber(minutes) :: number) * 60 + (tonumber(seconds) :: number)
255
+ return if sign == "-" then -total else total
256
+ end
257
+
258
+ function MetricUnitParseUtils._readIsoDuration(text: string): (MetricUnitData?, string?)
259
+ local datePart = string.match(text, ISO_DATE_PART) or ""
260
+ if string.find(datePart, "[YM]") then
261
+ return nil, 'ISO years and months are calendar spans; write them as "1 year" or "2 months"'
262
+ end
263
+
264
+ local ok, seconds = pcall(TimeDurationUtils.toSeconds, text)
265
+ if not ok then
266
+ return nil, string.format("Could not read the duration %q", text)
267
+ end
268
+ return MetricUnitParseUtils._durationData(seconds :: number), nil
269
+ end
270
+
271
+ function MetricUnitParseUtils._durationData(seconds: number): MetricUnitData
272
+ return { amount = seconds, dimensions = { time = 1 } }
273
+ end
274
+
275
+ function MetricUnitParseUtils._readMeasurement(
276
+ text: string,
277
+ expected: Expectation,
278
+ options: ParseOptions
279
+ ): (MetricUnitData?, string?)
280
+ local decimalMark = if options.locale then MetricUnitFormatUtils.getDecimalSeparator(options.locale) else "."
281
+ local index = MetricUnitParseUtils._indexFor(options._system)
282
+ local terms, err = MetricUnitParseUtils._splitTerms(text, decimalMark, index)
283
+ if not terms then
284
+ return nil, err
285
+ end
286
+
287
+ local isBareNumber = #terms == 1 and terms[1].unitText == ""
288
+ if isBareNumber then
289
+ return MetricUnitParseUtils._readBareNumber(terms[1].number, expected, options)
290
+ end
291
+ return MetricUnitParseUtils._combineTerms(terms, text, expected)
292
+ end
293
+
294
+ function MetricUnitParseUtils._splitTerms(text: string, decimalMark: string, index: UnitIndex): ({ Term }?, string?)
295
+ local terms: { Term } = {}
296
+ local position = 1
297
+
298
+ while position <= #text do
299
+ local number, afterNumber = MetricUnitParseUtils._readNumber(text, position, decimalMark)
300
+ local startsWithBareRate = #terms == 0 and string.sub(text, position, position) == "/"
301
+ if number == nil and startsWithBareRate then
302
+ number = 1
303
+ end
304
+ if number == nil then
305
+ return nil, string.format("Could not read %q", text)
306
+ end
307
+
308
+ local unitText, afterUnit = MetricUnitParseUtils._readUnitText(text, afterNumber, index)
309
+ local units = MetricUnitParseUtils._unitsNamedBy(unitText, index)
310
+ local isUnknownUnit = unitText ~= "" and #units == 0
311
+ if isUnknownUnit then
312
+ return nil, string.format("Unknown unit %q", unitText)
313
+ end
314
+
315
+ table.insert(terms, { number = number, unitText = unitText, units = units })
316
+ position = MetricUnitParseUtils._skipTermSeparator(text, afterUnit)
317
+ end
318
+
319
+ return terms, nil
320
+ end
321
+
322
+ function MetricUnitParseUtils._readNumber(text: string, position: number, decimalMark: string): (number?, number)
323
+ local sign = string.match(text, "^[-+]", position)
324
+ local afterSign = if sign then position + 1 else position
325
+
326
+ local groupMarks = MetricUnitParseUtils._groupMarkPatterns(decimalMark)
327
+ local integerDigits, afterInteger = MetricUnitParseUtils._readGroupedDigits(text, afterSign, groupMarks)
328
+ local fractionDigits, afterFraction = MetricUnitParseUtils._readFraction(text, afterInteger, decimalMark)
329
+ if integerDigits == "" and fractionDigits == "" then
330
+ return nil, position
331
+ end
332
+
333
+ local value = tonumber(
334
+ (if integerDigits == "" then "0" else integerDigits)
335
+ .. "."
336
+ .. (if fractionDigits == "" then "0" else fractionDigits)
337
+ )
338
+ assert(value, "Bad number")
339
+ return if sign == "-" then -value else value, afterFraction
340
+ end
341
+
342
+ function MetricUnitParseUtils._groupMarkPatterns(decimalMark: string): { string }
343
+ return if decimalMark == "," then { "%.", " " } else { "," }
344
+ end
345
+
346
+ function MetricUnitParseUtils._readGroupedDigits(
347
+ text: string,
348
+ position: number,
349
+ groupMarks: { string }
350
+ ): (string, number)
351
+ local digits = string.match(text, "^%d+", position)
352
+ if not digits then
353
+ return "", position
354
+ end
355
+
356
+ local index = position + #digits
357
+ while true do
358
+ local groupDigits, afterGroup = MetricUnitParseUtils._readDigitGroup(text, index, groupMarks)
359
+ if not groupDigits then
360
+ break
361
+ end
362
+ digits ..= groupDigits
363
+ index = afterGroup
364
+ end
365
+ return digits, index
366
+ end
367
+
368
+ function MetricUnitParseUtils._readDigitGroup(text: string, position: number, groupMarks: { string }): (string?, number)
369
+ for _, mark in groupMarks do
370
+ local group, groupDigits = string.match(text, "^(" .. mark .. "(%d%d%d))", position)
371
+ local afterGroup = position + #(group or "")
372
+ local isExactlyThreeDigits = group ~= nil and not string.match(text, "^%d", afterGroup)
373
+ if isExactlyThreeDigits then
374
+ return groupDigits, afterGroup
375
+ end
376
+ end
377
+ return nil, position
378
+ end
379
+
380
+ function MetricUnitParseUtils._readFraction(text: string, position: number, decimalMark: string): (string, number)
381
+ local afterMark = position + #decimalMark
382
+ if string.sub(text, position, afterMark - 1) ~= decimalMark then
383
+ return "", position
384
+ end
385
+
386
+ local digits = string.match(text, "^%d+", afterMark)
387
+ if not digits then
388
+ return "", position
389
+ end
390
+ return digits, afterMark + #digits
391
+ end
392
+
393
+ function MetricUnitParseUtils._readUnitText(text: string, position: number, index: UnitIndex): (string, number)
394
+ local unitStart = position + #(string.match(text, "^%s*", position) :: string)
395
+ local unitText = string.sub(text, unitStart, MetricUnitParseUtils._findNextTermSeparator(text, unitStart) - 1)
396
+ if #MetricUnitParseUtils._unitsNamedBy(unitText, index) == 0 then
397
+ unitText = MetricUnitParseUtils._unitBeforeGluedNumber(unitText, index) or unitText
398
+ end
399
+
400
+ local afterUnit = unitStart + #unitText
401
+ return (string.gsub(unitText, "[%s,]+$", "")), afterUnit
402
+ end
403
+
404
+ function MetricUnitParseUtils._findNextTermSeparator(text: string, position: number): number
405
+ local endOfText = #text + 1
406
+ local beforeNumber = string.find(text, "[%s,]+[-+]?%d", position) or endOfText
407
+ local beforeAndNumber = string.find(text, "%s+and%s+[-+]?%d", position) or endOfText
408
+ return math.min(beforeNumber, beforeAndNumber)
409
+ end
410
+
411
+ function MetricUnitParseUtils._unitBeforeGluedNumber(unitText: string, index: UnitIndex): string?
412
+ local letters = string.match(unitText, "^([^%d%s]+)%d")
413
+ if letters and #MetricUnitParseUtils._findUnitsBestFirst(letters, index) > 0 then
414
+ return letters
415
+ end
416
+ return nil
417
+ end
418
+
419
+ function MetricUnitParseUtils._skipTermSeparator(text: string, position: number): number
420
+ local separator = string.match(text, "^[%s,]*and%s+", position) or string.match(text, "^[%s,]*", position)
421
+ return position + #(separator :: string)
422
+ end
423
+
424
+ function MetricUnitParseUtils._unitsNamedBy(unitText: string, index: UnitIndex): { MeasuredUnit }
425
+ local units: { MeasuredUnit } = {}
426
+ for _, unit in MetricUnitParseUtils._findUnitsBestFirst(unitText, index) do
427
+ table.insert(units, unit)
428
+ end
429
+ if #units > 0 then
430
+ return units
431
+ end
432
+ local composed = MetricUnitParseUtils._composeUnit(unitText, index)
433
+ return if composed then { composed } else {}
434
+ end
435
+
436
+ function MetricUnitParseUtils._wellKnownIndex(): UnitIndex
437
+ local found = wellKnownUnits
438
+ if found then
439
+ return found
440
+ end
441
+ local index = buildIndex(MetricUnitDefinitions.getDeclaredUnits(), nil)
442
+ for timeAlias, id in MetricUnitDefinitions.getTimeAliases() do
443
+ addSymbol(index, timeAlias, assert(MetricUnitDefinitions.getUnit(id), id), AliasPriority.TIME_ALIAS)
444
+ end
445
+ wellKnownUnits = index
446
+ return index
447
+ end
448
+
449
+ function MetricUnitParseUtils._indexFor(system: MetricUnitSystem.MetricUnitSystem?): UnitIndex
450
+ if not system then
451
+ return MetricUnitParseUtils._wellKnownIndex()
452
+ end
453
+ local index = INDEX_BY_SYSTEM[system]
454
+ if not index then
455
+ index = buildIndex(system:GetUnits(), MetricUnitParseUtils._indexFor(system:GetFallback()))
456
+ INDEX_BY_SYSTEM[system] = index
457
+ end
458
+ return index
459
+ end
460
+
461
+ function MetricUnitParseUtils._findUnitsBestFirst(text: string, index: UnitIndex): { UnitDefinition }
462
+ local spelledExactly = MetricUnitParseUtils._findUnitsNamed(text, index, true)
463
+ if #spelledExactly > 0 then
464
+ return spelledExactly
465
+ end
466
+ local prefixed = MetricUnitParseUtils._findPrefixedUnits(text, index)
467
+ if #prefixed > 0 then
468
+ return prefixed
469
+ end
470
+ return MetricUnitParseUtils._findUnitsNamed(text, index, false)
471
+ end
472
+
473
+ function MetricUnitParseUtils._findPrefixedUnits(text: string, index: UnitIndex): { UnitDefinition }
474
+ local lowercaseText = string.lower(text)
475
+ for _, spelling in MetricUnitParseUtils._prefixSpellingsLongestFirst() do
476
+ local start = if spelling.isName
477
+ then string.sub(lowercaseText, 1, #spelling.text)
478
+ else string.sub(text, 1, #spelling.text)
479
+ local rest = string.sub(text, #spelling.text + 1)
480
+ if start == spelling.text and rest ~= "" then
481
+ for _, base in MetricUnitParseUtils._findUnitsNamed(rest, index, false) do
482
+ local unit = MetricUnitDefinitions.getPrefixedUnit(spelling.prefix, base)
483
+ local isSpelledAsSiWritesIt = not spelling.isName
484
+ or (unit ~= nil and unit.id == spelling.text .. base.id)
485
+ if unit and isSpelledAsSiWritesIt then
486
+ return { unit }
487
+ end
488
+ end
489
+ end
490
+ end
491
+ return {}
492
+ end
493
+
494
+ function MetricUnitParseUtils._prefixSpellingsLongestFirst(): { PrefixSpelling }
495
+ local found = prefixSpellingsLongestFirst
496
+ if found then
497
+ return found
498
+ end
499
+ local spellings: { PrefixSpelling } = {}
500
+ for _, prefix in MetricUnitDefinitions.getPrefixes() do
501
+ table.insert(spellings, { prefix = prefix, text = prefix.symbol, isName = false })
502
+ table.insert(spellings, { prefix = prefix, text = prefix.name, isName = true })
503
+ local lastLetter = string.sub(prefix.name, -1)
504
+ if lastLetter == "a" or lastLetter == "o" then
505
+ table.insert(spellings, { prefix = prefix, text = string.sub(prefix.name, 1, -2), isName = true })
506
+ end
507
+ if prefix.name == "micro" then
508
+ table.insert(spellings, { prefix = prefix, text = "u", isName = false })
509
+ end
510
+ end
511
+ table.sort(spellings, function(a, b)
512
+ return #a.text > #b.text
513
+ end)
514
+ prefixSpellingsLongestFirst = spellings
515
+ return spellings
516
+ end
517
+
518
+ function MetricUnitParseUtils._findUnitsNamed(text: string, index: UnitIndex, matchCase: boolean): { UnitDefinition }
519
+ local lowercaseText = string.lower(text)
520
+ local matches: { UnitMatch }? = nil
521
+ local searched: UnitIndex? = index
522
+ while searched and not matches do
523
+ matches = searched.exactText[text] or (if matchCase then nil else searched.lowercaseNames[lowercaseText])
524
+ searched = searched.fallback
525
+ end
526
+ if not matches then
527
+ return {}
528
+ end
529
+
530
+ local ranked = table.clone(matches)
531
+ table.sort(ranked, function(a, b)
532
+ return a.priority < b.priority
533
+ end)
534
+
535
+ local units: { UnitDefinition } = {}
536
+ for _, match in ranked do
537
+ table.insert(units, match.unit)
538
+ end
539
+ return units
540
+ end
541
+
542
+ function MetricUnitParseUtils._hasCompositionMark(unitText: string): boolean
543
+ for _, mark in COMPOSITION_MARKS do
544
+ if string.find(unitText, mark, 1, true) then
545
+ return true
546
+ end
547
+ end
548
+ return false
549
+ end
550
+
551
+ function MetricUnitParseUtils._splitFactors(text: string): { string }
552
+ local factors: { string } = {}
553
+ for _, factorText in string.split((string.gsub(text, "·", "*")), "*") do
554
+ local trimmed = trim(factorText)
555
+ if trimmed ~= "" then
556
+ table.insert(factors, trimmed)
557
+ end
558
+ end
559
+ return factors
560
+ end
561
+
562
+ function MetricUnitParseUtils._composeUnit(unitText: string, index: UnitIndex): MeasuredUnit?
563
+ if not MetricUnitParseUtils._hasCompositionMark(unitText) then
564
+ return nil
565
+ end
566
+
567
+ local factor = 1
568
+ local dimensions: Dimensions = MetricUnitDimensionUtils.NONE
569
+ for position, part in string.split(unitText, "/") do
570
+ local isNumerator = position == 1
571
+ local exponentSign = if isNumerator then 1 else -1
572
+ local trimmedPart = trim(part)
573
+ if trimmedPart == "" then
574
+ if isNumerator then
575
+ continue
576
+ end
577
+ return nil
578
+ end
579
+
580
+ for _, factorText in MetricUnitParseUtils._splitFactors(trimmedPart) do
581
+ local unit, power = MetricUnitParseUtils._readUnitPower(factorText, index)
582
+ if not unit then
583
+ return nil
584
+ end
585
+ factor *= unit.factor ^ (exponentSign * power)
586
+ dimensions = MetricUnitDimensionUtils.combine(
587
+ dimensions,
588
+ MetricUnitDimensionUtils.scale(unit.dimensions, power),
589
+ exponentSign
590
+ )
591
+ end
592
+ end
593
+ return { factor = factor, dimensions = dimensions }
594
+ end
595
+
596
+ function MetricUnitParseUtils._readUnitPower(factorText: string, index: UnitIndex): (UnitDefinition?, number)
597
+ local unitText, power = MetricUnitParseUtils._splitPower(factorText)
598
+ local unit = MetricUnitParseUtils._findUnitsBestFirst(unitText, index)[1]
599
+ if unit and unit.per == nil then
600
+ return unit, power
601
+ end
602
+ return nil, power
603
+ end
604
+
605
+ function MetricUnitParseUtils._splitPower(factorText: string): (string, number)
606
+ for superscript, power in SUPERSCRIPT_POWERS do
607
+ if string.sub(factorText, -#superscript) == superscript then
608
+ return string.sub(factorText, 1, -#superscript - 1), power
609
+ end
610
+ end
611
+ local unitText, power = string.match(factorText, "^(.-)%^(%-?%d+)$")
612
+ if unitText then
613
+ return unitText, tonumber(power) :: number
614
+ end
615
+ return factorText, 1
616
+ end
617
+
618
+ function MetricUnitParseUtils._readBareNumber(
619
+ number: number,
620
+ expected: Expectation,
621
+ options: ParseOptions
622
+ ): (MetricUnitData?, string?)
623
+ if options.defaultUnit then
624
+ local system = options._system
625
+ local unit = if system
626
+ then system:GetUnit(options.defaultUnit)
627
+ else MetricUnitDefinitions.getUnit(options.defaultUnit)
628
+ assert(unit, string.format("Bad defaultUnit %q", tostring(options.defaultUnit)))
629
+ return MetricUnitParseUtils._unitData(number, unit, expected.absolute), nil
630
+ end
631
+
632
+ local expectedDimensions = expected.dimensions
633
+ if expectedDimensions and next(expectedDimensions) ~= nil then
634
+ return nil, MetricUnitParseUtils._addUnitMessage(number, expectedDimensions)
635
+ end
636
+ return { amount = number, dimensions = {} }, nil
637
+ end
638
+
639
+ function MetricUnitParseUtils._combineTerms(
640
+ terms: { Term },
641
+ text: string,
642
+ expected: Expectation
643
+ ): (MetricUnitData?, string?)
644
+ local dimensions = MetricUnitParseUtils._chooseDimensions(terms, expected.dimensions)
645
+ if #terms == 1 then
646
+ local unit, err = MetricUnitParseUtils._pickUnit(terms[1], dimensions, text)
647
+ if not unit then
648
+ return nil, err
649
+ end
650
+ return MetricUnitParseUtils._unitData(terms[1].number, unit, expected.absolute), nil
651
+ end
652
+ return MetricUnitParseUtils._sumTerms(terms, dimensions, text, expected)
653
+ end
654
+
655
+ function MetricUnitParseUtils._chooseDimensions(terms: { Term }, expectedDimensions: Dimensions?): Dimensions?
656
+ return expectedDimensions
657
+ or MetricUnitParseUtils._dimensionsEveryTermShares(terms)
658
+ or MetricUnitParseUtils._bestDimensionsOf(terms[1])
659
+ end
660
+
661
+ function MetricUnitParseUtils._dimensionsEveryTermShares(terms: { Term }): Dimensions?
662
+ for _, unit in terms[1].units do
663
+ if MetricUnitParseUtils._everyTermCanBe(terms, unit.dimensions) then
664
+ return unit.dimensions
665
+ end
666
+ end
667
+ return nil
668
+ end
669
+
670
+ function MetricUnitParseUtils._everyTermCanBe(terms: { Term }, dimensions: Dimensions): boolean
671
+ for _, term in terms do
672
+ if not MetricUnitParseUtils._termCanBe(term, dimensions) then
673
+ return false
674
+ end
675
+ end
676
+ return true
677
+ end
678
+
679
+ function MetricUnitParseUtils._termCanBe(term: Term, dimensions: Dimensions): boolean
680
+ for _, unit in term.units do
681
+ if MetricUnitDimensionUtils.equal(unit.dimensions, dimensions) then
682
+ return true
683
+ end
684
+ end
685
+ return false
686
+ end
687
+
688
+ function MetricUnitParseUtils._bestDimensionsOf(term: Term): Dimensions?
689
+ local best = term.units[1]
690
+ return if best then best.dimensions else nil
691
+ end
692
+
693
+ function MetricUnitParseUtils._pickUnit(term: Term, dimensions: Dimensions?, text: string): (MeasuredUnit?, string?)
694
+ for _, unit in term.units do
695
+ if dimensions == nil or MetricUnitDimensionUtils.equal(unit.dimensions, dimensions) then
696
+ return unit, nil
697
+ end
698
+ end
699
+ return nil, MetricUnitParseUtils._mismatchMessage(term.units[1].dimensions, dimensions :: Dimensions, text)
700
+ end
701
+
702
+ function MetricUnitParseUtils._sumTerms(
703
+ terms: { Term },
704
+ dimensions: Dimensions?,
705
+ text: string,
706
+ expected: Expectation
707
+ ): (MetricUnitData?, string?)
708
+ local amount = 0
709
+ local sumDimensions: Dimensions? = nil
710
+ local signOfTheWhole = if terms[1].number < 0 then -1 else 1
711
+ for _, term in terms do
712
+ if term.unitText == "" then
713
+ return nil, string.format("Add a unit to every number in %q", text)
714
+ end
715
+ local unit, err = MetricUnitParseUtils._pickUnit(term, dimensions, text)
716
+ if not unit then
717
+ return nil, err
718
+ end
719
+ local readingsArePoints = unit.zero ~= nil
720
+ if readingsArePoints then
721
+ return nil, string.format("Write a temperature as one number and unit, not %q", text)
722
+ end
723
+ amount += signOfTheWhole * math.abs(term.number) * unit.factor
724
+ sumDimensions = unit.dimensions
725
+ end
726
+
727
+ return {
728
+ amount = amount,
729
+ dimensions = table.clone(sumDimensions :: Dimensions),
730
+ absolute = if expected.absolute then true else nil,
731
+ },
732
+ nil
733
+ end
734
+
735
+ function MetricUnitParseUtils._unitData(number: number, unit: MeasuredUnit, expectedAbsolute: boolean?): MetricUnitData
736
+ local dimensions = table.clone(unit.dimensions)
737
+ local zero = unit.zero
738
+ local isDifferenceExpected = expectedAbsolute == false
739
+ if zero ~= nil and not isDifferenceExpected then
740
+ return { amount = number * unit.factor + zero, dimensions = dimensions, absolute = true }
741
+ end
742
+ return { amount = number * unit.factor, dimensions = dimensions, absolute = if expectedAbsolute then true else nil }
743
+ end
744
+
745
+ function MetricUnitParseUtils._addUnitMessage(number: number, dimensions: Dimensions): string
746
+ local unit = MetricUnitParseUtils._suggestedUnit(dimensions)
747
+ if not unit then
748
+ return string.format("Add a unit of %s", MetricUnitDimensionUtils.describe(dimensions))
749
+ end
750
+ local example = if UNSPACED_SYMBOLS[unit.symbol] then unit.symbol else " " .. unit.symbol
751
+ return string.format('Add a unit, such as "%s%s"', MetricUnitFormatUtils.formatNumber(number), example)
752
+ end
753
+
754
+ function MetricUnitParseUtils._suggestedUnit(dimensions: Dimensions): UnitDefinition?
755
+ local displayUnits = MetricUnitDisplayUnitUtils.getDisplayUnits(dimensions)
756
+ if displayUnits then
757
+ return MetricUnitDisplayUnitUtils.getBaseUnit(displayUnits)
758
+ end
759
+ local ids = MetricUnitDefinitions.getUnitIds(dimensions)
760
+ return if ids[1] then MetricUnitDefinitions.getUnit(ids[1]) else nil
761
+ end
762
+
763
+ function MetricUnitParseUtils._checkKind(
764
+ data: MetricUnitData,
765
+ expected: Expectation,
766
+ text: string
767
+ ): (MetricUnitData?, string?)
768
+ local expectedDimensions = expected.dimensions
769
+ if expectedDimensions == nil then
770
+ return data, nil
771
+ end
772
+ if not MetricUnitDimensionUtils.equal(data.dimensions, expectedDimensions) then
773
+ return nil, MetricUnitParseUtils._mismatchMessage(data.dimensions, expectedDimensions, text)
774
+ end
775
+
776
+ local isAbsolute = data.absolute == true
777
+ local isAbsoluteExpected = expected.absolute == true
778
+ if isAbsolute ~= isAbsoluteExpected then
779
+ return nil,
780
+ string.format(
781
+ "Expected %s, but %q is %s",
782
+ MetricUnitKinds.describeKind(expectedDimensions, isAbsoluteExpected),
783
+ text,
784
+ MetricUnitKinds.describeKind(data.dimensions, isAbsolute)
785
+ )
786
+ end
787
+ return data, nil
788
+ end
789
+
790
+ function MetricUnitParseUtils._mismatchMessage(got: Dimensions, expected: Dimensions, text: string): string
791
+ return string.format(
792
+ "Expected %s, but %q is %s",
793
+ MetricUnitDimensionUtils.describe(expected),
794
+ text,
795
+ MetricUnitDimensionUtils.describe(got)
796
+ )
797
+ end
798
+
799
+ return table.freeze(MetricUnitParseUtils)