From 58a46438b41fc3954a4f1fe9d9588d4726a18a19 Mon Sep 17 00:00:00 2001 From: gemmaro Date: Tue, 26 Mar 2024 20:08:14 +0900 Subject: [PATCH] Version 0.1.0. * .gitignore, LICENSE, README.md, shard.yml, spec/nsplist_spec.cr, spec/spec_helper.cr, src/nsplist.cr: Version 0.1.0. --- .editorconfig | 9 ++ .github/workflows/ci.yml | 14 +++ .gitignore | 10 ++ LICENSE | 21 +++++ README.md | 49 ++++++++++ shard.yml | 10 ++ spec/nsplist_spec.cr | 116 +++++++++++++++++++++++ spec/spec_helper.cr | 2 + src/nsplist.cr | 192 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 423 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/nsplist_spec.cr create mode 100644 spec/spec_helper.cr create mode 100644 src/nsplist.cr diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cc37923 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,14 @@ +on: + push: + pull_request: + branches: [master] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Download source + uses: actions/checkout@v2 + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + - name: Run tests + run: crystal spec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2089ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf + +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock +/.crystal/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4cfe1ef --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 your-name-here + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc69f7d --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# nsplist + +[![GitHub release](https://img.shields.io/github/release/gemmaro/nsplist.svg)](https://github.com/gemmaro/nsplist/releases) + +Old-style ASCII property lists parser. + +## Installation + +1. Add the dependency to your `shard.yml`: + + ```yaml + dependencies: + nsplist: + github: gemmaro/nsplist + ``` + +2. Run `shards install` + +## Usage + +```crystal +require "nsplist" + +NSPlist.parse(source) #=> property list data +``` + +## Development + +`crystal spec` for testing, and `crystal docs` to generate an API documentation. + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Related Works + +There are several related works on the property list parsing. For +parsing the property lists in the XML format, there is a +[plist-cr][xml] libarry. + +[xml]: https://github.com/egillet/plist-cr + +## Contributors + +- [gemmaro](https://github.com/gemmaro) - creator and maintainer diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..657445f --- /dev/null +++ b/shard.yml @@ -0,0 +1,10 @@ +name: nsplist +version: 0.1.0 +description: nsplist is a Crystal library for parsing the old-style ASCII property lists. + +authors: + - gemmaro + +crystal: '>= 1.11.1' + +license: MIT diff --git a/spec/nsplist_spec.cr b/spec/nsplist_spec.cr new file mode 100644 index 0000000..6b702e8 --- /dev/null +++ b/spec/nsplist_spec.cr @@ -0,0 +1,116 @@ +require "./spec_helper" + +describe NSPlist do + describe "Crystal standard API" do + it "can use to_u8 with base 16" do + 'a'.to_u8(base: 16).should eq(0xa) + end + end + + describe "version" do + version = NSPlist::VERSION + version.should be_a(String) + # version.should eq("0.1.0") + end + + describe NSPlist::NSString do + it "is compared with String" do + NSPlist::NSString.new("This is a string").should eq("This is a string") + end + end + + describe NSPlist::NSData do + it "is compared with Bytes" do + NSPlist::NSData.new(Bytes[0x0f, 0xbd, 0x77, 0x71, 0xc2, 0x73, 0x5a, 0xe]).should eq(Bytes[0x0f, 0xbd, 0x77, 0x71, 0xc2, 0x73, 0x5a, 0xe]) + end + end + + describe NSPlist::NSArray do + it "can be created from simple array" do + NSPlist::NSArray.new(["San Francisco", "New York"]).should be_a(NSPlist::NSArray) + end + end + + describe NSPlist::NSDictionary do + it "can be created from simple hash" do + NSPlist::NSDictionary.new({"user" => "wshakesp", "birth" => "1564"}).should be_a(NSPlist::NSDictionary) + end + end + + describe ".parse" do + it "can parse string" do + result = NSPlist.parse(%("This is a string")) + result.should be_a(NSPlist::NSString) + result.should eq("This is a string") + end + + it "can parse bare string" do + result = NSPlist.parse("2plus2is5") + result.should be_a(NSPlist::NSString) + result.should eq("2plus2is5") + end + + it "can parse quoted string with star" do + result = NSPlist.parse(%("str*ng")) + result.should be_a(NSPlist::NSString) + result.should eq("str*ng") + end + + it "can parse binary data" do + binary = "<0fbd777 1c2735ae>" + # 0123456 78901234 + # 0 1 2 3 4 5 6 7 + result = NSPlist.parse(binary) + result.should be_a(NSPlist::NSData) + result.should eq(Bytes[0x0f, 0xbd, 0x77, 0x71, 0xc2, 0x73, 0x5a, 0xe]) + end + + it "can parse longer binary data" do + NSPlist.parse("<9aa5d4cd0403c2d990262c15884181da5d1e32ae>").should be_a(NSPlist::NSData) + end + + it "can parse array" do + expected = NSPlist::NSArray.new(["San Francisco", "New York"]) + result = NSPlist.parse(%{("San Francisco", "New York")}) + result.should be_a(NSPlist::NSArray) + result.should eq(expected) + end + + it "can parse dictionary" do + expected = NSPlist::NSDictionary.new({"user" => "wshakesp", "birth" => "1564"}) + result = NSPlist.parse(%({ user = wshakesp; birth = 1564; })) + result.should be_a(NSPlist::NSDictionary) + result.should eq(expected) + end + + it "can parse dictionary, part 2" do + NSPlist.parse("{ pig = piggish; lamb = lambish; worm = wormy; }").should be_a(NSPlist::NSDictionary) + end + + it "can parse dictionary of multiple lines" do + NSPlist.parse(%[{ pig = oink; lamb = baa; worm = baa; + Lisa = "Why is the worm talking like a lamb?"; }]).should be_a(NSPlist::NSDictionary) + end + + it "can parse complex dictionary" do + NSPlist.parse(%[{ + AnimalSmells = { pig = piggish; lamb = lambish; worm = wormy; }; + AnimalSounds = { pig = oink; lamb = baa; worm = baa; + Lisa = "Why is the worm talking like a lamb?"; }; + AnimalColors = { pig = pink; lamb = black; worm = pink; }; +}]).should be_a(NSPlist::NSDictionary) + end + + it "can parse dictionary with tabs" do + NSPlist.parse("{ + boolean = YES; + integer = 42; + real = 3.14; +}").should be_a(NSPlist::NSDictionary) + end + + it "can parse comment" do + NSPlist.parse("/* some * text / here */{ key = value; }").should be_a(NSPlist::NSDictionary) + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..b43caca --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/nsplist" diff --git a/src/nsplist.cr b/src/nsplist.cr new file mode 100644 index 0000000..13e6ec8 --- /dev/null +++ b/src/nsplist.cr @@ -0,0 +1,192 @@ +require "string_scanner" + +# Old-style ASCII property lists parser. +# +# This format was used in the OpenStep frameworks. It is officially +# documentated at the page ["Old-Style ASCII Property Lists"][apple] +# in the Documentation Archive. +# +# [apple]: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/PropertyLists/OldStylePlists/OldStylePLists.html +# [gnustep]: https://gnustep.github.io/resources/documentation/Developer/Base/Reference/NSPropertyList.html +# [wiki]: https://en.wikipedia.org/wiki/Property_list +module NSPlist + VERSION = {{ `shards version #{__DIR__}`.chomp.stringify }} + + def self.parse(source) + Parser.new(source).parse + end + + private class Parser + def initialize(source) + @scanner = StringScanner.new(source) + end + + def parse + skip_separator + parse_expression + end + + def parse_expression + parse_array || parse_binary_data || parse_dictionary || parse_string + end + + def parse_string + parse_quoted_string || parse_bare_string + end + + def parse_quoted_string + @scanner.skip('"') || return + content = @scanner.scan_until('"').not_nil! + skip_separator + NSString.new(content[..-2]) + end + + def parse_bare_string + content = @scanner.scan(%r{ [a-zA-Z0-9_$+/:.-]+ }x) || return + skip_separator + NSString.new(content) + end + + def parse_array + @scanner.skip('(') || return + skip_separator + elements = [] of NSArray::Element + until @scanner.eos? + element = parse_expression || break + elements << NSArray::Element.new(element) + skip_separator + @scanner.skip(',') || break + skip_separator + end + @scanner.skip(')') || return + skip_separator + NSArray.new(elements) + end + + def parse_dictionary + @scanner.skip('{') || return + skip_separator + dictionary = parse_dictionary_body || return + @scanner.skip('}') || return + skip_separator + dictionary + end + + def parse_dictionary_body + dictionary = {} of NSString => NSDictionary::Value + until @scanner.eos? + key = parse_string || break + skip_separator + @scanner.skip('=') || return + skip_separator + value = parse_expression || return + skip_separator + @scanner.skip(';') || return + skip_separator + dictionary[key] = NSDictionary::Value.new(value) + end + NSDictionary.new(dictionary) + end + + def parse_binary_data + @scanner.skip('<') || return + skip_separator + bytes = "" + until @scanner.eos? + bytes += @scanner.scan(/ [0-9a-f]+ /x) || break + skip_separator + end + @scanner.skip('>') || return + skip_separator + + # TODO: Extract hexbytes definition and optimize. + bytes.size.even? || (bytes = bytes[..-2] + "0" + bytes[-1]) + NSData.new(bytes.hexbytes) + end + + def skip_separator + skip_spaces + while @scanner.skip("/*") + @scanner.skip_until("*/") || raise "no comment end" + skip_spaces + end + end + + def skip_spaces + @scanner.skip(/ [ \n\t]* /x) + end + + delegate eos?, to: @scanner + end + + class NSString + def initialize(@string : String) + end + + def ==(other : NSString) + @string == other.to_s + end + + def ==(other) + @string == other + end + + def to_s + @string + end + end + + class NSData + getter :bytes + + def initialize(@bytes : Bytes) + end + + def ==(other : NSData) + @bytes == other.bytes + end + + def ==(other) + @bytes == other + end + end + + class NSArray + record Element, value : NSArray | NSData | NSString | NSDictionary + + def initialize(@array : Array(Element)) + end + + def initialize(array) + @array = array.map { |element| Element.new(NSString.new(element)) } + end + + def ==(other : NSArray) + @array == other.to_a + end + + def to_a + @array + end + end + + class NSDictionary + record Value, value : NSArray | NSData | NSString | NSDictionary + + def initialize(@dictionary : Hash(NSString, Value)) + end + + def initialize(dictionary) + @dictionary = dictionary.transform_keys { |key| NSString.new(key) } + .transform_values { |value| Value.new(NSString.new(value)) } + end + + def ==(other : NSDictionary) + @dictionary == other.to_h + end + + def to_h + @dictionary + end + end +end