├── go.mod ├── .travis.yml ├── exploder.go ├── types_test.go ├── exploder_test.go ├── .github └── workflows │ ├── go.yml │ └── codeql-analysis.yml ├── dictionary_test.go ├── types.go ├── go.sum ├── LICENSE ├── example_test.go ├── README.md ├── number.go ├── number_test.go ├── numbers.go ├── numbers_test.go ├── numwords.go ├── numwords_test.go ├── dictionary.go ├── patterns_test.go └── patterns.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rodaine/numwords 2 | 3 | go 1.14 4 | 5 | require github.com/stretchr/testify v1.6.1 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: go 3 | go: 1.5 4 | 5 | branches: 6 | only: 7 | - master 8 | 9 | install: 10 | - go get -t ./... 11 | - go get -u github.com/kisielk/errcheck 12 | - go get -u github.com/golang/lint/golint 13 | 14 | script: 15 | - gofmt -l . 16 | - golint ./... 17 | - go tool vet -test . 18 | - errcheck ./... 19 | - go test -race -cover ./... 20 | -------------------------------------------------------------------------------- /exploder.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "bufio" 5 | "strings" 6 | ) 7 | 8 | func explode(s string) (out []string) { 9 | s = strings.Replace(s, "-", " ", -1) 10 | s = strings.Replace(s, ",", "", -1) 11 | 12 | r := strings.NewReader(s) 13 | scanner := bufio.NewScanner(r) 14 | scanner.Split(bufio.ScanWords) 15 | 16 | for scanner.Scan() { 17 | out = append(out, scanner.Text()) 18 | } 19 | 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /types_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestTypes_String(t *testing.T) { 10 | t.Parallel() 11 | 12 | for typ, expected := range typeStrings { 13 | assert.Equal(t, expected, typ.String()) 14 | } 15 | assert.Equal(t, "_", numberType(-1).String()) 16 | } 17 | 18 | func TestTypes_MaxType(t *testing.T) { 19 | t.Parallel() 20 | 21 | assert.Equal(t, numSingle, maxType(numDirect, numSingle)) 22 | assert.Equal(t, numDone, maxType(numDone, numBigOrdinal)) 23 | assert.Equal(t, numTens, maxType(numTens, numTens)) 24 | } 25 | -------------------------------------------------------------------------------- /exploder_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExploder_Explode(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | in string 14 | expected []string 15 | }{ 16 | {"1,000,000 dollars", []string{"1000000", "dollars"}}, 17 | {"Foo Bar", []string{"Foo", "Bar"}}, 18 | {"twenty-one", []string{"twenty", "one"}}, 19 | {"Nintey-Nine Red Balloons, by Nena", []string{"Nintey", "Nine", "Red", "Balloons", "by", "Nena"}}, 20 | } 21 | 22 | for _, test := range tests { 23 | assert.EqualValues(t, test.expected, explode(test.in), "%+v", test) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.14 20 | id: go 21 | 22 | - name: Check out code into the Go module directory 23 | uses: actions/checkout@v2 24 | 25 | - name: Get dependencies 26 | run: go get -v -t -d ./... 27 | 28 | - name: Build 29 | run: go build -v ./... 30 | 31 | - name: Test 32 | run: go test -v -race -cover ./... 33 | -------------------------------------------------------------------------------- /dictionary_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDictionary_IncludeSecond(t *testing.T) { 10 | t.Parallel() 11 | 12 | n, ok := dictionary.m["second"] 13 | assert.True(t, ok) 14 | 15 | IncludeSecond(false) 16 | n, ok = dictionary.m["second"] 17 | assert.False(t, ok) 18 | 19 | IncludeSecond(true) 20 | n, ok = dictionary.m["second"] 21 | assert.True(t, ok) 22 | assert.EqualValues(t, second, n) 23 | } 24 | 25 | func TestDictionary_LookupNumber(t *testing.T) { 26 | t.Parallel() 27 | 28 | _, ok := lookupNumber("one") 29 | assert.True(t, ok) 30 | 31 | _, ok = lookupNumber("ONE") 32 | assert.True(t, ok) 33 | 34 | _, ok = lookupNumber("foobar") 35 | assert.False(t, ok) 36 | } 37 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | type numberType int8 4 | 5 | const ( 6 | numAnd numberType = iota 7 | numDirect 8 | numSingle 9 | numTens 10 | numBig 11 | numFraction 12 | numDirectOrdinal 13 | numSingleOrdinal 14 | numTensOrdinal 15 | numBigOrdinal 16 | numDone 17 | ) 18 | 19 | var typeStrings = map[numberType]string{ 20 | numAnd: "&", 21 | numDirect: "d", 22 | numSingle: "s", 23 | numTens: "t", 24 | numBig: "b", 25 | numFraction: "f", 26 | numDirectOrdinal: "D", 27 | numSingleOrdinal: "S", 28 | numTensOrdinal: "T", 29 | numBigOrdinal: "B", 30 | } 31 | 32 | func (t numberType) String() string { 33 | if s, ok := typeStrings[t]; ok { 34 | return s 35 | } 36 | return "_" 37 | } 38 | 39 | func maxType(a, b numberType) numberType { 40 | if a > b { 41 | return a 42 | } 43 | return b 44 | } 45 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 7 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 11 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Roche 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import "fmt" 4 | 5 | func Example() { 6 | s := "I've got three apples and two and a half bananas" 7 | fmt.Println(ParseString(s)) 8 | 9 | s = "My chili won second place at the county fair" 10 | fmt.Println(ParseString(s)) 11 | 12 | i, _ := ParseInt("fourteen ninety two") 13 | fmt.Println(i) 14 | 15 | f, _ := ParseFloat("eight and three quarters") 16 | fmt.Println(f) 17 | 18 | // Output: 19 | // I've got 3 apples and 2.5 bananas 20 | // My chili won 2nd place at the county fair 21 | // 1492 22 | // 8.75 23 | } 24 | 25 | func ExampleParseString() { 26 | s := "I've got three apples and two and a half bananas" 27 | fmt.Println(ParseString(s)) 28 | 29 | // Output: 30 | // I've got 3 apples and 2.5 bananas 31 | } 32 | 33 | func ExampleParseFloat() { 34 | f, _ := ParseFloat("eight and three quarters") 35 | fmt.Println(f) 36 | 37 | // Output: 38 | // 8.75 39 | } 40 | 41 | func ExampleParseInt() { 42 | i, _ := ParseInt("fourteen ninety two") 43 | fmt.Println(i) 44 | 45 | // Output: 46 | // 1492 47 | } 48 | 49 | func ExampleIncludeSecond() { 50 | s := "My chili won second place at the county fair" 51 | fmt.Println(ParseString(s)) 52 | 53 | s = "One second ago" 54 | IncludeSecond(false) 55 | fmt.Println(ParseString(s)) 56 | 57 | // Output: 58 | // My chili won 2nd place at the county fair 59 | // 1 second ago 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # numwords
[![Build Status](https://travis-ci.org/rodaine/numwords.svg)](https://travis-ci.org/rodaine/numwords) [![GoDoc](https://godoc.org/github.com/rodaine/numwords?status.svg)](https://godoc.org/github.com/rodaine/numwords) 2 | 3 | **numwords** is a utility package for Go (golang) that converts natural language numbers 4 | to their actual numeric values. The numbers can be parsed out as strings, 5 | integers, or floats as desired. 6 | 7 | ```go 8 | func Example() { 9 | s := "I've got three apples and two and a half bananas" 10 | fmt.Println(ParseString(s)) 11 | 12 | s = "My chili won second place at the county fair" 13 | fmt.Println(ParseString(s)) 14 | 15 | i, _ := ParseInt("fourteen ninety two") 16 | fmt.Println(i) 17 | 18 | f, _ := ParseFloat("eight and three quarters") 19 | fmt.Println(f) 20 | 21 | // Output: 22 | // I've got 3 apples and 2.5 bananas 23 | // My chili won 2nd place at the county fair 24 | // 1492 25 | // 8.75 26 | } 27 | ``` 28 | 29 | This package is largely inspired by the excellent [Numerizer Ruby gem](https://github.com/jduff/numerizer). 30 | 31 | ## Some Valid Conversions 32 | 33 | | String Input | Output Value | 34 | | :------ | ----: | 35 | | fifteen | 15 | 36 | | twenty five | 25 | 37 | | twenty-five | 25 | 38 | | eleven hundred | 1100 | 39 | | three hundred twenty five | 325 | 40 | | three hundred thousand | 300000 | 41 | | one hundred twenty one | 121 | 42 | | fourteen hundred sixty seven | 1467 | 43 | | nineteen eighty-eight | 1988 | 44 | | nine hundred and ninety nine | 999 | 45 | | a half | 0.5 | 46 | | three halves | 1.5 | 47 | | a quarter | 0.25 | 48 | | three quarters | 0.75 | 49 | | one ninth | 0.111111 | 50 | | two thirds | 0.666667 | 51 | | two and three eighths | 2.375 | 52 | | zeroth | 0th | 53 | | twenty second | 22nd | 54 | | 5 hundred | 500 | 55 | | one million two hundred fifty thousand and seven | 1250007 | 56 | 57 | ## License 58 | 59 | This package is released under the MIT [License](https://github.com/rodaine/numwords/blob/master/LICENSE). 60 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '18 17 * * 6' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['go'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /number.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "math/big" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type number struct { 10 | numerator int 11 | denominator int 12 | typ numberType 13 | ordinal bool 14 | } 15 | 16 | var ordinals = map[int]string{ 17 | 1: "st", 18 | 2: "nd", 19 | 3: "rd", 20 | 4: "th", 21 | } 22 | 23 | func (n number) Value() float64 { 24 | return float64(n.numerator) / float64(n.denominator) 25 | } 26 | 27 | func (n number) String() string { 28 | if n.ordinal { 29 | suffix := "th" 30 | if r := n.numerator % 10; r > 0 && r < 4 { 31 | if rr := n.numerator % 100; rr < 11 || rr > 13 { 32 | suffix = ordinals[r] 33 | } 34 | } 35 | return strconv.Itoa(n.numerator) + suffix 36 | } 37 | 38 | if n.denominator != 1 { 39 | s := strconv.FormatFloat(n.Value(), 'f', 6, 64) 40 | return strings.TrimRight(s, ".0") 41 | } 42 | 43 | return strconv.Itoa(n.numerator) 44 | } 45 | 46 | func maybeNumeric(s string) (n number, ok bool) { 47 | s = strings.Replace(s, ",", "", -1) 48 | 49 | ord := false 50 | for _, suffix := range ordinals { 51 | if strings.HasSuffix(s, suffix) { 52 | ord = true 53 | s = strings.TrimSuffix(s, suffix) 54 | break 55 | } 56 | } 57 | 58 | if i, err := strconv.Atoi(s); err == nil { 59 | ok = true 60 | n.numerator = i 61 | n.denominator = 1 62 | n.ordinal = ord 63 | } else if f, err := strconv.ParseFloat(s, 64); err == nil { 64 | rat := big.NewRat(1, 1).SetFloat64(f) 65 | if rat != nil { 66 | ok = true 67 | n.numerator = int(rat.Num().Int64()) 68 | n.denominator = int(rat.Denom().Int64()) 69 | n.typ = numFraction 70 | } 71 | return 72 | } else { 73 | return 74 | } 75 | 76 | if !ord { 77 | switch { 78 | case n.numerator < 10 && n.numerator > 0: 79 | n.typ = numSingle 80 | case n.numerator >= 20 && n.numerator < 100: 81 | n.typ = numTens 82 | case n.numerator >= 100: 83 | n.typ = numBig 84 | default: 85 | n.typ = numDirect 86 | } 87 | } else { 88 | switch { 89 | case n.numerator < 10 && n.numerator > 0: 90 | n.typ = numSingleOrdinal 91 | case n.numerator >= 20 && n.numerator < 100: 92 | n.typ = numTensOrdinal 93 | case n.numerator >= 100: 94 | n.typ = numBigOrdinal 95 | default: 96 | n.typ = numDirectOrdinal 97 | } 98 | } 99 | 100 | return 101 | } 102 | -------------------------------------------------------------------------------- /number_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNumber_Value(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | n int 14 | d int 15 | expected float64 16 | }{ 17 | {1, 1, 1}, 18 | {2, 1, 2}, 19 | {1, 2, 0.5}, 20 | } 21 | 22 | for _, test := range tests { 23 | n := number{numerator: test.n, denominator: test.d} 24 | assert.Equal(t, test.expected, n.Value(), "%+v", test) 25 | } 26 | } 27 | 28 | func TestNumber_String(t *testing.T) { 29 | t.Parallel() 30 | 31 | tests := []struct { 32 | n int 33 | d int 34 | o bool 35 | expected string 36 | }{ 37 | {1, 1, false, "1"}, 38 | {2, 1, false, "2"}, 39 | {1, 2, false, "0.5"}, 40 | {1, 3, false, "0.333333"}, 41 | {2, 3, false, "0.666667"}, 42 | {1, 1, true, "1st"}, 43 | {2, 1, true, "2nd"}, 44 | {3, 1, true, "3rd"}, 45 | {4, 1, true, "4th"}, 46 | {11, 1, true, "11th"}, 47 | {12, 1, true, "12th"}, 48 | {13, 1, true, "13th"}, 49 | {21, 1, true, "21st"}, 50 | {22, 1, true, "22nd"}, 51 | {23, 1, true, "23rd"}, 52 | {24, 1, true, "24th"}, 53 | {1, 2, true, "1st"}, 54 | } 55 | 56 | for _, test := range tests { 57 | n := number{ 58 | numerator: test.n, 59 | denominator: test.d, 60 | ordinal: test.o, 61 | } 62 | assert.Equal(t, test.expected, n.String(), "%+v", test) 63 | } 64 | } 65 | 66 | func TestNumber_MaybeNumeric(t *testing.T) { 67 | t.Parallel() 68 | 69 | tests := []struct { 70 | in string 71 | val float64 72 | typ numberType 73 | ok bool 74 | }{ 75 | {in: "foo", ok: false}, 76 | {in: "fist", ok: false}, 77 | 78 | {"12", float64(12), numDirect, true}, 79 | {"2", float64(2), numSingle, true}, 80 | {"22", float64(22), numTens, true}, 81 | {"222", float64(222), numBig, true}, 82 | 83 | {"0.5", float64(.5), numFraction, true}, 84 | 85 | {"12th", float64(12), numDirectOrdinal, true}, 86 | {"2nd", float64(2), numSingleOrdinal, true}, 87 | {"22nd", float64(22), numTensOrdinal, true}, 88 | {"222nd", float64(222), numBigOrdinal, true}, 89 | 90 | {"2,222,222", float64(2222222), numBig, true}, 91 | } 92 | 93 | for _, test := range tests { 94 | n, ok := maybeNumeric(test.in) 95 | if assert.Equal(t, test.ok, ok) && ok { 96 | assert.Equal(t, test.val, n.Value()) 97 | assert.Equal(t, test.typ, n.typ) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /numbers.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | // ErrNoNumbers is returned if the a value for numbers is requested but there are 10 | // no number values inside of it 11 | ErrNoNumbers = errors.New("the input contains no number values") 12 | 13 | // ErrManyNumbers is returned if the value for numbers is requested but there are 14 | // more than one number values inside of it 15 | ErrManyNumbers = errors.New("the input contains more than one number") 16 | 17 | // ErrNonNumber is returned if ParseInt or ParseFloat encounters a non-number in 18 | // the input string. 19 | ErrNonNumber = errors.New("the string contains a non-number") 20 | ) 21 | 22 | type numbers []number 23 | 24 | // Pattern returns the string that represents the types of numbers in it 25 | func (ns numbers) pattern() (p string) { 26 | for _, n := range ns { 27 | p += n.typ.String() 28 | } 29 | return 30 | } 31 | 32 | // Strings gets the string representations of each contained number 33 | func (ns numbers) strings() []string { 34 | buf := make([]string, len(ns)) 35 | for i, n := range ns { 36 | buf[i] = n.String() 37 | } 38 | return buf 39 | } 40 | 41 | // String returns the space separated string representation of the numbers 42 | func (ns numbers) String() string { 43 | return strings.Join(ns.strings(), " ") 44 | } 45 | 46 | // Float returns a single value for the post-reduced numbers. If the length of 47 | // numbers is not one, an error is returned instead 48 | func (ns numbers) Float() (float64, error) { 49 | if len(ns) == 0 { 50 | return -1, ErrNoNumbers 51 | } else if len(ns) > 1 { 52 | return -1, ErrManyNumbers 53 | } 54 | 55 | return ns[0].Value(), nil 56 | } 57 | 58 | // Int returns a single integer value for the post-reduced numbers, similar to 59 | // number.Float. 60 | func (ns numbers) Int() (int, error) { 61 | out, err := ns.Float() 62 | return int(out), err 63 | } 64 | 65 | func (ns numbers) flush(s []string) []string { 66 | if len(ns) > 0 { 67 | s = append(s, reduce(ns).strings()...) 68 | } 69 | return s 70 | } 71 | 72 | // Reduce destructivley converts the numbers set to its minimal form based on 73 | // known numeric patterns. NB: the input slice will be modified significantly 74 | func reduce(ns numbers) numbers { 75 | for found := true; found; { 76 | found = false 77 | pattern := ns.pattern() 78 | for _, p := range patterns { 79 | if idx := strings.LastIndex(pattern, p); idx >= 0 { 80 | found = true 81 | ns = patternHandlers[p](ns, idx) 82 | break 83 | } 84 | } 85 | } 86 | return ns 87 | } 88 | -------------------------------------------------------------------------------- /numbers_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNumbers_Pattern(t *testing.T) { 10 | t.Parallel() 11 | 12 | ns := numbers{ 13 | {typ: numAnd}, 14 | {typ: numDirect}, 15 | {typ: numSingle}, 16 | {typ: numTens}, 17 | {typ: numBig}, 18 | {typ: numFraction}, 19 | {typ: numDirectOrdinal}, 20 | {typ: numSingleOrdinal}, 21 | {typ: numTensOrdinal}, 22 | {typ: numBigOrdinal}, 23 | {typ: numDone}, 24 | {typ: numDone + 1}, 25 | } 26 | 27 | assert.Equal(t, "&dstbfDSTB__", ns.pattern()) 28 | } 29 | 30 | func TestNumbers_Strings(t *testing.T) { 31 | t.Parallel() 32 | 33 | ns := numbers{ 34 | number{numerator: 1000, denominator: 1, typ: numBig}, 35 | number{numerator: 2, denominator: 1, typ: numSingleOrdinal, ordinal: true}, 36 | number{numerator: 1, denominator: 2, typ: numFraction}, 37 | } 38 | 39 | assert.EqualValues(t, []string{"1000", "2nd", "0.5"}, ns.strings()) 40 | } 41 | 42 | func TestNumbers_String(t *testing.T) { 43 | t.Parallel() 44 | 45 | ns := numbers{ 46 | number{numerator: 1000, denominator: 1, typ: numBig}, 47 | number{numerator: 2, denominator: 1, typ: numSingleOrdinal, ordinal: true}, 48 | number{numerator: 1, denominator: 2, typ: numFraction}, 49 | } 50 | 51 | assert.EqualValues(t, "1000 2nd 0.5", ns.String()) 52 | } 53 | 54 | func TestNumbers_Reduce(t *testing.T) { 55 | t.Parallel() 56 | 57 | ns := numbers{ 58 | number{numerator: 1000, denominator: 1, typ: numBig}, 59 | number{numerator: 2, denominator: 1, typ: numSingle}, 60 | number{numerator: 100, denominator: 1, typ: numBig}, 61 | } 62 | 63 | out := reduce(ns) 64 | assert.Len(t, out, 1) 65 | assert.Equal(t, float64(1200), out[0].Value()) 66 | assert.Equal(t, numBig, out[0].typ) 67 | } 68 | 69 | func TestNumbers_Float(t *testing.T) { 70 | t.Parallel() 71 | 72 | ns := numbers{ 73 | number{numerator: 3, denominator: 2}, 74 | } 75 | 76 | out, err := ns.Float() 77 | assert.NoError(t, err) 78 | assert.Equal(t, float64(1.5), out) 79 | 80 | ns = append(ns, number{}) 81 | 82 | _, err = ns.Float() 83 | assert.Equal(t, ErrManyNumbers, err) 84 | 85 | ns = ns[:0] 86 | _, err = ns.Float() 87 | assert.Equal(t, ErrNoNumbers, err) 88 | } 89 | 90 | func TestNumbers_Int(t *testing.T) { 91 | t.Parallel() 92 | 93 | ns := numbers{ 94 | number{numerator: 3, denominator: 2}, 95 | } 96 | 97 | out, err := ns.Int() 98 | assert.NoError(t, err) 99 | assert.Equal(t, 1, out) 100 | 101 | ns = append(ns, number{}) 102 | 103 | _, err = ns.Int() 104 | assert.Equal(t, ErrManyNumbers, err) 105 | 106 | ns = ns[:0] 107 | _, err = ns.Int() 108 | assert.Equal(t, ErrNoNumbers, err) 109 | } 110 | -------------------------------------------------------------------------------- /numwords.go: -------------------------------------------------------------------------------- 1 | // Package numwords is a utility that converts textual numbers to their 2 | // actual numeric values. The converted numbers can be parsed out as strings, 3 | // integers, or floats as desired. 4 | // 5 | // Source: https://github.com/rodaine/numwords 6 | package numwords 7 | 8 | import "strings" 9 | 10 | // ParseFloat reads a text string and converts it to its float value. An error 11 | // is returned if the if the string cannot be resolved to a single float value. 12 | func ParseFloat(s string) (float64, error) { 13 | in := explode(s) 14 | buf := numbers{} 15 | 16 | ok := false 17 | for i := range in { 18 | if buf, ok = readIntoBuffer(i, in, buf); !ok { 19 | return -1, ErrNonNumber 20 | } 21 | } 22 | 23 | return reduce(buf).Float() 24 | } 25 | 26 | // ParseInt reads a text string and converts it to its integer value. An error 27 | // is returned if the if the string cannot be resolved to a single integer value. 28 | // Fractional portions of the number will be truncated. 29 | func ParseInt(s string) (int, error) { 30 | in := explode(s) 31 | buf := numbers{} 32 | 33 | ok := false 34 | for i := range in { 35 | if buf, ok = readIntoBuffer(i, in, buf); !ok { 36 | return -1, ErrNonNumber 37 | } 38 | } 39 | 40 | return reduce(buf).Int() 41 | } 42 | 43 | // ParseString reads a text string and converts all numbers contained within to 44 | // their appropriate values. Integers are preserved exactly while floating point 45 | // numbers are limited to six decimal places. The rest of the string is preserved. 46 | func ParseString(s string) string { 47 | in := explode(s) 48 | out := ParseStrings(in) 49 | return strings.Join(out, " ") 50 | } 51 | 52 | // ParseStrings performs the same actions as ParseString but operates on a pre- 53 | // sanitized and split string. This method is exposed for convenience if further 54 | // processing of the string is required. 55 | func ParseStrings(in []string) []string { 56 | out := make([]string, 0, 1) 57 | buf := numbers{} 58 | 59 | ok := false 60 | for i, s := range in { 61 | if buf, ok = readIntoBuffer(i, in, buf); !ok { 62 | out = buf.flush(out) 63 | buf = buf[:0] 64 | out = append(out, s) 65 | } 66 | } 67 | 68 | return buf.flush(out) 69 | } 70 | 71 | func readIntoBuffer(i int, in []string, buf numbers) (out numbers, ok bool) { 72 | s := in[i] 73 | n, ok := lookupNumber(s) 74 | 75 | if ok && n.typ != numAnd { 76 | buf = append(buf, n) 77 | return buf, ok 78 | } else if ok && n.typ == numAnd && shouldIncludeAnd(in, buf, i) { 79 | buf = append(buf, n) 80 | return buf, ok 81 | } else if n, ok = maybeNumeric(s); ok { 82 | buf = append(buf, n) 83 | return buf, ok 84 | } 85 | 86 | return buf, false 87 | } 88 | 89 | func shouldIncludeAnd(in []string, buf numbers, idx int) bool { 90 | if len(buf) == 0 || idx+1 >= len(in) { 91 | return false 92 | } 93 | 94 | prev := buf[len(buf)-1] 95 | 96 | if prev.ordinal || prev.typ == numFraction { 97 | return false 98 | } 99 | 100 | s := in[idx+1] 101 | if _, ok := lookupNumber(s); !ok { 102 | _, ok = maybeNumeric(s) 103 | return ok 104 | } 105 | 106 | return true 107 | } 108 | -------------------------------------------------------------------------------- /numwords_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNumWords_ParseFloat(t *testing.T) { 10 | t.Parallel() 11 | 12 | _, err := ParseFloat("foobar") 13 | assert.Equal(t, ErrNonNumber, err) 14 | 15 | tests := []struct { 16 | in string 17 | out float64 18 | }{ 19 | {"one half", 0.5}, 20 | {"one quarter", 0.25}, 21 | {"three and a quarter", 3.25}, 22 | {"one fifth", 0.2}, 23 | {"nineteen eighty eight", 1988}, 24 | } 25 | 26 | for _, test := range tests { 27 | f, err := ParseFloat(test.in) 28 | if assert.NoError(t, err) { 29 | assert.Equal(t, test.out, f, test.in) 30 | } 31 | } 32 | } 33 | 34 | func TestNumWords_ParseInt(t *testing.T) { 35 | t.Parallel() 36 | 37 | _, err := ParseInt("foobar") 38 | assert.Equal(t, ErrNonNumber, err) 39 | 40 | tests := []struct { 41 | in string 42 | out int 43 | }{ 44 | {"twelve", 12}, 45 | {"twelve and a half", 12}, 46 | {"zero", 0}, 47 | {"nineteen eighty eight", 1988}, 48 | } 49 | 50 | for _, test := range tests { 51 | i, err := ParseInt(test.in) 52 | if assert.NoError(t, err) { 53 | assert.Equal(t, test.out, i, test.in) 54 | } 55 | } 56 | } 57 | 58 | func TestNumWords_ParseString(t *testing.T) { 59 | t.Parallel() 60 | 61 | tests := []struct { 62 | in string 63 | out string 64 | }{ 65 | {"foo", "foo"}, 66 | {"foo bar baz", "foo bar baz"}, 67 | {"foo eleven", "foo 11"}, 68 | {"a foo", "1 foo"}, 69 | {"zero bar three foo", "0 bar 3 foo"}, 70 | {"fifteen", "15"}, 71 | {"ten eleven", "10 11"}, 72 | {"one", "1"}, 73 | {"two three", "2 3"}, 74 | {"fifteen three", "15 3"}, 75 | {"seven eighteen", "7 18"}, 76 | {"one eighteen seven thirteen three three", "1 18 7 13 3 3"}, 77 | {"twenty", "20"}, 78 | {"twenty five", "25"}, 79 | {"twenty zero", "20 0"}, 80 | {"twenty five twenty", "25 20"}, 81 | {"zero twenty thirty", "0 20 30"}, 82 | {"three twenty three", "3 23"}, 83 | {"ninety nine red balloons", "99 red balloons"}, 84 | {"hundred", "100"}, 85 | {"eleven hundred", "1100"}, 86 | {"hundred eleven", "111"}, 87 | {"four thousand", "4000"}, 88 | {"thousand four", "1004"}, 89 | {"three hundred twenty five", "325"}, 90 | {"three hundred thousand", "300000"}, 91 | {"twenty five hundred", "2500"}, 92 | {"one hundred twenty one", "121"}, 93 | {"thousand one hundred", "1100"}, 94 | {"four hundred thirty one", "431"}, 95 | {"fourteen hundred sixty seven", "1467"}, 96 | {"one thousand four hundred sixty seven", "1467"}, 97 | {"four thousand three hundred twenty one", "4321"}, 98 | {"one million three hundred thousand", "1300000"}, 99 | {"nineteen eighty eight", "1988"}, 100 | {"twenty ten", "2010"}, 101 | {"one half", "0.5"}, 102 | {"three halves", "1.5"}, 103 | {"one ninth", "0.111111"}, 104 | {"one twentieth", "0.05"}, 105 | {"one sixteenth", "0.0625"}, 106 | {"one hundredth", "0.01"}, 107 | {"seven o'clock", "7 o'clock"}, 108 | {"two thirds", "0.666667"}, 109 | {"one quarter of americans were born before nineteen eighty", "0.25 of americans were born before 1980"}, 110 | {"ten fourtieths", "0.25"}, 111 | {"nine hundred and ninety nine", "999"}, 112 | {"zeroth", "0th"}, 113 | {"one", "1"}, 114 | {"five", "5"}, 115 | {"ten", "10"}, 116 | {"twenty seven", "27"}, 117 | {"forty one", "41"}, 118 | {"fourty two", "42"}, 119 | {"a hundred", "100"}, 120 | {"one hundred", "100"}, 121 | {"one hundred and fifty", "150"}, 122 | {"5 hundred", "500"}, 123 | {"one thousand", "1000"}, 124 | {"one thousand two hundred", "1200"}, 125 | {"seventeen thousand", "17000"}, 126 | {"twenty one thousand four hundred and seventy three", "21473"}, 127 | {"seventy four thousand and two", "74002"}, 128 | {"ninety nine thousand nine hundred ninety nine", "99999"}, 129 | {"100 thousand", "100000"}, 130 | {"two hundred fifty thousand", "250000"}, 131 | {"one million two hundred fifty thousand and seven", "1250007"}, 132 | {"the world population is seven billion two hundred seventy five million five hundred seventy eight thousand eight hundred eighty seven", "the world population is 7275578887"}, 133 | {"two and a half", "2.5"}, 134 | {"1 quarter", "0.25"}, 135 | {"three quarters", "0.75"}, 136 | {"one and a quarter", "1.25"}, 137 | {"two & three eighths", "2.375"}, 138 | {"1/2", "1/2"}, 139 | {"07/10", "07/10"}, 140 | {"three sixteenths", "0.1875"}, 141 | } 142 | 143 | for _, test := range tests { 144 | assert.Equal(t, test.out, ParseString(test.in), test.in) 145 | } 146 | } 147 | 148 | func TestNumWords_ShouldIncludeAnd(t *testing.T) { 149 | t.Parallel() 150 | 151 | in := []string{"cat", "and"} 152 | buf := numbers{} 153 | ok := shouldIncludeAnd(in, buf, 1) 154 | assert.False(t, ok, "empty buffer, nothing to and") 155 | 156 | in = []string{"two", "and"} 157 | buf = numbers{number{}} 158 | ok = shouldIncludeAnd(in, buf, 1) 159 | assert.False(t, ok, "no more input strings available") 160 | 161 | in = []string{"2nd", "and", "three"} 162 | buf = numbers{number{ordinal: true}} 163 | ok = shouldIncludeAnd(in, buf, 1) 164 | assert.False(t, ok, "previous is ordinal") 165 | 166 | in = []string{"half", "and", "three"} 167 | buf = numbers{number{typ: numFraction}} 168 | ok = shouldIncludeAnd(in, buf, 1) 169 | assert.False(t, ok, "previous is a fraction") 170 | 171 | in = []string{"two", "and", "foo"} 172 | buf = numbers{number{}} 173 | ok = shouldIncludeAnd(in, buf, 1) 174 | assert.False(t, ok, "next is not a number") 175 | 176 | in = []string{"two", "and", "3"} 177 | buf = numbers{number{}} 178 | ok = shouldIncludeAnd(in, buf, 1) 179 | assert.True(t, ok, "numeric is ok") 180 | 181 | in = []string{"two", "and", "three"} 182 | buf = numbers{number{}} 183 | ok = shouldIncludeAnd(in, buf, 1) 184 | assert.True(t, ok, "the ideal case") 185 | } 186 | -------------------------------------------------------------------------------- /dictionary.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | ) 7 | 8 | var second = number{2, 1, numSingleOrdinal, true} 9 | 10 | var dictionary = struct { 11 | sync.RWMutex 12 | m map[string]number 13 | }{ 14 | m: map[string]number{ 15 | // Direct 16 | "zero": {0, 1, numDirect, false}, 17 | "a": {1, 1, numDirect, false}, 18 | "ten": {10, 1, numDirect, false}, 19 | "eleven": {11, 1, numDirect, false}, 20 | "twelve": {12, 1, numDirect, false}, 21 | "thirteen": {13, 1, numDirect, false}, 22 | "fourteen": {14, 1, numDirect, false}, 23 | "forteen": {14, 1, numDirect, false}, 24 | "fifteen": {15, 1, numDirect, false}, 25 | "sixteen": {16, 1, numDirect, false}, 26 | "seventeen": {17, 1, numDirect, false}, 27 | "eighteen": {18, 1, numDirect, false}, 28 | "nineteen": {19, 1, numDirect, false}, 29 | "ninteen": {19, 1, numDirect, false}, 30 | 31 | // Single 32 | "one": {1, 1, numSingle, false}, 33 | "two": {2, 1, numSingle, false}, 34 | "three": {3, 1, numSingle, false}, 35 | "four": {4, 1, numSingle, false}, 36 | "five": {5, 1, numSingle, false}, 37 | "six": {6, 1, numSingle, false}, 38 | "seven": {7, 1, numSingle, false}, 39 | "eight": {8, 1, numSingle, false}, 40 | "nine": {9, 1, numSingle, false}, 41 | 42 | // Tens 43 | "twenty": {20, 1, numTens, false}, 44 | "thirty": {30, 1, numTens, false}, 45 | "forty": {40, 1, numTens, false}, 46 | "fourty": {40, 1, numTens, false}, 47 | "fifty": {50, 1, numTens, false}, 48 | "sixty": {60, 1, numTens, false}, 49 | "seventy": {70, 1, numTens, false}, 50 | "eighty": {80, 1, numTens, false}, 51 | "ninety": {90, 1, numTens, false}, 52 | 53 | // Bigs 54 | "hundred": {100, 1, numBig, false}, 55 | "thousand": {1000, 1, numBig, false}, 56 | "million": {1000000, 1, numBig, false}, 57 | "billion": {1000000000, 1, numBig, false}, 58 | "trillion": {1000000000000, 1, numBig, false}, 59 | 60 | // Fractions 61 | "half": {1, 2, numFraction, false}, 62 | "halve": {1, 2, numFraction, false}, 63 | "halfs": {1, 2, numFraction, false}, 64 | "halves": {1, 2, numFraction, false}, 65 | "thirds": {1, 3, numFraction, false}, 66 | "fourths": {1, 4, numFraction, false}, 67 | "quarter": {1, 4, numFraction, false}, 68 | "quarters": {1, 4, numFraction, false}, 69 | "fifths": {1, 5, numFraction, false}, 70 | "sixths": {1, 6, numFraction, false}, 71 | "sevenths": {1, 7, numFraction, false}, 72 | "eighths": {1, 8, numFraction, false}, 73 | "nineths": {1, 9, numFraction, false}, 74 | "tenths": {1, 10, numFraction, false}, 75 | "elevenths": {1, 11, numFraction, false}, 76 | "twelfths": {1, 12, numFraction, false}, 77 | "thirteenths": {1, 13, numFraction, false}, 78 | "fourteenths": {1, 14, numFraction, false}, 79 | "fifteenths": {1, 15, numFraction, false}, 80 | "sixteenths": {1, 16, numFraction, false}, 81 | "seventeenths": {1, 17, numFraction, false}, 82 | "eighteenths": {1, 18, numFraction, false}, 83 | "nineteenths": {1, 19, numFraction, false}, 84 | "twentieths": {1, 20, numFraction, false}, 85 | "thirtieths": {1, 30, numFraction, false}, 86 | "fourtieths": {1, 40, numFraction, false}, 87 | "fiftieths": {1, 50, numFraction, false}, 88 | "sixtieths": {1, 60, numFraction, false}, 89 | "seventieths": {1, 70, numFraction, false}, 90 | "eightieths": {1, 80, numFraction, false}, 91 | "ninetieths": {1, 90, numFraction, false}, 92 | "hundredths": {1, 100, numFraction, false}, 93 | "thousandths": {1, 1000, numFraction, false}, 94 | "millionths": {1, 1000000, numFraction, false}, 95 | "billionths": {1, 1000000000, numFraction, false}, 96 | "trillionths": {1, 1000000000000, numFraction, false}, 97 | 98 | // Direct Ordinals 99 | "zeroth": {0, 1, numDirectOrdinal, true}, 100 | "tenth": {10, 1, numDirectOrdinal, true}, 101 | "eleventh": {11, 1, numDirectOrdinal, true}, 102 | "twelfth": {12, 1, numDirectOrdinal, true}, 103 | "thirteenth": {13, 1, numDirectOrdinal, true}, 104 | "fourteenth": {14, 1, numDirectOrdinal, true}, 105 | "fifteenth": {15, 1, numDirectOrdinal, true}, 106 | "sixteenth": {16, 1, numDirectOrdinal, true}, 107 | "seventeenth": {17, 1, numDirectOrdinal, true}, 108 | "eighteenth": {18, 1, numDirectOrdinal, true}, 109 | "nineteenth": {19, 1, numDirectOrdinal, true}, 110 | 111 | // Single Ordinals 112 | "first": {1, 1, numSingleOrdinal, true}, 113 | "second": second, // see IncludeSecond 114 | "third": {3, 1, numSingleOrdinal, true}, 115 | "fourth": {4, 1, numSingleOrdinal, true}, 116 | "fifth": {5, 1, numSingleOrdinal, true}, 117 | "sixth": {6, 1, numSingleOrdinal, true}, 118 | "seventh": {7, 1, numSingleOrdinal, true}, 119 | "eighth": {8, 1, numSingleOrdinal, true}, 120 | "ninth": {9, 1, numSingleOrdinal, true}, 121 | 122 | // Tens Ordiinals 123 | "twentieth": {20, 1, numTensOrdinal, true}, 124 | "thirtieth": {30, 1, numTensOrdinal, true}, 125 | "fourtieth": {40, 1, numTensOrdinal, true}, 126 | "fiftieth": {50, 1, numTensOrdinal, true}, 127 | "sixtieth": {60, 1, numTensOrdinal, true}, 128 | "seventieth": {70, 1, numTensOrdinal, true}, 129 | "eightieth": {80, 1, numTensOrdinal, true}, 130 | "ninetieth": {90, 1, numTensOrdinal, true}, 131 | 132 | // Big Ordinals 133 | "hundredth": {100, 1, numBigOrdinal, true}, 134 | "thousandth": {1000, 1, numBigOrdinal, true}, 135 | "millionth": {1000000, 1, numBigOrdinal, true}, 136 | "billionth": {1000000000, 1, numBigOrdinal, true}, 137 | "trillionth": {1000000000000, 1, numBigOrdinal, true}, 138 | 139 | // Glue 140 | "and": {0, 0, numAnd, false}, 141 | "&": {0, 0, numAnd, false}, 142 | }, 143 | } 144 | 145 | // IncludeSecond toggles whether or not "second" should be included in the 146 | // interpreted words. If true "second" will be read as "2nd", otherwise the 147 | // word will be ignored. The default is set to true. 148 | func IncludeSecond(include bool) { 149 | dictionary.Lock() 150 | defer dictionary.Unlock() 151 | if include { 152 | dictionary.m["second"] = second 153 | } else { 154 | delete(dictionary.m, "second") 155 | } 156 | } 157 | 158 | // LookupNumber safely accesses the dictionary for a number. The input string is 159 | // case insensitive. 160 | func lookupNumber(s string) (n number, ok bool) { 161 | dictionary.RLock() 162 | defer dictionary.RUnlock() 163 | n, ok = dictionary.m[strings.ToLower(s)] 164 | return 165 | } 166 | -------------------------------------------------------------------------------- /patterns_test.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPatterns_Done(t *testing.T) { 10 | t.Parallel() 11 | 12 | ns := numbers{ 13 | number{numerator: 20, denominator: 1, typ: numTens}, 14 | } 15 | 16 | out := done(ns, 0) 17 | assert.Len(t, out, 1) 18 | assert.Equal(t, float64(20), out[0].Value()) 19 | assert.Equal(t, numDone, out[0].typ) 20 | } 21 | 22 | func TestPatterns_Drop(t *testing.T) { 23 | t.Parallel() 24 | 25 | ns := numbers{ 26 | number{numerator: 1}, 27 | number{numerator: 2}, 28 | } 29 | 30 | out := drop(ns, 0) 31 | assert.Len(t, out, 1) 32 | assert.Equal(t, 2, out[0].numerator) 33 | } 34 | 35 | func TestPatterns_Add(t *testing.T) { 36 | t.Parallel() 37 | 38 | ns := numbers{ 39 | number{numerator: 20, denominator: 1, typ: numTens}, 40 | number{numerator: 3, denominator: 1, typ: numSingle}, 41 | } 42 | 43 | out := add(ns, 0) 44 | assert.Len(t, out, 1) 45 | assert.Equal(t, float64(23), out[0].Value()) 46 | assert.Equal(t, numTens, out[0].typ) 47 | 48 | ns = numbers{ 49 | number{numerator: 100, denominator: 1, typ: numBig}, 50 | number{numerator: 1, denominator: 2, typ: numFraction}, 51 | } 52 | 53 | out = add(ns, 0) 54 | assert.Len(t, out, 1) 55 | assert.Equal(t, float64(100.5), out[0].Value()) 56 | assert.Equal(t, numFraction, out[0].typ) 57 | } 58 | 59 | func TestPatterns_Multiply(t *testing.T) { 60 | t.Parallel() 61 | 62 | ns := numbers{ 63 | number{numerator: 3, denominator: 1, typ: numSingle}, 64 | number{numerator: 100, denominator: 1, typ: numBig}, 65 | } 66 | 67 | out := multiply(ns, 0) 68 | assert.Len(t, out, 1) 69 | assert.Equal(t, float64(300), out[0].Value()) 70 | assert.Equal(t, numBig, out[0].typ) 71 | 72 | ns = numbers{ 73 | number{numerator: 100, denominator: 1, typ: numBig}, 74 | number{numerator: 1, denominator: 4, typ: numFraction}, 75 | } 76 | 77 | out = multiply(ns, 0) 78 | assert.Len(t, out, 1) 79 | assert.Equal(t, float64(25), out[0].Value()) 80 | assert.Equal(t, numFraction, out[0].typ) 81 | } 82 | 83 | func TestPatterns_Combine(t *testing.T) { 84 | t.Parallel() 85 | 86 | ns := numbers{ 87 | number{numerator: 3, denominator: 1, typ: numSingle}, 88 | number{numerator: 100, denominator: 1, typ: numBig}, 89 | } 90 | 91 | out := combine(ns, 0) 92 | assert.Len(t, out, 1) 93 | assert.Equal(t, float64(300), out[0].Value()) 94 | assert.Equal(t, numBig, out[0].typ) 95 | 96 | ns = numbers{ 97 | number{numerator: 100, denominator: 1, typ: numBig}, 98 | number{numerator: 3, denominator: 1, typ: numSingle}, 99 | } 100 | 101 | out = combine(ns, 0) 102 | assert.Len(t, out, 1) 103 | assert.Equal(t, float64(103), out[0].Value()) 104 | assert.Equal(t, numBig, out[0].typ) 105 | } 106 | 107 | func TestPatterns_CombineToLowest(t *testing.T) { 108 | t.Parallel() 109 | 110 | ns := numbers{ 111 | number{numerator: 1000, denominator: 1, typ: numBig}, 112 | number{numerator: 3, denominator: 1, typ: numSingle}, 113 | number{numerator: 100, denominator: 1, typ: numBig}, 114 | } 115 | 116 | out := combineToLowest(ns, 0) 117 | assert.Len(t, out, 2) 118 | assert.Equal(t, float64(300), out[1].Value()) 119 | assert.Equal(t, numBig, out[1].typ) 120 | assert.Equal(t, float64(1000), out[0].Value()) 121 | 122 | ns = numbers{ 123 | number{numerator: 100, denominator: 1, typ: numBig}, 124 | number{numerator: 3, denominator: 1, typ: numSingle}, 125 | number{numerator: 1000, denominator: 1, typ: numBig}, 126 | } 127 | 128 | out = combineToLowest(ns, 0) 129 | assert.Len(t, out, 2) 130 | assert.Equal(t, float64(103), out[0].Value()) 131 | assert.Equal(t, numBig, out[0].typ) 132 | assert.Equal(t, float64(1000), out[1].Value()) 133 | } 134 | 135 | func TestPatterns_YearOrDone(t *testing.T) { 136 | t.Parallel() 137 | 138 | ns := numbers{ 139 | number{numerator: 19, denominator: 1, typ: numDirect}, 140 | number{numerator: 88, denominator: 1, typ: numTens}, 141 | } 142 | 143 | out := yearOrDone(ns, 0) 144 | assert.Len(t, out, 1) 145 | assert.Equal(t, float64(1988), out[0].Value()) 146 | assert.Equal(t, numDone, out[0].typ) 147 | 148 | ns = numbers{ 149 | number{numerator: 20, denominator: 1, typ: numTens}, 150 | number{numerator: 15, denominator: 1, typ: numDirect}, 151 | } 152 | 153 | out = yearOrDone(ns, 0) 154 | assert.Len(t, out, 1) 155 | assert.Equal(t, float64(2015), out[0].Value()) 156 | assert.Equal(t, numDone, out[0].typ) 157 | 158 | ns = numbers{ 159 | number{numerator: 30, denominator: 1, typ: numTens}, 160 | number{numerator: 0, denominator: 1, typ: numDirect}, 161 | } 162 | 163 | out = yearOrDone(ns, 0) 164 | assert.Len(t, out, 2) 165 | assert.Equal(t, numDone, out[1].typ) 166 | } 167 | 168 | func TestPatterns_FractionOrDone(t *testing.T) { 169 | t.Parallel() 170 | 171 | ns := numbers{ 172 | number{numerator: 1, denominator: 1, typ: numSingle}, 173 | number{numerator: 4, denominator: 1, typ: numSingleOrdinal, ordinal: true}, 174 | } 175 | 176 | out := fractionOrDone(ns, 0) 177 | assert.Len(t, out, 1) 178 | assert.Equal(t, float64(0.25), out[0].Value()) 179 | assert.Equal(t, numFraction, out[0].typ) 180 | assert.False(t, out[0].ordinal) 181 | 182 | ns = numbers{ 183 | number{numerator: 2, denominator: 1, typ: numSingle}, 184 | number{numerator: 4, denominator: 1, typ: numSingleOrdinal, ordinal: true}, 185 | } 186 | 187 | out = fractionOrDone(ns, 0) 188 | assert.Len(t, out, 2) 189 | assert.Equal(t, float64(2), out[0].Value()) 190 | assert.Equal(t, numDone, out[0].typ) 191 | assert.Equal(t, float64(4), out[1].Value()) 192 | assert.Equal(t, numSingleOrdinal, out[1].typ) 193 | assert.True(t, out[1].ordinal) 194 | } 195 | 196 | func TestPatterns_FractionOrCombine(t *testing.T) { 197 | t.Parallel() 198 | 199 | ns := numbers{ 200 | number{numerator: 1, denominator: 1, typ: numDirect}, 201 | number{numerator: 4, denominator: 1, typ: numSingleOrdinal, ordinal: true}, 202 | } 203 | 204 | out := fractionOrCombine(ns, 0) 205 | assert.Len(t, out, 1) 206 | assert.Equal(t, float64(0.25), out[0].Value()) 207 | assert.Equal(t, numFraction, out[0].typ) 208 | assert.False(t, out[0].ordinal) 209 | 210 | ns = numbers{ 211 | number{numerator: 20, denominator: 1, typ: numTens}, 212 | number{numerator: 4, denominator: 1, typ: numSingleOrdinal, ordinal: true}, 213 | } 214 | 215 | out = fractionOrCombine(ns, 0) 216 | assert.Len(t, out, 1) 217 | assert.Equal(t, float64(24), out[0].Value()) 218 | assert.Equal(t, numSingleOrdinal, out[0].typ) 219 | assert.True(t, out[0].ordinal) 220 | } 221 | 222 | func TestPatterns_AddAnd(t *testing.T) { 223 | t.Parallel() 224 | 225 | ns := numbers{ 226 | number{numerator: 2, denominator: 1, typ: numSingle}, 227 | number{numerator: 0, denominator: 0, typ: numAnd}, 228 | number{numerator: 1, denominator: 2, typ: numFraction}, 229 | } 230 | 231 | out := addAnd(ns, 0) 232 | assert.Len(t, out, 1) 233 | assert.Equal(t, float64(2.5), out[0].Value()) 234 | assert.Equal(t, numFraction, out[0].typ) 235 | } 236 | 237 | func TestPatterns_AllHaveHandlers(t *testing.T) { 238 | t.Parallel() 239 | 240 | for _, p := range patterns { 241 | _, ok := patternHandlers[p] 242 | assert.True(t, ok, "no handler for pattern `%s`", p) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /patterns.go: -------------------------------------------------------------------------------- 1 | package numwords 2 | 3 | type patternHandler func(ns numbers, idx int) numbers 4 | 5 | // Patterns describes all the salient number patterns that could be in a 6 | // numbers set. These patterns are evaluated in the order listed here until all 7 | // options are exhausted. 8 | var patterns = []string{ 9 | // tens 10 | "ts", // twenty three => 23 11 | 12 | // big 13 | "bdb", // million eighteen thousand => 1018000 14 | "db", // eleven hundred => 1100 15 | "sb", // one hundred => 100 16 | "btb", // million twenty thousand => 1020000 17 | "tb", // twenty thousand => 20000 18 | "bd", // hundred eleven => 111 19 | "bsb", // thousand two hundred => 1200 20 | "bs", // hundred one => 101 21 | "bt", // hundred twenty => 120 22 | "bbb", // million hundred thousand => 1100000 23 | "bb", // hundred thousand => 100000 24 | 25 | // direct 26 | "dd", // nineteen ten => 1910 27 | "dt", // nineteen eighty => 1980 28 | "td", // twenty fifteen => 2015 29 | 30 | // fraction 31 | "df", // fifteen twentieths => 0.75 32 | "sf", // three fourths => 0.75 33 | "tf", // thirty fourtieths => 0.75 34 | "bf", // hundred thousandths => 0.1 35 | 36 | // ordinals that could possibly be singluar fractions 37 | "dD", // a tenth => 0.1 || fifteen tenth => 15 10th 38 | "dS", // a fourth => 0.25 || fifteen fourth => 15 4th 39 | "dT", // a twentieth => 0.05 || fifteen twentieth => 15 20th 40 | "dB", // a hundredth => 0.01 || fifteen hundredth => 1500th 41 | "sD", // one tenth => 0.1 || two tenth => 2 10th 42 | "sS", // one fourth => 0.25 || two fourth => 2 4th 43 | "sT", // one twentieth => 0.05 || two twentieth => 15 20th 44 | "sB", // one hundredth => 0.01 || fifteen hundredth => 1500th 45 | 46 | // all other ordinals 47 | "tS", // twenty first => 21st 48 | "tB", // twenty thousandth => 20000th 49 | "bS", // hundred first => 101st 50 | "bB", // hundred thousandth => 100000th 51 | 52 | // glue 53 | "d&f", // zero and a half => 0.5 54 | "s&f", // two and a half => 2.5 55 | "t&f", // twenty and a half => 20.5 56 | "b&f", // hundred and a half => 100.5 57 | "&", // 100 and 50 => 100 50 => 150 58 | } 59 | 60 | var patternHandlers = map[string]patternHandler{ 61 | "tS": add, 62 | "bS": add, 63 | 64 | "df": multiply, 65 | "sf": multiply, 66 | "tf": multiply, 67 | "bf": multiply, 68 | "tB": multiply, 69 | "bT": multiply, 70 | 71 | "ts": combine, 72 | "db": combine, 73 | "sb": combine, 74 | "tb": combine, 75 | "bd": combine, 76 | "bs": combine, 77 | "bt": combine, 78 | "bb": combine, 79 | "bB": combine, 80 | 81 | "bbb": combineToLowest, 82 | "bdb": combineToLowest, 83 | "bsb": combineToLowest, 84 | "btb": combineToLowest, 85 | 86 | "dd": yearOrDone, 87 | "dt": yearOrDone, 88 | "td": yearOrDone, 89 | 90 | "dD": fractionOrDone, 91 | "dS": fractionOrDone, 92 | "dT": fractionOrDone, 93 | "sD": fractionOrDone, 94 | "sS": fractionOrDone, 95 | "sT": fractionOrDone, 96 | 97 | "dB": fractionOrCombine, 98 | "sB": fractionOrCombine, 99 | 100 | "d&f": addAnd, 101 | "s&f": addAnd, 102 | "t&f": addAnd, 103 | "b&f": addAnd, 104 | 105 | "&": drop, 106 | } 107 | 108 | // Done flags the number at the given index as "done" and no longer 109 | // will attempt to resolve it as part of a pattern 110 | func done(ns numbers, idx int) numbers { 111 | ns[idx].typ = numDone 112 | return ns 113 | } 114 | 115 | // Drop removes the number at the specified index from the set of numbers 116 | func drop(ns numbers, idx int) numbers { 117 | out := ns[:idx] 118 | out = append(out, ns[idx+1:]...) 119 | return out 120 | } 121 | 122 | // Add merges two numbers by adding their values together 123 | // This typically occurs when a large number proceeds a smaller one. 124 | func add(ns numbers, idx int) numbers { 125 | a := ns[idx] 126 | b := ns[idx+1] 127 | 128 | ns[idx].numerator = a.numerator*b.denominator + a.denominator*b.numerator 129 | ns[idx].denominator = a.denominator * b.denominator 130 | ns[idx].typ = maxType(a.typ, b.typ) 131 | ns[idx].ordinal = b.ordinal 132 | 133 | return drop(ns, idx+1) 134 | } 135 | 136 | // AddAnd adds together two numbers split by an (arbitrary) "and" number. 137 | func addAnd(ns numbers, idx int) numbers { 138 | ns = drop(ns, idx+1) 139 | return add(ns, idx) 140 | } 141 | 142 | // Multiply merges two numbers by multiplying their values together 143 | // This typically occurs when a small number proceeds a larger one. 144 | func multiply(ns numbers, idx int) numbers { 145 | a := ns[idx] 146 | b := ns[idx+1] 147 | 148 | ns[idx].numerator = a.numerator * b.numerator 149 | ns[idx].denominator = a.denominator * b.denominator 150 | ns[idx].typ = maxType(a.typ, b.typ) 151 | ns[idx].ordinal = b.ordinal 152 | 153 | return drop(ns, idx+1) 154 | } 155 | 156 | // Combine merges two numbers by addition or multiplication depending 157 | // on the relative values of the numbers 158 | func combine(ns numbers, idx int) numbers { 159 | a := ns[idx] 160 | b := ns[idx+1] 161 | 162 | if a.numerator > b.numerator { 163 | return add(ns, idx) 164 | } 165 | 166 | return multiply(ns, idx) 167 | } 168 | 169 | // CombineToLowest combines the two lowest adjacent values in a triple 170 | // of number values. This typically occurs when a number is sandwiched 171 | // between two large values. 172 | func combineToLowest(ns numbers, idx int) numbers { 173 | l := ns[idx] 174 | r := ns[idx+2] 175 | 176 | if l.numerator <= r.numerator { 177 | return combine(ns, idx) 178 | } 179 | 180 | return combine(ns, idx+1) 181 | } 182 | 183 | // YearOrDone potentially combines two double-digit consecutive values 184 | // if they appear to be a colloquial year (eg, ninteen eighty eight => 1988). 185 | // Acceptable years captured by this function range from 1010 to 2100 (exclusive) 186 | // and does not include the first decade of each century (eg, 2000-2009). If the 187 | // heuristic isn't satisfied, the second number in the potential year is marked 188 | // as done to advance evaluation. Likewise, on successful conversion, the date 189 | // is marked done due to its semantic change (from arbitrary number to year). 190 | // 191 | // TODO: capture first decades with "oh": ninteen oh eight => 1908 192 | func yearOrDone(ns numbers, idx int) numbers { 193 | a := ns[idx] 194 | b := ns[idx+1] 195 | 196 | if a.numerator > 10 && a.numerator <= 20 && b.numerator >= 10 && b.numerator < 100 { 197 | ns[idx].numerator *= 100 198 | ns = add(ns, idx) 199 | return done(ns, idx) 200 | } 201 | 202 | return done(ns, idx+1) 203 | } 204 | 205 | // FractionOr builds a patternHandler that converts ordinals to 1-numerator 206 | // fractions based on context: one hundredth => 0.001 vs. two hundredth => 200th 207 | // If the heuristic fails, the passed in patternHandler is applied instead. 208 | func fractionOr(ph patternHandler) patternHandler { 209 | return func(ns numbers, idx int) numbers { 210 | if ns[idx].numerator == 1 { 211 | ns[idx+1].ordinal = false 212 | ns[idx+1].denominator = ns[idx+1].numerator 213 | ns[idx+1].numerator = 1 214 | ns[idx+1].typ = numFraction 215 | return multiply(ns, idx) 216 | } 217 | return ph(ns, idx) 218 | } 219 | } 220 | 221 | var ( 222 | // FractionOrDone marks the number done if it is not part of a singular fraction value 223 | fractionOrDone = fractionOr(done) 224 | 225 | // FractionOrCombine combines two numbers if it is not a singular fraction value 226 | fractionOrCombine = fractionOr(combine) 227 | ) 228 | --------------------------------------------------------------------------------