├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ └── CookInSwift.xcscheme
├── Makefile
├── .github
└── workflows
│ └── swift.yml
├── Sources
├── CookInSwift
│ ├── Semantic Analyzer
│ │ ├── Helpers.swift
│ │ ├── SemanticAnalyzer.swift
│ │ ├── Visitor.swift
│ │ ├── IngredientModel+Extentions.swift
│ │ ├── IngredientModel.swift
│ │ ├── SemanticModel+Extensions.swift
│ │ └── SemanticModel.swift
│ ├── Extensions
│ │ └── Naming+Extensions.swift
│ ├── Lexer
│ │ ├── Token.swift
│ │ ├── Token+Extensions.swift
│ │ └── Lexer.swift
│ ├── FatalError.swift
│ ├── Parser
│ │ ├── AST.swift
│ │ ├── AST+Extensions.swift
│ │ └── Parser.swift
│ └── Utils.swift
├── i18n
│ ├── Lemmatizer.swift
│ ├── Pluralization Rules
│ │ └── EnPluralizerFactory.swift
│ ├── Lemmatization Lists
│ │ └── EnLemmatizerFactory.swift
│ └── Pluralizer.swift
└── ConfigParser
│ └── ConfigParser.swift
├── LICENSE
├── Tests
├── CookInSwiftTests
│ ├── ParserTests.swift
│ ├── generate_canonical_tests.rb
│ ├── IngredientModelTests.swift
│ ├── SemanticAnalyzerTests.swift
│ ├── LexerTests.swift
│ └── ParserCanonicalTests.swift
└── ConfigParserTests
│ └── ConfigParserTests.swift
├── Package.swift
├── Docs
└── Forking.md
├── .devcontainer
├── devcontainer.json
├── Dockerfile
└── library-scripts
│ ├── node-debian.sh
│ └── common-debian.sh
├── README.md
├── .gitignore
└── CONTRIBUTING.md
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | dev:
2 | swift build
3 |
4 | test:
5 | swift test
6 |
7 | # generate canonical tests
8 | canonical:
9 | # expects spec repository located in next to the parser's directory
10 | ruby Tests/CookInSwiftTests/generate_canonical_tests.rb ../spec/tests/canonical.yaml
11 |
--------------------------------------------------------------------------------
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 | name: Swift
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | build:
11 | strategy:
12 | matrix:
13 | os: [ ubuntu-latest, macos-latest ]
14 |
15 |
16 | runs-on: ${{ matrix.os }}
17 |
18 | steps:
19 | - uses: actions/checkout@v2
20 | - name: Build
21 | run: swift build -v
22 | - name: Run tests
23 | run: swift test -v
24 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Semantic Analyzer/Helpers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 07/03/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public func mergeIngredientTables(_ left: IngredientTable, _ right: IngredientTable) -> IngredientTable {
11 | var result = IngredientTable()
12 |
13 | for (name, amounts) in left.ingredients {
14 | result.add(name: name, amounts: amounts)
15 | }
16 |
17 | for (name, amounts) in right.ingredients {
18 | result.add(name: name, amounts: amounts)
19 | }
20 |
21 | return result
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Extensions/Naming+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Naming+Extensions.swift
3 | // SwiftPascalInterpreter
4 | //
5 | // Created by Alexey Dubovskoy on 09/12/2020.
6 | // Copyright © 2017 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Lexer: CustomStringConvertible {
12 | public var description: String {
13 | return "Lexer"
14 | }
15 | }
16 |
17 | extension Parser: CustomStringConvertible {
18 | public var description: String {
19 | return "Parser"
20 | }
21 | }
22 |
23 | extension SemanticAnalyzer: CustomStringConvertible {
24 | public var description: String {
25 | return "SemanticAnalyzer"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Lexer/Token.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Token.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 06/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 |
12 | enum Braces {
13 | case left
14 | case right
15 | }
16 |
17 | enum Constant {
18 | case integer(Int)
19 | case decimal(Decimal)
20 | case fractional((Int, Int))
21 | case string(String)
22 | case space
23 | }
24 |
25 | enum Token {
26 | case eof
27 | case eol
28 | case at
29 | case percent
30 | case hash
31 | case tilde
32 | case chevron
33 | case colon
34 | case pipe
35 | case braces(Braces)
36 | case constant(Constant)
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/FatalError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FatalError.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 10/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /**
12 | Taken from https://medium.com/@marcosantadev/how-to-test-fatalerror-in-swift-e1be9ff11a29
13 | */
14 | func fatalError(_ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) -> Never {
15 | FatalErrorUtil.fatalErrorClosure(message(), file, line)
16 | }
17 |
18 | struct FatalErrorUtil {
19 |
20 | // 1
21 | static var fatalErrorClosure: (String, StaticString, UInt) -> Never = defaultFatalErrorClosure
22 |
23 | // 2
24 | private static let defaultFatalErrorClosure = { Swift.fatalError($0, file: $1, line: $2) }
25 |
26 | // 3
27 | static func replaceFatalError(closure: @escaping (String, StaticString, UInt) -> Never) {
28 | fatalErrorClosure = closure
29 | }
30 |
31 | // 4
32 | static func restoreFatalError() {
33 | fatalErrorClosure = defaultFatalErrorClosure
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 cooklang
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 |
--------------------------------------------------------------------------------
/Tests/CookInSwiftTests/ParserTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ParserTests.swift
3 | // SwiftCookInSwiftTests
4 | //
5 | // Created by Alexey Dubovskoy on 07/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | @testable import CookInSwift
12 |
13 | class ParserTests: XCTestCase {
14 |
15 | // TODO add tests for errors and edge-cases
16 | // TODO spaces in all possible random places
17 | // special symbols, quotes
18 |
19 | func testTimerInteger() {
20 | let recipe =
21 | """
22 | Fry for ~{10 minutes}
23 | """
24 |
25 | let result = try! Parser.parse(recipe) as! RecipeNode
26 |
27 | let steps: [StepNode] = [
28 | StepNode(instructions: [
29 | DirectionNode("Fry for "),
30 | TimerNode(quantity: "10 minutes", units: "", name: ""),
31 | ]),
32 | ]
33 |
34 | let metadata: [MetadataNode] = []
35 |
36 | let node = RecipeNode(steps: steps, metadata: metadata)
37 |
38 | XCTAssertEqual(result, node)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "CookInSwift",
8 | products: [
9 | .library(
10 | name: "CookInSwift",
11 | targets: ["CookInSwift"]),
12 | .library(
13 | name: "ConfigParser",
14 | targets: ["ConfigParser"]),
15 | .library(
16 | name: "i18n",
17 | targets: ["i18n"]),
18 | ],
19 | targets: [
20 | .target(
21 | name: "i18n",
22 | dependencies: []),
23 | .target(
24 | name: "ConfigParser",
25 | dependencies: []),
26 | .target(
27 | name: "CookInSwift",
28 | dependencies: ["ConfigParser", "i18n"]),
29 | .testTarget(
30 | name: "CookInSwiftTests",
31 | dependencies: ["CookInSwift"]),
32 | .testTarget(
33 | name: "ConfigParserTests",
34 | dependencies: ["ConfigParser", "CookInSwift"])
35 | ]
36 | )
37 |
--------------------------------------------------------------------------------
/Docs/Forking.md:
--------------------------------------------------------------------------------
1 | # Forking cooklang-swift
2 |
3 | Community members wishing to contribute code to `cooklang-swift` must fork the `cooklang-swift` project
4 | (`your-github-username/cooklang-swift`). Branches pushed to that fork can then be submitted
5 | as pull requests to the upstream project (`cooklang/cooklang-swift`).
6 |
7 | To locally clone the repo so that you can pull the latest from the upstream project
8 | (`cooklang/cooklang-swift`) and push changes to your own fork (`your-github-username/cooklang-swift`):
9 |
10 | 1. [Create the forked repository](https://docs.github.com/en/get-started/quickstart/fork-a-repo#forking-a-repository) (`your-github-username/cooklang-swift`)
11 | 2. Clone the `cooklang/cooklang-swift` repository and `cd` into the folder
12 | 3. Make `cooklang/cooklang-swift` the `upstream` remote rather than `origin`:
13 | `git remote rename origin upstream`.
14 | 4. Add your fork as the `origin` remote. For example:
15 | `git remote add origin https://github.com/myusername/cooklang-swift`
16 | 5. Checkout a feature branch: `git checkout -t -b new-feature`
17 | 6. [Make changes](../CONTRIBUTING.md#prerequisites).
18 | 7. Push changes to the fork when ready to [submit a PR](../CONTRIBUTING.md#submitting-a-pull-request):
19 | `git push -u origin new-feature`
20 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Semantic Analyzer/SemanticAnalyzer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SymbolTableBuilder.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 10/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public class SemanticAnalyzer: Visitor {
12 | private var currentStep: Step = Step()
13 | private var currentRecipe: Recipe = Recipe()
14 |
15 | public init() {}
16 |
17 | func analyze(node: AST) -> Recipe {
18 | visit(node: node)
19 |
20 | return currentRecipe
21 | }
22 |
23 | func visit(direction: DirectionNode) {
24 | currentStep.addText(direction)
25 | }
26 |
27 | func visit(ingredient: IngredientNode) {
28 | currentStep.addIngredient(ingredient)
29 | }
30 |
31 | func visit(equipment: EquipmentNode) {
32 | currentRecipe.addEquipment(equipment)
33 | currentStep.addEquipment(equipment)
34 | }
35 |
36 | func visit(timer: TimerNode) {
37 | currentStep.addTimer(timer)
38 | }
39 |
40 | func visit(recipe: RecipeNode) {
41 | currentRecipe.addMetadata(recipe.metadata)
42 |
43 | for step in recipe.steps {
44 | visit(step: step)
45 |
46 | currentRecipe.addStep(currentStep)
47 | currentStep = Step()
48 | }
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/swift
3 | {
4 | "name": "Swift (Community)",
5 | "build": {
6 | "dockerfile": "Dockerfile",
7 | "args": {
8 | // Update the VARIANT arg to pick a Swift version
9 | "VARIANT": "5.3",
10 | "INSTALL_ZSH": "false"
11 | }
12 | },
13 | "runArgs": [
14 | "--init",
15 | "--cap-add=SYS_PTRACE",
16 | "--security-opt",
17 | "seccomp=unconfined"
18 | ],
19 |
20 | // Set *default* container specific settings.json values on container create.
21 | "settings": {
22 | "lldb.adapterType": "bundled",
23 | "lldb.executable": "/usr/bin/lldb",
24 | "sde.languageservermode": "sourcekite",
25 | "swift.path.sourcekite": "/usr/local/bin/sourcekite"
26 | },
27 |
28 | // Add the IDs of extensions you want installed when the container is created.
29 | "extensions": [
30 | "vknabel.vscode-swift-development-environment",
31 | "vadimcn.vscode-lldb"
32 | ],
33 |
34 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
35 | // "forwardPorts": [],
36 |
37 | // Use 'postCreateCommand' to run commands after the container is created.
38 | // "postCreateCommand": "swiftc --version",
39 |
40 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
41 | "remoteUser": "vscode"
42 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # cooklang-swift
2 |
3 | NOTE! Very first implementation of Cooklang parser, now outdated as we migrated to [Rust](https://github.com/cooklang/cooklang-rs)
4 |
5 | This project is an implementation of [Cook Language Spec](https://github.com/cooklang/spec) in Swift language.
6 |
7 | ## Key Features
8 |
9 | - Full support of spec
10 | - macOS/Linux compatible
11 |
12 | ## Install
13 |
14 | Install via the [**Swift Package Manger**](https://swift.org/package-manager/) by declaring **cooklang-swift** as a dependency in your `Package.swift`:
15 |
16 | ``` swift
17 | .package(url: "https://github.com/cooklang/cooklang-swift", from: "0.1.0")
18 | ```
19 |
20 | Remember to add **cooklang-swift** to your target as a dependency.
21 |
22 | ## Documentation
23 |
24 | #### Using
25 | Creating Swift datastructures from the string containing recipe markup:
26 |
27 | ``` swift
28 | let parsedRecipe = try! Recipe.from(text: program)
29 | ```
30 |
31 | #### Config parser
32 | Creating Swift datastructures from the string containing cook config:
33 |
34 | ``` swift
35 | func parseConfig(_ content: String) -> CookConfig {
36 | let parser = ConfigParser(textConfig)
37 | return parser.parse()
38 | }
39 | ```
40 |
41 | ## Development
42 |
43 | See [Contributing](CONTRIBUTING.md)
44 | ### Codespaces
45 |
46 | - We are using the default Swift Community template from [microsoft/vscode-dev-containers](https://github.com/microsoft/vscode-dev-containers/tree/main/containers/swift)
47 | - build the package: `swift build --enable-test-discovery`
48 | - run the tests: `swift test --enable-test-discovery`
49 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Semantic Analyzer/Visitor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Visitor.swift
3 | // CookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 14/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol Visitor: AnyObject {
12 | func visit(node: AST)
13 | func visit(recipe: RecipeNode)
14 | func visit(step: StepNode)
15 | func visit(ingredient: IngredientNode)
16 | func visit(direction: DirectionNode)
17 | func visit(timer: TimerNode)
18 | func visit(equipment: EquipmentNode)
19 | }
20 |
21 | extension Visitor {
22 | func visit(node: AST) {
23 | switch node {
24 | case let direction as DirectionNode:
25 | visit(direction: direction)
26 | case let ingredient as IngredientNode:
27 | visit(ingredient: ingredient)
28 | case let timer as TimerNode:
29 | visit(timer: timer)
30 | case let equipment as EquipmentNode:
31 | visit(equipment: equipment)
32 | case let step as StepNode:
33 | visit(step: step)
34 | case let recipe as RecipeNode:
35 | visit(recipe: recipe)
36 | default:
37 | fatalError("Unsupported node type \(node)")
38 | }
39 | }
40 |
41 | func visit(direction: DirectionNode) {
42 | }
43 |
44 | func visit(ingredient: IngredientNode) {
45 | }
46 |
47 | func visit(timer: TimerNode) {
48 | }
49 |
50 | func visit(equipment: EquipmentNode) {
51 | }
52 |
53 | func visit(step: StepNode) {
54 | for item in step.children {
55 | visit(node: item)
56 | }
57 | }
58 |
59 | func visit(recipe: RecipeNode) {
60 | }
61 |
62 | }
63 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Semantic Analyzer/IngredientModel+Extentions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IngredientModel+Extentions.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 18/04/2021.
6 | //
7 |
8 | import Foundation
9 |
10 |
11 |
12 | extension IngredientTable: CustomStringConvertible {
13 | public var description: String {
14 | if ingredients.count == 0 {
15 | return "–"
16 | } else {
17 | return ingredients.sorted{ $0.key < $1.key }.map{ "\($0): \($1)" }.joined(separator: "; ")
18 | }
19 |
20 | }
21 | }
22 |
23 | extension IngredientAmount: CustomStringConvertible {
24 | public var description: String {
25 | return displayAmount(quantity: quantity, units: units)
26 | }
27 | }
28 |
29 |
30 |
31 | extension IngredientAmountCollection: CustomStringConvertible {
32 | public var description: String {
33 | return enumerated().sorted{ $0.1.units < $1.1.units }.map{ "\($1)" }.joined(separator: ", ")
34 | }
35 | }
36 |
37 | extension IngredientAmountCollection {
38 | public struct Iterator {
39 |
40 | private var _i1: Array.Iterator , _i2: Array.Iterator
41 |
42 | fileprivate init(_ amountsCountable: [String: Decimal], _ amountsUncountable: [String: String]) {
43 | _i1 = amountsCountable.map{ IngredientAmount($1, $0) }.makeIterator()
44 | _i2 = amountsUncountable.map{ IngredientAmount(String($1), $0) }.makeIterator()
45 | }
46 | }
47 | }
48 |
49 | extension IngredientAmountCollection.Iterator: IteratorProtocol {
50 | public typealias Element = Array.Element
51 |
52 | public mutating func next() -> Element? {
53 | return _i1.next() ?? _i2.next()
54 | }
55 | }
56 |
57 | extension IngredientAmountCollection: Sequence {
58 | public func makeIterator() -> Iterator {
59 | return Iterator(amountsCountable, amountsUncountable)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Sources/i18n/Lemmatizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 10/03/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public class Lemmatizer {
11 | public init() {}
12 |
13 | private var singularRules: [InflectorRule] = []
14 |
15 | private var words: Set = Set()
16 | private var regexCache: [String: NSRegularExpression] = [:]
17 |
18 | public func lemma(_ string: String) -> String {
19 | return singularize(string: string)
20 | }
21 |
22 | func singularize(string: String) -> String {
23 | return apply(rules: singularRules, forString: string)
24 | }
25 |
26 | func addIrregularRule(singular: String, andPlural plural: String) {
27 | let singularRule: String = "\(plural)$"
28 | addSingularRule(rule: singularRule, forReplacement: singular)
29 | }
30 |
31 | func addSingularRule(rule: String, forReplacement replacement: String) {
32 | singularRules.append(InflectorRule(rule: rule, replacement: replacement))
33 | regexCache[rule] = try! NSRegularExpression(pattern: rule, options: .caseInsensitive)
34 | }
35 |
36 | func uncountableWord(word: String) {
37 | words.insert(word)
38 | }
39 |
40 | func unchanging(word: String) {
41 | words.insert(word)
42 | }
43 |
44 | private func apply(rules: [InflectorRule], forString string: String) -> String {
45 | if string == "" {
46 | return ""
47 | }
48 |
49 | if words.contains(string) {
50 | return string
51 | } else {
52 | for rule in rules {
53 | let range = NSMakeRange(0, string.count)
54 | let regex: NSRegularExpression = regexCache[rule.rule]!
55 | if let _ = regex.firstMatch(in: string, options: [], range: range) {
56 | return regex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: rule.replacement)
57 | }
58 | }
59 | }
60 | return string
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | # [Choice] Swift version: 5.3, 5.2, 5.1, 4.2
2 | ARG VARIANT=5.3
3 | FROM swift:${VARIANT}
4 |
5 | # [Option] Install zsh
6 | ARG INSTALL_ZSH="true"
7 | # [Option] Upgrade OS packages to their latest versions
8 | ARG UPGRADE_PACKAGES="false"
9 |
10 | # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies.
11 | ARG USERNAME=vscode
12 | ARG USER_UID=1000
13 | ARG USER_GID=$USER_UID
14 | COPY library-scripts/common-debian.sh /tmp/library-scripts/
15 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
16 | && /bin/bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \
17 | && apt-get -y install --no-install-recommends lldb python3-minimal libpython3.7 \
18 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* && rm -rf /tmp/library-scripts
19 |
20 | # Install SourceKite, see https://github.com/vknabel/vscode-swift-development-environment/blob/master/README.md#installation
21 | RUN git clone https://github.com/vknabel/sourcekite \
22 | && export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/lib/swift:/usr/lib \
23 | && ln -s /usr/lib/libsourcekitdInProc.so /usr/lib/sourcekitdInProc \
24 | && cd sourcekite && make install PREFIX=/usr/local -j2
25 |
26 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
27 | ARG NODE_VERSION="none"
28 | ENV NVM_DIR=/usr/local/share/nvm
29 | ENV NVM_SYMLINK_CURRENT=true \
30 | PATH=${NVM_DIR}/current/bin:${PATH}
31 | COPY library-scripts/node-debian.sh /tmp/library-scripts/
32 | RUN bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}" \
33 | && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
34 |
35 | # [Optional] Uncomment this section to install additional OS packages you may want.
36 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
37 | # && apt-get -y install --no-install-recommends
38 |
39 | # [Optional] Uncomment this line to install global node packages.
40 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
41 |
--------------------------------------------------------------------------------
/Sources/i18n/Pluralization Rules/EnPluralizerFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 10/03/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public class EnPluralizerFactory {
11 | static let uncountables = ["alcohol", "bacon", "beef", "beer", "bread", "butter", "cheese", "coffee", "cream", "fish", "flour", "flu", "food", "garlic", "ground", "honey", "ice", "iron", "jelly", "mayonnaise", "meat", "milk", "oil", "pasta", "rice", "rum", "salad", "sheep", "soup", "steam", "toast", "veal", "vengeance", "g", "kg", "tbsp", "tsp", "ml", "l", "small", "medium", "large"]
12 |
13 | static let singularToPlural = [
14 | ("$", "s"),
15 | ("s$", "s"),
16 | ("^(ax|test)is$", "$1es"),
17 | ("(octop|vir)us$", "$1i"),
18 | ("(octop|vir)i$", "$1i"),
19 | ("(alias|status)$", "$1es"),
20 | ("(bu)s$", "$1ses"),
21 | ("(buffal|tomat)o$", "$1oes"),
22 | ("([ti])um$", "$1a"),
23 | ("([ti])a$", "$1a"),
24 | ("sis$", "ses"),
25 | ("(?:([^f])fe|([lr])f)$", "$1$2ves"),
26 | ("(hive)$", "$1s"),
27 | ("([^aeiouy]|qu)y$", "$1ies"),
28 | ("(x|ch|ss|sh)$", "$1es"),
29 | ("(matr|vert|ind)(?:ix|ex)$", "$1ices"),
30 | ("^(m|l)ouse$", "$1ice"),
31 | ("^(m|l)ice$", "$1ice"),
32 | ("^(ox)$", "$1en"),
33 | ("^(oxen)$", "$1"),
34 | ("(quiz)$", "$1zes")]
35 |
36 | static let unchangings = [
37 | "sheep",
38 | "deer",
39 | "moose",
40 | "swine",
41 | "bison",
42 | "corps"]
43 |
44 | static let irregulars = [
45 | ("person", "people"),
46 | ]
47 |
48 | public static func create() -> Pluralizer {
49 | let pluralizer = Pluralizer()
50 |
51 | irregulars.forEach { (key, value) in
52 | pluralizer.addIrregularRule(singular: key, andPlural: value)
53 | }
54 |
55 | singularToPlural.reversed().forEach { (key, value) in
56 | pluralizer.addPluralRule(rule: key, forReplacement: value)
57 | }
58 |
59 | unchangings.forEach { pluralizer.unchanging(word: $0) }
60 | uncountables.forEach { pluralizer.uncountableWord(word: $0) }
61 |
62 | return pluralizer
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/Sources/ConfigParser/ConfigParser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigParser.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 07/04/2021.
6 | //
7 |
8 |
9 | import Foundation
10 |
11 | public class CookConfig {
12 |
13 | public var sections: [String: [String]] = [:]
14 | public var items: [String: String] = [:]
15 |
16 | public func add(item: String, section: String) {
17 | items[item] = section
18 |
19 | if sections[section] == nil {
20 | sections[section] = []
21 | }
22 |
23 | sections[section]?.append(item)
24 |
25 | }
26 | }
27 |
28 | extension CookConfig: CustomStringConvertible {
29 | public var description: String {
30 | return sections.description
31 | }
32 | }
33 |
34 | extension CookConfig: Equatable {
35 | public static func == (lhs: CookConfig, rhs: CookConfig) -> Bool {
36 | return lhs.sections == rhs.sections
37 | }
38 | }
39 |
40 | func trim(_ s: String) -> String {
41 | let whitespaces = CharacterSet(charactersIn: " \n\r\t")
42 | return s.trimmingCharacters(in: whitespaces)
43 | }
44 |
45 |
46 | func parseSectionHeader(_ line: String) -> String {
47 | let from = line.index(after: line.startIndex)
48 | let to = line.index(before: line.endIndex)
49 | return String(line[from.. [String] {
54 | return String(line).components(separatedBy: "|").map{ trim($0) }
55 | }
56 |
57 |
58 | public class ConfigParser {
59 | var text: String
60 | // MARK: - Fields
61 |
62 | public init(_ text: String) {
63 | self.text = text
64 | }
65 |
66 | public func parse() -> CookConfig {
67 | let config = CookConfig()
68 | var currentSectionName = "Main"
69 | for line in text.components(separatedBy: "\n") {
70 | let line = trim(line)
71 | if (line == "") { continue }
72 |
73 | if line.hasPrefix("[") && line.hasSuffix("]") {
74 | currentSectionName = parseSectionHeader(line)
75 | } else {
76 | parseLine(line).forEach { item in
77 | config.add(item: item, section: currentSectionName)
78 | }
79 | }
80 | }
81 |
82 | return config
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Tests/ConfigParserTests/ConfigParserTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigParserTests.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 07/04/2021.
6 | //
7 |
8 | import Foundation
9 | @testable import CookInSwift
10 | import XCTest
11 | import ConfigParser
12 |
13 | class ConfigParserTests: XCTestCase {
14 |
15 | func testBasicConfig() {
16 | let textConfig =
17 | """
18 | [fruit and veg]
19 | apples
20 | bananas
21 | beetroots
22 |
23 | [milk and dairy]
24 | butter
25 | egg
26 | """
27 |
28 | let parser = ConfigParser(textConfig)
29 | let result = parser.parse()
30 |
31 | let sections = ["fruit and veg": ["apples", "bananas", "beetroots"],
32 | "milk and dairy": ["butter", "egg"]]
33 |
34 | let items = ["egg": "milk and dairy",
35 | "butter": "milk and dairy",
36 | "beetroots": "fruit and veg",
37 | "apples": "fruit and veg",
38 | "bananas": "fruit and veg"]
39 |
40 | XCTAssertEqual(result.sections, sections)
41 | XCTAssertEqual(result.items, items)
42 | }
43 |
44 | func testBasicConfigWithSynonyms() {
45 | let textConfig =
46 | """
47 | [fruit and veg]
48 | apples|apple
49 | bananas|banana
50 | beetroots|beetroot
51 |
52 | [milk and dairy]
53 | butter
54 | egg|eggs
55 | """
56 |
57 | let parser = ConfigParser(textConfig)
58 | let result = parser.parse()
59 |
60 | let sections = ["fruit and veg": ["apples", "apple", "bananas", "banana", "beetroots", "beetroot"],
61 | "milk and dairy": ["butter", "egg", "eggs"]]
62 |
63 | let items = ["apple": "fruit and veg",
64 | "banana": "fruit and veg",
65 | "egg": "milk and dairy",
66 | "beetroot": "fruit and veg",
67 | "eggs": "milk and dairy",
68 | "beetroots": "fruit and veg",
69 | "apples": "fruit and veg",
70 | "bananas": "fruit and veg",
71 | "butter": "milk and dairy"]
72 |
73 | XCTAssertEqual(result.sections, sections)
74 | XCTAssertEqual(result.items, items)
75 | }
76 |
77 | }
78 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Semantic Analyzer/IngredientModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IngredientModel.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 17/04/2021.
6 | //
7 |
8 | import Foundation
9 | import i18n
10 |
11 | public protocol ValueProtocol {}
12 |
13 | extension String:ValueProtocol {}
14 | extension Int:ValueProtocol {}
15 | extension Decimal:ValueProtocol {}
16 |
17 | public struct IngredientAmount {
18 | public var quantity: ValueProtocol
19 | public var units: String
20 |
21 | init(_ quantity: ValueProtocol, _ units: String) {
22 | self.quantity = quantity
23 | self.units = units
24 | }
25 |
26 | init(_ quantity: Int, _ units: String) {
27 | self.quantity = quantity
28 | self.units = units
29 | }
30 |
31 | init(_ quantity: Decimal, _ units: String) {
32 | self.quantity = quantity
33 | self.units = units
34 | }
35 |
36 | init(_ quantity: String, _ units: String) {
37 | self.quantity = quantity
38 | self.units = units
39 | }
40 | }
41 |
42 |
43 | public struct IngredientAmountCollection {
44 | var amountsCountable: [String: Decimal] = [:]
45 | var amountsUncountable: [String: String] = [:]
46 |
47 | mutating func add(_ amount: IngredientAmount) {
48 | // TODO locale
49 | let units = RuntimeSupport.lemmatizer.lemma(amount.units)
50 |
51 | switch amount.quantity.self {
52 | case let value as Int:
53 | amountsCountable[units] = amountsCountable[units, default: 0] + Decimal(value)
54 | case let value as Decimal:
55 | amountsCountable[units] = amountsCountable[units, default: 0] + value
56 | case let value as String:
57 | amountsUncountable[amount.units] = value
58 | default:
59 | fatalError("Unrecognised value type")
60 | }
61 | }
62 | }
63 |
64 | public struct IngredientTable {
65 | public var ingredients: [String: IngredientAmountCollection] = [:]
66 |
67 | public init() {
68 | }
69 |
70 | mutating public func add(name: String, amount: IngredientAmount) {
71 | if ingredients[name] == nil {
72 | ingredients[name] = IngredientAmountCollection()
73 | }
74 |
75 | ingredients[name]?.add(amount)
76 | }
77 |
78 | mutating public func add(name: String, amounts: IngredientAmountCollection) {
79 | amounts.forEach {
80 | add(name: name, amount: $0)
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/Sources/i18n/Lemmatization Lists/EnLemmatizerFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Alexey Dubovskoy on 10/03/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public class EnLemmatizerFactory {
11 | static let uncountables = ["alcohol", "bacon", "beef", "beer", "bread", "butter", "cheese", "coffee", "cream", "fish", "flour", "flu", "food", "garlic", "ground", "honey", "ice", "iron", "jelly", "mayonnaise", "meat", "milk", "oil", "pasta", "rice", "rum", "salad", "sheep", "soup", "steam", "toast", "veal", "vengeance", "g", "kg", "tbsp", "tsp", "ml", "l", "small", "medium", "large"]
12 |
13 | static let pluralToSingular = [
14 | ("s$", ""),
15 | ("(ss)$", "$1"),
16 | ("(n)ews$", "$1ews"),
17 | ("([ti])a$", "$1um"),
18 | ("((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$", "$1sis"),
19 | ("(^analy)(sis|ses)$", "$1sis"),
20 | ("([^f])ves$", "$1fe"),
21 | ("(hive)s$", "$1"),
22 | ("(tive)s$", "$1"),
23 | ("([lr])ves$", "$1f"),
24 | ("([^aeiouy]|qu)ies$", "$1y"),
25 | ("(s)eries$", "$1eries"),
26 | ("(m)ovies$", "$1ovie"),
27 | ("(x|ch|ss|sh)es$", "$1"),
28 | ("^(m|l)ice$", "$1ouse"),
29 | ("(bus)(es)?$", "$1"),
30 | ("(o)es$", "$1"),
31 | ("(shoe)s$", "$1"),
32 | ("(cris|test)(is|es)$", "$1is"),
33 | ("^(a)x[ie]s$", "$1xis"),
34 | ("(octop|vir)(us|i)$", "$1us"),
35 | ("(alias|status)(es)?$", "$1"),
36 | ("^(ox)en", "$1"),
37 | ("(vert|ind)ices$", "$1ex"),
38 | ("(matr)ices$", "$1ix"),
39 | ("(quiz)zes$", "$1"),
40 | ("(database)s$", "$1")]
41 |
42 | static let unchangings = [
43 | "sheep",
44 | "deer",
45 | "moose",
46 | "swine",
47 | "bison",
48 | "corps"]
49 |
50 | static let irregulars = [
51 | ("person", "people"),
52 | ]
53 |
54 | public static func create() -> Lemmatizer {
55 | let lemmatizer = Lemmatizer()
56 |
57 | irregulars.forEach { (key, value) in
58 | lemmatizer.addIrregularRule(singular: key, andPlural: value)
59 | }
60 |
61 | pluralToSingular.reversed().forEach { (key, value) in
62 | lemmatizer.addSingularRule(rule: key, forReplacement: value)
63 | }
64 |
65 | unchangings.forEach { lemmatizer.unchanging(word: $0) }
66 | uncountables.forEach { lemmatizer.uncountableWord(word: $0) }
67 |
68 | return lemmatizer
69 | }
70 |
71 | }
72 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
92 | .DS_Store
93 |
--------------------------------------------------------------------------------
/Tests/CookInSwiftTests/generate_canonical_tests.rb:
--------------------------------------------------------------------------------
1 | require "yaml"
2 |
3 | definitions = YAML.load_file(ARGV[0])
4 |
5 | output = <<-SWIFT
6 | // file is autogenerated from canonical_tests.yaml
7 | //
8 | // $ ruby Tests/CookInSwiftTests/generate_canonical_tests.rb ../spec/tests/canonical.yaml
9 | //
10 | // don't edit this file
11 | //
12 | // version: #{definitions["version"]}
13 | //
14 |
15 | import Foundation
16 | import XCTest
17 | @testable import CookInSwift
18 |
19 | class ParserCanonicalTests: XCTestCase {
20 | SWIFT
21 |
22 | class String
23 | def indent!(amount)
24 | gsub!(/^(?!$)/, " " * amount)
25 | end
26 | end
27 |
28 |
29 | definitions["tests"].each do |name, definition|
30 | metadata = "["
31 |
32 | definition["result"]["metadata"].each do |key, value|
33 | # TODO values which are numbers
34 | metadata << %Q|
35 | MetadataNode("#{key}", "#{value}"),|
36 | end
37 |
38 | metadata << "]"
39 |
40 | steps = ""
41 |
42 | definition["result"]["steps"].each do |step|
43 | directions = ""
44 |
45 | step.each do |direction|
46 | directions << case direction["type"]
47 | when "text"
48 | %Q|
49 | DirectionNode("#{direction["value"]}"),|
50 | when "ingredient"
51 | quantity = direction["quantity"]
52 |
53 | if quantity.kind_of?(String)
54 | # wrap into quotes
55 | quantity = %Q("#{quantity}")
56 | end
57 | %Q|
58 | IngredientNode(name: "#{direction["name"]}", amount: AmountNode(quantity: #{quantity}, units: "#{direction["units"]}")),|
59 | when "cookware"
60 | quantity = direction["quantity"]
61 |
62 | if quantity.kind_of?(String)
63 | # wrap into quotes
64 | quantity = %Q("#{quantity}")
65 | end
66 | # TODO add amount: AmountNode(quantity: #{quantity})
67 | %Q|
68 | EquipmentNode(name: "#{direction["name"]}"),|
69 | when "timer"
70 | quantity = direction["quantity"]
71 |
72 | if quantity.kind_of?(String)
73 | # wrap into quotes
74 | quantity = %Q("#{quantity}")
75 | end
76 | # TODO add
77 | %Q|
78 | TimerNode(quantity: #{quantity}, units: "#{direction["units"]}", name: "#{direction["name"]}"),|
79 | else
80 | ""
81 | end
82 | end
83 |
84 | steps << %Q|
85 | StepNode(instructions: [#{directions}
86 | ]),|
87 | end
88 |
89 | output << %Q|
90 | func #{name}() {
91 | let recipe =
92 | """
93 | #{definition["source"].strip.indent!(16, )}
94 | """
95 |
96 | let result = try! Parser.parse(recipe) as! RecipeNode
97 |
98 | let steps: [StepNode] = [#{steps}
99 | ]
100 |
101 | let metadata: [MetadataNode] = #{metadata}
102 |
103 | let node = RecipeNode(steps: steps, metadata: metadata)
104 |
105 | XCTAssertEqual(result, node)
106 | }
107 | |
108 | end
109 |
110 | output << "}"
111 |
112 |
113 | File.write(File.join(File.dirname(__FILE__), "ParserCanonicalTests.swift"), output)
114 |
--------------------------------------------------------------------------------
/Sources/i18n/Pluralizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Pluralize.swift
3 | // link:
4 | // https://github.com/joshualat/Pluralize.swift
5 | //
6 | // usage:
7 | // "Tooth".pluralize
8 | // "Nutrtion".pluralize
9 | // "House".pluralize(count: 1)
10 | // "Person".pluralize(count: 2, with: "Persons")
11 | //
12 | // Copyright (c) 2014 Joshua Arvin Lat
13 | //
14 | // MIT License
15 | //
16 | // Permission is hereby granted, free of charge, to any person obtaining
17 | // a copy of this software and associated documentation files (the
18 | // "Software"), to deal in the Software without restriction, including
19 | // without limitation the rights to use, copy, modify, merge, publish,
20 | // distribute, sublicense, and/or sell copies of the Software, and to
21 | // permit persons to whom the Software is furnished to do so, subject to
22 | // the following conditions:
23 | //
24 | // The above copyright notice and this permission notice shall be
25 | // included in all copies or substantial portions of the Software.
26 | //
27 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
28 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
29 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
30 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
31 | // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
32 | // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
33 | // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
34 | import Foundation
35 |
36 | struct InflectorRule {
37 | var rule: String
38 | var replacement: String
39 | }
40 |
41 | public class Pluralizer {
42 |
43 | private var pluralRules: [InflectorRule] = []
44 | private var words: Set = Set()
45 | private var regexCache: [String: NSRegularExpression] = [:]
46 |
47 |
48 | public func pluralize(string: String) -> String {
49 | return apply(rules: pluralRules, forString: string)
50 | }
51 |
52 | public func pluralize(string: String, count: Int) -> String {
53 | if count == 1 { return string }
54 | return pluralize(string: string)
55 | }
56 |
57 | func addIrregularRule(singular: String, andPlural plural: String) {
58 | let pluralRule: String = "\(singular)$"
59 | addPluralRule(rule: pluralRule, forReplacement: plural)
60 | }
61 |
62 | func addPluralRule(rule: String, forReplacement replacement: String) {
63 | pluralRules.append(InflectorRule(rule: rule, replacement: replacement))
64 | regexCache[rule] = try! NSRegularExpression(pattern: rule, options: .caseInsensitive)
65 | }
66 |
67 | func uncountableWord(word: String) {
68 | words.insert(word)
69 | }
70 |
71 | func unchanging(word: String) {
72 | words.insert(word)
73 | }
74 |
75 | private func apply(rules: [InflectorRule], forString string: String) -> String {
76 | if string == "" {
77 | return ""
78 | }
79 |
80 | if words.contains(string) {
81 | return string
82 | } else {
83 | for rule in rules {
84 | let range = NSMakeRange(0, string.count)
85 | let regex: NSRegularExpression = regexCache[rule.rule]!
86 | if let _ = regex.firstMatch(in: string, options: [], range: range) {
87 | return regex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: rule.replacement)
88 | }
89 | }
90 | }
91 | return string
92 | }
93 | }
94 |
95 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/CookInSwift.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
47 |
53 |
54 |
55 |
56 |
57 |
67 |
68 |
74 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Semantic Analyzer/SemanticModel+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Symbol+Extensions.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 10/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import i18n
11 |
12 |
13 | extension Recipe: Equatable {
14 | public static func == (lhs: Recipe, rhs: Recipe) -> Bool {
15 | return lhs.steps == rhs.steps && lhs.metadata == rhs.metadata
16 | }
17 | }
18 |
19 |
20 |
21 | extension Step: CustomStringConvertible {
22 | public var description: String {
23 | return """
24 | Directions:
25 | > \(directions.description)
26 | Ingredients:
27 | > \(ingredientsTable.description)
28 | """
29 | }
30 | }
31 |
32 | extension Step: Equatable {
33 | public static func ==(lhs: Step, rhs: Step) -> Bool {
34 | // TODO too expensive
35 | return lhs.directions.map { $0.description }.joined() == rhs.directions.map { $0.description }.joined()
36 | }
37 | }
38 |
39 |
40 |
41 | extension TextItem: CustomStringConvertible {
42 | public var description: String {
43 | return value
44 | }
45 | }
46 |
47 | extension TextItem: Equatable {
48 | public static func == (lhs: TextItem, rhs: TextItem) -> Bool {
49 | return lhs.value == rhs.value
50 | }
51 | }
52 |
53 |
54 |
55 | extension Equipment: Equatable {
56 | public static func == (lhs: Equipment, rhs: Equipment) -> Bool {
57 | return lhs.name == rhs.name
58 | }
59 | }
60 |
61 | extension Equipment: CustomStringConvertible {
62 | public var description: String {
63 | return name
64 | }
65 | }
66 |
67 |
68 |
69 |
70 | extension Timer: Equatable {
71 | public static func == (lhs: Timer, rhs: Timer) -> Bool {
72 | switch lhs.quantity.self {
73 | case let lhsValue as Int:
74 | if type(of: rhs.quantity) == Int.self {
75 | return lhsValue == (rhs.quantity as! Int)
76 | } else {
77 | return false
78 | }
79 | case let lhsValue as Decimal:
80 | if type(of: rhs.quantity) == Decimal.self {
81 | return lhsValue == (rhs.quantity as! Decimal)
82 | } else {
83 | return false
84 | }
85 | case let lhsValue as String:
86 | if type(of: rhs.quantity) == String.self {
87 | return lhsValue == (rhs.quantity as! String)
88 | } else {
89 | return false
90 | }
91 | default:
92 | fatalError("Unrecognised value type")
93 | }
94 | }
95 | }
96 |
97 | extension Timer: CustomStringConvertible {
98 | public var description: String {
99 | return displayAmount(quantity: quantity, units: units)
100 | }
101 | }
102 |
103 |
104 |
105 | extension Ingredient: Equatable {
106 | public static func == (lhs: Ingredient, rhs: Ingredient) -> Bool {
107 | return lhs.name == rhs.name
108 | }
109 | }
110 |
111 |
112 | extension Ingredient: CustomStringConvertible {
113 | public var description: String {
114 | return name
115 | }
116 | }
117 |
118 |
119 | extension Recipe {
120 | public static func from(text: String) throws -> Recipe {
121 | RuntimeSupport.setLemmatizer(EnLemmatizerFactory.create())
122 | RuntimeSupport.setPluralizer(EnPluralizerFactory.create())
123 |
124 | let analyzer = SemanticAnalyzer()
125 |
126 | let node = try Parser.parse(text) as! RecipeNode
127 |
128 | return analyzer.analyze(node: node)
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/Tests/CookInSwiftTests/IngredientModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IngredientModelTests.swift
3 | // CookInSwiftTests
4 | //
5 | // Created by Alexey Dubovskoy on 16/02/2021.
6 | // Copyright © 2021 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 |
10 | import Foundation
11 | @testable import CookInSwift
12 | import XCTest
13 |
14 | class IngredientModelTests: XCTestCase {
15 |
16 | func testIngredientTableAddition() {
17 | var table1 = IngredientTable()
18 |
19 | table1.add(name: "chilli", amount: IngredientAmount(3, "items"))
20 | table1.add(name: "chilli", amount: IngredientAmount(1, "medium"))
21 |
22 | var table2 = IngredientTable()
23 |
24 | table2.add(name: "chilli", amount: IngredientAmount(5, "items"))
25 | table1.add(name: "chilli", amount: IngredientAmount(3, "small"))
26 |
27 | XCTAssertEqual(mergeIngredientTables(table1, table2).description, "chilli: 8 items, 1 medium, 3 small")
28 | }
29 |
30 | func testSameUnitsAndType() {
31 | var collection = IngredientAmountCollection()
32 |
33 | collection.add(IngredientAmount(1, "g"))
34 | collection.add(IngredientAmount(3, "g"))
35 |
36 |
37 | XCTAssertEqual(collection.description, "4 g")
38 | }
39 |
40 | func testDifferentUnits() {
41 | var collection = IngredientAmountCollection()
42 |
43 | collection.add(IngredientAmount(50, "g"))
44 | collection.add(IngredientAmount(50, "g"))
45 | collection.add(IngredientAmount(1, "kg"))
46 |
47 |
48 | XCTAssertEqual(collection.description, "100 g, 1 kg")
49 | }
50 |
51 | func testDifferenQuantityTypes() {
52 | var collection = IngredientAmountCollection()
53 |
54 | collection.add(IngredientAmount(500, "g"))
55 | collection.add(IngredientAmount(1.5, "kg"))
56 | collection.add(IngredientAmount(Decimal(1), "kg"))
57 |
58 |
59 | XCTAssertEqual(collection.description, "500 g, 2.5 kg")
60 | }
61 |
62 | func testFractionsQuantityTypes() {
63 | var collection = IngredientAmountCollection()
64 |
65 | collection.add(IngredientAmount(0.5, "cup"))
66 | collection.add(IngredientAmount(1, "cup"))
67 |
68 |
69 | XCTAssertEqual(collection.description, "1.5 cups")
70 | }
71 |
72 | func testFractionsQuantityDecimalTypes() {
73 | var collection = IngredientAmountCollection()
74 |
75 | collection.add(IngredientAmount(Decimal(1) / Decimal(3), "cup"))
76 | collection.add(IngredientAmount(1, "cup"))
77 |
78 |
79 | XCTAssertEqual(collection.description, "1.3 cups")
80 | }
81 |
82 | func testWithPluralUnits() {
83 | var collection = IngredientAmountCollection()
84 |
85 | collection.add(IngredientAmount(1, "cup"))
86 | collection.add(IngredientAmount(2, "cups"))
87 |
88 |
89 | XCTAssertEqual(collection.description, "3 cups")
90 | }
91 |
92 | func testWithPluralAndSingularIngredient() {
93 | var collection = IngredientAmountCollection()
94 |
95 | collection.add(IngredientAmount(1, "onion"))
96 | collection.add(IngredientAmount(2, "onions"))
97 |
98 | XCTAssertEqual(collection.description, "3 onions")
99 | }
100 |
101 | func testWithTextQuantity() {
102 | var collection = IngredientAmountCollection()
103 |
104 | collection.add(IngredientAmount("few", "springs"))
105 |
106 | XCTAssertEqual(collection.description, "few springs")
107 | }
108 |
109 |
110 | // test valid ingridient: when only units, but no name of in
111 |
112 |
113 | }
114 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Parser/AST.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AST.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 07/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol AST {}
12 |
13 | enum ValueNode: AST {
14 | case integer(Int)
15 | case decimal(Decimal)
16 | case string(String)
17 | }
18 |
19 | struct DirectionNode: AST {
20 | let value: String
21 |
22 | init(_ value: String) {
23 | self.value = value
24 | }
25 | }
26 |
27 |
28 | struct MetadataNode: AST {
29 | let key: String
30 | let value: ValueNode
31 |
32 | init(_ key: String, _ value: ValueNode) {
33 | self.key = key
34 | self.value = value
35 | }
36 |
37 | init(_ key: String, _ value: String) {
38 | self.value = ValueNode.string(value)
39 | self.key = key
40 | }
41 |
42 | init(_ key: String, _ value: Int) {
43 | self.value = ValueNode.integer(value)
44 | self.key = key
45 | }
46 |
47 | init(_ key: String, _ value: Decimal) {
48 | self.value = ValueNode.decimal(value)
49 | self.key = key
50 | }
51 |
52 | }
53 |
54 |
55 | struct AmountNode: AST {
56 | let quantity: ValueNode
57 | let units: String
58 |
59 | init(quantity: ValueNode, units: String = "") {
60 | self.quantity = quantity
61 | self.units = units
62 | }
63 |
64 | init(quantity: String, units: String = "") {
65 | self.quantity = ValueNode.string(quantity)
66 | self.units = units
67 | }
68 |
69 | init(quantity: Int, units: String = "") {
70 | self.quantity = ValueNode.integer(quantity)
71 | self.units = units
72 | }
73 |
74 | init(quantity: Decimal, units: String = "") {
75 | self.quantity = ValueNode.decimal(quantity)
76 | self.units = units
77 | }
78 |
79 | }
80 |
81 | struct IngredientNode: AST {
82 | let name: String
83 | let amount: AmountNode
84 |
85 | init(name: String, amount: AmountNode) {
86 | self.name = name
87 | self.amount = amount
88 | }
89 | }
90 |
91 | struct EquipmentNode: AST {
92 | let name: String
93 | let quantity: ValueNode?
94 |
95 | init(name: String, quantity: ValueNode? = nil) {
96 | self.name = name
97 | self.quantity = quantity
98 | }
99 | }
100 |
101 | struct TimerNode: AST {
102 | let quantity: ValueNode
103 | let units: String
104 | let name: String
105 |
106 | init(quantity: ValueNode, units: String, name: String = "") {
107 | self.quantity = quantity
108 | self.units = units
109 | self.name = name
110 | }
111 |
112 | init(quantity: String, units: String, name: String = "") {
113 | self.quantity = ValueNode.string(quantity)
114 | self.units = units
115 | self.name = name
116 | }
117 |
118 | init(quantity: Int, units: String, name: String = "") {
119 | self.quantity = ValueNode.integer(quantity)
120 | self.units = units
121 | self.name = name
122 | }
123 |
124 | init(quantity: Decimal, units: String, name: String = "") {
125 | self.quantity = ValueNode.decimal(quantity)
126 | self.units = units
127 | self.name = name
128 | }
129 | }
130 |
131 | struct StepNode: AST {
132 | let instructions: [AST]
133 |
134 | init(instructions: [AST]) {
135 | self.instructions = instructions
136 | }
137 | }
138 |
139 | struct RecipeNode: AST {
140 | let steps: [StepNode]
141 | let metadata: [MetadataNode]
142 |
143 | init(steps: [StepNode], metadata: [MetadataNode]) {
144 | self.steps = steps
145 | self.metadata = metadata
146 | }
147 |
148 | init(steps: [StepNode]) {
149 | self.steps = steps
150 | self.metadata = []
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Getting Started
4 |
5 | ### Some Ways to Contribute
6 |
7 | * Report potential bugs.
8 | * Suggest parser enhancements.
9 | * Increase our test coverage.
10 | * Fix a [bug](https://github.com/cooklang/cooklang-swift/labels/bug).
11 | * Implement a requested [enhancement](https://github.com/cooklang/cooklang-swift/labels/enhancement).
12 |
13 | ### Reporting an Issue
14 |
15 | > Note: Issues on GitHub for `cooklang-swift` are intended to be related to bugs or feature requests.
16 | > Questions should be directed to [Spec Discussions](https://github.com/cooklang/spec/discussions).
17 |
18 | * Check existing issues (both open and closed) to make sure it has not been
19 | reported previously.
20 |
21 | * Provide a reproducible test case. If a contributor can't reproduce an issue,
22 | then it dramatically lowers the chances it'll get fixed.
23 |
24 | * Aim to respond promptly to any questions made by the `cooklang-swift` team on your
25 | issue. Stale issues will be closed.
26 |
27 | ### Issue Lifecycle
28 |
29 | 1. The issue is reported.
30 |
31 | 2. The issue is verified and categorized by a `cooklang-swift` maintainer.
32 | Categorization is done via tags. For example, bugs are tagged as "bug".
33 |
34 | 3. Unless it is critical, the issue is left for a period of time (sometimes many
35 | weeks), giving outside contributors a chance to address the issue.
36 |
37 | 4. The issue is addressed in a pull request or commit. The issue will be
38 | referenced in the commit message so that the code that fixes it is clearly
39 | linked. Any change a `cooklang-swift` user might need to know about will include a
40 | changelog entry in the PR.
41 |
42 | 5. The issue is closed.
43 |
44 | ## Making Changes to `cooklang-swift`
45 |
46 | ### Prerequisites
47 |
48 | If you wish to work on `cooklang-swift` itself, you'll first need to:
49 | - install [Swift](https://www.swift.org/download/#releases) for macOS, Linux or Windows.
50 | - [fork the `cooklang-swift` repo](../Docs/Forking.md)
51 |
52 | ### Building `cooklang-swift`
53 |
54 | `cooklang-swift` is a library intended to used by other programs. Sometimes to validate you might want to build `cooklang-swift`, do it by running `swift build`.
55 |
56 | >Note: `swift build` will build for your local machine's os/architecture.
57 |
58 | ### Testing
59 |
60 | Examples (run from the repository root):
61 | - `swift test` will run all tests
62 | - `swift test --filter CookInSwiftTests.LexerTests` will run all tests in `Lexer` module.
63 |
64 | All available options for skipping and filtering tests available via `swift test --help`.
65 |
66 | When a pull request is opened CI will run all tests to verify the change.
67 |
68 | ### Canonical tests
69 |
70 | If your changes of the parser should be generalized (for other parsers too), consider updating [Canonical tests](https://github.com/cooklang/spec/tree/main/tests).
71 |
72 | ### Submitting a Pull Request
73 |
74 | Before writing any code, we recommend:
75 | - Create a Github issue if none already exists for the code change you'd like to make.
76 | - Write a comment on the Github issue indicating you're interested in contributing so
77 | maintainers can provide their perspective if needed.
78 |
79 | Keep your pull requests (PRs) small and open them early so you can get feedback on
80 | approach from maintainers before investing your time in larger changes.
81 |
82 | When you're ready to submit a pull request:
83 | 1. Include evidence that your changes work as intended (e.g., add/modify unit tests;
84 | describe manual tests you ran, in what environment,
85 | and the results including screenshots or terminal output).
86 | 2. Open the PR from your fork against base repository `cooklang/cooklang-swift` and branch `main`.
87 | - [Link the PR to its associated issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
88 | 3. Include any specific questions that you have for the reviewer in the PR description
89 | or as a PR comment in Github.
90 | - If there's anything you find the need to explain or clarify in the PR, consider
91 | whether that explanation should be added in the source code as comments.
92 | - You can submit a [draft PR](https://github.blog/2019-02-14-introducing-draft-pull-requests/)
93 | if your changes aren't finalized but would benefit from in-process feedback.
94 | 6. After you submit, the `cooklang-swift` maintainers team needs time to carefully review your
95 | contribution and ensure it is production-ready, considering factors such as: correctness,
96 | backwards-compatibility, potential regressions, etc.
97 | 7. After you address `cooklang-swift` maintainer feedback and the PR is approved, a `cooklang-swift` maintainer
98 | will merge it. Your contribution will be available from the next minor release.
99 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Semantic Analyzer/SemanticModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Symbol.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 10/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import i18n
11 |
12 | public class RuntimeSupport {
13 | static var pluralizer: Pluralizer = EnPluralizerFactory.create()
14 | static var lemmatizer: Lemmatizer = EnLemmatizerFactory.create()
15 |
16 | static func setPluralizer(_ p: Pluralizer) {
17 | pluralizer = p
18 | }
19 |
20 | static func setLemmatizer(_ l: Lemmatizer) {
21 | lemmatizer = l
22 | }
23 | }
24 |
25 | public struct Recipe {
26 | public var ingredientsTable: IngredientTable = IngredientTable()
27 | public var steps: [Step] = []
28 | public var equipment: [Equipment] = []
29 | public var metadata: [String: String] = [:]
30 |
31 | mutating func addStep(_ step: Step) {
32 | steps.append(step)
33 | ingredientsTable = mergeIngredientTables(ingredientsTable, step.ingredientsTable)
34 | }
35 |
36 | mutating func addEquipment(_ e: EquipmentNode) {
37 | equipment.append(Equipment(e.name))
38 | }
39 |
40 | mutating func addMetadata(_ m: [MetadataNode]) {
41 | m.forEach{ item in
42 | metadata[item.key] = item.value.description
43 | }
44 | }
45 | }
46 |
47 | public struct Step {
48 | public var ingredientsTable: IngredientTable = IngredientTable()
49 | public var directions: [DirectionItem] = []
50 | public var timers: [Timer] = []
51 | public var equipments: [Equipment] = []
52 |
53 | mutating func addIngredient(_ ingredient: IngredientNode) {
54 | let name = ingredient.name
55 | var quantity: ValueProtocol
56 |
57 | switch ingredient.amount.quantity {
58 | case let .integer(value):
59 | quantity = value
60 | case let .decimal(value):
61 | quantity = value
62 | case let .string(value):
63 | quantity = value
64 | }
65 |
66 | let amount = IngredientAmount(quantity, ingredient.amount.units)
67 | let ingredient = Ingredient(name, amount)
68 |
69 | ingredientsTable.add(name: name, amount: amount)
70 | directions.append(ingredient)
71 | }
72 |
73 | mutating func addTimer(_ timer: TimerNode) {
74 | var quantity: ValueProtocol
75 |
76 | switch timer.quantity {
77 | case let .integer(value):
78 | quantity = value
79 | case let .decimal(value):
80 | quantity = value
81 | case let .string(value):
82 | quantity = value
83 | }
84 |
85 | let timer = Timer(quantity, timer.units, timer.name)
86 |
87 | timers.append(timer)
88 | directions.append(timer)
89 | }
90 |
91 | mutating func addText(_ direction: DirectionNode) {
92 | let text = TextItem(direction.value.description)
93 |
94 | directions.append(text)
95 | }
96 |
97 | mutating func addEquipment(_ equipment: EquipmentNode) {
98 | let equipment = Equipment(equipment.name)
99 |
100 | equipments.append(equipment)
101 | directions.append(equipment)
102 | }
103 | }
104 |
105 | public struct TextItem: DirectionItem {
106 | public var value: String
107 |
108 | init(_ value: String) {
109 | self.value = value
110 | }
111 | }
112 |
113 |
114 | public struct Ingredient: DirectionItem {
115 | public var name: String
116 | public var amount: IngredientAmount
117 |
118 | init(_ name: String, _ amount: IngredientAmount) {
119 | self.name = name
120 | self.amount = amount
121 | }
122 | }
123 |
124 | public struct Equipment: DirectionItem {
125 | public var name: String
126 |
127 | init(_ name: String) {
128 | self.name = name
129 | }
130 | }
131 |
132 | public struct Timer: DirectionItem {
133 | public var quantity: ValueProtocol
134 | public var units: String
135 | public var name: String
136 |
137 | init(_ quantity: ValueProtocol, _ units: String, _ name: String = "") {
138 | self.quantity = quantity
139 | self.units = units
140 | self.name = name
141 | }
142 |
143 | init(_ quantity: Int, _ units: String, _ name: String = "") {
144 | self.quantity = quantity
145 | self.units = units
146 | self.name = name
147 | }
148 |
149 | init(_ quantity: String, _ units: String, _ name: String = "") {
150 | self.quantity = quantity
151 | self.units = units
152 | self.name = name
153 | }
154 |
155 | init(_ quantity: Decimal, _ units: String, _ name: String = "") {
156 | self.quantity = quantity
157 | self.units = units
158 | self.name = name
159 | }
160 | }
161 |
162 | public protocol DirectionItem {
163 | var description: String { get }
164 | }
165 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Lexer/Token+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Token+Extensions.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 09/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol Literal {
12 | var literal: String { get }
13 | }
14 |
15 | extension Token: Equatable {
16 | static func == (lhs: Token, rhs: Token) -> Bool {
17 | switch (lhs, rhs) {
18 | case (.eof, .eof):
19 | return true
20 | case (.eol, .eol):
21 | return true
22 | case (.at, .at):
23 | return true
24 | case (.percent, .percent):
25 | return true
26 | case (.chevron, .chevron):
27 | return true
28 | case (.colon, .colon):
29 | return true
30 | case (.pipe, .pipe):
31 | return true
32 | case (.tilde, .tilde):
33 | return true
34 | case (.hash, .hash):
35 | return true
36 | case let (.constant(left), .constant(right)):
37 | return left == right
38 | case let (.braces(left), .braces(right)):
39 | return left == right
40 | default:
41 | return false
42 | }
43 | }
44 | }
45 |
46 | extension Constant: Equatable {
47 | static func == (lhs: Constant, rhs: Constant) -> Bool {
48 | switch (lhs, rhs) {
49 | case let (.integer(left), .integer(right)):
50 | return left == right
51 | case let (.decimal(left), .decimal(right)):
52 | return left == right
53 | case let (.fractional((leftN, leftD)), .fractional((rightN, rightD))):
54 | return leftN == rightN && leftD == rightD
55 | case let (.string(left), .string(right)):
56 | return left == right
57 | case (.space, .space):
58 | return true
59 | default:
60 | return false
61 | }
62 | }
63 | }
64 |
65 | extension Token: Literal {
66 | var literal: String {
67 | get {
68 | switch self {
69 | case .at:
70 | return "@"
71 | case .eof:
72 | return ""
73 | case .eol:
74 | return ""
75 | case .percent:
76 | return "%"
77 | case .hash:
78 | return "#"
79 | case .tilde:
80 | return "~"
81 | case .chevron:
82 | return ">"
83 | case .colon:
84 | return ":"
85 | case .pipe:
86 | return "|"
87 | case let .braces(braces):
88 | return braces.literal
89 | case let .constant(constant):
90 | return constant.literal
91 | }
92 | }
93 | }
94 | }
95 |
96 | extension Braces: Literal {
97 | var literal: String {
98 | get {
99 | switch self {
100 | case .left:
101 | return "{"
102 | case .right:
103 | return "}"
104 | }
105 | }
106 | }
107 | }
108 |
109 | extension Constant: Literal {
110 | var literal: String {
111 | get {
112 | switch self {
113 | case let .integer(value):
114 | return String(value)
115 | case let .decimal(value):
116 | return "\(value)"
117 | case let .fractional((nominator, denominator)):
118 | return "\(nominator)/\(denominator)"
119 | case let .string(value):
120 | return value
121 | case .space:
122 | return " "
123 | }
124 | }
125 | }
126 | }
127 |
128 | extension Braces: CustomStringConvertible {
129 | var description: String {
130 | switch self {
131 | case .left:
132 | return "LBRACE"
133 | case .right:
134 | return "RBRACE"
135 | }
136 | }
137 | }
138 |
139 | extension Constant: CustomStringConvertible {
140 | var description: String {
141 | switch self {
142 | case let .integer(value):
143 | return "INTEGER_CONST(\(value))"
144 | case let .decimal(value):
145 | return "DECIMAL_CONST(\(value))"
146 | case let .fractional((nominator, denominator)):
147 | return "FRACTIONAL_CONST(\(nominator)/\(denominator)"
148 | case let .string(value):
149 | return "STRING_CONST(\(value))"
150 | case .space:
151 | return "SPACE_CONST"
152 | }
153 | }
154 | }
155 |
156 | extension Token: CustomStringConvertible {
157 | var description: String {
158 | switch self {
159 | case .eof:
160 | return "EOF"
161 | case .eol:
162 | return "EOL"
163 | case .at:
164 | return "@"
165 | case .percent:
166 | return "%"
167 | case .hash:
168 | return "#"
169 | case .tilde:
170 | return "~"
171 | case .chevron:
172 | return ">"
173 | case .colon:
174 | return ":"
175 | case .pipe:
176 | return "|"
177 | case let .constant(constant):
178 | return constant.description
179 | case let .braces(braces):
180 | return braces.description
181 | }
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Parser/AST+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AST+Extensions.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 09/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Decimal {
12 | var cleanValue: String {
13 | let formatter = NumberFormatter()
14 | formatter.maximumSignificantDigits = 2
15 | return formatter.string(from: self as NSDecimalNumber)!
16 | }
17 | }
18 |
19 |
20 | extension ValueNode {
21 | init(_ value: Int) {
22 | self = .integer(value)
23 | }
24 |
25 | init(_ value: Decimal) {
26 | self = .decimal(value)
27 | }
28 |
29 | init(_ value: String) {
30 | self = .string(value)
31 | }
32 | }
33 |
34 |
35 | extension ValueNode: CustomStringConvertible {
36 | var description: String {
37 | switch self {
38 | case let .integer(value):
39 | return "\(value)"
40 | case let .decimal(value):
41 | return value.cleanValue
42 | case let .string(value):
43 | return "\(value)"
44 | }
45 | }
46 | }
47 |
48 | extension ValueNode: Equatable {
49 | static func == (lhs: ValueNode, rhs: ValueNode) -> Bool {
50 | switch (lhs, rhs) {
51 | case let (.integer(left), .integer(right)):
52 | return left == right
53 | case let (.decimal(left), .decimal(right)):
54 | return left == right
55 | case let (.decimal(left), .integer(right)):
56 | return left == Decimal(right)
57 | case let (.integer(left), .decimal(right)):
58 | return Decimal(left) == right
59 | case let (.string(left), .string(right)):
60 | return left == right
61 | case (.string(_), _):
62 | return false
63 | case (_, .string(_)):
64 | return false
65 | }
66 | }
67 | }
68 |
69 | extension RecipeNode: Equatable {
70 | static func == (lhs: RecipeNode, rhs: RecipeNode) -> Bool {
71 | return lhs.steps == rhs.steps && lhs.metadata == rhs.metadata
72 | }
73 | }
74 |
75 | extension StepNode: Equatable {
76 | static func == (lhs: StepNode, rhs: StepNode) -> Bool {
77 | return lhs.instructions.map{ ($0.value ) } == rhs.instructions.map{ ($0.value ) }
78 | }
79 | }
80 |
81 |
82 | extension MetadataNode: Equatable {
83 | static func == (lhs: MetadataNode, rhs: MetadataNode) -> Bool {
84 | return lhs.key == rhs.key && lhs.value == rhs.value
85 | }
86 | }
87 |
88 | extension EquipmentNode: Equatable {
89 | static func == (lhs: EquipmentNode, rhs: EquipmentNode) -> Bool {
90 | return lhs.name == rhs.name
91 | }
92 | }
93 |
94 | extension TimerNode: Equatable {
95 | static func == (lhs: TimerNode, rhs: TimerNode) -> Bool {
96 | return lhs.quantity == rhs.quantity && lhs.units == rhs.units
97 | }
98 | }
99 |
100 | extension AST {
101 | var value: String {
102 | switch self {
103 | case let v as ValueNode:
104 |
105 | switch v {
106 | case let .string(v):
107 | return "\(v)"
108 | case let .integer(v):
109 | return "\(v)"
110 | case let .decimal(v):
111 | return "\(v.cleanValue)"
112 | }
113 |
114 | case is RecipeNode:
115 | return "recipe"
116 | case is StepNode:
117 | return "step"
118 | case let m as MetadataNode:
119 | return "\(m.key) => \(m.value)"
120 | case let direction as DirectionNode:
121 | return direction.value
122 | case let ingredient as IngredientNode:
123 | return "ING: \(ingredient.name) [\(ingredient.amount.value)]"
124 | case let equipment as EquipmentNode:
125 | return "EQ: \(equipment.name)"
126 | case let timer as TimerNode:
127 | return "TIMER(\(timer.name)): \(timer.quantity) \(timer.units)"
128 | case let amount as AmountNode:
129 | return displayAmount(quantity: amount.quantity.value, units: amount.units)
130 | default:
131 | fatalError("Missed AST case \(self)")
132 | }
133 | }
134 |
135 | var children: [AST] {
136 | switch self {
137 | case is ValueNode:
138 | return []
139 | case is String:
140 | return []
141 | case let recipe as RecipeNode:
142 | return recipe.steps + recipe.metadata
143 | case let step as StepNode:
144 | return step.instructions
145 | case is DirectionNode:
146 | return []
147 | case is IngredientNode:
148 | return []
149 | case is TimerNode:
150 | return []
151 | case is EquipmentNode:
152 | return []
153 | case is AmountNode:
154 | return []
155 | case is MetadataNode:
156 | return []
157 | default:
158 | fatalError("Missed AST case \(self)")
159 | }
160 | }
161 |
162 | func treeLines(_ nodeIndent: String = "", _ childIndent: String = "") -> [String] {
163 | return [nodeIndent + value]
164 | + children.enumerated().map { ($0 < children.count - 1, $1) }
165 | .flatMap { $0 ? $1.treeLines("┣╸", "┃ ") : $1.treeLines("┗╸", " ") }
166 | .map { childIndent + $0 }
167 | }
168 |
169 | func printTree() -> String { return treeLines().joined(separator: "\n") }
170 | }
171 |
172 |
--------------------------------------------------------------------------------
/.devcontainer/library-scripts/node-debian.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #-------------------------------------------------------------------------------------------------------------
3 | # Copyright (c) Microsoft Corporation. All rights reserved.
4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
5 | #-------------------------------------------------------------------------------------------------------------
6 | #
7 | # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/node.md
8 | # Maintainer: The VS Code and Codespaces Teams
9 | #
10 | # Syntax: ./node-debian.sh [directory to install nvm] [node version to install (use "none" to skip)] [non-root user] [Update rc files flag] [install node-gyp deps]
11 |
12 | export NVM_DIR=${1:-"/usr/local/share/nvm"}
13 | export NODE_VERSION=${2:-"lts"}
14 | USERNAME=${3:-"automatic"}
15 | UPDATE_RC=${4:-"true"}
16 | INSTALL_TOOLS_FOR_NODE_GYP="${5:-true}"
17 | export NVM_VERSION="0.38.0"
18 |
19 | set -e
20 |
21 | if [ "$(id -u)" -ne 0 ]; then
22 | echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
23 | exit 1
24 | fi
25 |
26 | # Ensure that login shells get the correct path if the user updated the PATH using ENV.
27 | rm -f /etc/profile.d/00-restore-env.sh
28 | echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
29 | chmod +x /etc/profile.d/00-restore-env.sh
30 |
31 | # Determine the appropriate non-root user
32 | if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
33 | USERNAME=""
34 | POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
35 | for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
36 | if id -u ${CURRENT_USER} > /dev/null 2>&1; then
37 | USERNAME=${CURRENT_USER}
38 | break
39 | fi
40 | done
41 | if [ "${USERNAME}" = "" ]; then
42 | USERNAME=root
43 | fi
44 | elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
45 | USERNAME=root
46 | fi
47 |
48 | updaterc() {
49 | if [ "${UPDATE_RC}" = "true" ]; then
50 | echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..."
51 | if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then
52 | echo -e "$1" >> /etc/bash.bashrc
53 | fi
54 | if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then
55 | echo -e "$1" >> /etc/zsh/zshrc
56 | fi
57 | fi
58 | }
59 |
60 | # Function to run apt-get if needed
61 | apt_get_update_if_needed()
62 | {
63 | if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
64 | echo "Running apt-get update..."
65 | apt-get update
66 | else
67 | echo "Skipping apt-get update."
68 | fi
69 | }
70 |
71 | # Checks if packages are installed and installs them if not
72 | check_packages() {
73 | if ! dpkg -s "$@" > /dev/null 2>&1; then
74 | apt_get_update_if_needed
75 | apt-get -y install --no-install-recommends "$@"
76 | fi
77 | }
78 |
79 | # Ensure apt is in non-interactive to avoid prompts
80 | export DEBIAN_FRONTEND=noninteractive
81 |
82 | # Install dependencies
83 | check_packages apt-transport-https curl ca-certificates tar gnupg2 dirmngr
84 |
85 | # Install yarn
86 | if type yarn > /dev/null 2>&1; then
87 | echo "Yarn already installed."
88 | else
89 | # Import key safely (new method rather than deprecated apt-key approach) and install
90 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor > /usr/share/keyrings/yarn-archive-keyring.gpg
91 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
92 | apt-get update
93 | apt-get -y install --no-install-recommends yarn
94 | fi
95 |
96 | # Adjust node version if required
97 | if [ "${NODE_VERSION}" = "none" ]; then
98 | export NODE_VERSION=
99 | elif [ "${NODE_VERSION}" = "lts" ]; then
100 | export NODE_VERSION="lts/*"
101 | fi
102 |
103 | # Create a symlink to the installed version for use in Dockerfile PATH statements
104 | export NVM_SYMLINK_CURRENT=true
105 |
106 | # Install the specified node version if NVM directory already exists, then exit
107 | if [ -d "${NVM_DIR}" ]; then
108 | echo "NVM already installed."
109 | if [ "${NODE_VERSION}" != "" ]; then
110 | su ${USERNAME} -c ". $NVM_DIR/nvm.sh && nvm install ${NODE_VERSION} && nvm clear-cache"
111 | fi
112 | exit 0
113 | fi
114 |
115 | # Create nvm group, nvm dir, and set sticky bit
116 | if ! cat /etc/group | grep -e "^nvm:" > /dev/null 2>&1; then
117 | groupadd -r nvm
118 | fi
119 | umask 0002
120 | usermod -a -G nvm ${USERNAME}
121 | mkdir -p ${NVM_DIR}
122 | chown :nvm ${NVM_DIR}
123 | chmod g+s ${NVM_DIR}
124 | su ${USERNAME} -c "$(cat << EOF
125 | set -e
126 | umask 0002
127 | # Do not update profile - we'll do this manually
128 | export PROFILE=/dev/null
129 | curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash
130 | source ${NVM_DIR}/nvm.sh
131 | if [ "${NODE_VERSION}" != "" ]; then
132 | nvm alias default ${NODE_VERSION}
133 | fi
134 | nvm clear-cache
135 | EOF
136 | )" 2>&1
137 | # Update rc files
138 | if [ "${UPDATE_RC}" = "true" ]; then
139 | updaterc "$(cat < /dev/null 2>&1; then
152 | to_install="${to_install} make"
153 | fi
154 | if ! type gcc > /dev/null 2>&1; then
155 | to_install="${to_install} gcc"
156 | fi
157 | if ! type g++ > /dev/null 2>&1; then
158 | to_install="${to_install} g++"
159 | fi
160 | if ! type python3 > /dev/null 2>&1; then
161 | to_install="${to_install} python3-minimal"
162 | fi
163 | if [ ! -z "${to_install}" ]; then
164 | apt_get_update_if_needed
165 | apt-get -y install ${to_install}
166 | fi
167 | fi
168 |
169 | echo "Done!"
170 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Utils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Utils.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 07/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import i18n
11 |
12 | /**
13 | Generic tree formatting code from https://stackoverflow.com/a/43903427/581164
14 | */
15 | func treeString(_ node: T, using nodeInfo: (T) -> (String, T?, T?)) -> String {
16 | // node value string and sub nodes
17 | let (stringValue, leftNode, rightNode) = nodeInfo(node)
18 |
19 | let stringValueWidth = stringValue.count
20 |
21 | // recurse to sub nodes to obtain line blocks on left and right
22 | let leftTextBlock = leftNode == nil ? []
23 | : treeString(leftNode!, using: nodeInfo)
24 | .components(separatedBy: "\n")
25 |
26 | let rightTextBlock = rightNode == nil ? []
27 | : treeString(rightNode!, using: nodeInfo)
28 | .components(separatedBy: "\n")
29 |
30 | // count common and maximum number of sub node lines
31 | let commonLines = min(leftTextBlock.count, rightTextBlock.count)
32 | let subLevelLines = max(rightTextBlock.count, leftTextBlock.count)
33 |
34 | // extend lines on shallower side to get same number of lines on both sides
35 | let leftSubLines = leftTextBlock
36 | + Array(repeating: "", count: subLevelLines - leftTextBlock.count)
37 | let rightSubLines = rightTextBlock
38 | + Array(repeating: "", count: subLevelLines - rightTextBlock.count)
39 |
40 | // compute location of value or link bar for all left and right sub nodes
41 | // * left node's value ends at line's width
42 | // * right node's value starts after initial spaces
43 | let leftLineWidths = leftSubLines.map { $0.count }
44 | let rightLineIndents = rightSubLines.map { $0.prefix { $0 == " " }.count }
45 |
46 | // top line value locations, will be used to determine position of current node & link bars
47 | let firstLeftWidth = leftLineWidths.first ?? 0
48 | let firstRightIndent = rightLineIndents.first ?? 0
49 |
50 | // width of sub node link under node value (i.e. with slashes if any)
51 | // aims to center link bars under the value if value is wide enough
52 | //
53 | // ValueLine: v vv vvvvvv vvvvv
54 | // LinkLine: / \ / \ / \ / \
55 | //
56 | let linkSpacing = min(stringValueWidth, 2 - stringValueWidth % 2)
57 | let leftLinkBar = leftNode == nil ? 0 : 1
58 | let rightLinkBar = rightNode == nil ? 0 : 1
59 | let minLinkWidth = leftLinkBar + linkSpacing + rightLinkBar
60 | let valueOffset = (stringValueWidth - linkSpacing) / 2
61 |
62 | // find optimal position for right side top node
63 | // * must allow room for link bars above and between left and right top nodes
64 | // * must not overlap lower level nodes on any given line (allow gap of minSpacing)
65 | // * can be offset to the left if lower subNodes of right node
66 | // have no overlap with subNodes of left node
67 | let minSpacing = 2
68 | let rightNodePosition = zip(leftLineWidths, rightLineIndents[0 ..< commonLines])
69 | .reduce(firstLeftWidth + minLinkWidth) { max($0, $1.0 + minSpacing + firstRightIndent - $1.1) }
70 |
71 | // extend basic link bars (slashes) with underlines to reach left and right
72 | // top nodes.
73 | //
74 | // vvvvv
75 | // __/ \__
76 | // L R
77 | //
78 | let linkExtraWidth = max(0, rightNodePosition - firstLeftWidth - minLinkWidth)
79 | let rightLinkExtra = linkExtraWidth / 2
80 | let leftLinkExtra = linkExtraWidth - rightLinkExtra
81 |
82 | // build value line taking into account left indent and link bar extension (on left side)
83 | let valueIndent = max(0, firstLeftWidth + leftLinkExtra + leftLinkBar - valueOffset)
84 | let valueLine = String(repeating: " ", count: max(0, valueIndent))
85 | + stringValue
86 |
87 | // build left side of link line
88 | let leftLink = leftNode == nil ? ""
89 | : String(repeating: " ", count: firstLeftWidth)
90 | + String(repeating: "_", count: leftLinkExtra)
91 | + "/"
92 |
93 | // build right side of link line (includes blank spaces under top node value)
94 | let rightLinkOffset = linkSpacing + valueOffset * (1 - leftLinkBar)
95 | let rightLink = rightNode == nil ? ""
96 | : String(repeating: " ", count: rightLinkOffset)
97 | + "\\"
98 | + String(repeating: "_", count: rightLinkExtra)
99 |
100 | // full link line (will be empty if there are no sub nodes)
101 | let linkLine = leftLink + rightLink
102 |
103 | // will need to offset left side lines if right side sub nodes extend beyond left margin
104 | // can happen if left subtree is shorter (in height) than right side subtree
105 | let leftIndentWidth = max(0, firstRightIndent - rightNodePosition)
106 | let leftIndent = String(repeating: " ", count: leftIndentWidth)
107 | let indentedLeftLines = leftSubLines.map { $0.isEmpty ? $0 : (leftIndent + $0) }
108 |
109 | // compute distance between left and right sublines based on their value position
110 | // can be negative if leading spaces need to be removed from right side
111 | let mergeOffsets = indentedLeftLines
112 | .map { $0.count }
113 | .map { leftIndentWidth + rightNodePosition - firstRightIndent - $0 }
114 | .enumerated()
115 | .map { rightSubLines[$0].isEmpty ? 0 : $1 }
116 |
117 | // combine left and right lines using computed offsets
118 | // * indented left sub lines
119 | // * spaces between left and right lines
120 | // * right sub line with extra leading blanks removed.
121 | let mergedSubLines = zip(mergeOffsets.enumerated(), indentedLeftLines)
122 | .map { ($0.0, $0.1, $1 + String(repeating: " ", count: max(0, $0.1))) }
123 | .map { $2 + String(rightSubLines[$0].dropFirst(max(0, -$1))) }
124 |
125 | // Assemble final result combining
126 | // * node value string
127 | // * link line (if any)
128 | // * merged lines from left and right sub trees (if any)
129 | let result = [leftIndent + valueLine]
130 | + (linkLine.isEmpty ? [] : [leftIndent + linkLine])
131 | + mergedSubLines
132 |
133 | return result.joined(separator: "\n")
134 | }
135 |
136 | func displayAmount(quantity: ValueProtocol, units: String) -> String {
137 | switch quantity {
138 | case let value as Decimal:
139 | // TODO locale
140 | // when summing up quantities converted to Decimal in IngredientsTable
141 | // here we want to check if value can be converted back to integer before representation
142 | if let v = Int("\(value)") {
143 | if units == "" {
144 | return "\(value)"
145 | } else {
146 | return "\(value) \(RuntimeSupport.pluralizer.pluralize(string: units, count: v))"
147 | }
148 | } else {
149 | if units == "" {
150 | return "\(value.cleanValue)"
151 | } else {
152 | return "\(value.cleanValue) \(RuntimeSupport.pluralizer.pluralize(string: units, count: 2))"
153 | }
154 | }
155 | case is String:
156 | if units == "" {
157 | return "\(quantity)"
158 | } else {
159 | return "\(quantity) \(RuntimeSupport.pluralizer.pluralize(string: units, count: 2))"
160 | }
161 |
162 | case let value as Int:
163 | if units == "" {
164 | return "\(value)"
165 | } else {
166 | return "\(value) \(RuntimeSupport.pluralizer.pluralize(string: units, count: value))"
167 | }
168 |
169 | default:
170 | return ""
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Lexer/Lexer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Lexer.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 07/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum NumberParseStrategy {
12 | case integer
13 | case preSlashFractional
14 | case postSlashFractional
15 | case fractional
16 | case decimal
17 | case string
18 | }
19 |
20 | /**
21 | Basic lexical analyzer converting program text into tokens
22 | */
23 | class Lexer {
24 |
25 | // MARK: - Fields
26 | private let onlyLetters = CharacterSet.letters.subtracting(CharacterSet.nonBaseCharacters)
27 | private let text: [Unicode.Scalar]
28 | private let count: Int
29 | private var currentPosition: Int
30 | private var currentCharacter: Unicode.Scalar?
31 |
32 | // MARK: - Constants
33 |
34 | init(_ text: String) {
35 | self.text = Array(text.unicodeScalars)
36 | self.count = self.text.count
37 | currentPosition = 0
38 | currentCharacter = text.isEmpty ? nil : self.text[0]
39 | }
40 |
41 | // MARK: - Stream helpers
42 |
43 | /**
44 | Skips all the whitespace
45 | */
46 | private func skipWhitestace() {
47 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) {
48 | advance()
49 | }
50 | }
51 |
52 | /**
53 | Skips all the newlines and whitespaces
54 | */
55 | private func skipNewlines() {
56 | while let character = currentCharacter, CharacterSet.newlines.contains(character) {
57 | advance()
58 | }
59 | }
60 |
61 |
62 | /**
63 | Skips comments
64 | */
65 | private func skipComment() {
66 | while let character = currentCharacter, !CharacterSet.newlines.contains(character) {
67 | advance()
68 | }
69 | }
70 |
71 | /**
72 | Skips block comments
73 | */
74 | private func skipBlockComment() {
75 | var prevCharacter = Unicode.Scalar("");
76 |
77 | while let character = currentCharacter {
78 | if prevCharacter == "-" && character == "]" {
79 | advance()
80 |
81 | return
82 | }
83 |
84 | prevCharacter = character
85 | advance()
86 | }
87 | }
88 |
89 | /**
90 | Advances by one character forward, sets the current character (if still any available)
91 | */
92 | private func advance() {
93 | currentPosition += 1
94 |
95 | guard currentPosition < count else {
96 | currentCharacter = nil
97 | return
98 | }
99 |
100 | currentCharacter = text[currentPosition]
101 | }
102 |
103 | /**
104 | Returns the next character without advancing
105 |
106 | Returns: Character if not at the end of the text, nil otherwise
107 | */
108 | private func peek() -> Unicode.Scalar? {
109 | let peekPosition = currentPosition + 1
110 |
111 | guard peekPosition < count else {
112 | return nil
113 | }
114 |
115 | return text[peekPosition]
116 | }
117 |
118 | // MARK: - Parsing helpers
119 |
120 | /**
121 | Reads a possible multidigit integer starting at the current position
122 | */
123 | private func number() -> Token {
124 | var lexem = ""
125 |
126 | var strategy: NumberParseStrategy = .integer
127 |
128 | var i = currentPosition + 1
129 |
130 | // need to look ahead to define if we can use numbers
131 | strategyLookAhead: while i < count {
132 | let character = text[i]
133 |
134 | switch strategy {
135 | case .integer:
136 | if CharacterSet.decimalDigits.contains(character) {
137 | break
138 | } else if character == "." {
139 | strategy = .decimal
140 | break
141 | } else if character == "/" {
142 | strategy = .postSlashFractional
143 | break
144 | } else if CharacterSet.whitespaces.contains(character) {
145 | strategy = .preSlashFractional
146 | break
147 | } else if CharacterSet.newlines.contains(character) || CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character) {
148 | break strategyLookAhead
149 | } else {
150 | strategy = .string
151 | break strategyLookAhead
152 | }
153 | case .decimal:
154 | if CharacterSet.decimalDigits.contains(character) {
155 | break
156 | } else if CharacterSet.newlines.contains(character) || CharacterSet.whitespaces.contains(character) || CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character) {
157 | break strategyLookAhead
158 | } else {
159 | strategy = .string
160 | break strategyLookAhead
161 | }
162 | case .preSlashFractional:
163 | if CharacterSet.whitespaces.contains(character) {
164 | break
165 | } else if character == "/" {
166 | strategy = .postSlashFractional
167 | break
168 | } else {
169 | strategy = .integer
170 | break strategyLookAhead
171 | }
172 |
173 | case .postSlashFractional:
174 | if CharacterSet.decimalDigits.contains(character) {
175 | strategy = .fractional
176 | break
177 | } else if CharacterSet.whitespaces.contains(character) {
178 | break
179 | } else {
180 | strategy = .string
181 | break strategyLookAhead
182 | }
183 |
184 | case .fractional:
185 | if CharacterSet.decimalDigits.contains(character) {
186 | break
187 | } else if CharacterSet.newlines.contains(character) || CharacterSet.whitespaces.contains(character) || CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character) {
188 | break strategyLookAhead
189 | } else {
190 | strategy = .string
191 | break strategyLookAhead
192 | }
193 | default:
194 | fatalError("Lexer bug, unspecified case for number parsing")
195 | }
196 |
197 | i += 1
198 | }
199 |
200 | switch strategy {
201 | case .decimal:
202 | let nextCharacter = peek()
203 |
204 | if let character = currentCharacter, character == "0" && nextCharacter! != "." {
205 | return word()
206 | }
207 |
208 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) {
209 | lexem += String(character)
210 | advance()
211 | }
212 |
213 | if let character = currentCharacter, character == "." {
214 | lexem += "."
215 | advance()
216 | }
217 |
218 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) {
219 | lexem += String(character)
220 | advance()
221 | }
222 |
223 | return .constant(.decimal(Decimal(floatLiteral: Double(lexem)!)))
224 | case .integer:
225 | if let character = currentCharacter, character == "0" {
226 | return word()
227 | }
228 |
229 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) {
230 | lexem += String(character)
231 | advance()
232 | }
233 |
234 | return .constant(.integer(Int(lexem)!))
235 | case .fractional:
236 | if let character = currentCharacter, character == "0" {
237 | return word()
238 | }
239 |
240 | var nominator = ""
241 | var denominator = ""
242 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) {
243 | nominator += String(character)
244 | advance()
245 | }
246 |
247 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) {
248 | advance()
249 | }
250 |
251 | if let character = currentCharacter, character == "/" {
252 | advance()
253 | }
254 |
255 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) {
256 | advance()
257 | }
258 |
259 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) {
260 | denominator += String(character)
261 | advance()
262 | }
263 |
264 | return .constant(.fractional((Int(nominator)!, Int(denominator)!)))
265 | case .string:
266 | return word()
267 | default:
268 | fatalError("Oops, something went wrong")
269 | }
270 | }
271 |
272 | private func word() -> Token {
273 | var lexem = ""
274 | while let character = currentCharacter, CharacterSet.alphanumerics.contains(character) {
275 | lexem += String(character)
276 | advance()
277 | }
278 | return .constant(.string(lexem))
279 | }
280 |
281 | private func punctuation() -> Token {
282 | var lexem = ""
283 | while let character = currentCharacter, !["{", "}", "@", "%", ":", ">", "|"].contains(character) && (CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character)) {
284 | lexem += String(character)
285 | advance()
286 | }
287 | return .constant(.string(lexem))
288 | }
289 |
290 | private func whitespace() -> Token {
291 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) {
292 | advance()
293 | }
294 | return .constant(.space)
295 | }
296 |
297 | // MARK: - Public methods
298 |
299 | /**
300 | Reads the text at current position and returns next token
301 |
302 | - Returns: Next token in text
303 | */
304 | func getNextToken() -> Token {
305 | while let currentCharacter = currentCharacter {
306 | if CharacterSet.newlines.contains(currentCharacter) {
307 | skipNewlines()
308 | return .eol
309 | }
310 |
311 | if CharacterSet.whitespaces.contains(currentCharacter) {
312 | return whitespace()
313 | }
314 |
315 | // if the character is a digit, convert it to int, create an integer token and move position
316 | if CharacterSet.decimalDigits.contains(currentCharacter) {
317 | return number()
318 | }
319 |
320 | if currentCharacter == "[" {
321 | let nextCharacter = peek()
322 |
323 | advance()
324 |
325 | if let unwrapped = nextCharacter {
326 | if unwrapped == "-" {
327 | advance()
328 | skipBlockComment()
329 | continue
330 | } else {
331 | return .constant(.string("["))
332 | }
333 | } else {
334 | return .constant(.string("["))
335 | }
336 | }
337 |
338 | if currentCharacter == "@" {
339 | advance()
340 | return .at
341 | }
342 |
343 | if currentCharacter == "%" {
344 | advance()
345 | return .percent
346 | }
347 |
348 | if currentCharacter == "{" {
349 | advance()
350 |
351 | return .braces(.left)
352 | }
353 |
354 | if currentCharacter == "}" {
355 | advance()
356 | return .braces(.right)
357 | }
358 |
359 | if currentCharacter == ":" {
360 | advance()
361 | return .colon
362 | }
363 |
364 | if currentCharacter == ">" {
365 | advance()
366 | return .chevron
367 | }
368 |
369 | if currentCharacter == "|" {
370 | advance()
371 | return .pipe
372 | }
373 |
374 | if currentCharacter == "#" {
375 | advance()
376 | return .hash
377 | }
378 |
379 | if currentCharacter == "~" {
380 | advance()
381 | return .tilde
382 | }
383 |
384 | if currentCharacter == "-" {
385 | let nextCharacter = peek()
386 |
387 | advance()
388 |
389 | if let unwrapped = nextCharacter {
390 | if unwrapped == "-" {
391 | advance()
392 | skipComment()
393 | continue
394 | } else {
395 | return .constant(.string("-"))
396 | }
397 | } else {
398 | return .constant(.string("-"))
399 | }
400 | }
401 |
402 | if CharacterSet.punctuationCharacters.contains(currentCharacter) || CharacterSet.symbols.contains(currentCharacter) {
403 | return punctuation()
404 | }
405 |
406 | if CharacterSet.alphanumerics.contains(currentCharacter) {
407 | return word()
408 | }
409 | }
410 |
411 | return .eof
412 | }
413 |
414 | func lex() -> [Token] {
415 | var token = getNextToken()
416 | var all = [token]
417 | while token != .eof {
418 | token = getNextToken()
419 | all.append(token)
420 | }
421 |
422 | return all
423 | }
424 | }
425 |
--------------------------------------------------------------------------------
/Sources/CookInSwift/Parser/Parser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Parser.swift
3 | // SwiftCookInSwift
4 | //
5 | // Created by Alexey Dubovskoy on 07/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | // GRAMMAR
10 | // recipe: metadata step;
11 | // metadata: key value;
12 | //
13 |
14 | import Foundation
15 |
16 | enum TaggedParseStrategy {
17 | case noBraces
18 | case withBraces
19 | }
20 |
21 | enum QuantityParseStrategy {
22 | case number
23 | case string
24 | }
25 |
26 | enum ParserError: Error {
27 | case terminatorNotFound
28 | }
29 |
30 | class Parser {
31 |
32 | // MARK: - Fields
33 |
34 | private var tokenIndex = 0
35 | private let tokens: [Token]
36 |
37 | private var currentToken: Token {
38 | return tokens[tokenIndex]
39 | }
40 |
41 | private var nextToken: Token {
42 | return tokens[tokenIndex + 1]
43 | }
44 |
45 | init(_ tokens: [Token]) {
46 | self.tokens = tokens
47 | }
48 |
49 | static func parse(_ text: String) throws -> AST {
50 | let lexer = Lexer(text)
51 | let tokens = lexer.lex()
52 | let parser = Parser(tokens)
53 | return parser.parse()
54 | }
55 |
56 | // MARK: - Helpers
57 |
58 | /**
59 | Compares the current token with the given token, if they match, the next token is read,
60 | otherwise an error is thrown
61 |
62 | - Parameter token: Expected token
63 | */
64 | private func eat(_ token: Token) {
65 | if currentToken == token {
66 | tokenIndex += 1
67 | } else {
68 | fatalError("Syntax error, expected \(token), got \(currentToken)")
69 | }
70 | }
71 |
72 | // MARK: - Grammar rules
73 |
74 | /**
75 |
76 | */
77 | private func stringUntilTerminator(terminators: [Token]) throws -> String {
78 | var parts: [String] = []
79 |
80 | while true {
81 | if terminators.contains(currentToken) {
82 | break
83 | // TODO should really be configurable, because in some cases we consider next @ to be a stopper
84 | } else if currentToken == .eol || currentToken == .eof {
85 | throw ParserError.terminatorNotFound
86 | } else {
87 | parts.append(currentToken.literal)
88 | eat(currentToken)
89 | }
90 | }
91 |
92 | return parts.joined().trimmingCharacters(in: CharacterSet.whitespaces)
93 | }
94 |
95 | /**
96 |
97 | */
98 | private func direction() -> DirectionNode {
99 | var items: [String] = []
100 |
101 | while true {
102 | switch currentToken {
103 | case .eof, .eol:
104 | return DirectionNode(items.joined())
105 | case let .constant(.string(value)):
106 | eat(.constant(.string(value)))
107 | items.append(value)
108 | case let .constant(.integer(value)):
109 | eat(.constant(.integer(value)))
110 | items.append(String(value))
111 | case let .constant(.decimal(value)):
112 | eat(.constant(.decimal(value)))
113 | items.append("\(value)")
114 | case let .constant(.fractional((nom, denom))):
115 | eat(.constant(.fractional((nom, denom))))
116 | items.append("\(nom)/\(denom)")
117 | case .tilde:
118 | if case .constant(.string) = nextToken {
119 | return DirectionNode(items.joined())
120 | } else if nextToken == .braces(.left) {
121 | return DirectionNode(items.joined())
122 | } else {
123 | items.append(currentToken.literal)
124 | eat(currentToken)
125 | }
126 | case .at, .hash:
127 | if case .constant(.string) = nextToken {
128 | return DirectionNode(items.joined())
129 | } else if case .constant(.integer) = nextToken {
130 | return DirectionNode(items.joined())
131 | } else {
132 | items.append(currentToken.literal)
133 | eat(currentToken)
134 | }
135 | default:
136 | items.append(currentToken.literal)
137 | eat(currentToken)
138 | }
139 | }
140 | }
141 |
142 | private func ignoreWhitespace() {
143 | while currentToken == .constant(.space) {
144 | eat(.constant(.space))
145 | }
146 | }
147 |
148 | /**
149 |
150 | */
151 | private func values(defaultValue: ValueNode) -> ValueNode {
152 | var v = defaultValue
153 |
154 | var strategy: QuantityParseStrategy = .string
155 | var i = tokenIndex
156 |
157 | // need to look ahead to define if we can use numbers
158 | strategyLookAhead: while i < tokens.count {
159 | switch tokens[i] {
160 | case .percent, .braces(.right):
161 | break strategyLookAhead
162 | case .constant(.decimal):
163 | strategy = .number
164 | case .constant(.integer):
165 | strategy = .number
166 | case .constant(.fractional):
167 | strategy = .number
168 | case .constant(.space):
169 | break
170 | case let .constant(.string(value)):
171 | // TODO not 100% right as this is valid only for fractional
172 | if CharacterSet.whitespaces.contains(value.unicodeScalars.first!) {
173 | break
174 | } else {
175 | strategy = .string
176 | break strategyLookAhead
177 | }
178 | default:
179 | strategy = .string
180 | break strategyLookAhead
181 | }
182 |
183 | i += 1
184 | }
185 |
186 | if strategy == .number {
187 | ignoreWhitespace()
188 |
189 | switch currentToken {
190 |
191 | case let .constant(.decimal(value)):
192 | v = ValueNode.decimal(value)
193 | eat(.constant(.decimal(value)))
194 |
195 | case let .constant(.integer(value)):
196 | eat(.constant(.integer(value)))
197 | v = ValueNode.integer(value)
198 |
199 | case let .constant(.fractional((n, d))):
200 | eat(.constant(.fractional((n, d))))
201 | v = ValueNode.decimal(Decimal(n) / Decimal(d))
202 |
203 | default:
204 | if !(currentToken == .braces(.right) || currentToken == .percent) {
205 | fatalError("Number or word is expected, got \(currentToken)")
206 | }
207 | }
208 |
209 | } else {
210 | var value = ""
211 |
212 | do {
213 | value = try stringUntilTerminator(terminators: [.percent, .braces(.right)])
214 | } catch ParserError.terminatorNotFound {
215 | print("woops")
216 | } catch {
217 | fatalError("Unexpected exception")
218 | }
219 |
220 | if value != "" {
221 | v = ValueNode.string(value)
222 | }
223 | }
224 |
225 | ignoreWhitespace()
226 |
227 | return v
228 | }
229 |
230 | /**
231 |
232 | */
233 | private func amount() throws -> AmountNode {
234 | eat(.braces(.left))
235 |
236 | var q = values(defaultValue: ValueNode.string(""))
237 |
238 | var units = ""
239 |
240 | if currentToken == .percent {
241 | eat(.percent)
242 |
243 | units = try stringUntilTerminator(terminators: [.braces(.right)])
244 | }
245 |
246 | eat(.braces(.right))
247 |
248 | if q.value == "" {
249 | if units.isEmpty {
250 | q = ValueNode.string("some")
251 | }
252 | }
253 |
254 | return AmountNode(quantity: q, units: units)
255 | }
256 |
257 |
258 |
259 | /**
260 |
261 | */
262 | private func taggedName() -> String {
263 | var i = tokenIndex
264 | var strategy: TaggedParseStrategy?
265 |
266 | // need to look ahead to define if we need to wait for braces or not
267 | while i < tokens.count {
268 | if tokens[i] == .braces(.left) {
269 | strategy = .withBraces
270 | break
271 | }
272 |
273 | if tokens[i] == .eof || tokens[i] == .eol || tokens[i] == .at || tokens[i] == .hash || tokens[i] == .tilde {
274 | strategy = .noBraces
275 | break
276 | }
277 |
278 | i += 1
279 | }
280 |
281 | switch strategy {
282 | case .withBraces:
283 | var value = ""
284 |
285 | do {
286 | value = try stringUntilTerminator(terminators: [.braces(.left)])
287 | } catch ParserError.terminatorNotFound {
288 | print("woops")
289 | } catch {
290 | fatalError("Unexpected exception")
291 | }
292 |
293 | return value
294 | case .noBraces:
295 | // TODO not stable place
296 | guard case let .constant(.string(value)) = currentToken else {
297 | fatalError("String expected, got \(currentToken)")
298 | }
299 |
300 | eat(.constant(.string(value)))
301 |
302 | return value
303 | default:
304 | fatalError("Can't understand strategy")
305 | }
306 | }
307 |
308 | /**
309 |
310 | */
311 | private func ingredient() -> IngredientNode {
312 | eat(.at)
313 |
314 | let name = taggedName()
315 | var ingridientAmount = AmountNode(quantity: "some", units: "")
316 |
317 | if currentToken == .braces(.left) {
318 | do {
319 | ingridientAmount = try amount()
320 | } catch ParserError.terminatorNotFound {
321 | print("Warning: expected '}' but got end of line")
322 | } catch {
323 | fatalError("Unexpected exception")
324 | }
325 | }
326 |
327 | return IngredientNode(name: name, amount: ingridientAmount)
328 | }
329 |
330 | /**
331 |
332 | */
333 | private func equipment() -> EquipmentNode {
334 | eat(.hash)
335 |
336 | let name = taggedName()
337 |
338 | if currentToken == .braces(.left) {
339 | eat(.braces(.left))
340 | ignoreWhitespace()
341 |
342 | if currentToken == .braces(.right) {
343 | eat(.braces(.right))
344 | } else {
345 | print("Warning: expected '}' but got \(currentToken.literal)")
346 | }
347 | }
348 |
349 | return EquipmentNode(name: name)
350 | }
351 |
352 | /**
353 |
354 | */
355 | private func timer() -> TimerNode {
356 | eat(.tilde)
357 | let name = taggedName()
358 | eat(.braces(.left))
359 | let quantity = values(defaultValue: ValueNode.integer(0))
360 | var units = ""
361 | if currentToken == .percent {
362 | eat(.percent)
363 |
364 | do {
365 | units = try stringUntilTerminator(terminators: [.braces(.right)])
366 | } catch ParserError.terminatorNotFound {
367 | print("Warning: expected '}' but got end of line")
368 | return TimerNode(quantity: "", units: "", name: "Invalid timer syntax")
369 | } catch {
370 | fatalError("Unexpected exception")
371 | }
372 | }
373 |
374 | eat(.braces(.right))
375 |
376 | return TimerNode(quantity: quantity, units: units, name: name)
377 | }
378 |
379 | /**
380 | >> key: value
381 |
382 | */
383 | private func metadata() -> MetadataNode {
384 | eat(.chevron)
385 | eat(.chevron)
386 |
387 | var key = ""
388 |
389 | do {
390 | key = try stringUntilTerminator(terminators: [.colon])
391 | } catch ParserError.terminatorNotFound {
392 | return MetadataNode("Invalid key syntax", "Invalid value syntax")
393 | } catch {
394 | fatalError("Unexpected exception")
395 | }
396 |
397 | eat(.colon)
398 |
399 | var value = ""
400 |
401 | do {
402 | value = try stringUntilTerminator(terminators: [.eol, .eof])
403 | } catch ParserError.terminatorNotFound {
404 | // TODO this is redundant
405 | } catch {
406 | fatalError("Unexpected exception")
407 | }
408 |
409 | if currentToken == .eol {
410 | eat(.eol)
411 | }
412 |
413 | return MetadataNode(key, value)
414 | }
415 |
416 | /**
417 |
418 | */
419 | private func step() -> StepNode {
420 | var instructions: [AST] = []
421 |
422 | while true {
423 | switch currentToken {
424 | case .eol:
425 | eat(.eol)
426 |
427 | if !instructions.isEmpty {
428 | return StepNode(instructions: instructions)
429 | }
430 | case .eof:
431 | return StepNode(instructions: instructions)
432 | case .at:
433 | switch nextToken {
434 | case .constant(.string), .constant(.integer):
435 | instructions.append(ingredient())
436 | default:
437 | instructions.append(direction())
438 | }
439 | case .hash:
440 | switch nextToken {
441 | case .constant(.string), .constant(.integer):
442 | instructions.append(equipment())
443 | default:
444 | instructions.append(direction())
445 | }
446 | case .tilde:
447 | switch nextToken {
448 | case .constant(.string), .braces(.left):
449 | instructions.append(timer())
450 | default:
451 | instructions.append(direction())
452 | }
453 | default:
454 | instructions.append(direction())
455 | }
456 | }
457 | }
458 |
459 | /**
460 |
461 | */
462 | private func recipe() -> RecipeNode {
463 | var steps: [StepNode] = []
464 | var meta: [MetadataNode] = []
465 |
466 | while currentToken != .eof {
467 | if currentToken == .chevron && nextToken == .chevron {
468 | meta.append(metadata())
469 | } else {
470 | steps.append(step())
471 | }
472 | }
473 |
474 | let node = RecipeNode(steps: steps, metadata: meta)
475 |
476 | return node
477 | }
478 |
479 | // MARK: - Public methods.
480 |
481 | /**
482 |
483 | */
484 | func parse() -> AST {
485 | let node = recipe()
486 |
487 | if currentToken != .eof {
488 | fatalError("Syntax error, end of file expected")
489 | }
490 |
491 | return node
492 | }
493 | }
494 |
495 |
496 |
--------------------------------------------------------------------------------
/Tests/CookInSwiftTests/SemanticAnalyzerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SymbolTableTests.swift
3 | // SwiftCookInSwiftTests
4 | //
5 | // Created by Alexey Dubovskoy on 10/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | @testable import CookInSwift
11 | import XCTest
12 |
13 | class SemanticAnalyzerTests: XCTestCase {
14 | func testSemanticAnalyzer() {
15 | let program =
16 | """
17 | >> cooking time: 30 min
18 | Add @chilli{3}, @ginger{10%g} and @milk{1%litre} place in #oven and cook for ~{10%minutes}
19 | """
20 |
21 | let parsedRecipe = try! Recipe.from(text: program)
22 |
23 | var recipe = Recipe()
24 | recipe.metadata["cooking time"] = "30 min"
25 |
26 | var step = Step()
27 | step.directions = [
28 | TextItem("Add "),
29 | Ingredient("chilli", IngredientAmount(3, "items")),
30 | TextItem(", "),
31 | Ingredient("ginger", IngredientAmount(10, "g")),
32 | TextItem(" and "),
33 | Ingredient("milk", IngredientAmount(1, "litre")),
34 | TextItem(" place in "),
35 | Equipment("oven"),
36 | TextItem(" and cook for "),
37 | Timer(10, "minutes")
38 | ]
39 | recipe.steps = [step]
40 |
41 | XCTAssertEqual(parsedRecipe, recipe)
42 |
43 | let text = parsedRecipe.steps.map{ step in
44 | step.directions.map { $0.description }.joined()
45 | }
46 | XCTAssertEqual(text, ["Add chilli, ginger and milk place in oven and cook for 10 minutes"])
47 | }
48 |
49 |
50 | func testShoppingListCombination() {
51 | let recipe1 =
52 | """
53 | Add @chilli{3}, @ginger{10%g} and @milk{1%litre} place in #oven and cook for ~{10%minutes}. Add a bit of @cinnamon.
54 | """
55 |
56 | let recipe2 =
57 | """
58 | Simmer @milk{250%ml} with @honey{2%tbsp} for ~{20%minutes}. Add a bit of @cinnamon.
59 | """
60 |
61 | let parsedRecipe1 = try! Recipe.from(text: recipe1)
62 | let parsedRecipe2 = try! Recipe.from(text: recipe2)
63 |
64 | var table = IngredientTable()
65 |
66 | table = mergeIngredientTables(parsedRecipe1.ingredientsTable, parsedRecipe2.ingredientsTable)
67 |
68 | XCTAssertEqual(table.description, "chilli: 3; cinnamon: some; ginger: 10 g; honey: 2 tbsp; milk: 1 litre, 250 ml")
69 | }
70 |
71 | // test valid ingridient: when only units, but no name of in
72 |
73 | func testBurrito() {
74 |
75 | let recipe =
76 | """
77 | Preheat your oven to the lowest setting. Drain the @cannellini beans{2%tins} in a sieve. Place a saucepan on a medium heat.
78 |
79 | Peel and dinely slice the @garlic clove{2}. add the @olive oil{1%tbsp} and sliced garlic to the hot pan.
80 |
81 | Crubmle the @red chilli{1%item} into the pan, then stir and fry until the grlic turns golden.
82 |
83 | Add the @tinned tomatoes{2%tins} and drained cannellini beans to the pan, reduce to a low heat and simmer gently for around 20 minutes, or until reduced and nice and thick. Meanwhile...
84 |
85 | Peel, halve and finely chop the @red onion{1}. Roughly chop the @cherry tomatoes{10}. Finely chop the @coriander{1%bunch} stalks and roughly chop the leaves.
86 |
87 | Coarsely grate the @cheddar cheese{75%g}. Cut @lime{1} in half and the other @lime{1} into six wedges.
88 |
89 | Cut the @avocados{2} in half lengthways, use a spppon to sccoop out and dicard the stone, then scoop the fles into a bowl to make your guacamole.
90 |
91 | Roughly mash the avocado with the back of a fork, then add the onion, cherry tomatoes, coriander stalks and @ground cumin{1%pinch}. Season with @sea salt{} and @black pepper{} and squeeze in the juice from one of the lime halves.
92 |
93 | Mix well then have a taste of your guacoamole and tweak with more salt, pepper and lime jouice until you've got a good balance of flovours and its tasing delicious. Set aside.
94 |
95 | Loosely wrap the @tortillas{6%large} in tin foil then pop in the hot oven to warm through, along with two plates. Finely chop the @fresh red chilli{2} and put it aside for later.
96 |
97 | Make your table look respectable - get the cutlery, salt and pepper and drinks laid out nicely.
98 |
99 | By now your beans should be done, so have a taste and season with salt and pepper. Turn the heat off and pop a lid on th pan sothey stay nice and warm.
100 |
101 | Put a small non-stick saucepan on a low heat. Add the @butter{30%g} and leave to melt. Meanwhile...
102 |
103 | Crack the @eggs{8%large} into a bowl, add a pinch of @salt{} and @black pepper{} and beat with a fork. When the buter has melted, add the eggs to the pan. Stir the eggs slowly with a spatula, getting right into the sides of the pan. Cook gently for 5 to 10 minutes until they just start to scramble then turn the heat off - they'll continute to cook on their own.
104 |
105 | Get two plates and pop a warm tortilla on each one. Divide the scrambled eggs between them then top with a good spoonful of you home-made beans.
106 |
107 | Scatter each portion with grated cheese and as much chilli as youdare, then roll each tortilla up.
108 |
109 | Spoon guacamole and @sour cream{200%ml} on top of each one, scatter with coriander leaves and dust with a little @smoked paprika{1%pinch}. Serve each portion with wedge of lime for squeezing over, and tuck in.
110 |
111 | """
112 |
113 |
114 |
115 | measure {
116 | let parsedRecipe = try! Recipe.from(text: recipe)
117 |
118 | XCTAssertEqual(parsedRecipe.ingredientsTable.description, "avocados: 2; black pepper: some; butter: 30 g; cannellini beans: 2 tins; cheddar cheese: 75 g; cherry tomatoes: 10; coriander: 1 bunch; eggs: 8 large; fresh red chilli: 2; garlic clove: 2; ground cumin: 1 pinch; lime: 2; olive oil: 1 tbsp; red chilli: 1 item; red onion: 1; salt: some; sea salt: some; smoked paprika: 1 pinch; sour cream: 200 ml; tinned tomatoes: 2 tins; tortillas: 6 large")
119 | }
120 |
121 | }
122 |
123 | func testLagman() {
124 | let recipe =
125 | """
126 | >> Prep Time: 15 minutes
127 | >> Cook Time: 30 minutes
128 | >> Total Time: 45 minutes
129 | >> Servings: 6 servings
130 |
131 | Cook the @linguine pasta{230%g} according to the instructions. Drain and rinse with cold water. Keep covered until ready to use so it does not dry out.
132 |
133 | Cube the @lamb{450%g} into small cubes. In a dutch oven, heat @oil. Once hot add meat, cook about ~{5%minutes}.
134 |
135 | Meanwhile, finely chop the @onion{1%medium}. Finely dice @tomatoes{2%large}. Cube @carrots{1%large} and @red peppers{1/2%large} into even sizes. Cube @potatoes{420%g} into small cubes.
136 |
137 | Add onions to the meat in the Dutch oven. Turn heat down to medium heat, cook until onions are tender.
138 |
139 | Add tomatoes and @garlic{1%glove}, cook ~{2%minutes}, stirring as needed.
140 |
141 | Add potatoes, carrots, peppers and mix well.
142 |
143 | Pour in @water{1.4%kg}, @black pepper{1%tsp}, @ground coriander{1%tsp}, @ground cumin{1%tsp} and @bay leaves{2}, @star anise{1%small} and cook about ~{20%minutes}, until all of the vegetables are tender. Remove star anise once the soup is cooked.
144 |
145 | Meanwhile, cook pasta. Drain and return to pot, cover to prevent from drying out.
146 |
147 | Serve hearty soup over pasta.
148 |
149 | """
150 |
151 | let parsedRecipe = try! Recipe.from(text: recipe)
152 |
153 | XCTAssertEqual(parsedRecipe.ingredientsTable.description, "bay leaves: 2; black pepper: 1 tsp; carrots: 1 large; garlic: 1 glove; ground coriander: 1 tsp; ground cumin: 1 tsp; lamb: 450 g; linguine pasta: 230 g; oil: some; onion: 1 medium; potatoes: 420 g; red peppers: 0.5 large; star anise: 1 small; tomatoes: 2 large; water: 1.4 kg")
154 |
155 | }
156 |
157 | func testManti() {
158 | let recipe =
159 | """
160 | Attach the dough hook to the Kitchen Aid mixer. Mix the @milk{160%g}, @salt{1%tsp}, @egg{1%large} and @water{180%g}.
161 |
162 | Add the @flour{530%g}, continue mixing until the dough is smooth and flour is well incorporated. (Dough should not stick to your hands.)
163 |
164 | Keep the dough covered until ready to use.
165 |
166 | Place @butter{110%g} into freezer. Cube @chicken thighs{450%g} into small pieces. Chop @onion{1%small} really fine. Peel @potatoes{450%g} and cube into small pieces, place in bowl and cover with cold water, set aside.
167 |
168 | Divide dough into two. Return the one half to the bowl and cover. Lightly flour the working surface. Roll out the dough into about 20" to 22" circle, adding flour as needed, it needs to be really thin. Fold the circle as if it's and accordion going back and forth. With a sharp knife cut about every 2 1/2 inches. Take the strips and stack them. When stacking, the strips will all be different lengths, stack them all starting at one end, not in the middle. Cut again about every 2 2/12 inches.
169 |
170 | Lay out the squares. Some pieces may not be complete squares, edges, just take two pieces and stick them together.
171 |
172 | Finish the filling. Take out the butter from the freezer and either grate or cube. Drain potatoes add to the bowl, add @ground coriander{1/2%tsp}, @ground cumin{1/4%tsp}, @black pepper{1/4%tsp}, minced @garlic{2%cloves} add chopped @parsley{1%bunch}. Mix everything.
173 |
174 | Take spoonfuls of the filling and add to the center of the squares.
175 |
176 | Take a square, place one corner over the other and pinch together. Take the other corner and repeat. Pinch together the four openings. Now take the two edges and pinch them together as well, placing over edge over the other.
177 |
178 | Add water to a tiered steamer. Lay out Manti on tiers. Cover steamer and cook 20-25 minutes. All steamers are different, check a Manti to see if it's ready. If using different meat, it'll need about 40 - 60 minutes.
179 |
180 | Enjoy with butter and sour cream.
181 | """
182 |
183 | let parsedRecipe = try! Recipe.from(text: recipe)
184 |
185 | XCTAssertEqual(parsedRecipe.ingredientsTable.description, "black pepper: 0.25 tsp; butter: 110 g; chicken thighs: 450 g; egg: 1 large; flour: 530 g; garlic: 2 cloves; ground coriander: 0.5 tsp; ground cumin: 0.25 tsp; milk: 160 g; onion: 1 small; parsley: 1 bunch; potatoes: 450 g; salt: 1 tsp; water: 180 g")
186 |
187 | }
188 |
189 | func testBrokenManti() {
190 | let recipe =
191 | """
192 | > sdfsf: fff
193 | >> fsdfd:::
194 | >>:fdsfd
195 | >>sfsdfd:
196 | > > fsdf
197 | Attach the dough @ hook > to the Kitchen Aid mixer. Mix the @milk{160g}, @ salt{1%tsp}, @egg{%large} and @water{180%}.
198 |
199 | Add the @flour{530%g, continue mixing until the dough is smooth and flour is well incorporated. (Dough should not stick to your hands.)
200 |
201 | Keep the dough --covered until ready to use.
202 |
203 | Place @butter{%} into freezer. Cube @chicken > thighs{450%g} into small pieces. Chop @onion{1 really fine. Peel @potatoes{450%g} and cube into small pieces, place in bowl and cover with cold water, set aside.
204 |
205 | Divide dough into two. Return # the one half | to the bowl : and cover >. Lightly flour the working surface. Roll out the dough into about 20" to 22" circle, adding flour as needed, it needs to be really thin. Fold the circle as if it's and accordion going back and forth. With a sharp knife cut about every 2 1/2 inches. Take the strips and stack them. When stacking, the strips will all be different lengths, stack them all starting at one end, not in the middle. Cut again about every 2 2/12 #inches{.
206 |
207 | Lay out the squares. Some pieces may not be complete squares, edges, }just take{ two pieces and stick them together.
208 |
209 | Finish the filling. Take out the butter from the freezer and either % grate or cube. Drain potatoes add to the bowl, add @ground coriander{1/2%tsp}, @ground cumin{1/4%tsp}, @black pepper{1/4%tsp}, minced @garlic{2%cloves} add chopped @parsley{1%bunch}. Mix everything.
210 |
211 | >> Take spoonfuls of the filling and add to the ~center of the squares.
212 |
213 | Hehe, ~ and # and @ here.
214 |
215 | @
216 |
217 | ~
218 |
219 | #
220 |
221 | Take a square, place one corner over the other ~and{%} pinch together. Take the other corner and repeat. Pinch together the four openings. Now take the two edges and pinch them together as well, placing over edge over the other.
222 |
223 | Add water to a tiered steamer. Lay out Manti on tiers. Cover steamer and cook ~{20-25%minutes}. All steamers are different, check a Manti to see if it's ready. If using different meat, it'll need about 40 - 60 minutes.
224 |
225 | Enjoy with butter and sour cream.
226 | """
227 |
228 | let parsedRecipe = try! Recipe.from(text: recipe)
229 |
230 | let text = parsedRecipe.steps.map{ step in
231 | step.directions.map { $0.description }.joined()
232 | }
233 |
234 | XCTAssertEqual(parsedRecipe.metadata, ["sfsdfd": "",
235 | "Invalid key syntax": "Invalid value syntax",
236 | "": "fdsfd",
237 | "fsdfd": "::"])
238 |
239 | XCTAssertEqual(text, ["> sdfsf: fff",
240 | "> > fsdf",
241 | "Attach the dough @ hook > to the Kitchen Aid mixer. Mix the milk, @ salt{1%tsp}, egg and water.",
242 | "Add the flour",
243 | "Keep the dough ",
244 | "Place butter into freezer. Cube chicken > thighs into small pieces. Chop onion and cube into small pieces, place in bowl and cover with cold water, set aside.",
245 | "Divide dough into two. Return # the one half | to the bowl : and cover >. Lightly flour the working surface. Roll out the dough into about 20\" to 22\" circle, adding flour as needed, it needs to be really thin. Fold the circle as if it\'s and accordion going back and forth. With a sharp knife cut about every 2 1/2 inches. Take the strips and stack them. When stacking, the strips will all be different lengths, stack them all starting at one end, not in the middle. Cut again about every 2 2/12 inches.",
246 | "Lay out the squares. Some pieces may not be complete squares, edges, }just take{ two pieces and stick them together.",
247 | "Finish the filling. Take out the butter from the freezer and either % grate or cube. Drain potatoes add to the bowl, add ground coriander, ground cumin, black pepper, minced garlic add chopped parsley. Mix everything.",
248 | "Hehe, ~ and # and @ here.",
249 | "@",
250 | "~",
251 | "#",
252 | "Take a square, place one corner over the other 0 pinch together. Take the other corner and repeat. Pinch together the four openings. Now take the two edges and pinch them together as well, placing over edge over the other.",
253 | "Add water to a tiered steamer. Lay out Manti on tiers. Cover steamer and cook 20-25 minutes. All steamers are different, check a Manti to see if it\'s ready. If using different meat, it\'ll need about 40 - 60 minutes.",
254 | "Enjoy with butter and sour cream."]
255 |
256 | )
257 |
258 | }
259 |
260 |
261 |
262 | }
263 |
--------------------------------------------------------------------------------
/.devcontainer/library-scripts/common-debian.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #-------------------------------------------------------------------------------------------------------------
3 | # Copyright (c) Microsoft Corporation. All rights reserved.
4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
5 | #-------------------------------------------------------------------------------------------------------------
6 | #
7 | # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/common.md
8 | # Maintainer: The VS Code and Codespaces Teams
9 | #
10 | # Syntax: ./common-debian.sh [install zsh flag] [username] [user UID] [user GID] [upgrade packages flag] [install Oh My Zsh! flag] [Add non-free packages]
11 |
12 | set -e
13 |
14 | INSTALL_ZSH=${1:-"true"}
15 | USERNAME=${2:-"automatic"}
16 | USER_UID=${3:-"automatic"}
17 | USER_GID=${4:-"automatic"}
18 | UPGRADE_PACKAGES=${5:-"true"}
19 | INSTALL_OH_MYS=${6:-"true"}
20 | ADD_NON_FREE_PACKAGES=${7:-"false"}
21 | SCRIPT_DIR="$(cd $(dirname "${BASH_SOURCE[0]}") && pwd)"
22 | MARKER_FILE="/usr/local/etc/vscode-dev-containers/common"
23 |
24 |
25 | if [ "$(id -u)" -ne 0 ]; then
26 | echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
27 | exit 1
28 | fi
29 |
30 | # Ensure that login shells get the correct path if the user updated the PATH using ENV.
31 | rm -f /etc/profile.d/00-restore-env.sh
32 | echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
33 | chmod +x /etc/profile.d/00-restore-env.sh
34 |
35 | # If in automatic mode, determine if a user already exists, if not use vscode
36 | if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
37 | USERNAME=""
38 | POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
39 | for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
40 | if id -u ${CURRENT_USER} > /dev/null 2>&1; then
41 | USERNAME=${CURRENT_USER}
42 | break
43 | fi
44 | done
45 | if [ "${USERNAME}" = "" ]; then
46 | USERNAME=vscode
47 | fi
48 | elif [ "${USERNAME}" = "none" ]; then
49 | USERNAME=root
50 | USER_UID=0
51 | USER_GID=0
52 | fi
53 |
54 | # Load markers to see which steps have already run
55 | if [ -f "${MARKER_FILE}" ]; then
56 | echo "Marker file found:"
57 | cat "${MARKER_FILE}"
58 | source "${MARKER_FILE}"
59 | fi
60 |
61 | # Ensure apt is in non-interactive to avoid prompts
62 | export DEBIAN_FRONTEND=noninteractive
63 |
64 | # Function to call apt-get if needed
65 | apt_get_update_if_needed()
66 | {
67 | if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
68 | echo "Running apt-get update..."
69 | apt-get update
70 | else
71 | echo "Skipping apt-get update."
72 | fi
73 | }
74 |
75 | # Run install apt-utils to avoid debconf warning then verify presence of other common developer tools and dependencies
76 | if [ "${PACKAGES_ALREADY_INSTALLED}" != "true" ]; then
77 |
78 | package_list="apt-utils \
79 | openssh-client \
80 | gnupg2 \
81 | dirmngr \
82 | iproute2 \
83 | procps \
84 | lsof \
85 | htop \
86 | net-tools \
87 | psmisc \
88 | curl \
89 | wget \
90 | rsync \
91 | ca-certificates \
92 | unzip \
93 | zip \
94 | nano \
95 | vim-tiny \
96 | less \
97 | jq \
98 | lsb-release \
99 | apt-transport-https \
100 | dialog \
101 | libc6 \
102 | libgcc1 \
103 | libkrb5-3 \
104 | libgssapi-krb5-2 \
105 | libicu[0-9][0-9] \
106 | liblttng-ust0 \
107 | libstdc++6 \
108 | zlib1g \
109 | locales \
110 | sudo \
111 | ncdu \
112 | man-db \
113 | strace \
114 | manpages \
115 | manpages-dev \
116 | init-system-helpers"
117 |
118 | # Needed for adding manpages-posix and manpages-posix-dev which are non-free packages in Debian
119 | if [ "${ADD_NON_FREE_PACKAGES}" = "true" ]; then
120 | # Bring in variables from /etc/os-release like VERSION_CODENAME
121 | . /etc/os-release
122 | sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list
123 | sed -i -E "s/deb-src http:\/\/(deb|httredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list
124 | sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list
125 | sed -i -E "s/deb-src http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list
126 | sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list
127 | sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list
128 | sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list
129 | sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list
130 | # Handle bullseye location for security https://www.debian.org/releases/bullseye/amd64/release-notes/ch-information.en.html
131 | sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list
132 | sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list
133 | echo "Running apt-get update..."
134 | apt-get update
135 | package_list="${package_list} manpages-posix manpages-posix-dev"
136 | else
137 | apt_get_update_if_needed
138 | fi
139 |
140 | # Install libssl1.1 if available
141 | if [[ ! -z $(apt-cache --names-only search ^libssl1.1$) ]]; then
142 | package_list="${package_list} libssl1.1"
143 | fi
144 |
145 | # Install appropriate version of libssl1.0.x if available
146 | libssl_package=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1 || echo '')
147 | if [ "$(echo "$LIlibssl_packageBSSL" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then
148 | if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then
149 | # Debian 9
150 | package_list="${package_list} libssl1.0.2"
151 | elif [[ ! -z $(apt-cache --names-only search ^libssl1.0.0$) ]]; then
152 | # Ubuntu 18.04, 16.04, earlier
153 | package_list="${package_list} libssl1.0.0"
154 | fi
155 | fi
156 |
157 | echo "Packages to verify are installed: ${package_list}"
158 | apt-get -y install --no-install-recommends ${package_list} 2> >( grep -v 'debconf: delaying package configuration, since apt-utils is not installed' >&2 )
159 |
160 | # Install git if not already installed (may be more recent than distro version)
161 | if ! type git > /dev/null 2>&1; then
162 | apt-get -y install --no-install-recommends git
163 | fi
164 |
165 | PACKAGES_ALREADY_INSTALLED="true"
166 | fi
167 |
168 | # Get to latest versions of all packages
169 | if [ "${UPGRADE_PACKAGES}" = "true" ]; then
170 | apt_get_update_if_needed
171 | apt-get -y upgrade --no-install-recommends
172 | apt-get autoremove -y
173 | fi
174 |
175 | # Ensure at least the en_US.UTF-8 UTF-8 locale is available.
176 | # Common need for both applications and things like the agnoster ZSH theme.
177 | if [ "${LOCALE_ALREADY_SET}" != "true" ] && ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then
178 | echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen
179 | locale-gen
180 | LOCALE_ALREADY_SET="true"
181 | fi
182 |
183 | # Create or update a non-root user to match UID/GID.
184 | if id -u ${USERNAME} > /dev/null 2>&1; then
185 | # User exists, update if needed
186 | if [ "${USER_GID}" != "automatic" ] && [ "$USER_GID" != "$(id -G $USERNAME)" ]; then
187 | groupmod --gid $USER_GID $USERNAME
188 | usermod --gid $USER_GID $USERNAME
189 | fi
190 | if [ "${USER_UID}" != "automatic" ] && [ "$USER_UID" != "$(id -u $USERNAME)" ]; then
191 | usermod --uid $USER_UID $USERNAME
192 | fi
193 | else
194 | # Create user
195 | if [ "${USER_GID}" = "automatic" ]; then
196 | groupadd $USERNAME
197 | else
198 | groupadd --gid $USER_GID $USERNAME
199 | fi
200 | if [ "${USER_UID}" = "automatic" ]; then
201 | useradd -s /bin/bash --gid $USERNAME -m $USERNAME
202 | else
203 | useradd -s /bin/bash --uid $USER_UID --gid $USERNAME -m $USERNAME
204 | fi
205 | fi
206 |
207 | # Add add sudo support for non-root user
208 | if [ "${USERNAME}" != "root" ] && [ "${EXISTING_NON_ROOT_USER}" != "${USERNAME}" ]; then
209 | echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME
210 | chmod 0440 /etc/sudoers.d/$USERNAME
211 | EXISTING_NON_ROOT_USER="${USERNAME}"
212 | fi
213 |
214 | # ** Shell customization section **
215 | if [ "${USERNAME}" = "root" ]; then
216 | user_rc_path="/root"
217 | else
218 | user_rc_path="/home/${USERNAME}"
219 | fi
220 |
221 | # Restore user .bashrc defaults from skeleton file if it doesn't exist or is empty
222 | if [ ! -f "${user_rc_path}/.bashrc" ] || [ ! -s "${user_rc_path}/.bashrc" ] ; then
223 | cp /etc/skel/.bashrc "${user_rc_path}/.bashrc"
224 | fi
225 |
226 | # Restore user .profile defaults from skeleton file if it doesn't exist or is empty
227 | if [ ! -f "${user_rc_path}/.profile" ] || [ ! -s "${user_rc_path}/.profile" ] ; then
228 | cp /etc/skel/.profile "${user_rc_path}/.profile"
229 | fi
230 |
231 | # .bashrc/.zshrc snippet
232 | rc_snippet="$(cat << 'EOF'
233 |
234 | if [ -z "${USER}" ]; then export USER=$(whoami); fi
235 | if [[ "${PATH}" != *"$HOME/.local/bin"* ]]; then export PATH="${PATH}:$HOME/.local/bin"; fi
236 |
237 | # Display optional first run image specific notice if configured and terminal is interactive
238 | if [ -t 1 ] && [[ "${TERM_PROGRAM}" = "vscode" || "${TERM_PROGRAM}" = "codespaces" ]] && [ ! -f "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed" ]; then
239 | if [ -f "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" ]; then
240 | cat "/usr/local/etc/vscode-dev-containers/first-run-notice.txt"
241 | elif [ -f "/workspaces/.codespaces/shared/first-run-notice.txt" ]; then
242 | cat "/workspaces/.codespaces/shared/first-run-notice.txt"
243 | fi
244 | mkdir -p "$HOME/.config/vscode-dev-containers"
245 | # Mark first run notice as displayed after 10s to avoid problems with fast terminal refreshes hiding it
246 | ((sleep 10s; touch "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed") &)
247 | fi
248 |
249 | # Set the default git editor if not already set
250 | if [ -z "$(git config --get core.editor)" ] && [ -z "${GIT_EDITOR}" ]; then
251 | if [ "${TERM_PROGRAM}" = "vscode" ]; then
252 | if [[ -n $(command -v code-insiders) && -z $(command -v code) ]]; then
253 | export GIT_EDITOR="code-insiders --wait"
254 | else
255 | export GIT_EDITOR="code --wait"
256 | fi
257 | fi
258 | fi
259 |
260 | EOF
261 | )"
262 |
263 | # code shim, it fallbacks to code-insiders if code is not available
264 | cat << 'EOF' > /usr/local/bin/code
265 | #!/bin/sh
266 |
267 | get_in_path_except_current() {
268 | which -a "$1" | grep -A1 "$0" | grep -v "$0"
269 | }
270 |
271 | code="$(get_in_path_except_current code)"
272 |
273 | if [ -n "$code" ]; then
274 | exec "$code" "$@"
275 | elif [ "$(command -v code-insiders)" ]; then
276 | exec code-insiders "$@"
277 | else
278 | echo "code or code-insiders is not installed" >&2
279 | exit 127
280 | fi
281 | EOF
282 | chmod +x /usr/local/bin/code
283 |
284 | # systemctl shim - tells people to use 'service' if systemd is not running
285 | cat << 'EOF' > /usr/local/bin/systemctl
286 | #!/bin/sh
287 | set -e
288 | if [ -d "/run/systemd/system" ]; then
289 | exec /bin/systemctl/systemctl "$@"
290 | else
291 | echo '\n"systemd" is not running in this container due to its overhead.\nUse the "service" command to start services intead. e.g.: \n\nservice --status-all'
292 | fi
293 | EOF
294 | chmod +x /usr/local/bin/systemctl
295 |
296 | # Codespaces bash and OMZ themes - partly inspired by https://github.com/ohmyzsh/ohmyzsh/blob/master/themes/robbyrussell.zsh-theme
297 | codespaces_bash="$(cat \
298 | <<'EOF'
299 |
300 | # Codespaces bash prompt theme
301 | __bash_prompt() {
302 | local userpart='`export XIT=$? \
303 | && [ ! -z "${GITHUB_USER}" ] && echo -n "\[\033[0;32m\]@${GITHUB_USER} " || echo -n "\[\033[0;32m\]\u " \
304 | && [ "$XIT" -ne "0" ] && echo -n "\[\033[1;31m\]➜" || echo -n "\[\033[0m\]➜"`'
305 | local gitbranch='`\
306 | export BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null); \
307 | if [ "${BRANCH}" != "" ]; then \
308 | echo -n "\[\033[0;36m\](\[\033[1;31m\]${BRANCH}" \
309 | && if git ls-files --error-unmatch -m --directory --no-empty-directory -o --exclude-standard ":/*" > /dev/null 2>&1; then \
310 | echo -n " \[\033[1;33m\]✗"; \
311 | fi \
312 | && echo -n "\[\033[0;36m\]) "; \
313 | fi`'
314 | local lightblue='\[\033[1;34m\]'
315 | local removecolor='\[\033[0m\]'
316 | PS1="${userpart} ${lightblue}\w ${gitbranch}${removecolor}\$ "
317 | unset -f __bash_prompt
318 | }
319 | __bash_prompt
320 |
321 | EOF
322 | )"
323 |
324 | codespaces_zsh="$(cat \
325 | <<'EOF'
326 | # Codespaces zsh prompt theme
327 | __zsh_prompt() {
328 | local prompt_username
329 | if [ ! -z "${GITHUB_USER}" ]; then
330 | prompt_username="@${GITHUB_USER}"
331 | else
332 | prompt_username="%n"
333 | fi
334 | PROMPT="%{$fg[green]%}${prompt_username} %(?:%{$reset_color%}➜ :%{$fg_bold[red]%}➜ )" # User/exit code arrow
335 | PROMPT+='%{$fg_bold[blue]%}%(5~|%-1~/…/%3~|%4~)%{$reset_color%} ' # cwd
336 | PROMPT+='$(git_prompt_info)%{$fg[white]%}$ %{$reset_color%}' # Git status
337 | unset -f __zsh_prompt
338 | }
339 | ZSH_THEME_GIT_PROMPT_PREFIX="%{$fg_bold[cyan]%}(%{$fg_bold[red]%}"
340 | ZSH_THEME_GIT_PROMPT_SUFFIX="%{$reset_color%} "
341 | ZSH_THEME_GIT_PROMPT_DIRTY=" %{$fg_bold[yellow]%}✗%{$fg_bold[cyan]%})"
342 | ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg_bold[cyan]%})"
343 | __zsh_prompt
344 |
345 | EOF
346 | )"
347 |
348 | # Add notice that Oh My Bash! has been removed from images and how to provide information on how to install manually
349 | omb_readme="$(cat \
350 | <<'EOF'
351 | "Oh My Bash!" has been removed from this image in favor of a simple shell prompt. If you
352 | still wish to use it, remove "~/.oh-my-bash" and install it from: https://github.com/ohmybash/oh-my-bash
353 | You may also want to consider "Bash-it" as an alternative: https://github.com/bash-it/bash-it
354 | See here for infomation on adding it to your image or dotfiles: https://aka.ms/codespaces/omb-remove
355 | EOF
356 | )"
357 | omb_stub="$(cat \
358 | <<'EOF'
359 | #!/usr/bin/env bash
360 | if [ -t 1 ]; then
361 | cat $HOME/.oh-my-bash/README.md
362 | fi
363 | EOF
364 | )"
365 |
366 | # Add RC snippet and custom bash prompt
367 | if [ "${RC_SNIPPET_ALREADY_ADDED}" != "true" ]; then
368 | echo "${rc_snippet}" >> /etc/bash.bashrc
369 | echo "${codespaces_bash}" >> "${user_rc_path}/.bashrc"
370 | echo 'export PROMPT_DIRTRIM=4' >> "${user_rc_path}/.bashrc"
371 | if [ "${USERNAME}" != "root" ]; then
372 | echo "${codespaces_bash}" >> "/root/.bashrc"
373 | echo 'export PROMPT_DIRTRIM=4' >> "/root/.bashrc"
374 | fi
375 | chown ${USERNAME}:${USERNAME} "${user_rc_path}/.bashrc"
376 | RC_SNIPPET_ALREADY_ADDED="true"
377 | fi
378 |
379 | # Add stub for Oh My Bash!
380 | if [ ! -d "${user_rc_path}/.oh-my-bash}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then
381 | mkdir -p "${user_rc_path}/.oh-my-bash" "/root/.oh-my-bash"
382 | echo "${omb_readme}" >> "${user_rc_path}/.oh-my-bash/README.md"
383 | echo "${omb_stub}" >> "${user_rc_path}/.oh-my-bash/oh-my-bash.sh"
384 | chmod +x "${user_rc_path}/.oh-my-bash/oh-my-bash.sh"
385 | if [ "${USERNAME}" != "root" ]; then
386 | echo "${omb_readme}" >> "/root/.oh-my-bash/README.md"
387 | echo "${omb_stub}" >> "/root/.oh-my-bash/oh-my-bash.sh"
388 | chmod +x "/root/.oh-my-bash/oh-my-bash.sh"
389 | fi
390 | chown -R "${USERNAME}:${USERNAME}" "${user_rc_path}/.oh-my-bash"
391 | fi
392 |
393 | # Optionally install and configure zsh and Oh My Zsh!
394 | if [ "${INSTALL_ZSH}" = "true" ]; then
395 | if ! type zsh > /dev/null 2>&1; then
396 | apt_get_update_if_needed
397 | apt-get install -y zsh
398 | fi
399 | if [ "${ZSH_ALREADY_INSTALLED}" != "true" ]; then
400 | echo "${rc_snippet}" >> /etc/zsh/zshrc
401 | ZSH_ALREADY_INSTALLED="true"
402 | fi
403 |
404 | # Adapted, simplified inline Oh My Zsh! install steps that adds, defaults to a codespaces theme.
405 | # See https://github.com/ohmyzsh/ohmyzsh/blob/master/tools/install.sh for official script.
406 | oh_my_install_dir="${user_rc_path}/.oh-my-zsh"
407 | if [ ! -d "${oh_my_install_dir}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then
408 | template_path="${oh_my_install_dir}/templates/zshrc.zsh-template"
409 | user_rc_file="${user_rc_path}/.zshrc"
410 | umask g-w,o-w
411 | mkdir -p ${oh_my_install_dir}
412 | git clone --depth=1 \
413 | -c core.eol=lf \
414 | -c core.autocrlf=false \
415 | -c fsck.zeroPaddedFilemode=ignore \
416 | -c fetch.fsck.zeroPaddedFilemode=ignore \
417 | -c receive.fsck.zeroPaddedFilemode=ignore \
418 | "https://github.com/ohmyzsh/ohmyzsh" "${oh_my_install_dir}" 2>&1
419 | echo -e "$(cat "${template_path}")\nDISABLE_AUTO_UPDATE=true\nDISABLE_UPDATE_PROMPT=true" > ${user_rc_file}
420 | sed -i -e 's/ZSH_THEME=.*/ZSH_THEME="codespaces"/g' ${user_rc_file}
421 |
422 | mkdir -p ${oh_my_install_dir}/custom/themes
423 | echo "${codespaces_zsh}" > "${oh_my_install_dir}/custom/themes/codespaces.zsh-theme"
424 | # Shrink git while still enabling updates
425 | cd "${oh_my_install_dir}"
426 | git repack -a -d -f --depth=1 --window=1
427 | # Copy to non-root user if one is specified
428 | if [ "${USERNAME}" != "root" ]; then
429 | cp -rf "${user_rc_file}" "${oh_my_install_dir}" /root
430 | chown -R ${USERNAME}:${USERNAME} "${user_rc_path}"
431 | fi
432 | fi
433 | fi
434 |
435 | # Persist image metadata info, script if meta.env found in same directory
436 | meta_info_script="$(cat << 'EOF'
437 | #!/bin/sh
438 | . /usr/local/etc/vscode-dev-containers/meta.env
439 |
440 | # Minimal output
441 | if [ "$1" = "version" ] || [ "$1" = "image-version" ]; then
442 | echo "${VERSION}"
443 | exit 0
444 | elif [ "$1" = "release" ]; then
445 | echo "${GIT_REPOSITORY_RELEASE}"
446 | exit 0
447 | elif [ "$1" = "content" ] || [ "$1" = "content-url" ] || [ "$1" = "contents" ] || [ "$1" = "contents-url" ]; then
448 | echo "${CONTENTS_URL}"
449 | exit 0
450 | fi
451 |
452 | #Full output
453 | echo
454 | echo "Development container image information"
455 | echo
456 | if [ ! -z "${VERSION}" ]; then echo "- Image version: ${VERSION}"; fi
457 | if [ ! -z "${DEFINITION_ID}" ]; then echo "- Definition ID: ${DEFINITION_ID}"; fi
458 | if [ ! -z "${VARIANT}" ]; then echo "- Variant: ${VARIANT}"; fi
459 | if [ ! -z "${GIT_REPOSITORY}" ]; then echo "- Source code repository: ${GIT_REPOSITORY}"; fi
460 | if [ ! -z "${GIT_REPOSITORY_RELEASE}" ]; then echo "- Source code release/branch: ${GIT_REPOSITORY_RELEASE}"; fi
461 | if [ ! -z "${BUILD_TIMESTAMP}" ]; then echo "- Timestamp: ${BUILD_TIMESTAMP}"; fi
462 | if [ ! -z "${CONTENTS_URL}" ]; then echo && echo "More info: ${CONTENTS_URL}"; fi
463 | echo
464 | EOF
465 | )"
466 | if [ -f "${SCRIPT_DIR}/meta.env" ]; then
467 | mkdir -p /usr/local/etc/vscode-dev-containers/
468 | cp -f "${SCRIPT_DIR}/meta.env" /usr/local/etc/vscode-dev-containers/meta.env
469 | echo "${meta_info_script}" > /usr/local/bin/devcontainer-info
470 | chmod +x /usr/local/bin/devcontainer-info
471 | fi
472 |
473 | # Write marker file
474 | mkdir -p "$(dirname "${MARKER_FILE}")"
475 | echo -e "\
476 | PACKAGES_ALREADY_INSTALLED=${PACKAGES_ALREADY_INSTALLED}\n\
477 | LOCALE_ALREADY_SET=${LOCALE_ALREADY_SET}\n\
478 | EXISTING_NON_ROOT_USER=${EXISTING_NON_ROOT_USER}\n\
479 | RC_SNIPPET_ALREADY_ADDED=${RC_SNIPPET_ALREADY_ADDED}\n\
480 | ZSH_ALREADY_INSTALLED=${ZSH_ALREADY_INSTALLED}" > "${MARKER_FILE}"
481 |
482 | echo "Done!"
483 |
--------------------------------------------------------------------------------
/Tests/CookInSwiftTests/LexerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TokenizerTests.swift
3 | // SwiftCookInSwiftTests
4 | //
5 | // Created by Alexey Dubovskoy on 06/12/2020.
6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | @testable import CookInSwift
11 | import XCTest
12 |
13 | class LexerTests: XCTestCase {
14 |
15 | func testEmptyInput() {
16 | let lexer = Lexer("")
17 | XCTAssertEqual(lexer.getNextToken(), .eof)
18 | }
19 |
20 | func testWhitespaceOnlyInput() {
21 | let lexer = Lexer(" ")
22 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
23 | XCTAssertEqual(lexer.getNextToken(), .eof)
24 | }
25 |
26 | func testNewLineInput() {
27 | let input = " \n "
28 | let lexer = Lexer(input)
29 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
30 | XCTAssertEqual(lexer.getNextToken(), .eol)
31 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
32 | XCTAssertEqual(lexer.getNextToken(), .eof)
33 | }
34 |
35 | func testWindowsNewLineInput() {
36 | let input = "abc\r\ndef\r\nghi"
37 | let lexer = Lexer(input)
38 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
39 | XCTAssertEqual(lexer.getNextToken(), .eol)
40 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("def")))
41 | XCTAssertEqual(lexer.getNextToken(), .eol)
42 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("ghi")))
43 | XCTAssertEqual(lexer.getNextToken(), .eof)
44 | }
45 |
46 | func testMultipleNewLinesInput() {
47 | let input = " \n\n "
48 | let lexer = Lexer(input)
49 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
50 | XCTAssertEqual(lexer.getNextToken(), .eol)
51 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
52 | XCTAssertEqual(lexer.getNextToken(), .eof)
53 | }
54 |
55 | func testBasicString() {
56 | let input = "abc"
57 | let lexer = Lexer(input)
58 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
59 | XCTAssertEqual(lexer.getNextToken(), .eof)
60 | }
61 |
62 | func testEmoji() {
63 | let input = "🍚"
64 | let lexer = Lexer(input)
65 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("🍚")))
66 | XCTAssertEqual(lexer.getNextToken(), .eof)
67 | }
68 |
69 | func testConcessiveStrings() {
70 | let input = "abc xyz"
71 | let lexer = Lexer(input)
72 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
73 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
74 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
75 | XCTAssertEqual(lexer.getNextToken(), .eof)
76 | }
77 |
78 | func testStringWithNumbers() {
79 | let input = "abc 70770 xyz"
80 | let lexer = Lexer(input)
81 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
82 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
83 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(70770)))
84 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
85 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
86 | XCTAssertEqual(lexer.getNextToken(), .eof)
87 | }
88 |
89 | func testStringWithDecimalNumbers() {
90 | let input = "abc 0.70770 xyz"
91 | let lexer = Lexer(input)
92 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
93 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
94 | XCTAssertEqual(lexer.getNextToken(), .constant(.decimal(0.70770)))
95 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
96 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
97 | XCTAssertEqual(lexer.getNextToken(), .eof)
98 | }
99 |
100 | func testStringWithDecimalLikeNumbers() {
101 | let input = "abc 01.70770 xyz"
102 | let lexer = Lexer(input)
103 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
104 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
105 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("01")))
106 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(".")))
107 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(70770)))
108 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
109 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
110 | XCTAssertEqual(lexer.getNextToken(), .eof)
111 | }
112 |
113 | func testDecimalNumbers() {
114 | let input = "0.70770"
115 | let lexer = Lexer(input)
116 | XCTAssertEqual(lexer.getNextToken(), .constant(.decimal(0.70770)))
117 | XCTAssertEqual(lexer.getNextToken(), .eof)
118 | }
119 |
120 | func testIntegerNumbers() {
121 | let input = "70770"
122 | let lexer = Lexer(input)
123 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(70770)))
124 | XCTAssertEqual(lexer.getNextToken(), .eof)
125 | }
126 |
127 | func testIntegerLikeNumbers() {
128 | let input = "70770hehe"
129 | let lexer = Lexer(input)
130 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("70770hehe")))
131 | XCTAssertEqual(lexer.getNextToken(), .eof)
132 | }
133 |
134 |
135 | func testFractionalNumbers() {
136 | let input = "1/2"
137 | let lexer = Lexer(input)
138 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2))))
139 | XCTAssertEqual(lexer.getNextToken(), .eof)
140 | }
141 |
142 | func testFractionalNumbersWithSpaces1() {
143 | let input = "1 / 2"
144 | let lexer = Lexer(input)
145 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2))))
146 | XCTAssertEqual(lexer.getNextToken(), .eof)
147 | }
148 |
149 | func testFractionalNumbersWithSpaces2() {
150 | let input = " 1 / 2"
151 | let lexer = Lexer(input)
152 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
153 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2))))
154 | XCTAssertEqual(lexer.getNextToken(), .eof)
155 | }
156 |
157 | func testFractionalNumbersWithSpacesAfter() {
158 | let input = " 1 / 2 "
159 | let lexer = Lexer(input)
160 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
161 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2))))
162 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
163 | XCTAssertEqual(lexer.getNextToken(), .eof)
164 | }
165 |
166 | func testAlmostFractionalNumbers() {
167 | let input = "01/2"
168 | let lexer = Lexer(input)
169 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("01")))
170 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("/")))
171 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(2)))
172 | XCTAssertEqual(lexer.getNextToken(), .eof)
173 | }
174 |
175 | func testStringWithLeadingZeroNumbers() {
176 | let input = "abc 0777 xyz"
177 | let lexer = Lexer(input)
178 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
179 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
180 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("0777")))
181 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
182 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
183 | XCTAssertEqual(lexer.getNextToken(), .eof)
184 | }
185 |
186 | func testStringWithLikeNumbers() {
187 | let input = "abc 7peppers xyz"
188 | let lexer = Lexer(input)
189 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
190 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
191 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("7peppers")))
192 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
193 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
194 | XCTAssertEqual(lexer.getNextToken(), .eof)
195 | }
196 |
197 | func testStringWithPunctuation() {
198 | let input = "abc – xyz: lol,"
199 | let lexer = Lexer(input)
200 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
201 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
202 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("–")))
203 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
204 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
205 | XCTAssertEqual(lexer.getNextToken(), .colon)
206 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
207 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("lol")))
208 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(",")))
209 | XCTAssertEqual(lexer.getNextToken(), .eof)
210 | }
211 |
212 | func testStringWithPunctuationRepeated() {
213 | let input = "abc – ...,,xyz: lol,"
214 | let lexer = Lexer(input)
215 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc")))
216 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
217 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("–")))
218 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
219 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("...,,")))
220 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz")))
221 | XCTAssertEqual(lexer.getNextToken(), .colon)
222 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
223 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("lol")))
224 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(",")))
225 | XCTAssertEqual(lexer.getNextToken(), .eof)
226 | }
227 |
228 | func testIngridientsOneLiner() {
229 | let input = "Add @onions{3%medium} chopped finely"
230 | let lexer = Lexer(input)
231 |
232 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Add")))
233 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
234 | XCTAssertEqual(lexer.getNextToken(), .at)
235 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
236 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
237 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(3)))
238 | XCTAssertEqual(lexer.getNextToken(), .percent)
239 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium")))
240 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
241 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
242 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped")))
243 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
244 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("finely")))
245 | XCTAssertEqual(lexer.getNextToken(), .eof)
246 | }
247 |
248 | func testIngridientsDecimal() {
249 | let input = "@onions{3.5%medium}"
250 | let lexer = Lexer(input)
251 |
252 | XCTAssertEqual(lexer.getNextToken(), .at)
253 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
254 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
255 | XCTAssertEqual(lexer.getNextToken(), .constant(.decimal(3.5)))
256 | XCTAssertEqual(lexer.getNextToken(), .percent)
257 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium")))
258 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
259 | XCTAssertEqual(lexer.getNextToken(), .eof)
260 | }
261 |
262 | func testIngridientsFractions() {
263 | let input = "@onions{1/4%medium}"
264 | let lexer = Lexer(input)
265 |
266 | XCTAssertEqual(lexer.getNextToken(), .at)
267 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
268 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
269 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1,4))))
270 | XCTAssertEqual(lexer.getNextToken(), .percent)
271 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium")))
272 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
273 | XCTAssertEqual(lexer.getNextToken(), .eof)
274 | }
275 |
276 | func testIngridientsFractionsWithSpaces() {
277 | let input = "@onions{1 / 4 %medium}"
278 | let lexer = Lexer(input)
279 |
280 | XCTAssertEqual(lexer.getNextToken(), .at)
281 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
282 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
283 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1,4))))
284 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
285 | XCTAssertEqual(lexer.getNextToken(), .percent)
286 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium")))
287 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
288 | XCTAssertEqual(lexer.getNextToken(), .eof)
289 | }
290 |
291 | func testComments() {
292 | let input = "-- testing comments"
293 | let lexer = Lexer(input)
294 |
295 | XCTAssertEqual(lexer.getNextToken(), .eof)
296 | }
297 |
298 | func testBlockComments() {
299 | let input = "visible [- hidden -] visible"
300 | let lexer = Lexer(input)
301 |
302 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible")))
303 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
304 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
305 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible")))
306 | XCTAssertEqual(lexer.getNextToken(), .eof)
307 | }
308 |
309 | func testBlockCommentsMultiline() {
310 | let input = """
311 | visible [- hidden
312 | hidden
313 | hidden -] visible
314 | """
315 | let lexer = Lexer(input)
316 |
317 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible")))
318 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
319 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
320 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible")))
321 | XCTAssertEqual(lexer.getNextToken(), .eof)
322 | }
323 |
324 | func testBlockCommentsMultilineUnfinished() {
325 | let input = """
326 | visible [- hidden
327 | hidden
328 | hidden
329 | """
330 | let lexer = Lexer(input)
331 |
332 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible")))
333 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
334 | XCTAssertEqual(lexer.getNextToken(), .eof)
335 | }
336 |
337 | func testBlockCommentsUnfinished() {
338 | let input = "visible [- hidden"
339 | let lexer = Lexer(input)
340 |
341 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible")))
342 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
343 | XCTAssertEqual(lexer.getNextToken(), .eof)
344 | }
345 |
346 | func testDashLast() {
347 | let input = "onions -"
348 | let lexer = Lexer(input)
349 |
350 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
351 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
352 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("-")))
353 | XCTAssertEqual(lexer.getNextToken(), .eof)
354 | }
355 |
356 | func testDashInText() {
357 | let input = "Preheat the oven to 200℃-Fan 180°C."
358 | let lexer = Lexer(input)
359 |
360 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Preheat")))
361 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
362 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("the")))
363 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
364 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("oven")))
365 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
366 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("to")))
367 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
368 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(200)))
369 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("℃-")))
370 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Fan")))
371 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
372 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(180)))
373 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("°")))
374 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("C")))
375 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(".")))
376 | XCTAssertEqual(lexer.getNextToken(), .eof)
377 | }
378 |
379 | func testSquareInText() {
380 | let input = "Preheat the oven to 200℃[Fan] 180°C."
381 | let lexer = Lexer(input)
382 |
383 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Preheat")))
384 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
385 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("the")))
386 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
387 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("oven")))
388 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
389 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("to")))
390 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
391 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(200)))
392 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("℃[")))
393 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Fan")))
394 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("]")))
395 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
396 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(180)))
397 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("°")))
398 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("C")))
399 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(".")))
400 | XCTAssertEqual(lexer.getNextToken(), .eof)
401 | }
402 |
403 | func testIngridientsNoUnits() {
404 | let input = "@onions{3}"
405 | let lexer = Lexer(input)
406 |
407 | XCTAssertEqual(lexer.getNextToken(), .at)
408 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
409 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
410 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(3)))
411 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
412 | XCTAssertEqual(lexer.getNextToken(), .eof)
413 | }
414 |
415 | func testIngridientsNoAmount() {
416 | let input = "@onions"
417 | let lexer = Lexer(input)
418 |
419 | XCTAssertEqual(lexer.getNextToken(), .at)
420 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
421 | XCTAssertEqual(lexer.getNextToken(), .eof)
422 | }
423 |
424 | func testIngridientsMultiWordNoAmount() {
425 | let input = "@red onions{}"
426 | let lexer = Lexer(input)
427 |
428 | XCTAssertEqual(lexer.getNextToken(), .at)
429 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("red")))
430 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
431 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
432 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
433 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
434 | XCTAssertEqual(lexer.getNextToken(), .eof)
435 | }
436 |
437 | func testIngridientsMultiWordWithPunctuation() {
438 | let input = "@onions, chopped"
439 | let lexer = Lexer(input)
440 |
441 | XCTAssertEqual(lexer.getNextToken(), .at)
442 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
443 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(",")))
444 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
445 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped")))
446 | XCTAssertEqual(lexer.getNextToken(), .eof)
447 | }
448 |
449 | func testIngridientsWordNoAmount() {
450 | let input = "an @onion finely chopped"
451 | let lexer = Lexer(input)
452 |
453 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("an")))
454 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
455 | XCTAssertEqual(lexer.getNextToken(), .at)
456 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onion")))
457 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
458 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("finely")))
459 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
460 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped")))
461 | XCTAssertEqual(lexer.getNextToken(), .eof)
462 | }
463 |
464 | func testEquipment() {
465 | let input = "put into #oven"
466 | let lexer = Lexer(input)
467 |
468 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("put")))
469 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
470 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("into")))
471 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
472 | XCTAssertEqual(lexer.getNextToken(), .hash)
473 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("oven")))
474 | XCTAssertEqual(lexer.getNextToken(), .eof)
475 | }
476 |
477 | func testEquipmentMultiWord() {
478 | let input = "fry on #frying pan{}"
479 | let lexer = Lexer(input)
480 |
481 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("fry")))
482 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
483 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("on")))
484 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
485 | XCTAssertEqual(lexer.getNextToken(), .hash)
486 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("frying")))
487 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
488 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("pan")))
489 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
490 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
491 | XCTAssertEqual(lexer.getNextToken(), .eof)
492 | }
493 |
494 | func testTimer() {
495 | let input = "cook for ~{10%minutes}"
496 | let lexer = Lexer(input)
497 |
498 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("cook")))
499 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
500 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("for")))
501 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
502 | XCTAssertEqual(lexer.getNextToken(), .tilde)
503 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
504 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(10)))
505 | XCTAssertEqual(lexer.getNextToken(), .percent)
506 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("minutes")))
507 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
508 | XCTAssertEqual(lexer.getNextToken(), .eof)
509 | }
510 |
511 | func testBrokenTimer() {
512 | let input = "cook for ~{10 minutes}"
513 | let lexer = Lexer(input)
514 |
515 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("cook")))
516 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
517 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("for")))
518 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
519 | XCTAssertEqual(lexer.getNextToken(), .tilde)
520 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
521 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(10)))
522 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
523 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("minutes")))
524 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
525 | XCTAssertEqual(lexer.getNextToken(), .eof)
526 | }
527 |
528 | func testIngridientsMultiLiner() {
529 | let input = """
530 | Add @onions{3%medium} chopped finely
531 |
532 | Bonne appetite!
533 | """
534 | let lexer = Lexer(input)
535 |
536 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Add")))
537 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
538 | XCTAssertEqual(lexer.getNextToken(), .at)
539 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions")))
540 | XCTAssertEqual(lexer.getNextToken(), .braces(.left))
541 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(3)))
542 | XCTAssertEqual(lexer.getNextToken(), .percent)
543 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium")))
544 | XCTAssertEqual(lexer.getNextToken(), .braces(.right))
545 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
546 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped")))
547 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
548 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("finely")))
549 | XCTAssertEqual(lexer.getNextToken(), .eol)
550 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Bonne")))
551 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
552 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("appetite")))
553 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("!")))
554 | XCTAssertEqual(lexer.getNextToken(), .eof)
555 | }
556 |
557 | func testMetadata() {
558 | let input = ">> servings: 4|5|6"
559 | let lexer = Lexer(input)
560 |
561 | XCTAssertEqual(lexer.getNextToken(), .chevron)
562 | XCTAssertEqual(lexer.getNextToken(), .chevron)
563 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
564 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("servings")))
565 | XCTAssertEqual(lexer.getNextToken(), .colon)
566 | XCTAssertEqual(lexer.getNextToken(), .constant(.space))
567 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(4)))
568 | XCTAssertEqual(lexer.getNextToken(), .pipe)
569 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(5)))
570 | XCTAssertEqual(lexer.getNextToken(), .pipe)
571 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(6)))
572 | XCTAssertEqual(lexer.getNextToken(), .eof)
573 | }
574 |
575 | }
576 |
--------------------------------------------------------------------------------
/Tests/CookInSwiftTests/ParserCanonicalTests.swift:
--------------------------------------------------------------------------------
1 | // file is autogenerated from canonical_tests.yaml
2 | //
3 | // $ ruby Tests/CookInSwiftTests/generate_canonical_tests.rb ../spec/tests/canonical.yaml
4 | //
5 | // don't edit this file
6 | //
7 | // version: 4
8 | //
9 |
10 | import Foundation
11 | import XCTest
12 | @testable import CookInSwift
13 |
14 | class ParserCanonicalTests: XCTestCase {
15 |
16 | func testBasicDirection() {
17 | let recipe =
18 | """
19 | Add a bit of chilli
20 | """
21 |
22 | let result = try! Parser.parse(recipe) as! RecipeNode
23 |
24 | let steps: [StepNode] = [
25 | StepNode(instructions: [
26 | DirectionNode("Add a bit of chilli"),
27 | ]),
28 | ]
29 |
30 | let metadata: [MetadataNode] = []
31 |
32 | let node = RecipeNode(steps: steps, metadata: metadata)
33 |
34 | XCTAssertEqual(result, node)
35 | }
36 |
37 | func testComments() {
38 | let recipe =
39 | """
40 | -- testing comments
41 | """
42 |
43 | let result = try! Parser.parse(recipe) as! RecipeNode
44 |
45 | let steps: [StepNode] = [
46 | ]
47 |
48 | let metadata: [MetadataNode] = []
49 |
50 | let node = RecipeNode(steps: steps, metadata: metadata)
51 |
52 | XCTAssertEqual(result, node)
53 | }
54 |
55 | func testCommentsAfterIngredients() {
56 | let recipe =
57 | """
58 | @thyme{2%springs} -- testing comments
59 | and some text
60 | """
61 |
62 | let result = try! Parser.parse(recipe) as! RecipeNode
63 |
64 | let steps: [StepNode] = [
65 | StepNode(instructions: [
66 | IngredientNode(name: "thyme", amount: AmountNode(quantity: 2, units: "springs")),
67 | DirectionNode(" "),
68 | ]),
69 | StepNode(instructions: [
70 | DirectionNode("and some text"),
71 | ]),
72 | ]
73 |
74 | let metadata: [MetadataNode] = []
75 |
76 | let node = RecipeNode(steps: steps, metadata: metadata)
77 |
78 | XCTAssertEqual(result, node)
79 | }
80 |
81 | func testCommentsWithIngredients() {
82 | let recipe =
83 | """
84 | -- testing comments
85 | @thyme{2%springs}
86 | """
87 |
88 | let result = try! Parser.parse(recipe) as! RecipeNode
89 |
90 | let steps: [StepNode] = [
91 | StepNode(instructions: [
92 | IngredientNode(name: "thyme", amount: AmountNode(quantity: 2, units: "springs")),
93 | ]),
94 | ]
95 |
96 | let metadata: [MetadataNode] = []
97 |
98 | let node = RecipeNode(steps: steps, metadata: metadata)
99 |
100 | XCTAssertEqual(result, node)
101 | }
102 |
103 | func testDirectionsWithDegrees() {
104 | let recipe =
105 | """
106 | Heat oven up to 200°C
107 | """
108 |
109 | let result = try! Parser.parse(recipe) as! RecipeNode
110 |
111 | let steps: [StepNode] = [
112 | StepNode(instructions: [
113 | DirectionNode("Heat oven up to 200°C"),
114 | ]),
115 | ]
116 |
117 | let metadata: [MetadataNode] = []
118 |
119 | let node = RecipeNode(steps: steps, metadata: metadata)
120 |
121 | XCTAssertEqual(result, node)
122 | }
123 |
124 | func testDirectionsWithNumbers() {
125 | let recipe =
126 | """
127 | Heat 5L of water
128 | """
129 |
130 | let result = try! Parser.parse(recipe) as! RecipeNode
131 |
132 | let steps: [StepNode] = [
133 | StepNode(instructions: [
134 | DirectionNode("Heat 5L of water"),
135 | ]),
136 | ]
137 |
138 | let metadata: [MetadataNode] = []
139 |
140 | let node = RecipeNode(steps: steps, metadata: metadata)
141 |
142 | XCTAssertEqual(result, node)
143 | }
144 |
145 | func testDirectionWithIngrident() {
146 | let recipe =
147 | """
148 | Add @chilli{3%items}, @ginger{10%g} and @milk{1%l}.
149 | """
150 |
151 | let result = try! Parser.parse(recipe) as! RecipeNode
152 |
153 | let steps: [StepNode] = [
154 | StepNode(instructions: [
155 | DirectionNode("Add "),
156 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "items")),
157 | DirectionNode(", "),
158 | IngredientNode(name: "ginger", amount: AmountNode(quantity: 10, units: "g")),
159 | DirectionNode(" and "),
160 | IngredientNode(name: "milk", amount: AmountNode(quantity: 1, units: "l")),
161 | DirectionNode("."),
162 | ]),
163 | ]
164 |
165 | let metadata: [MetadataNode] = []
166 |
167 | let node = RecipeNode(steps: steps, metadata: metadata)
168 |
169 | XCTAssertEqual(result, node)
170 | }
171 |
172 | func testEquipmentMultipleWords() {
173 | let recipe =
174 | """
175 | Fry in #frying pan{}
176 | """
177 |
178 | let result = try! Parser.parse(recipe) as! RecipeNode
179 |
180 | let steps: [StepNode] = [
181 | StepNode(instructions: [
182 | DirectionNode("Fry in "),
183 | EquipmentNode(name: "frying pan"),
184 | ]),
185 | ]
186 |
187 | let metadata: [MetadataNode] = []
188 |
189 | let node = RecipeNode(steps: steps, metadata: metadata)
190 |
191 | XCTAssertEqual(result, node)
192 | }
193 |
194 | func testEquipmentMultipleWordsWithLeadingNumber() {
195 | let recipe =
196 | """
197 | Fry in #7-inch nonstick frying pan{ }
198 | """
199 |
200 | let result = try! Parser.parse(recipe) as! RecipeNode
201 |
202 | let steps: [StepNode] = [
203 | StepNode(instructions: [
204 | DirectionNode("Fry in "),
205 | EquipmentNode(name: "7-inch nonstick frying pan"),
206 | ]),
207 | ]
208 |
209 | let metadata: [MetadataNode] = []
210 |
211 | let node = RecipeNode(steps: steps, metadata: metadata)
212 |
213 | XCTAssertEqual(result, node)
214 | }
215 |
216 | func testEquipmentMultipleWordsWithSpaces() {
217 | let recipe =
218 | """
219 | Fry in #frying pan{ }
220 | """
221 |
222 | let result = try! Parser.parse(recipe) as! RecipeNode
223 |
224 | let steps: [StepNode] = [
225 | StepNode(instructions: [
226 | DirectionNode("Fry in "),
227 | EquipmentNode(name: "frying pan"),
228 | ]),
229 | ]
230 |
231 | let metadata: [MetadataNode] = []
232 |
233 | let node = RecipeNode(steps: steps, metadata: metadata)
234 |
235 | XCTAssertEqual(result, node)
236 | }
237 |
238 | func testEquipmentOneWord() {
239 | let recipe =
240 | """
241 | Simmer in #pan for some time
242 | """
243 |
244 | let result = try! Parser.parse(recipe) as! RecipeNode
245 |
246 | let steps: [StepNode] = [
247 | StepNode(instructions: [
248 | DirectionNode("Simmer in "),
249 | EquipmentNode(name: "pan"),
250 | DirectionNode(" for some time"),
251 | ]),
252 | ]
253 |
254 | let metadata: [MetadataNode] = []
255 |
256 | let node = RecipeNode(steps: steps, metadata: metadata)
257 |
258 | XCTAssertEqual(result, node)
259 | }
260 |
261 | func testFractions() {
262 | let recipe =
263 | """
264 | @milk{1/2%cup}
265 | """
266 |
267 | let result = try! Parser.parse(recipe) as! RecipeNode
268 |
269 | let steps: [StepNode] = [
270 | StepNode(instructions: [
271 | IngredientNode(name: "milk", amount: AmountNode(quantity: 0.5, units: "cup")),
272 | ]),
273 | ]
274 |
275 | let metadata: [MetadataNode] = []
276 |
277 | let node = RecipeNode(steps: steps, metadata: metadata)
278 |
279 | XCTAssertEqual(result, node)
280 | }
281 |
282 | func testFractionsInDirections() {
283 | let recipe =
284 | """
285 | knife cut about every 1/2 inches
286 | """
287 |
288 | let result = try! Parser.parse(recipe) as! RecipeNode
289 |
290 | let steps: [StepNode] = [
291 | StepNode(instructions: [
292 | DirectionNode("knife cut about every 1/2 inches"),
293 | ]),
294 | ]
295 |
296 | let metadata: [MetadataNode] = []
297 |
298 | let node = RecipeNode(steps: steps, metadata: metadata)
299 |
300 | XCTAssertEqual(result, node)
301 | }
302 |
303 | func testFractionsLike() {
304 | let recipe =
305 | """
306 | @milk{01/2%cup}
307 | """
308 |
309 | let result = try! Parser.parse(recipe) as! RecipeNode
310 |
311 | let steps: [StepNode] = [
312 | StepNode(instructions: [
313 | IngredientNode(name: "milk", amount: AmountNode(quantity: "01/2", units: "cup")),
314 | ]),
315 | ]
316 |
317 | let metadata: [MetadataNode] = []
318 |
319 | let node = RecipeNode(steps: steps, metadata: metadata)
320 |
321 | XCTAssertEqual(result, node)
322 | }
323 |
324 | func testFractionsWithSpaces() {
325 | let recipe =
326 | """
327 | @milk{1 / 2 %cup}
328 | """
329 |
330 | let result = try! Parser.parse(recipe) as! RecipeNode
331 |
332 | let steps: [StepNode] = [
333 | StepNode(instructions: [
334 | IngredientNode(name: "milk", amount: AmountNode(quantity: 0.5, units: "cup")),
335 | ]),
336 | ]
337 |
338 | let metadata: [MetadataNode] = []
339 |
340 | let node = RecipeNode(steps: steps, metadata: metadata)
341 |
342 | XCTAssertEqual(result, node)
343 | }
344 |
345 | func testIngredientMultipleWordsWithLeadingNumber() {
346 | let recipe =
347 | """
348 | Top with @1000 island dressing{ }
349 | """
350 |
351 | let result = try! Parser.parse(recipe) as! RecipeNode
352 |
353 | let steps: [StepNode] = [
354 | StepNode(instructions: [
355 | DirectionNode("Top with "),
356 | IngredientNode(name: "1000 island dressing", amount: AmountNode(quantity: "some", units: "")),
357 | ]),
358 | ]
359 |
360 | let metadata: [MetadataNode] = []
361 |
362 | let node = RecipeNode(steps: steps, metadata: metadata)
363 |
364 | XCTAssertEqual(result, node)
365 | }
366 |
367 | func testIngredientWithEmoji() {
368 | let recipe =
369 | """
370 | Add some @🧂
371 | """
372 |
373 | let result = try! Parser.parse(recipe) as! RecipeNode
374 |
375 | let steps: [StepNode] = [
376 | StepNode(instructions: [
377 | DirectionNode("Add some "),
378 | IngredientNode(name: "🧂", amount: AmountNode(quantity: "some", units: "")),
379 | ]),
380 | ]
381 |
382 | let metadata: [MetadataNode] = []
383 |
384 | let node = RecipeNode(steps: steps, metadata: metadata)
385 |
386 | XCTAssertEqual(result, node)
387 | }
388 |
389 | func testIngridentExplicitUnits() {
390 | let recipe =
391 | """
392 | @chilli{3%items}
393 | """
394 |
395 | let result = try! Parser.parse(recipe) as! RecipeNode
396 |
397 | let steps: [StepNode] = [
398 | StepNode(instructions: [
399 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "items")),
400 | ]),
401 | ]
402 |
403 | let metadata: [MetadataNode] = []
404 |
405 | let node = RecipeNode(steps: steps, metadata: metadata)
406 |
407 | XCTAssertEqual(result, node)
408 | }
409 |
410 | func testIngridentExplicitUnitsWithSpaces() {
411 | let recipe =
412 | """
413 | @chilli{ 3 % items }
414 | """
415 |
416 | let result = try! Parser.parse(recipe) as! RecipeNode
417 |
418 | let steps: [StepNode] = [
419 | StepNode(instructions: [
420 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "items")),
421 | ]),
422 | ]
423 |
424 | let metadata: [MetadataNode] = []
425 |
426 | let node = RecipeNode(steps: steps, metadata: metadata)
427 |
428 | XCTAssertEqual(result, node)
429 | }
430 |
431 | func testIngridentImplicitUnits() {
432 | let recipe =
433 | """
434 | @chilli{3}
435 | """
436 |
437 | let result = try! Parser.parse(recipe) as! RecipeNode
438 |
439 | let steps: [StepNode] = [
440 | StepNode(instructions: [
441 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "")),
442 | ]),
443 | ]
444 |
445 | let metadata: [MetadataNode] = []
446 |
447 | let node = RecipeNode(steps: steps, metadata: metadata)
448 |
449 | XCTAssertEqual(result, node)
450 | }
451 |
452 | func testIngridentNoUnits() {
453 | let recipe =
454 | """
455 | @chilli
456 | """
457 |
458 | let result = try! Parser.parse(recipe) as! RecipeNode
459 |
460 | let steps: [StepNode] = [
461 | StepNode(instructions: [
462 | IngredientNode(name: "chilli", amount: AmountNode(quantity: "some", units: "")),
463 | ]),
464 | ]
465 |
466 | let metadata: [MetadataNode] = []
467 |
468 | let node = RecipeNode(steps: steps, metadata: metadata)
469 |
470 | XCTAssertEqual(result, node)
471 | }
472 |
473 | func testIngridentNoUnitsNotOnlyString() {
474 | let recipe =
475 | """
476 | @5peppers
477 | """
478 |
479 | let result = try! Parser.parse(recipe) as! RecipeNode
480 |
481 | let steps: [StepNode] = [
482 | StepNode(instructions: [
483 | IngredientNode(name: "5peppers", amount: AmountNode(quantity: "some", units: "")),
484 | ]),
485 | ]
486 |
487 | let metadata: [MetadataNode] = []
488 |
489 | let node = RecipeNode(steps: steps, metadata: metadata)
490 |
491 | XCTAssertEqual(result, node)
492 | }
493 |
494 | func testIngridentWithNumbers() {
495 | let recipe =
496 | """
497 | @tipo 00 flour{250%g}
498 | """
499 |
500 | let result = try! Parser.parse(recipe) as! RecipeNode
501 |
502 | let steps: [StepNode] = [
503 | StepNode(instructions: [
504 | IngredientNode(name: "tipo 00 flour", amount: AmountNode(quantity: 250, units: "g")),
505 | ]),
506 | ]
507 |
508 | let metadata: [MetadataNode] = []
509 |
510 | let node = RecipeNode(steps: steps, metadata: metadata)
511 |
512 | XCTAssertEqual(result, node)
513 | }
514 |
515 | func testIngridentWithoutStopper() {
516 | let recipe =
517 | """
518 | @chilli cut into pieces
519 | """
520 |
521 | let result = try! Parser.parse(recipe) as! RecipeNode
522 |
523 | let steps: [StepNode] = [
524 | StepNode(instructions: [
525 | IngredientNode(name: "chilli", amount: AmountNode(quantity: "some", units: "")),
526 | DirectionNode(" cut into pieces"),
527 | ]),
528 | ]
529 |
530 | let metadata: [MetadataNode] = []
531 |
532 | let node = RecipeNode(steps: steps, metadata: metadata)
533 |
534 | XCTAssertEqual(result, node)
535 | }
536 |
537 | func testMetadata() {
538 | let recipe =
539 | """
540 | >> sourced: babooshka
541 | """
542 |
543 | let result = try! Parser.parse(recipe) as! RecipeNode
544 |
545 | let steps: [StepNode] = [
546 | ]
547 |
548 | let metadata: [MetadataNode] = [
549 | MetadataNode("sourced", "babooshka"),]
550 |
551 | let node = RecipeNode(steps: steps, metadata: metadata)
552 |
553 | XCTAssertEqual(result, node)
554 | }
555 |
556 | func testMetadataBreak() {
557 | let recipe =
558 | """
559 | hello >> sourced: babooshka
560 | """
561 |
562 | let result = try! Parser.parse(recipe) as! RecipeNode
563 |
564 | let steps: [StepNode] = [
565 | StepNode(instructions: [
566 | DirectionNode("hello >> sourced: babooshka"),
567 | ]),
568 | ]
569 |
570 | let metadata: [MetadataNode] = []
571 |
572 | let node = RecipeNode(steps: steps, metadata: metadata)
573 |
574 | XCTAssertEqual(result, node)
575 | }
576 |
577 | func testMetadataMultiwordKey() {
578 | let recipe =
579 | """
580 | >> cooking time: 30 mins
581 | """
582 |
583 | let result = try! Parser.parse(recipe) as! RecipeNode
584 |
585 | let steps: [StepNode] = [
586 | ]
587 |
588 | let metadata: [MetadataNode] = [
589 | MetadataNode("cooking time", "30 mins"),]
590 |
591 | let node = RecipeNode(steps: steps, metadata: metadata)
592 |
593 | XCTAssertEqual(result, node)
594 | }
595 |
596 | func testMetadataMultiwordKeyWithSpaces() {
597 | let recipe =
598 | """
599 | >>cooking time :30 mins
600 | """
601 |
602 | let result = try! Parser.parse(recipe) as! RecipeNode
603 |
604 | let steps: [StepNode] = [
605 | ]
606 |
607 | let metadata: [MetadataNode] = [
608 | MetadataNode("cooking time", "30 mins"),]
609 |
610 | let node = RecipeNode(steps: steps, metadata: metadata)
611 |
612 | XCTAssertEqual(result, node)
613 | }
614 |
615 | func testMultiLineDirections() {
616 | let recipe =
617 | """
618 | Add a bit of chilli
619 |
620 | Add a bit of hummus
621 | """
622 |
623 | let result = try! Parser.parse(recipe) as! RecipeNode
624 |
625 | let steps: [StepNode] = [
626 | StepNode(instructions: [
627 | DirectionNode("Add a bit of chilli"),
628 | ]),
629 | StepNode(instructions: [
630 | DirectionNode("Add a bit of hummus"),
631 | ]),
632 | ]
633 |
634 | let metadata: [MetadataNode] = []
635 |
636 | let node = RecipeNode(steps: steps, metadata: metadata)
637 |
638 | XCTAssertEqual(result, node)
639 | }
640 |
641 | func testMultipleLines() {
642 | let recipe =
643 | """
644 | >> Prep Time: 15 minutes
645 | >> Cook Time: 30 minutes
646 | """
647 |
648 | let result = try! Parser.parse(recipe) as! RecipeNode
649 |
650 | let steps: [StepNode] = [
651 | ]
652 |
653 | let metadata: [MetadataNode] = [
654 | MetadataNode("Prep Time", "15 minutes"),
655 | MetadataNode("Cook Time", "30 minutes"),]
656 |
657 | let node = RecipeNode(steps: steps, metadata: metadata)
658 |
659 | XCTAssertEqual(result, node)
660 | }
661 |
662 | func testMultiWordIngrident() {
663 | let recipe =
664 | """
665 | @hot chilli{3}
666 | """
667 |
668 | let result = try! Parser.parse(recipe) as! RecipeNode
669 |
670 | let steps: [StepNode] = [
671 | StepNode(instructions: [
672 | IngredientNode(name: "hot chilli", amount: AmountNode(quantity: 3, units: "")),
673 | ]),
674 | ]
675 |
676 | let metadata: [MetadataNode] = []
677 |
678 | let node = RecipeNode(steps: steps, metadata: metadata)
679 |
680 | XCTAssertEqual(result, node)
681 | }
682 |
683 | func testMultiWordIngridentNoAmount() {
684 | let recipe =
685 | """
686 | @hot chilli{}
687 | """
688 |
689 | let result = try! Parser.parse(recipe) as! RecipeNode
690 |
691 | let steps: [StepNode] = [
692 | StepNode(instructions: [
693 | IngredientNode(name: "hot chilli", amount: AmountNode(quantity: "some", units: "")),
694 | ]),
695 | ]
696 |
697 | let metadata: [MetadataNode] = []
698 |
699 | let node = RecipeNode(steps: steps, metadata: metadata)
700 |
701 | XCTAssertEqual(result, node)
702 | }
703 |
704 | func testMutipleIngridentsWithoutStopper() {
705 | let recipe =
706 | """
707 | @chilli cut into pieces and @garlic
708 | """
709 |
710 | let result = try! Parser.parse(recipe) as! RecipeNode
711 |
712 | let steps: [StepNode] = [
713 | StepNode(instructions: [
714 | IngredientNode(name: "chilli", amount: AmountNode(quantity: "some", units: "")),
715 | DirectionNode(" cut into pieces and "),
716 | IngredientNode(name: "garlic", amount: AmountNode(quantity: "some", units: "")),
717 | ]),
718 | ]
719 |
720 | let metadata: [MetadataNode] = []
721 |
722 | let node = RecipeNode(steps: steps, metadata: metadata)
723 |
724 | XCTAssertEqual(result, node)
725 | }
726 |
727 | func testQuantityAsText() {
728 | let recipe =
729 | """
730 | @thyme{few%springs}
731 | """
732 |
733 | let result = try! Parser.parse(recipe) as! RecipeNode
734 |
735 | let steps: [StepNode] = [
736 | StepNode(instructions: [
737 | IngredientNode(name: "thyme", amount: AmountNode(quantity: "few", units: "springs")),
738 | ]),
739 | ]
740 |
741 | let metadata: [MetadataNode] = []
742 |
743 | let node = RecipeNode(steps: steps, metadata: metadata)
744 |
745 | XCTAssertEqual(result, node)
746 | }
747 |
748 | func testQuantityDigitalString() {
749 | let recipe =
750 | """
751 | @water{7 k }
752 | """
753 |
754 | let result = try! Parser.parse(recipe) as! RecipeNode
755 |
756 | let steps: [StepNode] = [
757 | StepNode(instructions: [
758 | IngredientNode(name: "water", amount: AmountNode(quantity: "7 k", units: "")),
759 | ]),
760 | ]
761 |
762 | let metadata: [MetadataNode] = []
763 |
764 | let node = RecipeNode(steps: steps, metadata: metadata)
765 |
766 | XCTAssertEqual(result, node)
767 | }
768 |
769 | func testServings() {
770 | let recipe =
771 | """
772 | >> servings: 1|2|3
773 | """
774 |
775 | let result = try! Parser.parse(recipe) as! RecipeNode
776 |
777 | let steps: [StepNode] = [
778 | ]
779 |
780 | let metadata: [MetadataNode] = [
781 | MetadataNode("servings", "1|2|3"),]
782 |
783 | let node = RecipeNode(steps: steps, metadata: metadata)
784 |
785 | XCTAssertEqual(result, node)
786 | }
787 |
788 | func testSlashInText() {
789 | let recipe =
790 | """
791 | Preheat the oven to 200℃/Fan 180°C.
792 | """
793 |
794 | let result = try! Parser.parse(recipe) as! RecipeNode
795 |
796 | let steps: [StepNode] = [
797 | StepNode(instructions: [
798 | DirectionNode("Preheat the oven to 200℃/Fan 180°C."),
799 | ]),
800 | ]
801 |
802 | let metadata: [MetadataNode] = []
803 |
804 | let node = RecipeNode(steps: steps, metadata: metadata)
805 |
806 | XCTAssertEqual(result, node)
807 | }
808 |
809 | func testTimerDecimal() {
810 | let recipe =
811 | """
812 | Fry for ~{1.5%minutes}
813 | """
814 |
815 | let result = try! Parser.parse(recipe) as! RecipeNode
816 |
817 | let steps: [StepNode] = [
818 | StepNode(instructions: [
819 | DirectionNode("Fry for "),
820 | TimerNode(quantity: 1.5, units: "minutes", name: ""),
821 | ]),
822 | ]
823 |
824 | let metadata: [MetadataNode] = []
825 |
826 | let node = RecipeNode(steps: steps, metadata: metadata)
827 |
828 | XCTAssertEqual(result, node)
829 | }
830 |
831 | func testTimerFractional() {
832 | let recipe =
833 | """
834 | Fry for ~{1/2%hour}
835 | """
836 |
837 | let result = try! Parser.parse(recipe) as! RecipeNode
838 |
839 | let steps: [StepNode] = [
840 | StepNode(instructions: [
841 | DirectionNode("Fry for "),
842 | TimerNode(quantity: 0.5, units: "hour", name: ""),
843 | ]),
844 | ]
845 |
846 | let metadata: [MetadataNode] = []
847 |
848 | let node = RecipeNode(steps: steps, metadata: metadata)
849 |
850 | XCTAssertEqual(result, node)
851 | }
852 |
853 | func testTimerInteger() {
854 | let recipe =
855 | """
856 | Fry for ~{10%minutes}
857 | """
858 |
859 | let result = try! Parser.parse(recipe) as! RecipeNode
860 |
861 | let steps: [StepNode] = [
862 | StepNode(instructions: [
863 | DirectionNode("Fry for "),
864 | TimerNode(quantity: 10, units: "minutes", name: ""),
865 | ]),
866 | ]
867 |
868 | let metadata: [MetadataNode] = []
869 |
870 | let node = RecipeNode(steps: steps, metadata: metadata)
871 |
872 | XCTAssertEqual(result, node)
873 | }
874 |
875 | func testTimerWithName() {
876 | let recipe =
877 | """
878 | Fry for ~potato{42%minutes}
879 | """
880 |
881 | let result = try! Parser.parse(recipe) as! RecipeNode
882 |
883 | let steps: [StepNode] = [
884 | StepNode(instructions: [
885 | DirectionNode("Fry for "),
886 | TimerNode(quantity: 42, units: "minutes", name: "potato"),
887 | ]),
888 | ]
889 |
890 | let metadata: [MetadataNode] = []
891 |
892 | let node = RecipeNode(steps: steps, metadata: metadata)
893 |
894 | XCTAssertEqual(result, node)
895 | }
896 | }
--------------------------------------------------------------------------------