Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ on:
jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

Expand All @@ -25,35 +24,58 @@ jobs:
- name: Run tests
run: crystal spec

format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: latest

- name: Check formatting
run: crystal tool format --check

static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: latest

- name: Install dependencies
run: shards install

- name: Run static analysis
run: crystal build --no-codegen src/jmespath.cr

release:
needs: test
needs: [test, format, static-analysis]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write

steps:
- uses: actions/checkout@v3

- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: latest

- name: Install dependencies
run: shards install

- name: Build release
run: |
crystal build --release --static src/jmespath.cr -o jmespath
tar -czf jmespath-${GITHUB_REF#refs/tags/}.tar.gz jmespath

- name: Create Release
uses: softprops/action-gh-release@v1
with:
Expand Down
62 changes: 53 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ JMESPath.search("people[*].age", data) # => [20, 25, 30]

# Filters
data = %({"people": [
{"name": "bob", "age": 20},
{"name": "bob", "age": 20},
{"name": "alice", "age": 25}
]})
JMESPath.search("people[?age > `20`].name", data) # => ["alice"]
Expand All @@ -43,6 +43,52 @@ data = %({"foo": {"bar": "baz", "qux": "quux"}})
JMESPath.search("foo.{b: bar, q: qux}", data) # => {"b": "baz", "q": "quux"}
```

### Built-in Functions

```crystal
# String functions
JMESPath.search("length(foo)", %({"foo": "hello"})) # => 5
JMESPath.search("starts_with(foo, 'hel')", %({"foo": "hello"})) # => true
JMESPath.search("join(', ', foo)", %({"foo": ["a", "b", "c"]})) # => "a, b, c"

# Array functions
JMESPath.search("sort(foo)", %({"foo": [3, 1, 2]})) # => [1, 2, 3]
JMESPath.search("reverse(foo)", %({"foo": [1, 2, 3]})) # => [3, 2, 1]
JMESPath.search("contains(foo, `2`)", %({"foo": [1, 2, 3]})) # => true

# Number functions
JMESPath.search("sum(foo)", %({"foo": [1, 2, 3]})) # => 6
JMESPath.search("avg(foo)", %({"foo": [10, 20, 30]})) # => 20.0
JMESPath.search("abs(foo)", %({"foo": -5})) # => 5

# Object functions
JMESPath.search("keys(foo)", %({"foo": {"a": 1, "b": 2}})) # => ["a", "b"]
JMESPath.search("values(foo)", %({"foo": {"a": 1, "b": 2}})) # => [1, 2]

# Type conversion
JMESPath.search("to_string(foo)", %({"foo": 42})) # => "42"
JMESPath.search("to_number(foo)", %({"foo": "42"})) # => 42
JMESPath.search("type(foo)", %({"foo": "hello"})) # => "string"
```

### Expression References (expref)

The `&` operator creates a reference to an expression that is evaluated later by functions like `sort_by`, `max_by`, `min_by`, and `map`:

```crystal
# Sort by a field
data = %({"people": [{"name": "bob", "age": 30}, {"name": "alice", "age": 25}]})
JMESPath.search("sort_by(people, &age)[*].name", data) # => ["alice", "bob"]

# Find max/min by expression
JMESPath.search("max_by(people, &age).name", data) # => "bob"
JMESPath.search("min_by(people, &age).name", data) # => "alice"

# Map an expression over an array
JMESPath.search("map(&name, people)", data) # => ["bob", "alice"]
```


## Features

The library supports most JMESPath expressions including:
Expand All @@ -54,17 +100,17 @@ The library supports most JMESPath expressions including:
- Pipe expressions
- Literal values
- Comparisons and logical operators
- Expression references (`&expr`) for deferred evaluation
- 26 built-in functions: `abs`, `avg`, `ceil`, `contains`, `ends_with`, `floor`, `join`, `keys`, `length`, `map`, `max`, `max_by`, `merge`, `min`, `min_by`, `not_null`, `reverse`, `sort`, `sort_by`, `starts_with`, `sum`, `to_array`, `to_number`, `to_string`, `type`, `values`
- Informative error messages with expression context and caret pointing to the error position

## TODO

The following features are still pending implementation:

1. Built-in Functions
- No built-in functions are currently implemented
- Need to add support for string, array, number manipulation functions
1. ~~Built-in Functions~~ (done in v0.3.0)

2. Expression References (expref)
- The `&` operator for function references is not implemented
2. ~~Expression References (expref)~~ (done in v0.3.0)

3. JMESPath Compliance
- Need to implement comprehensive compliance test suite
Expand All @@ -82,9 +128,7 @@ The following features are still pending implementation:
- Optimize parser for large expressions
- Add benchmarking suite

7. Error Handling Improvements
- More detailed error messages
- Better error recovery strategies
7. ~~Error Handling Improvements~~ (done in v0.3.0)

## Development

Expand Down
2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: jmespath
version: 0.2.0
version: 0.3.0

description: |
A Crystal implementation of JMESPath - JSON Query Language
Expand Down
143 changes: 143 additions & 0 deletions spec/error_messages_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
require "./spec_helper"

describe "Error messages" do
describe "ParseError" do
it "reports correct position for trailing tokens" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("foo bar") }
ex.lex_position.should eq(4)
ex.message.not_nil!.should contain("foo bar")
ex.message.not_nil!.should contain("at column 4")
end

it "reports correct position for unexpected token in expression" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("foo.||") }
ex.message.not_nil!.should contain("foo.||")
end

it "reports correct position for invalid dot RHS" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("foo.)") }
ex.lex_position.should eq(4)
ex.message.not_nil!.should contain("foo.)")
ex.message.not_nil!.should contain("at column 4")
end

it "reports correct position for unmatched bracket" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("foo[0") }
ex.message.not_nil!.should contain("foo[0")
end

it "reports correct position for invalid slice syntax" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("foo[0:a]") }
ex.lex_position.should eq(6)
ex.message.not_nil!.should contain("foo[0:a]")
end

it "reports correct position for unmatched paren" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("(foo") }
ex.message.not_nil!.should contain("(foo")
end

it "reports correct position for invalid bracket operation" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("foo[)") }
ex.lex_position.should eq(4)
ex.message.not_nil!.should contain("foo[)")
end

it "reports correct position for quoted identifier as function name" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("\"foo\"()") }
ex.message.not_nil!.should contain("Quoted identifier not allowed for function names")
ex.message.not_nil!.should contain("\"foo\"()")
end

it "reports position for invalid multi-select hash key" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("{123: foo}") }
ex.message.not_nil!.should contain("{123: foo}")
end

it "includes expression and caret in error message" do
parser = Parser.new
ex = expect_raises(ParseError) { parser.parse("foo bar") }
lines = ex.message.not_nil!.split("\n")
# Should have: message line, expression line, caret line
lines.size.should eq(3)
lines[1].should eq("foo bar")
lines[2].should contain("^")
end
end

describe "LexerError" do
it "reports correct position for unknown token" do
lexer = Lexer.new
ex = expect_raises(LexerError) { lexer.tokenize("foo^bar") }
ex.lexer_position.should eq(3)
ex.message.not_nil!.should contain("foo^bar")
ex.message.not_nil!.should contain("^")
end

it "reports position for unknown token at start" do
lexer = Lexer.new
ex = expect_raises(LexerError) { lexer.tokenize("^foo") }
ex.lexer_position.should eq(0)
ex.message.not_nil!.should contain("^foo")
end

it "reports position for unclosed string literal" do
lexer = Lexer.new
ex = expect_raises(LexerError) { lexer.tokenize("'unclosed") }
ex.message.not_nil!.should contain("Unclosed literal")
ex.message.not_nil!.should contain("'unclosed")
end

it "reports position for unclosed backtick literal" do
lexer = Lexer.new
ex = expect_raises(LexerError) { lexer.tokenize("`unclosed") }
ex.message.not_nil!.should contain("Unclosed literal")
ex.message.not_nil!.should contain("`unclosed")
end

it "reports position for lone equals sign" do
lexer = Lexer.new
ex = expect_raises(LexerError) { lexer.tokenize("foo =") }
ex.message.not_nil!.should contain("Unknown token '='")
end

it "reports position for invalid negative number" do
lexer = Lexer.new
ex = expect_raises(LexerError) { lexer.tokenize("foo[-abc]") }
ex.message.not_nil!.should contain("Unknown token '-'")
ex.message.not_nil!.should contain("foo[-abc]")
end

it "includes expression and caret in error message" do
lexer = Lexer.new
ex = expect_raises(LexerError) { lexer.tokenize("foo^bar") }
lines = ex.message.not_nil!.split("\n")
lines.size.should eq(3)
lines[1].should eq("foo^bar")
# Caret should be at position 3
lines[2].should eq(" ^")
end
end

describe "integration error messages" do
it "JMESPath.search shows correct position" do
ex = expect_raises(ParseError) { JMESPath.search("foo.[}", %({"foo": 1})) }
ex.message.not_nil!.should contain("foo.[}")
end

it "shows position for deeply nested error" do
ex = expect_raises(ParseError) { JMESPath.search("foo.bar.baz[0].qux.)", %({})) }
ex.lex_position.should eq(19)
ex.message.not_nil!.should contain("at column 19")
end
end
end
Loading
Loading