diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e2ed838..063fce2 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,16 +3,16 @@ "isRoot": true, "tools": { "fantomas": { - "version": "6.3.0-alpha-008", + "version": "6.3.7", "commands": [ "fantomas" ] }, "fsharp-analyzers": { - "version": "0.25.0", + "version": "0.26.0", "commands": [ "fsharp-analyzers" ] } } -} \ No newline at end of file +} diff --git a/.github/workflows/dotnet.yaml b/.github/workflows/dotnet.yaml index 1371cae..d454a97 100644 --- a/.github/workflows/dotnet.yaml +++ b/.github/workflows/dotnet.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/github-workflow.json name: .NET on: @@ -146,7 +147,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: nuget-package - path: PrattParser/bin/Release/PrattParser.*.nupkg + path: PrattParser/bin/Release/WoofWare.PrattParser.*.nupkg expected-pack: needs: [nuget-pack] @@ -158,11 +159,63 @@ jobs: name: nuget-package - name: Check NuGet contents # Verify that there is exactly one nupkg in the artifact that would be NuGet published - run: if [[ $(find . -maxdepth 1 -name 'PrattParser.*.nupkg' -printf c | wc -c) -ne "1" ]]; then exit 1; fi + run: if [[ $(find . -maxdepth 1 -name 'WoofWare.PrattParser.*.nupkg' -printf c | wc -c) -ne "1" ]]; then exit 1; fi + + github-release-plugin-dry-run: + needs: [nuget-pack] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download NuGet artifact (plugin) + uses: actions/download-artifact@v4 + with: + name: nuget-package + - name: Tag and release + env: + DRY_RUN: 1 + GITHUB_TOKEN: mock-token + run: sh .github/workflows/tag.sh all-required-checks-complete: - needs: [check-dotnet-format, check-nix-format, build, build-nix, linkcheck, flake-check, analyzers, nuget-pack, expected-pack] + needs: [check-dotnet-format, check-nix-format, build, build-nix, linkcheck, flake-check, analyzers, nuget-pack, expected-pack, github-release-plugin-dry-run] runs-on: ubuntu-latest steps: - run: echo "All required checks complete." + nuget-publish: + runs-on: ubuntu-latest + if: ${{ !github.event.repository.fork && github.ref == 'refs/heads/main' }} + needs: [all-required-checks-complete] + environment: main-deploy + steps: + - uses: actions/checkout@v4 + - name: Install Nix + uses: cachix/install-nix-action@V27 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + - name: Download NuGet artifact + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: packed + - name: Publish to NuGet + run: nix develop --command dotnet nuget push "packed/WoofWare.PrattParser.*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + github-release: + runs-on: ubuntu-latest + if: ${{ !github.event.repository.fork && github.ref == 'refs/heads/main' }} + needs: [all-required-checks-complete] + environment: main-deploy + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - name: Download NuGet artifact + uses: actions/download-artifact@v4 + with: + name: nuget-package + - name: Tag and release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: sh .github/workflows/tag.sh diff --git a/.github/workflows/tag.sh b/.github/workflows/tag.sh new file mode 100644 index 0000000..f70483e --- /dev/null +++ b/.github/workflows/tag.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +echo "Dry-run? $DRY_RUN!" + +find . -maxdepth 1 -type f ! -name "$(printf "*\n*")" -name '*.nupkg' | while IFS= read -r file +do + tag=$(basename "$file" .nupkg) + git tag "$tag" + ${DRY_RUN:+echo} git push origin "$tag" +done + +export TAG +TAG=$(find . -maxdepth 1 -type f -name 'WoofWare.PrattParser.*.nupkg' -exec sh -c 'basename "$1" .nupkg' shell {} \; | grep -v Attributes) + +case "$TAG" in + *" +"*) + echo "Error: TAG contains a newline; multiple plugins found." + exit 1 + ;; +esac + +# target_commitish empty indicates the repo default branch +curl_body='{"tag_name":"'"$TAG"'","target_commitish":"","name":"'"$TAG"'","draft":false,"prerelease":false,"generate_release_notes":false}' + +echo "cURL body: $curl_body" + +failed_output=$(cat <<'EOF' +{ + "message": "Validation Failed", + "errors": [ + { + "resource": "Release", + "code": "already_exists", + "field": "tag_name" + } + ], + "documentation_url": "https://docs.github.com/rest/releases/releases#create-a-release" +} +EOF +) + +success_output=$(cat <<'EOF' +{ + "url": "https://api.github.com/repos/Smaug123/fsharp-prattparser/releases/158152116", + "assets_url": "https://api.github.com/repos/Smaug123/fsharp-prattparser/releases/158152116/assets", + "upload_url": "https://uploads.github.com/repos/Smaug123/fsharp-prattparser/releases/158152116/assets{?name,label}", + "html_url": "https://github.com/Smaug123/fsharp-prattparser/releases/tag/WoofWare.PrattParser.2.1.30", + "id": 158152116, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "site_admin": false + }, + "node_id": "RE_kwDOJfksgc4JbTW0", + "tag_name": "WoofWare.PrattParser.2.1.30", + "target_commitish": "main", + "name": "WoofWare.PrattParser.2.1.30", + "draft": false, + "prerelease": false, + "created_at": "2024-05-30T11:00:55Z", + "published_at": "2024-05-30T11:03:02Z", + "assets": [ + + ], + "tarball_url": "https://api.github.com/repos/Smaug123/fsharp-prattparser/tarball/WoofWare.PrattParser.2.1.30", + "zipball_url": "https://api.github.com/repos/Smaug123/fsharp-prattparser/zipball/WoofWare.PrattParser.2.1.30", + "body": null +} +EOF +) + +HANDLE_OUTPUT='' +handle_error() { + ERROR_OUTPUT="$1" + exit_message=$(echo "$ERROR_OUTPUT" | jq -r --exit-status 'if .errors | length == 1 then .errors[0].code else null end') + if [ "$exit_message" = "already_exists" ] ; then + HANDLE_OUTPUT="Did not create GitHub release because it already exists at this version." + else + echo "Unexpected error output from curl: $(cat curl_output.json)" + echo "JQ output: $(exit_message)" + exit 2 + fi +} + +run_tests() { + handle_error "$failed_output" + if [ "$HANDLE_OUTPUT" != "Did not create GitHub release because it already exists at this version." ]; then + echo "Bad output from handler: $HANDLE_OUTPUT" + exit 3 + fi + HANDLE_OUTPUT='' + echo "Tests passed." +} + +run_tests + +if [ "$DRY_RUN" != 1 ] ; then + if curl --fail-with-body -L -X POST -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $GITHUB_TOKEN" -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/Smaug123/fsharp-prattparser/releases -d "$curl_body" > curl_output.json; then + echo "Curl succeeded." + else + handle_error "$(cat curl_output.json)" + echo "$HANDLE_OUTPUT" + fi +fi diff --git a/PrattParser.Test/PrattParser.Test.fsproj b/PrattParser.Test/PrattParser.Test.fsproj index 40ea26b..137e84e 100644 --- a/PrattParser.Test/PrattParser.Test.fsproj +++ b/PrattParser.Test/PrattParser.Test.fsproj @@ -11,9 +11,11 @@ + + diff --git a/PrattParser.Test/TestSurface.fs b/PrattParser.Test/TestSurface.fs new file mode 100644 index 0000000..5a8238b --- /dev/null +++ b/PrattParser.Test/TestSurface.fs @@ -0,0 +1,26 @@ +namespace PrattParser.Test + +open NUnit.Framework +open PrattParser +open ApiSurface + +[] +module TestSurface = + let assembly = typedefof>.Assembly + + [] + let ``Ensure API surface has not been modified`` () = ApiSurface.assertIdentical assembly + + [] + [] + // https://github.com/nunit/nunit3-vs-adapter/issues/876 + let CheckVersionAgainstRemote () = + MonotonicVersion.validate assembly "WoofWare.PrattParser" + + [] + let ``Update API surface`` () = + ApiSurface.writeAssemblyBaseline assembly + + [] + let ``Ensure public API is fully documented`` () = + DocCoverage.assertFullyDocumented assembly diff --git a/PrattParser/PrattParser.fsproj b/PrattParser/PrattParser.fsproj index c461129..03c9a46 100644 --- a/PrattParser/PrattParser.fsproj +++ b/PrattParser/PrattParser.fsproj @@ -1,12 +1,33 @@ - + - net8.0 + netstandard2.0 true + true + Patrick Stevens + Copyright (c) Patrick Stevens 2024 + For the easy construction of Pratt parsers. + git + https://github.com/Smaug123/fsharp-prattparser + MIT + README.md + fsharp;pratt-parser;parsing;parser;top-down;precedence;recursive;descent + FS3559 + WoofWare.PrattParser + + + + True + \ + + + + + diff --git a/PrattParser/SurfaceBaseline.txt b/PrattParser/SurfaceBaseline.txt new file mode 100644 index 0000000..08bf0cf --- /dev/null +++ b/PrattParser/SurfaceBaseline.txt @@ -0,0 +1,18 @@ +PrattParser.BracketLikeParser`2 inherit obj +PrattParser.BracketLikeParser`2..ctor [constructor]: (bool, bool, 'tokenTag list, 'expr list -> 'expr) +PrattParser.BracketLikeParser`2.BoundaryTokens [property]: [read-only] 'tokenTag list +PrattParser.BracketLikeParser`2.Construct [property]: [read-only] 'expr list -> 'expr +PrattParser.BracketLikeParser`2.ConsumeAfterFinalToken [property]: [read-only] bool +PrattParser.BracketLikeParser`2.ConsumeBeforeInitialToken [property]: [read-only] bool +PrattParser.BracketLikeParser`2.get_BoundaryTokens [method]: unit -> 'tokenTag list +PrattParser.BracketLikeParser`2.get_Construct [method]: unit -> ('expr list -> 'expr) +PrattParser.BracketLikeParser`2.get_ConsumeAfterFinalToken [method]: unit -> bool +PrattParser.BracketLikeParser`2.get_ConsumeBeforeInitialToken [method]: unit -> bool +PrattParser.Parser inherit obj +PrattParser.Parser.execute [static method]: PrattParser.Parser<'tokenTag, 'token, 'expr> -> string -> 'token list -> ('expr * 'token list) +PrattParser.Parser.make [static method]: ('token -> 'tokenTag) -> (string -> 'token -> 'expr option) -> PrattParser.Parser<'tokenTag, 'token, 'expr> +PrattParser.Parser.withBracketLike [static method]: 'tokenTag -> PrattParser.BracketLikeParser<'tokenTag, 'expr> -> PrattParser.Parser<'tokenTag, 'token, 'expr> -> PrattParser.Parser<'tokenTag, 'token, 'expr> +PrattParser.Parser.withInfix [static method]: 'tokenTag -> (int, int) -> ('expr -> 'expr -> 'expr) -> PrattParser.Parser<'tokenTag, 'token, 'expr> -> PrattParser.Parser<'tokenTag, 'token, 'expr> +PrattParser.Parser.withUnaryPostfix [static method]: 'tokenTag -> (int, unit) -> ('expr -> 'expr) -> PrattParser.Parser<'tokenTag, 'token, 'expr> -> PrattParser.Parser<'tokenTag, 'token, 'expr> +PrattParser.Parser.withUnaryPrefix [static method]: 'tokenTag -> (unit, int) -> ('expr -> 'expr) -> PrattParser.Parser<'tokenTag, 'token, 'expr> -> PrattParser.Parser<'tokenTag, 'token, 'expr> +PrattParser.Parser`3 inherit obj \ No newline at end of file diff --git a/PrattParser/version.json b/PrattParser/version.json new file mode 100644 index 0000000..e0b6712 --- /dev/null +++ b/PrattParser/version.json @@ -0,0 +1,12 @@ +{ + "version": "0.1", + "publicReleaseRefSpec": [ + "^refs/heads/main$" + ], + "pathFilters": [ + ":/Directory.Build.props", + ":/README.md", + ":/global.json", + ":/PrattParser/" + ] +} diff --git a/README.md b/README.md index 6ae05e0..8adae46 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ -# PrattParser +# WoofWare.PrattParser -A Pratt parser, based on https://matklad.github.io/2020/04/13/simple-but-powerful-pratt-parsing.html . +A [Pratt parser](https://langdev.stackexchange.com/q/3254/1025), based on [Matklad's tutorial](https://matklad.github.io/2020/04/13/simple-but-powerful-pratt-parsing.html). + +Bug reports welcome; I wouldn't exactly say this is well-tested, although it has worked correctly on the two things I've used it for so far. + +See [the example](PrattParser.Example/) for how you should use this. +In brief: + +* [Define a lexer somehow](PrattParser.Example/Lexer.fs) (which produces a stream of `Token`s, where a `Token` is [a type you define](PrattParser.Example/Domain.fs)). +* Define what it means for a lexeme to be *atomic* (this is [`Expr.atom`](PrattParser.Example/Example.fs)). Atomic tokens can appear on their own to form an expression all by themselves, or they can be combined or modified using operations specified by non-atomic tokens. +* Define how the various non-atomic lexemes behave (this is `Parser.withUnaryPrefix` and friends) to combine subexpressions into larger expressions. + +We supply: + +* `Parser.withUnaryPrefix`, which specifies e.g. that the token `!` can appear as a unary prefix of another expression, and that when it does, it indicates (e.g.) negation. +* `Parser.withUnaryPostfix`, which specifies e.g. that the token `!` can appear as a unary suffix of another expression, and that when it does, it indicates (e.g.) the factorial. +* `Parser.withInfix`, which specifies e.g. that the token `+` can appear between two expressions, and that when it does, it indicates (e.g.) addition. +* `Parser.withBracketLike`, which specifies e.g. that the token `(` can appear before an expression, and that it's terminated by e.g. `)`. You can also implement `if/then/else` this way: that's just a weird kind of bracket and comma (and the `else` is kind of mixfix: it needs to consume one expression *after* itself, by contrast with the closing bracket `)` which does not). + +When specifying how expressions combine, you also provide precedences; numerically larger precedences are higher-precedence (so they bind more tightly). +Precedences are given as pairs, so that you can define e.g. left-associativity (resp. right-associativity) by having the infix operator bind more strongly to the right (resp. left). +For example, if `+` has precedence (10, 5), so it binds strongly on the left and weakly on the right: + +* `a + b + c` is morally `a+ b+ c` because the `+` binds strongly to the left; +* `a+ b+ c` can only be bracketed as `a+ (b+ c)` because the rightmost `+` wants to stick to the `b` more than it wants to stick to the `c`; +* so `a + b + c` is equal to `a + (b + c)`, i.e. `+` is right-associative. diff --git a/analyzers/analyzers.fsproj b/analyzers/analyzers.fsproj index 8a28bd1..8c59f41 100644 --- a/analyzers/analyzers.fsproj +++ b/analyzers/analyzers.fsproj @@ -10,7 +10,7 @@ - + diff --git a/nix/deps.nix b/nix/deps.nix index e0c59c8..432fe92 100644 --- a/nix/deps.nix +++ b/nix/deps.nix @@ -1,15 +1,25 @@ # This file was automatically generated by passthru.fetch-deps. # Please dont edit it manually, your changes might get overwritten! {fetchNuGet}: [ + (fetchNuGet { + pname = "ApiSurface"; + version = "4.0.40"; + sha256 = "1c9z0b6minlripwrjmv4yd5w8zj4lcpak4x41izh7ygx8kgmbvx0"; + }) (fetchNuGet { pname = "fantomas"; - version = "6.3.0-alpha-008"; - sha256 = "075mxkk7gac41aqp4l7v22c5gwi3f1lrfq1gv1r91w53kfxgi3xc"; + version = "6.3.7"; + sha256 = "1z1a5bw7vwz6g8nvfgkvx66jnm4hmvn62vbyq0as60nw0jlvaidl"; }) (fetchNuGet { pname = "fsharp-analyzers"; - version = "0.25.0"; - sha256 = "01i9yhqs7b0p9s1j9m8g3yd8w3a3xp9bp8791zmxp31l5ricjdwy"; + version = "0.26.0"; + sha256 = "0xgv5kvbwfdvcp6s8x7xagbbi4s3mqa4ixni6pazqvyflbgnah7b"; + }) + (fetchNuGet { + pname = "FSharp.Core"; + version = "6.0.0"; + sha256 = "1hjhvr39c1vpgrdmf8xln5q86424fqkvy9nirkr29vl2461d2039"; }) (fetchNuGet { pname = "FSharp.Core"; @@ -36,6 +46,16 @@ version = "17.9.0"; sha256 = "1lls1fly2gr1n9n1xyl9k33l2v4pwfmylyzkq8v4v5ldnwkl1zdb"; }) + (fetchNuGet { + pname = "Microsoft.NETCore.Platforms"; + version = "1.1.0"; + sha256 = "08vh1r12g6ykjygq5d3vq09zylgb84l63k49jc4v8faw9g93iqqm"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.Platforms"; + version = "2.0.0"; + sha256 = "1fk2fk2639i7nzy58m9dvpdnzql4vb8yl8vr19r2fp8lmj9w2jr0"; + }) (fetchNuGet { pname = "Microsoft.SourceLink.Common"; version = "8.0.0"; @@ -61,11 +81,51 @@ version = "3.6.133"; sha256 = "1cdw8krvsnx0n34f7fm5hiiy7bs6h3asvncqcikc0g46l50w2j80"; }) + (fetchNuGet { + pname = "NETStandard.Library"; + version = "2.0.3"; + sha256 = "1fn9fxppfcg4jgypp2pmrpr6awl3qz1xmnri0cygpkwvyx27df1y"; + }) (fetchNuGet { pname = "Newtonsoft.Json"; version = "13.0.1"; sha256 = "0fijg0w6iwap8gvzyjnndds0q4b8anwxxvik7y8vgq97dram4srb"; }) + (fetchNuGet { + pname = "Newtonsoft.Json"; + version = "13.0.3"; + sha256 = "0xrwysmrn4midrjal8g2hr1bbg38iyisl0svamb11arqws4w2bw7"; + }) + (fetchNuGet { + pname = "NuGet.Common"; + version = "6.10.0"; + sha256 = "0nizrnilmlcqbm945293h8q3wfqfchb4xi8g50x4kjn0rbpd1kbh"; + }) + (fetchNuGet { + pname = "NuGet.Configuration"; + version = "6.10.0"; + sha256 = "1aqaknaawnqx4mnvx9qw73wvj48jjzv0d78dzwl7m9zjlrl9myhz"; + }) + (fetchNuGet { + pname = "NuGet.Frameworks"; + version = "6.10.0"; + sha256 = "0hrd8y31zx9a0wps49czw0qgbrakb49zn3abfgylc9xrq990zkqk"; + }) + (fetchNuGet { + pname = "NuGet.Packaging"; + version = "6.10.0"; + sha256 = "18s53cvrf51lihmaqqdf48p2qi6ky1l48jv0hvbp76cxwdg7rba4"; + }) + (fetchNuGet { + pname = "NuGet.Protocol"; + version = "6.10.0"; + sha256 = "0hmv4q0ks9i34mfgpb13l01la9v3jjllfh1qd3aqv105xrqrdxac"; + }) + (fetchNuGet { + pname = "NuGet.Versioning"; + version = "6.10.0"; + sha256 = "1x19njx4x0sw9fz8y5fibi15xfsrw5avir0cx0599yd7p3ykik5g"; + }) (fetchNuGet { pname = "NUnit"; version = "4.1.0"; @@ -76,9 +136,54 @@ version = "4.5.0"; sha256 = "1srx1629s0k1kmf02nmz251q07vj6pv58mdafcr5dr0bbn1fh78i"; }) + (fetchNuGet { + pname = "System.Formats.Asn1"; + version = "6.0.0"; + sha256 = "1vvr7hs4qzjqb37r0w1mxq7xql2b17la63jwvmgv65s1hj00g8r9"; + }) + (fetchNuGet { + pname = "System.IO.Abstractions"; + version = "4.2.13"; + sha256 = "0s784iphsmj4vhkrzq9q3w39vsn76w44zclx3hsygsw458zbyh4y"; + }) + (fetchNuGet { + pname = "System.IO.FileSystem.AccessControl"; + version = "4.5.0"; + sha256 = "1gq4s8w7ds1sp8f9wqzf8nrzal40q5cd2w4pkf4fscrl2ih3hkkj"; + }) (fetchNuGet { pname = "System.Reflection.Metadata"; version = "1.6.0"; sha256 = "1wdbavrrkajy7qbdblpbpbalbdl48q3h34cchz24gvdgyrlf15r4"; }) + (fetchNuGet { + pname = "System.Security.AccessControl"; + version = "4.5.0"; + sha256 = "1wvwanz33fzzbnd2jalar0p0z3x0ba53vzx1kazlskp7pwyhlnq0"; + }) + (fetchNuGet { + pname = "System.Security.Cryptography.Pkcs"; + version = "6.0.4"; + sha256 = "0hh5h38pnxmlrnvs72f2hzzpz4b2caiiv6xf8y7fzdg84r3imvfr"; + }) + (fetchNuGet { + pname = "System.Security.Cryptography.ProtectedData"; + version = "4.4.0"; + sha256 = "1q8ljvqhasyynp94a1d7jknk946m20lkwy2c3wa8zw2pc517fbj6"; + }) + (fetchNuGet { + pname = "System.Security.Principal.Windows"; + version = "4.5.0"; + sha256 = "0rmj89wsl5yzwh0kqjgx45vzf694v9p92r4x4q6yxldk1cv1hi86"; + }) + (fetchNuGet { + pname = "System.Text.Encodings.Web"; + version = "7.0.0"; + sha256 = "1151hbyrcf8kyg1jz8k9awpbic98lwz9x129rg7zk1wrs6vjlpxl"; + }) + (fetchNuGet { + pname = "System.Text.Json"; + version = "7.0.3"; + sha256 = "0zjrnc9lshagm6kdb9bdh45dmlnkpwcpyssa896sda93ngbmj8k9"; + }) ]