diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index cfa4978..985458d 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -2,7 +2,11 @@ name: csharp on: push: - branches: main + branches: main + paths: + - 'csharp/**' + - '.github/workflows/csharp.yml' + pull_request: paths: - 'csharp/**' - '.github/workflows/csharp.yml' @@ -65,7 +69,7 @@ jobs: pushToNuget: runs-on: ubuntu-latest needs: [test] - if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' }} + if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v3 with: @@ -105,7 +109,7 @@ jobs: publishRelease: runs-on: ubuntu-latest needs: [pushToNuget, publishDocumentation] - if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' && needs.pushToNuget.result == 'success' }} + if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' && needs.pushToNuget.result == 'success' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v3 with: @@ -157,7 +161,7 @@ $PACKAGE_RELEASE_NOTES" generatePdfWithCode: runs-on: ubuntu-latest needs: [test] - if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' }} + if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v3 with: @@ -205,7 +209,7 @@ $PACKAGE_RELEASE_NOTES" publishDocumentation: runs-on: ubuntu-latest needs: [test, generatePdfWithCode] - if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' }} + if: ${{ needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 7b077d6..1c453a6 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -2,7 +2,11 @@ name: js on: push: - branches: main + branches: main + paths: + - 'js/**' + - '.github/workflows/js.yml' + pull_request: paths: - 'js/**' - '.github/workflows/js.yml' @@ -63,7 +67,7 @@ jobs: publishToNpm: needs: [test, findChangedJsFiles] - if: ${{ needs.findChangedJsFiles.outputs.isJsFilesChanged == 'true' }} + if: ${{ needs.findChangedJsFiles.outputs.isJsFilesChanged == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -103,7 +107,7 @@ jobs: publishRelease: runs-on: ubuntu-latest needs: [publishToNpm] - if: ${{ needs.findChangedJsFiles.outputs.isJsFilesChanged == 'true' && needs.publishToNpm.result == 'success' }} + if: ${{ needs.findChangedJsFiles.outputs.isJsFilesChanged == 'true' && needs.publishToNpm.result == 'success' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 58e60c4..00b7122 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,7 +2,11 @@ name: rust on: push: - branches: main + branches: main + paths: + - 'rust/**' + - '.github/workflows/rust.yml' + pull_request: paths: - 'rust/**' - '.github/workflows/rust.yml' @@ -78,7 +82,7 @@ jobs: publishToCratesIO: needs: [test, findChangedRustFiles] - if: ${{ needs.findChangedRustFiles.outputs.isRustFilesChanged == 'true' }} + if: ${{ needs.findChangedRustFiles.outputs.isRustFilesChanged == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -149,7 +153,7 @@ jobs: publishRelease: runs-on: ubuntu-latest needs: [publishToCratesIO] - if: ${{ needs.findChangedRustFiles.outputs.isRustFilesChanged == 'true' && needs.publishToCratesIO.result == 'success' }} + if: ${{ needs.findChangedRustFiles.outputs.isRustFilesChanged == 'true' && needs.publishToCratesIO.result == 'success' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v3 with: diff --git a/README.md b/README.md index b9cdfd2..1a4ca72 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,23 @@ I'm a friendly AI. parentheses may be ommitted if the whole line is a single link ``` +#### Indented Syntax + +Links can also use indented syntax for better readability: + +```lino +3: + papa + loves + mama +``` + +This is equivalent to: + +```lino +(3: papa loves mama) +``` + So that means that *this* text is also links notation. So most of the text in the world already may be parsed as links notation. That makes links notation the most easy an natural/intuitive/native one. diff --git a/csharp/Platform.Protocols.Lino.Tests/IndentedIdSyntaxTests.cs b/csharp/Platform.Protocols.Lino.Tests/IndentedIdSyntaxTests.cs new file mode 100644 index 0000000..9057174 --- /dev/null +++ b/csharp/Platform.Protocols.Lino.Tests/IndentedIdSyntaxTests.cs @@ -0,0 +1,201 @@ +using System; +using Xunit; + +namespace Platform.Protocols.Lino.Tests +{ + public static class IndentedIdSyntaxTests + { + [Fact] + public static void BasicIndentedIdSyntaxTest() + { + var indentedSyntax = @"3: + papa + loves + mama"; + var inlineSyntax = "(3: papa loves mama)"; + + var parser = new Parser(); + var indentedResult = parser.Parse(indentedSyntax); + var inlineResult = parser.Parse(inlineSyntax); + + var indentedFormatted = indentedResult.Format(); + var inlineFormatted = inlineResult.Format(); + + Assert.Equal(inlineFormatted, indentedFormatted); + Assert.Equal("(3: papa loves mama)", indentedFormatted); + } + + [Fact] + public static void IndentedIdSyntaxWithSingleValueTest() + { + var input = @"greeting: + hello"; + + var parser = new Parser(); + var result = parser.Parse(input); + var formatted = result.Format(); + + Assert.Equal("(greeting: hello)", formatted); + Assert.Single(result); + Assert.Equal("greeting", result[0].Id); + Assert.Single(result[0].Values); + Assert.Equal("hello", result[0].Values[0].Id); + } + + [Fact] + public static void IndentedIdSyntaxWithMultipleValuesTest() + { + var input = @"action: + run + fast + now"; + + var parser = new Parser(); + var result = parser.Parse(input); + var formatted = result.Format(); + + Assert.Equal("(action: run fast now)", formatted); + Assert.Single(result); + Assert.Equal("action", result[0].Id); + Assert.Equal(3, result[0].Values.Count); + } + + [Fact] + public static void IndentedIdSyntaxWithNumericIdTest() + { + var input = @"42: + answer + to + everything"; + + var parser = new Parser(); + var result = parser.Parse(input); + var formatted = result.Format(); + + Assert.Equal("(42: answer to everything)", formatted); + } + + [Fact] + public static void IndentedIdSyntaxWithQuotedIdTest() + { + var input = @"""complex id"": + value1 + value2"; + + var parser = new Parser(); + var result = parser.Parse(input); + var formatted = result.Format(); + + Assert.Equal("('complex id': value1 value2)", formatted); + } + + [Fact] + public static void MultipleIndentedIdLinksTest() + { + var input = @"first: + a + b +second: + c + d"; + + var parser = new Parser(); + var result = parser.Parse(input); + var formatted = result.Format(); + + Assert.Equal(2, result.Count); + Assert.Contains("(first: a b)", formatted); + Assert.Contains("(second: c d)", formatted); + } + + [Fact] + public static void MixedIndentedAndRegularSyntaxTest() + { + var input = @"first: + a + b +(second: c d) +third value"; + + var parser = new Parser(); + var result = parser.Parse(input); + + Assert.Equal(3, result.Count); + + var formatted = result.Format(); + Assert.Contains("(first: a b)", formatted); + Assert.Contains("(second: c d)", formatted); + Assert.Contains("third value", formatted); + } + + [Fact] + public static void UnsupportedColonOnlySyntaxShouldFailTest() + { + var input = @": + papa + loves + mama"; + + var parser = new Parser(); + Assert.Throws(() => parser.Parse(input)); + } + + [Fact] + public static void EmptyIndentedIdTest() + { + var input = "empty:"; + + var parser = new Parser(); + var result = parser.Parse(input); + + Assert.Single(result); + Assert.Equal("empty", result[0].Id); + Assert.True(result[0].Values == null || result[0].Values.Count == 0); + + var formatted = result.Format(); + Assert.Equal("(empty)", formatted); + } + + [Fact] + public static void EquivalenceTestComprehensiveTest() + { + var testCases = new[] + { + new { Indented = "test:\n one", Inline = "(test: one)" }, + new { Indented = "x:\n a\n b\n c", Inline = "(x: a b c)" }, + new { Indented = "\"quoted\":\n value", Inline = "(\"quoted\": value)" } + }; + + var parser = new Parser(); + + foreach (var testCase in testCases) + { + var indentedResult = parser.Parse(testCase.Indented); + var inlineResult = parser.Parse(testCase.Inline); + + var indentedFormatted = indentedResult.Format(); + var inlineFormatted = inlineResult.Format(); + + Assert.Equal(inlineFormatted, indentedFormatted); + } + } + + [Fact] + public static void IndentedIdWithDeeperNestingTest() + { + var input = @"root: + child1 + child2 + grandchild"; + + var parser = new Parser(); + var result = parser.Parse(input); + + Assert.NotEmpty(result); + + var rootLink = result[0]; + Assert.Equal("root", rootLink.Id); + Assert.Equal(2, rootLink.Values.Count); + } + } +} diff --git a/csharp/Platform.Protocols.Lino/ILinksGroupListExtensions.cs b/csharp/Platform.Protocols.Lino/ILinksGroupListExtensions.cs index 8af2de4..76ba506 100644 --- a/csharp/Platform.Protocols.Lino/ILinksGroupListExtensions.cs +++ b/csharp/Platform.Protocols.Lino/ILinksGroupListExtensions.cs @@ -20,9 +20,63 @@ public static List> ToLinksList(this IList>(); for (var i = 0; i < groups.Count; i++) { - groups[i].AppendToLinksList(list); + CollectLinksWithIndentedIdSyntaxSupport(list, groups[i], null); } return list; } + + private static void CollectLinksWithIndentedIdSyntaxSupport(List> list, LinksGroup group, Link? parentDependency) + { + var link = group.Link; + var groups = group.Groups; + + if (groups != null && groups.Count > 0) + { + bool isIndentedIdSyntax = link.Id != null && + (link.Values == null || link.Values.Count == 0) && + !parentDependency.HasValue; + + if (isIndentedIdSyntax) + { + var childValues = new List>(); + for (int i = 0; i < groups.Count; i++) + { + var innerGroup = groups[i]; + var innerLink = innerGroup.Link; + + if (innerLink.Values != null && innerLink.Values.Count == 1) + { + childValues.Add(innerLink.Values[0]); + } + else if (innerLink.Values != null && innerLink.Values.Count > 1) + { + childValues.Add(innerLink); + } + else if (innerLink.Id != null) + { + childValues.Add(new Link(innerLink.Id)); + } + } + + var linkWithChildren = new Link(link.Id, childValues); + list.Add(linkWithChildren); + } + else + { + var currentDependency = parentDependency.HasValue ? parentDependency.Value.Combine(link) : link; + list.Add(currentDependency); + + for (int i = 0; i < groups.Count; i++) + { + CollectLinksWithIndentedIdSyntaxSupport(list, groups[i], currentDependency); + } + } + } + else + { + var currentLink = parentDependency.HasValue ? parentDependency.Value.Combine(link) : link; + list.Add(currentLink); + } + } } } diff --git a/csharp/Platform.Protocols.Lino/Parser.peg b/csharp/Platform.Protocols.Lino/Parser.peg index 6a79128..caba5d7 100644 --- a/csharp/Platform.Protocols.Lino/Parser.peg +++ b/csharp/Platform.Protocols.Lino/Parser.peg @@ -7,7 +7,7 @@ firstLine > = l:element { l } line > = CHECK_INDENTATION l:element { l } element > = e:anyLink PUSH_INDENTATION l:links { new LinksGroup(e, l) } / e:anyLink { new LinksGroup(e) } referenceOrLink > = l:multiLineAnyLink { l } / i:reference { i } -anyLink > = ml:multiLineAnyLink eol { ml } / sl:singleLineAnyLink { sl } +anyLink > = ml:multiLineAnyLink eol { ml } / il:indentedIdLink { il } / sl:singleLineAnyLink { sl } multiLineAnyLink > = multiLineValueLink / multiLineLink singleLineAnyLink > = fl:singleLineLink eol { fl } / vl:singleLineValueLink eol { vl } multiLineValueAndWhitespace > = value:referenceOrLink _ { value } @@ -18,6 +18,7 @@ singleLineLink > = __ id:(reference) __ ":" v:singleLineValues { ne multiLineLink > = "(" _ id:(reference) _ ":" v:multiLineValues _ ")" { new Link(id, v) } singleLineValueLink > = v:singleLineValues { new Link(v) } multiLineValueLink > = "(" v:multiLineValues _ ")" { new Link(v) } +indentedIdLink > = id:(reference) __ ":" eol { new Link(id) } reference = doubleQuotedReference / singleQuotedReference / simpleReference simpleReference = "" referenceSymbol+ diff --git a/js/package-lock.json b/js/package-lock.json new file mode 100644 index 0000000..33f4ebf --- /dev/null +++ b/js/package-lock.json @@ -0,0 +1,130 @@ +{ + "name": "@linksplatform/protocols-lino", + "version": "0.6.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@linksplatform/protocols-lino", + "version": "0.6.0", + "license": "Unlicense", + "devDependencies": { + "bun-types": "^1.2.19", + "peggy": "^5.0.6" + } + }, + "node_modules/@peggyjs/from-mem": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@peggyjs/from-mem/-/from-mem-3.1.1.tgz", + "integrity": "sha512-m5OEjgJaePWpyNtQCvRZkpLoV+z44eh6QIO9yEwQuOThdUdkECO3wcKLT3tFA3H8WM5bxU/K/dpmo7r/X16UEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "7.7.2" + }, + "engines": { + "node": ">=20.8" + } + }, + "node_modules/@types/node": { + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/bun-types": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.21.tgz", + "integrity": "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, + "node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/peggy": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-5.0.6.tgz", + "integrity": "sha512-Sud8Zus0JAgE+U4zwkJv29OOaXhviFI7J90/6cGfy3OoqR8dpnieeF9a46dj0bTtqiFnrFatldA6ltQyOJvNmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peggyjs/from-mem": "3.1.1", + "commander": "^14.0.0", + "source-map-generator": "2.0.2" + }, + "bin": { + "peggy": "bin/peggy.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-generator": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-2.0.2.tgz", + "integrity": "sha512-unCl5BQhF/us51DiT7SvlSY3QUPhyfAdHJxd8l7FXdwzqxli0UDMV2dEuei2SeGp3Z4rB/AJ9zKi1mGOp2K2ww==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/js/src/Parser.js b/js/src/Parser.js index 16e53c1..c5c56b5 100644 --- a/js/src/Parser.js +++ b/js/src/Parser.js @@ -31,21 +31,44 @@ export class Parser { // For items with children (indented structure) if (item.children && item.children.length > 0) { - // First create the link for the current item - const currentLink = this.transformLink(item); - - // Add the link combined with parent path - if (parentPath.length === 0) { - result.push(currentLink); + // Special case: If this is an ID with empty values but has children, + // the children should become the values of the link (indented ID syntax) + if (item.id && (!item.values || item.values.length === 0)) { + const childValues = item.children.map((child) => { + // For indented children, extract the actual reference from the child's values + if (child.values && child.values.length === 1) { + return this.transformLink(child.values[0]); + } + return this.transformLink(child); + }); + const linkWithChildren = { + id: item.id, + values: childValues + }; + const currentLink = this.transformLink(linkWithChildren); + + if (parentPath.length === 0) { + result.push(currentLink); + } else { + result.push(this.combinePathElements(parentPath, currentLink)); + } } else { - result.push(this.combinePathElements(parentPath, currentLink)); - } - - // Process each child with this item in the path - const newPath = [...parentPath, currentLink]; - - for (const child of item.children) { - this.collectLinks(child, newPath, result); + // Regular indented structure - process as before + const currentLink = this.transformLink(item); + + // Add the link combined with parent path + if (parentPath.length === 0) { + result.push(currentLink); + } else { + result.push(this.combinePathElements(parentPath, currentLink)); + } + + // Process each child with this item in the path + const newPath = [...parentPath, currentLink]; + + for (const child of item.children) { + this.collectLinks(child, newPath, result); + } } } else { // Leaf item or item with inline values diff --git a/js/src/grammar.pegjs b/js/src/grammar.pegjs index b313eeb..90322d1 100644 --- a/js/src/grammar.pegjs +++ b/js/src/grammar.pegjs @@ -36,7 +36,7 @@ element = e:anyLink PUSH_INDENTATION l:links { referenceOrLink = l:multiLineAnyLink { return l; } / i:reference { return { id: i }; } -anyLink = ml:multiLineAnyLink eol { return ml; } / sl:singleLineAnyLink { return sl; } +anyLink = ml:multiLineAnyLink eol { return ml; } / il:indentedIdLink { return il; } / sl:singleLineAnyLink { return sl; } multiLineAnyLink = multiLineValueLink / multiLineLink @@ -59,6 +59,8 @@ singleLineValueLink = v:singleLineValues { return { values: v }; } multiLineValueLink = "(" v:multiLineValues _ ")" { return { values: v }; } +indentedIdLink = id:reference __ ":" eol { return { id: id, values: [] }; } + reference = doubleQuotedReference / singleQuotedReference / simpleReference simpleReference = chars:referenceSymbol+ { return chars.join(''); } diff --git a/js/src/parser-generated.js b/js/src/parser-generated.js index 40ea078..7c9fca1 100644 --- a/js/src/parser-generated.js +++ b/js/src/parser-generated.js @@ -192,6 +192,26 @@ function peg$parse(input, options) { const peg$e11 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false, false); const peg$e12 = peg$classExpectation([" ", "\t", "\n", "\r", "(", ":", ")"], true, false, false); + let indentationStack = [0]; + + function pushIndentation(spaces) { + indentationStack.push(spaces.length); + } + + function popIndentation() { + if (indentationStack.length > 1) { + indentationStack.pop(); + } + } + + function checkIndentation(spaces) { + return spaces.length >= indentationStack[indentationStack.length - 1]; + } + + function getCurrentIndentation() { + return indentationStack[indentationStack.length - 1]; + } + function peg$f0(links) { return links; } function peg$f1() { return []; } function peg$f2(fl, list) { popIndentation(); return [fl].concat(list || []); } @@ -204,23 +224,25 @@ function peg$parse(input, options) { function peg$f7(l) { return l; } function peg$f8(i) { return { id: i }; } function peg$f9(ml) { return ml; } - function peg$f10(sl) { return sl; } - function peg$f11(fl) { return fl; } - function peg$f12(vl) { return vl; } - function peg$f13(value) { return value; } - function peg$f14(list) { return list; } - function peg$f15(value) { return value; } - function peg$f16(list) { return list; } - function peg$f17(id, v) { return { id: id, values: v }; } - function peg$f18(id, v) { return { id: id, values: v }; } - function peg$f19(v) { return { values: v }; } + function peg$f10(il) { return il; } + function peg$f11(sl) { return sl; } + function peg$f12(fl) { return fl; } + function peg$f13(vl) { return vl; } + function peg$f14(value) { return value; } + function peg$f15(list) { return list; } + function peg$f16(value) { return value; } + function peg$f17(list) { return list; } + function peg$f18(id, v) { return { id, values: v }; } + function peg$f19(id, v) { return { id, values: v }; } function peg$f20(v) { return { values: v }; } - function peg$f21(chars) { return chars.join(''); } - function peg$f22(r) { return r.join(''); } - function peg$f23(r) { return r.join(''); } - function peg$f24(spaces) { return spaces.length > getCurrentIndentation(); } - function peg$f25(spaces) { pushIndentation(spaces); } - function peg$f26(spaces) { return checkIndentation(spaces); } + function peg$f21(v) { return { values: v }; } + function peg$f22(id) { return { id, values: [] }; } + function peg$f23(chars) { return chars.join(""); } + function peg$f24(r) { return r.join(""); } + function peg$f25(r) { return r.join(""); } + function peg$f26(spaces) { return spaces.length > getCurrentIndentation(); } + function peg$f27(spaces) { pushIndentation(spaces); } + function peg$f28(spaces) { return checkIndentation(spaces); } let peg$currPos = options.peg$currPos | 0; let peg$savedPos = peg$currPos; const peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -564,12 +586,21 @@ function peg$parse(input, options) { } if (s0 === peg$FAILED) { s0 = peg$currPos; - s1 = peg$parsesingleLineAnyLink(); + s1 = peg$parseindentedIdLink(); if (s1 !== peg$FAILED) { peg$savedPos = s0; s1 = peg$f10(s1); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parsesingleLineAnyLink(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f11(s1); + } + s0 = s1; + } } return s0; @@ -595,7 +626,7 @@ function peg$parse(input, options) { s2 = peg$parseeol(); if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f11(s1); + s0 = peg$f12(s1); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -611,7 +642,7 @@ function peg$parse(input, options) { s2 = peg$parseeol(); if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f12(s1); + s0 = peg$f13(s1); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -633,7 +664,7 @@ function peg$parse(input, options) { if (s1 !== peg$FAILED) { s2 = peg$parse_(); peg$savedPos = s0; - s0 = peg$f13(s1); + s0 = peg$f14(s1); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -654,7 +685,7 @@ function peg$parse(input, options) { s3 = peg$parsemultiLineValueAndWhitespace(); } peg$savedPos = s0; - s0 = peg$f14(s2); + s0 = peg$f15(s2); return s0; } @@ -667,7 +698,7 @@ function peg$parse(input, options) { s2 = peg$parsereferenceOrLink(); if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f15(s2); + s0 = peg$f16(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -692,7 +723,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(s1); + s1 = peg$f17(s1); } s0 = s1; @@ -718,7 +749,7 @@ function peg$parse(input, options) { s5 = peg$parsesingleLineValues(); if (s5 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f17(s2, s5); + s0 = peg$f18(s2, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -770,7 +801,7 @@ function peg$parse(input, options) { } if (s8 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f18(s3, s6); + s0 = peg$f19(s3, s6); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -798,7 +829,7 @@ function peg$parse(input, options) { s1 = peg$parsesingleLineValues(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(s1); + s1 = peg$f20(s1); } s0 = s1; @@ -828,7 +859,42 @@ function peg$parse(input, options) { } if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f20(s2); + s0 = peg$f21(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseindentedIdLink() { + let s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = peg$parsereference(); + if (s1 !== peg$FAILED) { + s2 = peg$parse__(); + if (input.charCodeAt(peg$currPos) === 58) { + s3 = peg$c0; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parseeol(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f22(s1); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } } else { peg$currPos = s0; s0 = peg$FAILED; @@ -871,7 +937,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f21(s1); + s1 = peg$f23(s1); } s0 = s1; @@ -922,7 +988,7 @@ function peg$parse(input, options) { } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f22(s2); + s0 = peg$f24(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -983,7 +1049,7 @@ function peg$parse(input, options) { } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f23(s2); + s0 = peg$f25(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1023,7 +1089,7 @@ function peg$parse(input, options) { } } peg$savedPos = peg$currPos; - s2 = peg$f24(s1); + s2 = peg$f26(s1); if (s2) { s2 = undefined; } else { @@ -1031,7 +1097,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f25(s1); + s0 = peg$f27(s1); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1063,7 +1129,7 @@ function peg$parse(input, options) { } } peg$savedPos = peg$currPos; - s2 = peg$f26(s1); + s2 = peg$f28(s1); if (s2) { s2 = undefined; } else { @@ -1210,27 +1276,6 @@ function peg$parse(input, options) { return s0; } - - let indentationStack = [0]; - - function pushIndentation(spaces) { - indentationStack.push(spaces.length); - } - - function popIndentation() { - if (indentationStack.length > 1) { - indentationStack.pop(); - } - } - - function checkIndentation(spaces) { - return spaces.length >= indentationStack[indentationStack.length - 1]; - } - - function getCurrentIndentation() { - return indentationStack[indentationStack.length - 1]; - } - peg$result = peg$startRuleFunction(); const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); diff --git a/js/tests/IndentedIdSyntax.test.js b/js/tests/IndentedIdSyntax.test.js new file mode 100644 index 0000000..0b848dc --- /dev/null +++ b/js/tests/IndentedIdSyntax.test.js @@ -0,0 +1,171 @@ +import { test, expect } from "bun:test"; +import { Parser } from "../src/Parser.js"; +import { formatLinks } from "../src/Link.js"; + +const parser = new Parser(); + +test("Basic indented ID syntax - issue #21", () => { + const indentedSyntax = `3: + papa + loves + mama`; + + const inlineSyntax = "(3: papa loves mama)"; + + const indentedResult = parser.parse(indentedSyntax); + const inlineResult = parser.parse(inlineSyntax); + + // Both should produce identical structures + expect(indentedResult).toEqual(inlineResult); + + // Both should format to the same inline syntax + expect(formatLinks(indentedResult)).toBe("(3: papa loves mama)"); + expect(formatLinks(inlineResult)).toBe("(3: papa loves mama)"); +}); + +test("Indented ID syntax with single value", () => { + const input = `greeting: + hello`; + + const result = parser.parse(input); + const formatted = formatLinks(result); + + expect(formatted).toBe("(greeting: hello)"); + expect(result.length).toBe(1); + expect(result[0].id).toBe("greeting"); + expect(result[0].values.length).toBe(1); + expect(result[0].values[0].id).toBe("hello"); +}); + +test("Indented ID syntax with multiple values", () => { + const input = `action: + run + fast + now`; + + const result = parser.parse(input); + const formatted = formatLinks(result); + + expect(formatted).toBe("(action: run fast now)"); + expect(result.length).toBe(1); + expect(result[0].id).toBe("action"); + expect(result[0].values.length).toBe(3); +}); + +test("Indented ID syntax with numeric ID", () => { + const input = `42: + answer + to + everything`; + + const result = parser.parse(input); + const formatted = formatLinks(result); + + expect(formatted).toBe("(42: answer to everything)"); +}); + +test("Indented ID syntax with quoted ID", () => { + const input = `"complex id": + value1 + value2`; + + const result = parser.parse(input); + const formatted = formatLinks(result); + + expect(formatted).toBe("('complex id': value1 value2)"); +}); + +test("Multiple indented ID links", () => { + const input = `first: + a + b +second: + c + d`; + + const result = parser.parse(input); + const formatted = formatLinks(result); + + expect(result.length).toBe(2); + expect(formatted).toBe("(first: a b)\n(second: c d)"); +}); + +test("Mixed indented and regular syntax", () => { + const input = `first: + a + b +(second: c d) +third value`; + + const result = parser.parse(input); + expect(result.length).toBe(3); + + const formatted = formatLinks(result); + expect(formatted).toContain("(first: a b)"); + expect(formatted).toContain("(second: c d)"); + expect(formatted).toContain("third value"); +}); + +test("Unsupported colon-only syntax should fail", () => { + const input = `: + papa + loves + mama`; + + expect(() => { + parser.parse(input); + }).toThrow(); +}); + +test("Indented ID with deeper nesting", () => { + const input = `root: + child1 + child2 + grandchild`; + + // This should work but the grandchild will be processed as a separate nested structure + const result = parser.parse(input); + expect(result.length).toBeGreaterThan(0); + + // The root should have child1 and child2 as values + const rootLink = result[0]; + expect(rootLink.id).toBe("root"); + expect(rootLink.values.length).toBe(2); +}); + +test("Empty indented ID should work", () => { + const input = "empty:"; + + const result = parser.parse(input); + expect(result.length).toBe(1); + expect(result[0].id).toBe("empty"); + expect(result[0].values.length).toBe(0); + + const formatted = formatLinks(result); + expect(formatted).toBe("(empty)"); +}); + +test("Equivalence test - comprehensive", () => { + const testCases = [ + { + indented: "test:\n one", + inline: "(test: one)" + }, + { + indented: "x:\n a\n b\n c", + inline: "(x: a b c)" + }, + { + indented: "\"quoted\":\n value", + inline: "(\"quoted\": value)" + } + ]; + + for (const testCase of testCases) { + const indentedResult = parser.parse(testCase.indented); + const inlineResult = parser.parse(testCase.inline); + + expect(indentedResult).toEqual(inlineResult); + expect(formatLinks(indentedResult)).toBe(formatLinks(inlineResult)); + } +}); \ No newline at end of file diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 985ab33..a3b1cd9 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -84,6 +84,47 @@ fn flatten_links(links: Vec) -> Vec> { } fn flatten_link_recursive(link: &parser::Link, parent: Option>, result: &mut Vec>) { + // Special case: If this is an indented ID (with colon) with children, + // the children should become the values of the link (indented ID syntax) + if link.is_indented_id && link.id.is_some() && link.values.is_empty() && !link.children.is_empty() { + let child_values: Vec> = link.children.iter().map(|child| { + // For indented children, if they have single values, extract them + if child.values.len() == 1 && child.values[0].id.is_some() && child.values[0].values.is_empty() && child.values[0].children.is_empty() { + LiNo::Ref(child.values[0].id.clone().unwrap()) + } else { + parser::Link { + id: child.id.clone(), + values: child.values.clone(), + children: vec![], + is_indented_id: false, + }.into() + } + }).collect(); + + let current = LiNo::Link { + id: link.id.clone(), + values: child_values + }; + + let combined = if let Some(parent) = parent { + // Wrap parent in parentheses if it's a reference + let wrapped_parent = match parent { + LiNo::Ref(ref_id) => LiNo::Link { id: None, values: vec![LiNo::Ref(ref_id)] }, + link => link + }; + + LiNo::Link { + id: None, + values: vec![wrapped_parent, current] + } + } else { + current + }; + + result.push(combined); + return; // Don't process children again + } + // Create the current link without children let current = if link.values.is_empty() { if let Some(id) = &link.id { @@ -96,7 +137,8 @@ fn flatten_link_recursive(link: &parser::Link, parent: Option>, res parser::Link { id: v.id.clone(), values: v.values.clone(), - children: vec![] + children: vec![], + is_indented_id: false, }.into() }).collect(); LiNo::Link { id: link.id.clone(), values } diff --git a/rust/src/parser.rs b/rust/src/parser.rs index cffc71c..8b7038a 100644 --- a/rust/src/parser.rs +++ b/rust/src/parser.rs @@ -15,6 +15,7 @@ pub struct Link { pub id: Option, pub values: Vec, pub children: Vec, + pub is_indented_id: bool, } impl Link { @@ -23,6 +24,16 @@ impl Link { id: Some(id), values: vec![], children: vec![], + is_indented_id: false, + } + } + + pub fn new_indented_id(id: String) -> Self { + Link { + id: Some(id), + values: vec![], + children: vec![], + is_indented_id: true, } } @@ -31,6 +42,7 @@ impl Link { id: None, values, children: vec![], + is_indented_id: false, } } @@ -39,6 +51,7 @@ impl Link { id, values, children: vec![], + is_indented_id: false, } } @@ -211,6 +224,16 @@ fn single_line_value_link<'a>(input: &'a str, state: &ParserState) -> IResult<&' .parse(input) } +fn indented_id_link<'a>(input: &'a str, _state: &ParserState) -> IResult<&'a str, Link> { + ( + reference, + horizontal_whitespace, + char(':'), + eol + ).map(|(id, _, _, _)| Link::new_indented_id(id)) + .parse(input) +} + fn multi_line_value_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { ( char('('), @@ -244,6 +267,7 @@ fn single_line_any_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a fn any_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { alt(( terminated(|i| multi_line_any_link(i, state), eol), + |i| indented_id_link(i, state), |i| single_line_any_link(i, state), )).parse(input) } diff --git a/rust/tests/indented_id_syntax_tests.rs b/rust/tests/indented_id_syntax_tests.rs new file mode 100644 index 0000000..9ba5878 --- /dev/null +++ b/rust/tests/indented_id_syntax_tests.rs @@ -0,0 +1,70 @@ +use lino::parse_lino_to_links; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_indented_id_syntax_test() { + let indented_syntax = "3:\n papa\n loves\n mama"; + let inline_syntax = "(3: papa loves mama)"; + + let indented_result = parse_lino_to_links(indented_syntax).unwrap(); + let inline_result = parse_lino_to_links(inline_syntax).unwrap(); + + // Both should produce the same structure when formatted + assert_eq!(indented_result.len(), 1); + assert_eq!(inline_result.len(), 1); + + println!("Indented result: {:?}", indented_result); + println!("Inline result: {:?}", inline_result); + + // Both should format to similar structure + assert_eq!(format!("{}", indented_result[0]), format!("{}", inline_result[0])); + } + + #[test] + fn indented_id_single_value_test() { + let input = "greeting:\n hello"; + let result = parse_lino_to_links(input).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(format!("{}", result[0]), "(greeting: hello)"); + } + + #[test] + fn indented_id_multiple_values_test() { + let input = "action:\n run\n fast\n now"; + let result = parse_lino_to_links(input).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(format!("{}", result[0]), "(action: run fast now)"); + } + + #[test] + fn indented_id_numeric_test() { + let input = "42:\n answer\n to\n everything"; + let result = parse_lino_to_links(input).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(format!("{}", result[0]), "(42: answer to everything)"); + } + + #[test] + fn unsupported_colon_only_syntax_test() { + let input = ":\n papa\n loves\n mama"; + + // This should fail + assert!(parse_lino_to_links(input).is_err()); + } + + #[test] + fn empty_indented_id_test() { + let input = "empty:"; + let result = parse_lino_to_links(input).unwrap(); + + assert_eq!(result.len(), 1); + // For empty ID, it shows just the ID as a reference + assert_eq!(format!("{}", result[0]), "empty"); + } +} \ No newline at end of file